Springboot와 JWT를 이용한 권한(Authorization) 처리

kindof

·

2022. 2. 15. 22:21

0. 들어가면서

현재 진행하고 있는 프로젝트에서 회원의 권한 인증 처리 등을 스프링 시큐리티와 JWT을 이용해 처리하고 있습니다.

 

이전에도 스프링 시큐리티에 관한 간단한 실습을 해봤는데, 이번에는 스프링부트에서 JWT를 이용한 사용자 인증 처리 방법에 대해 정리해보려고 합니다.

 

먼저 아래와 같이 스프링부트 Initializer를 통해 프로젝트를 생성하겠습니다.

 

Spring Initializer

그리고 실습할 코드들의 패키지와 클래스들의 구조는 아래와 같습니다.

jwttutorial

 

이제 JWT의 개념에 대해 공부해보고, 스프링부트에서 JWT 사용을 위한 설정부터 코드 작성까지 살펴보겠습니다.

 

 

1. 세션(Session)과 JWT(Json Web Token)

우리가 어떤 서비스를 이용할 때 가장 처음에 하는 행동은 회원가입과 로그인을 하는 것입니다. 

 

하지만 단순히 회원의 아이디와 비밀번호를 DB에 그대로 저장하고, 로그인할 때 그 사용자의 입력값과 DB의 값의 일치 여부만을 확인하는 것은 DB가 외부에 노출되었을 때 큰 파장을 일으킬 수 있기 때문에 해시를 통한 암호화가 필요하죠.

 

이에 따라, 회원이 로그인을 할 때도 보다 정밀한 인증(Authentication) 과정이 필요하게 되고, 그 이후에도 로그인 '상태'를 유지하고 각 회원이 할 수 있는 행동에 대한 '권한(Authorization)'을 처리하는 정책도 반드시 필요하게 됩니다.

 

다시 말해, 인증(Authentication)은 사용자가 자신의 계정을 사용하려고 할 때 필요한 절차이고 암호화를 통해 이루어지며, 권한(Authorization)은 사용자가 서비스를 이용할 때 서버가 로그인을 한 사용자를 인식하여 어떤 행위에 대한 허가를 내려주는 개념이죠.

 


 

이 맥락에서, JWT는 권한(Authorization)에 관련된 개념입니다.

 

전통적으로는 서버가 서비스에 사용자가 '로그인 되어 있음'을 알 수 있도록 하기 위해 '세션(Session)'을 사용해왔는데요.

 

먼저 세션은 다음과 같은 순서로 동작합니다.

  • 1) 클라이언트가 서버에 접속할 때 세션 ID를 발급합니다.
  • 2) 클라이언트는 세션 ID에 대해 쿠키를 사용해서 저장하고 있습니다.
  • 3) 클라이언트가 서버에 요청을 할 때 쿠키의 세션 ID를 같이 서버에 전달해서 요청합니다.
  • 4) 서버는 세션 ID를 받아서 세션에 존재하는 클라이언트의 정보를 가져와서 사용합니다.

하지만 세션 방식에는 다음과 같은 문제가 있습니다.

  • 사용자가 동시에 많이 접속을 하면 서버의 메모리에 과부하가 생길 수 있다.
  • 서버에 문제가 생기게 되면 사용자의 정보가 모두 날아간다.

 

이런 상황이 발생하면 사용자들의 입장에서 재로그인을 해야 하는 불편함이 생길 수 있고, 이를 해결하기 위해 세션 정보를 DB 같은 곳에 저장해두기에는 매 요청마다 부하가 크게 걸리는 문제점이 생기게 됩니다.

 

또한, 서비스를 유지하기 위해 여러 개의 서버를 두는 상황에서는 각 서버마다 회원의 세션을 유지하기 어려운 문제점이 생길 수 있으며 개별 사용자를 하나의 서버에만 구속시키기에도 어려움이 있을 수밖에 없습니다.

 

 

따라서 이러한 고민을 해결하기 위해 '토큰 방식'의 JWT가 고안되었는데요.

 

JWT(Json Web Token)을 사용하는 서비스에서는 사용자가 로그인을 하면 [Header, Payload, Signature] 세 부분으로 구성된 '토큰'이라는 암호화 된 정보를 사용자에게 부여합니다. 이 세 가지 영역에 대한 이해가 JWT 동작에 있어 핵심이라고 생각하는데요.

 

  • 먼저 헤더 부분에는 토큰의 타입인 JWT가 고정값으로 들어가며, 서명(Signature)을 만드는 데 사용되는 암호화 알고리즘(Alg)이 지정됩니다. 
  • 그리고 페이로드(Claim이라고도 합니다) 부분을 Base64로 디코딩하면 JSON 형식으로 토큰의 발급자, 부여자, 유효기간, 사용자의 권한 등이 담겨있는데요. 사용자가 로그인을 한 이후에는 모든 요청들마다 사용자가 가지고 있는 토큰 정보가 서버에게 보내지게 됩니다. 
  • 마지막으로 헤더와 페이로드, 그리고 '서버에 존재하는 Secret-key' 값을 헤더에 있는 암호화 알고리즘으로 암호화하면, 서명(Signature)값이 생성되고, 기존에 세번째 영역에 존재하던 서명값과 계산된 서명값을 비교하여 토큰의 유효성을 검증(Validation)할 수 있게 됩니다.

 

결국 JWT는 서버에 Secret-key 값만 가지고 있고 사용자에게 토큰만 부여하면, 매 번 토큰의 유효성과 유효 기간만을 검증하고 그 안에 있는 Payload를 통해 사용자의 권한 등을 파악할 수 있게 되는 것이죠.

 

 

하지만 그렇다고 해서 JWT가 항상 세션보다 우월한 것은 아닙니다.

 

세션은 사용자의 정보를 서버에서 관리하기 때문에 사용자의 세션을 관리할 수 있지만, JWT는 이미 발급한 토큰의 정보를 임의로 바꿔버릴 수 없기 때문이죠. 예를 들어, 여러 컴퓨터에서 사용자가 동일한 아이디로 로그인을 하려고 한다고 하면, 세션을 사용할 때는 기존 세션을 종료해서 로그아웃을 시킬 수 있지만 JWT는 그렇게 하기 힘듭니다. 뿐만 아니라, 어떤 토큰을 악의적으로 탈취한 사람에 대한 토큰 무효 처리도 다루기 쉽지 않죠.

 

이를 해결하기 위해 토큰을 두 개(Access, Refresh) 발급하는 방식도 있는데요. 이 부분에 대해서는 나중에 다른 글로 정리해보겠습니다.

 

어쨌든, 이번 포스팅에서는 이러한 JWT를 Springboot 환경에서 어떻게 다루는지에 대해 하나하나 살펴보도록 하겠습니다.

 

 

 

2. 스프링 시큐리티를 사용하면서 아무 일도 하지 않았을 때 - Unauthorized

JWT에 대해 공부하기 전에, 스프링 시큐리티를 사용하고 있는 아주 간단한 컨트롤러의 동작을 살펴보겠습니다.

 

먼저 controller 패키지를 만들고, 아래와 같이 HealthCheckController라는 클래스를 작성했습니다. 

@RestController
@RequestMapping("/api")
public class HealthCheckController {

    @GetMapping("/health-check")
    public ResponseEntity<String> healthCheck() {
        return ResponseEntity.ok("OK");
    }
}

스프링 시큐리티를 사용하고 있기 때문에 아래의 healthCheck() 메서드를 Get 메서드로 요청 시, Unauthorized 응답을 받게 됩니다.

 

Unathorized

 

 

3. Security Config의 permitAll()을 통해 Unauthorized 문제 해결

이제 SecurityConfig라는 클래스를 작성해보겠습니다.

 

SecurityConfig는 WebSecurityConfigureAdapter를 상속받아 configure() 메서드를 오버라이드합니다.

 

configure() 메서드는 httpServeletRequest를 사용하는 요청들(GET, POST 등)에 대해 접근 제한을 설정해주는데요. 위에서 /api/health-check 경로에 대해 GET 요청을 보냈을 때 Unathorized 응답을 받았지만, 이제는 이 요청에 대해서는 permitAll()을 해줌으로써 인증없이 접근을 허용해주었습니다.

// Web 보안을 활성화
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    public void configure(WebSecurity web) {
        web.ignoring()
                .antMatchers(
                        "/h2-console/**"
                        ,"/favicon.ico"
                        ,"/error"
                );
    }

    // WebSecurityConfigureAdapter의 configure 메서드 오버라이드
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests() // HttpServletRequest를 사용하는 요청들에 대한 접근 제한을 설정
                .antMatchers("/api/health-check").permitAll() // 인증없이 접근 허용
                .anyRequest().authenticated(); // 나머지 요청은 모두 인증이 필요
    }
}

그러면 아래와 같이 GET 요청을 보냈을 때 정상적으로 응답이 오는 것을 확인할 수 있습니다.

OK

하지만 우리가 해야하는 일은 위처럼 단순하게 permitAll()로 인증을 처리하는 것이 아닙니다.

 

JWT를 이용해서 토큰을 통해 어떤 요청에 대해 사용자가 인증이 된 사용자인지, 혹은 권한이 설정된 사용자인지를 확인해서 요청을 처리해주어야 하죠.

 

그럼 이제부터 프로젝트 환경으로 돌아가 DB와 JPA 관련 설정 및 엔티티를 생성한 뒤, JWT 설정과 코드 작성에 대해 살펴보겠습니다.

 

 

4. JWT 실습을 위한 H2 데이터베이스 및 엔티티 생성

4-1. application.yml 작성, Init Data 추가

application.yml

아래와 같이 application.yml 파일을 수정합니다. 테스트를 위해 h2 DB를 사용하고, 이를 위한 datasource를 설정합니다. 그리고 JPA 사용을 위한 내용도 작성해줍니다.

spring:

  h2:
    console:
      enabled: true

  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
    username: sa
    password:

  jpa:
    database-platform: org.hibernate.dialect.H2Dialect
    hibernate:
      ddl-auto: create-drop
    properties:
      hibernate:
        format_sql: true
        show_sql: true
    defer-datasource-initialization: true

logging:
  level:
    me.silvernine: DEBUG

 

Data.sql

아래와 같이 data.sql 파일을 resource 경로에 추가하면 스프링부트가 실행될 때 root classpath에 위치한 파일의 내용들이 실행됩니다.

 

그러면 아래 sql 내용에 따라 두 명의 유저(admin, user)와 두 개의 권한(ROLE_USER, ROLE_ADMIN)이 만들어지고, 각 유저에 권한을 매핑해주게 됩니다.

INSERT INTO USER (USER_ID, USERNAME, PASSWORD, NICKNAME, ACTIVATED) VALUES (1, 'admin', '$2a$08$lDnHPz7eUkSi6ao14Twuau08mzhWrL4kyZGGU5xfiGALO/Vxd5DOi', 'admin', 1);
INSERT INTO USER (USER_ID, USERNAME, PASSWORD, NICKNAME, ACTIVATED) VALUES (2, 'user', '$2a$08$UkVvwpULis18S19S5pZFn.YHPZt3oaqHZnDwqbCW9pft6uFtkXKDC', 'user', 1);

INSERT INTO AUTHORITY (AUTHORITY_NAME) values ('ROLE_USER');
INSERT INTO AUTHORITY (AUTHORITY_NAME) values ('ROLE_ADMIN');

INSERT INTO USER_AUTHORITY (USER_ID, AUTHORITY_NAME) values (1, 'ROLE_USER');
INSERT INTO USER_AUTHORITY (USER_ID, AUTHORITY_NAME) values (1, 'ROLE_ADMIN');
INSERT INTO USER_AUTHORITY (USER_ID, AUTHORITY_NAME) values (2, 'ROLE_USER');

 

마지막으로 User(회원)와 권한(Authority) 엔티티를 생성하고, 매핑해주겠습니다.

User.java

@Entity
@Table(name = "user")
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class User {

    @Id
    @Column(name = "user_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long userId;

    @Column(name = "username", length = 50, unique = true)
    private String username;

    @Column(name = "password", length = 100)
    private String password;

    @Column(name = "nickname", length = 50)
    private String nickname;

    @Column(name = "activated")
    private boolean activated;

    @ManyToMany
    @JoinTable(
            name = "user_authority",
            joinColumns = {@JoinColumn(name = "user_id", referencedColumnName = "user_id")},
            inverseJoinColumns = {@JoinColumn(name = "authority_name", referencedColumnName = "authority_name")})
    private Set<Authority> authorities;
}

 

Authority.java

@Entity
@Table(name = "authority")
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Authority {

    @Id
    @Column(name = "authority_name", length = 50)
    private String authorityName;
}
  • 실제 프로젝트에서는 엔티티에 @Setter 등을 잘 사용하지는 않지만, 단순히 JWT 튜토리얼을 위한 예시이기 때문에 편의 위주로 코드를 작성해주었습니다.
  • @ManyToMany @JoinTable 부분은 User, Authority 테이블의 다대다 관계를 일대다, 다대일 관계의 조인 테이블로 정의하기 위함입니다.

이 상태에서 서버를 실행시키게 되면, 아래와 같이 H2 콘솔을 통해 데이터가 들어가있는 것을 확인하실 수 있습니다.

USER
USER_AUTHORITY

 

 

5. JWT 설정 및 코드 작성

지금부터는 JWT 설정을 추가하고, 관련 코드를 작성해보겠습니다.

 

먼저 application.yml 파일에 아래와 같이 jwt 관련 내용을 추가합니다.

application.yml

...

jwt:
  header: Authorization
  # HS512 알고리즘을 사용할 것이기 때문에 512bit, 즉 64byte 이상의 secret key를 사용
  # echo 'jsh-springboot-and-jwt-tutorial-this-is-for-generating-jwt-secretkey-base64'|base64
  secret: anNoLXNwcmluZ2Jvb3QtYW5kLWp3dC10dXRvcmlhbC10aGlzLWlzLWZvci1nZW5lcmF0aW5nLWp3dC1zZWNyZXRrZXktYmFzZTY0Cg==
  token-validity-in-seconds: 86400

주석으로 작성한 echo 부분으로 자신의 고유한 64byte의 secret key를 생성할 수 있습니다. 

 

 

jwt 관련 라이브러리들을 build.gradle에 추가하겠습니다.

build.gradle

dependencies {
    ...
    ...
    
    
	implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
	implementation 'io.jsonwebtoken:jjwt-impl:0.11.2'
	implementation 'io.jsonwebtoken:jjwt-jackson:0.11.2'
}

 

이제 jwt 관련 코드를 작성할 차례입니다. 

 

'jwt' 패키지를 생성하고 토큰의 생성과 유효성 검증 등을 담당할 TokenProvider 클래스를 작성합니다.

TokenProvider.java

@Component
public class TokenProvider implements InitializingBean {

    private final Logger logger = LoggerFactory.getLogger(TokenProvider.class);
    private static final String AUTHORITIES_KEY = "auth";

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.token-validity-in-seconds}")
    private long tokenValidityInMilliseconds;

    private Key key;


    @Override
    public void afterPropertiesSet() {
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    public String createToken(Authentication authentication) {
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();
        Date validity = new Date(now + this.tokenValidityInMilliseconds);

        return Jwts.builder()
                .setSubject(authentication.getName())
                .claim(AUTHORITIES_KEY, authorities)
                .signWith(key, SignatureAlgorithm.HS512)
                .setExpiration(validity)
                .compact();
    }

    public Authentication getAuthentication(String token) {
        Claims claims = Jwts
                .parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();

        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        User principal = new User(claims.getSubject(), "", authorities);

        return new UsernamePasswordAuthenticationToken(principal, token, authorities);
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            logger.info("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            logger.info("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            logger.info("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            logger.info("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }
}
  • 먼저 yml 파일에서 정의한 jwt secret-key와 토큰 유효 시간을 @Value 어노테이션을 통해 주입받습니다. 
  • TokenProvider는 InitializingBean을 구현하고 있기 때문에, afterPropertiesSet()을 오버라이드하면 빈이 생성되고 의존성 주입까지 끝낸 시점에서 주입받은 secret 값을 base64 디코딩하여 key 변수에 할당하게 됩니다.
  • createToken 메서드는 Authentication 객체에 포함된 권한 정보들을 담은 토큰을 생성한 뒤, 현재 시간을 기준으로 토큰의 만료 시간을 설정합니다.
  • getAuthentication 메서드는 토큰에 담겨있는 권한 정보들을 이용해 Authentication 객체를 리턴합니다.
  • validateToken은 토큰을 검증하여 올바른 토큰이라면 true를, 잘못된 토큰이라면 예외 처리를 한 뒤 false를 리턴합니다.

맨 처음에 JWT 개념에 대한 설명에서 유효한 토큰인지를 검증하는 과정이 서버의 Secret-key와 헤더와 페이로드를 이용해서 만든 Signature값이 같은지 확인하는 것과 유효 시간을 확인하는 것이라고 했었습니다!

 

 

JwtFilter.java

@RequiredArgsConstructor
public class JwtFilter extends GenericFilterBean {

    private static final Logger logger = LoggerFactory.getLogger(JwtFilter.class);
    public static final String AUTHORIZATION_HEADER = "Authorization";
    private final TokenProvider tokenProvider;

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
            throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        String jwt = resolveToken(httpServletRequest);
        String requestURI = httpServletRequest.getRequestURI();

        if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
            Authentication authentication = tokenProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            logger.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI);
        } else {
            logger.debug("유효한 JWT 토큰이 없습니다, uri: {}", requestURI);
        }

        filterChain.doFilter(servletRequest, servletResponse);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}
  • JWT 사용을 위한 커스텀 필터를 작성하기 위해 GenericFilterBean을 상속하는 JwtFilter.java를 생성하고 위에서 작성한 TokenProvider를 주입받습니다.
  • 먼저 resolveToken() 메서드는 HttpServletRequest의 헤더 정보에 있는 토큰을 가져오는 역할을 합니다.
  • 그리고 doFilter() 메서드는 resolveToken() 메서드를 통해 토큰을 가져온 뒤, 해당 토큰에 대한 Validation을 검증하고 인증 정보를 Security Context에 저장하게 됩니다.

 

JwtSecurityConfig.java

@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    private final TokenProvider tokenProvider;

    @Override
    public void configure(HttpSecurity http) {
        JwtFilter customFilter = new JwtFilter(tokenProvider);
        http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
    }
}
  • JwtSecurityConfig.java는 SecurityConfigurerAdapter를 상속하여 configure메소드를 오버라이드합니다. 그리고 위에서 작성했던 JwtFilter를 Security 로직에 적용하는 역할을 수행합니다.

 

JwtAuthenticationEntryPoint.java

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        // 유효한 자격증명을 제공하지 않고 접근하려 할때 401
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}
  • JwtAuthenticationEntryPoint.java는 유효한 자격증명을 제공하지 않고 접근할 때 401 Unathorized 에러를 리턴하기 위해 AuthenticationEntryPoint를 구현하여 작성합니다.

 

JwtAccessDeniedHandler.java

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
        //필요한 권한이 없이 접근하려 할때 403
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
}
  • 마찬가지로 필요한 권한이 존재하지 않을 때, 403 Forbidden 에러를 리턴하기 위해 AccessDeniedHandler를 구현한 JwtAccessDeniedHandler.java를 구현했습니다.

 


이제 Jwt 사용을 위해 맨 처음에 작성했던 SecurityConfig.java의 내용을 수정해보겠습니다.

SecurityConfig.java

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final TokenProvider tokenProvider;
    private final CorsFilter corsFilter;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    public void configure(WebSecurity web) {
        web.ignoring()
                .antMatchers(
                        "/h2-console/**"
                        ,"/favicon.ico"
                        ,"/error"
                );
    }

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                // token을 사용하는 방식이기 때문에 csrf를 disable합니다.
                .csrf().disable()
                .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
                .exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .accessDeniedHandler(jwtAccessDeniedHandler)
                
                // enable h2-console
                .and()
                .headers()
                .frameOptions()
                .sameOrigin()
                
                // 세션을 사용하지 않기 때문에 STATELESS로 설정
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                
                .and()
                .authorizeRequests()
                .antMatchers("/api/hello").permitAll()
                .antMatchers("/api/authenticate").permitAll()
                .antMatchers("/api/signup").permitAll()

                .anyRequest().authenticated()

                .and()
                .apply(new JwtSecurityConfig(tokenProvider));
    }
}
  • @EnableGlobalMethodSecurity(prePostEnabled = true) 어노테이션은 메서드 단위로 @PreAuthorize 검증 어노테이션을 사용하기 위함입니다.
  • SecurityConfig.java는 이전에 작성한 TokenProvider 등을 @RequiredArgsConstructor를 통해 주입받고, Password를 인코딩하는 인코더는 BCryptPasswordEncoder를 사용합니다.

 

configure(HttpSecurity httpSecurity) 메서드를 살펴보겠습니다.

  • #1. 먼저 Token 사용을 위해 csrf 설정을 disable 하도록 합니다.
  • #2. 예외처리를 위해 작성했던 jwtAuthenticationEntryPoint, jwtAccessDeniedHandler 등을 지정합니다.
  • #3. 데이터를 저장하고 있는 h2-console을 위한 설정을 추가합니다.
  • #4. 현재 세션을 사용하고 있지 않기 때문에 세션 설정은 STATELESS로 지정합니다.
  • #5. /api/health-check, /api/authenticate(로그인을 위한 API), /api/signup(회원 가입 API) 세 가지 API에 대해서는 Token이 없어도 호출 가능하도록 열어둡니다.

 

6. DTO, Repository, 로그인 테스트

이제 실제로 API를 사용하기 위해 DTO와 Repository 클래스를 생성하겠습니다.

LoginDto.java

@Getter @Setter @Builder
@AllArgsConstructor
@NoArgsConstructor
public class LoginDto {

    @NotNull
    @Size(min = 3, max = 50)
    private String username;

    @NotNull
    @Size(min = 3, max = 100)
    private String password;
}

TokenDto.java

@Getter @Setter @Builder
@AllArgsConstructor
@NoArgsConstructor
public class TokenDto {
    private String token;
}

UserDto.java

@Getter @Setter @Builder
@AllArgsConstructor
@NoArgsConstructor
public class UserDto {

    @NotNull
    @Size(min = 3, max = 50)
    private String username;

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @NotNull
    @Size(min = 3, max = 100)
    private String password;

    @NotNull
    @Size(min = 3, max = 50)
    private String nickname;

    private Set<AuthorityDto> authorityDtoSet;

    public static UserDto from(User user) {
        if(user == null) return null;

        return UserDto.builder()
                .username(user.getUsername())
                .nickname(user.getNickname())
                .authorityDtoSet(user.getAuthorities().stream()
                        .map(authority -> AuthorityDto.builder().authorityName(authority.getAuthorityName()).build())
                        .collect(Collectors.toSet()))
                .build();
    }
}

UserRepository.java

public interface UserRepository extends JpaRepository<User, Long> {
    @EntityGraph(attributePaths = "authorities")
    Optional<User> findOneWithAuthoritiesByUsername(String username);
}

 

다음은 UserDetailsService를 구현하는 CustomUserDetailsService 클래스입니다.

CustomUserDetailsService.java

@Component("userDetailsService")
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
    private final UserRepository userRepository;

    @Override
    @Transactional
    public UserDetails loadUserByUsername(final String username) {
        return userRepository.findOneWithAuthoritiesByUsername(username)
                .map(user -> createUser(username, user))
                .orElseThrow(() -> new UsernameNotFoundException(username + " -> 데이터베이스에서 찾을 수 없습니다."));
    }

    private org.springframework.security.core.userdetails.User createUser(String username, User user) {
        if (!user.isActivated()) {
            throw new RuntimeException(username + " -> 활성화되어 있지 않습니다.");
        }
        List<GrantedAuthority> grantedAuthorities = user.getAuthorities().stream()
                .map(authority -> new SimpleGrantedAuthority(authority.getAuthorityName()))
                .collect(Collectors.toList());
        return new org.springframework.security.core.userdetails.User(user.getUsername(),
                user.getPassword(),
                grantedAuthorities);
    }
}

로그인 API에 요청을 하면 authenticate 메서드가 실행되는데, 이 때 DB에서 회원 정보를 조회해오는 loadUserByUsername 메서드가 실행됩니다. 이 후, 조회해 온 회원 정보와 권한 정보를 가지고 스프링 프레임워크에서 제공하는 User 객체로 반환하여 리턴합니다.

 

AuthController.java

@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class AuthController {
    private final TokenProvider tokenProvider;
    private final AuthenticationManagerBuilder authenticationManagerBuilder;

    @PostMapping("/authenticate")
    public ResponseEntity<TokenDto> authorize(@Valid @RequestBody LoginDto loginDto) {

        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());

        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
        SecurityContextHolder.getContext().setAuthentication(authentication);

        String jwt = tokenProvider.createToken(authentication);

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.add(JwtFilter.AUTHORIZATION_HEADER, "Bearer " + jwt);

        return new ResponseEntity<>(new TokenDto(jwt), httpHeaders, HttpStatus.OK);
    }
}

authorize() 메서드는 로그인 API를 처리하는 역할을 합니다.

 

우선 LoginDto에서 username, password를 받아와서 UsernamePasswordAuthenticationToken 객체를 생성합니다.

 

이 때, AuthenticationManagerBuilder 클래스는 아래와 같이 UserDetailsService를 가지고 있고, 위에서 작성한 CustomUserDetailsService는 UserDetailsService의 loadUserByUsername 메서드를 오버라이딩했다는 것을 기억해주셔야 합니다.

AuthenticationManagerBuilder

그러면 AuthenticationManagerBuilder는 해당 객체를 가지고 온 뒤, authenticate() 메서드 로직을 수행할 때 위에서 만들었던 loadUserByUsername 메서드가 실행되어 유저 정보를 조회하고 인증 정보를 생성하게 됩니다.

 

그리고 해당 인증 정보를 JwtFilter.java 클래스의 doFilter 메서드와 비슷하게 Security Context에 저장합니다.

 

또한, 해당 인증 정보를 기반으로 TokenProvider의 createToken 메서드를 통해 JWT 토큰을 생성하여 Response Header에 넣고, TokenDto 객체를 이용해 ResponseBody에 넣어 리턴하게 됩니다.

 


이제 PostMan을 통해 로그인 테스트를 진행해보겠습니다.

 

http://localhost:8080/api/authenticate 를 POST로 호출하고, Request Body에는 로그인을 위한 username, password을 넣어줍니다. admin 유저는 이전에 만들었던 data.sql을 통해 자동으로 Database에 저장된 유저 입니다.

로그인 성공

 

호출해보면 정상적으로 응답을 받았고 Response Body에 token이 들어있는 것을 볼 수 있습니다.

 

 

7. 회원가입과 권한에 따른 API 호출

먼저 아래와 같이 util 패키지 안에 SecurityUtil 클래스를 생성하겠습니다.

SecurityUtil.java

public class SecurityUtil {

    private static final Logger logger = LoggerFactory.getLogger(SecurityUtil.class);

    private SecurityUtil() {
    }

    public static Optional<String> getCurrentUsername() {
        final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (authentication == null) {
            logger.debug("Security Context에 인증 정보가 없습니다.");
            return Optional.empty();
        }

        String username = null;
        if (authentication.getPrincipal() instanceof UserDetails) {
            UserDetails springSecurityUser = (UserDetails) authentication.getPrincipal();
            username = springSecurityUser.getUsername();
        } else if (authentication.getPrincipal() instanceof String) {
            username = (String) authentication.getPrincipal();
        }

        return Optional.ofNullable(username);
    }
}

getCurrentUsername() 메서드는 이전에 JwtFilter 클래스의 doFilter 메서드에서 저장한 Security Context의 인증 정보에서 username을 가져오는 역할을 합니다.

JwtFilter

 

 

UserService.java

@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    @Transactional
    public UserDto signup(UserDto userDto) {
        if (userRepository.findOneWithAuthoritiesByUsername(userDto.getUsername()).orElse(null) != null) {
            throw new DuplicateMemberException("이미 가입되어 있는 유저입니다.");
        }

        Authority authority = Authority.builder()
                .authorityName("ROLE_USER")
                .build();

        User user = User.builder()
                .username(userDto.getUsername())
                .password(passwordEncoder.encode(userDto.getPassword()))
                .nickname(userDto.getNickname())
                .authorities(Collections.singleton(authority))
                .activated(true)
                .build();

        return UserDto.from(userRepository.save(user));
    }

    @Transactional(readOnly = true)
    public UserDto getUserWithAuthorities(String username) {
        return UserDto.from(userRepository.findOneWithAuthoritiesByUsername(username).orElse(null));
    }

    @Transactional(readOnly = true)
    public UserDto getMyUserWithAuthorities() {
        return UserDto.from(SecurityUtil.getCurrentUsername().flatMap(userRepository::findOneWithAuthoritiesByUsername).orElse(null));
    }
}
  • signup() 메서드는 UserDto에서 username, password, nickname 등을 불러오고, 기본적으로 'ROLE_USER'라는 권한을 부여하여 DB에 저장합니다.
  • getUserWithAuthorities() 메서드는 username을 통해 해당 유저의 정보 및 권한 정보를 리턴하고, getMyUserWithAuthrities() 메서드는 위에서 작성한 SecurityUtil의 getCurrentUsername() 메서드를 통해 username의 유저 및 권한 정보를 리턴합니다.

 

UserController.java

@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class UserController {
    private final UserService userService;

    @GetMapping("/hello")
    public ResponseEntity<String> hello() {
        return ResponseEntity.ok("hello");
    }

    @PostMapping("/test-redirect")
    public void testRedirect(HttpServletResponse response) throws IOException {
        response.sendRedirect("/api/user");
    }

    @PostMapping("/signup")
    public ResponseEntity<UserDto> signup(
            @Valid @RequestBody UserDto userDto
    ) {
        return ResponseEntity.ok(userService.signup(userDto));
    }

    @GetMapping("/user")
    @PreAuthorize("hasAnyRole('USER','ADMIN')")
    public ResponseEntity<UserDto> getMyUserInfo(HttpServletRequest request) {
        return ResponseEntity.ok(userService.getMyUserWithAuthorities());
    }

    @GetMapping("/user/{username}")
    @PreAuthorize("hasAnyRole('ADMIN')")
    public ResponseEntity<UserDto> getUserInfo(@PathVariable String username) {
        return ResponseEntity.ok(userService.getUserWithAuthorities(username));
    }
}
  • signup() 메서드는 SecurityConfig.java 에서 permitAll()을 설정했기 때문에 권한 없이 호출할 수 있습니다.
  • getMyUserInfo() 메서드는 현재 Security Context에 저장되어 있는 인증 정보의 username을 기준으로 한 유저 정보 및 권한 정보를 리턴하는 API 입니다. @PreAuthorize(“hasAnyRole(‘USER’,’ADMIN’)”) 어노테이션을 이용해서 ROLE_USER, ROLE_ADMIN 권한 모두 호출 가능하게 설정합니다.
  • getUserInfo() 메서드는 username을 파라미터로 받아 해당 username의 유저 정보 및 권한 정보를 리턴합니다. @PreAuthorize(“hasAnyRole(‘ADMIN’)”) 어노테이션을 이용해서 ROLE_ADMIN 권한을 소유한 토큰만 호출할 수 있도록 설정합니다.

 


이제 Postman을 통해서 테스트를 진행해보겠습니다.

회원가입

'josh96' 이라는 회원을 등록했고, signup 메서드에 따라 기본 권한은 'ROLE_USER'로 설정되었습니다.

 

이제 josh96 회원의 토큰을 /api/authenticate API를 통해 발급받습니다.

josh96 회원의 토큰

 

이제 위에서 받아온 token 값을 가지고 josh96 회원이 '/api/user/{username}' API를 호출하는 상황을 보겠습니다.

 

josh96 회원은 'ROLE_USER' 권한을 가지고 있고 UserController.java에서 'ROLE_ADMIN' 권한을 가진 유저만 API 사용이 가능하도록 해두었기 때문에 JwtAccessDeniedHandler에서 작성한 403 Forbidden 에러가 리턴되어야 하겠죠?

403 Forbidden

성공적으로 권한 처리가 되었습니다.

 

 

8. 나가면서

정말 긴 글로 Springboot에서 JWT를 사용하는 방법에 대해 기록해봤습니다.

 

처음에 이해되지 않는 부분도 많았지만, 이렇게 정리하면서 많은 부분이 다시 이해되는 것 같습니다.

 

다음에는 실제 프로젝트에서 어떻게 JWT를 이용해서 권한 처리를 다루는 지에 대해 고민해보고, 글을 작성해보겠습니다.

 

감사합니다.

 

9. 참고