JWT 인증 구현

우리는 회원 가입과 로그인 프로세스는 구현을 마쳤다. 여기에 JWT 토큰을 이용한 인증을 추가하면된다. 우리가 해야할 일은 다음과 같다.

  • 로그인 시 JWT 토큰 발급
  • 인증이 필요한 URL 요청 시, 요청에 포함된 JWT 토큰을 검증

이번 포스트에서 위 3가지를 순서대로 진행할 것이다. 먼저 JWT를 사용하기 위한 디펜던시를 추가해보자.

JWT 라이브러리 추가 및 설정

가장 먼저 JWT 관련 라이브러리를 추가해주어야 한다. 대표적인 자바의 JWT 라이브러리로는 jjwtjava-jwt가 존재한다. 어떤 것을 사용해도 상관 없지만 이번 프로젝트에서는 java-jwt를 사용해보려고 한다.

build.gradle에 다음과 같이 디펜던시를 추가한다.

1
2
3
4
dependencies {
    // jwt
    implementation 'com.auth0:java-jwt:4.4.0'
}

이제 JWT 관련 설정을 추가해 보자. application.yml에 다음과 같이 추가한다.

1
2
3
4
5
6
7
8
9
jwt:  
  properties:  
    secret: 123  
    access-token-expiration-seconds: 900 # 900 15분  
    refresh-token-expiration-seconds: 1209600 # 1209600 14일  
    token-prefix: Bearer  
  response:  
    access-token-header-name: Authorization
    refresh-token-header-name: Refresh-Token

refresh token은 추후에 구현할 예정이므로 미리 설정 정보에 포함시켰다.

실제 환경에서는 secret같은 민감한 정보는 노출되면 안된다. 실제 개발 시에는 spring-cloud-config또는 aws-secret-manager를 이용하는 방법이 있다.

Spring은 @Value 애노테이션을 통해 yml 파일의 값을 가져올 수 있는 기능을 제공한다.. 이 정보들은 @Value 애노테이션을 이용해 JwtService에서 사용할 것이다.

로그인 시 JWT 토큰 발급

우리는 로그인을 구현하면서 AuthenticationSuccessHandler를 구현했다. 이전 포스트에서는 간단히 200(OK)를 보내고 로그를 찍도록 구현했지만, 이제는 로그인에 성공 시, JWT 토큰을 발급해주어야 한다.

JwtService

먼저 JWT 토큰를 관리하는 JwtService 클래스를 만들고 JwtLoginSuccessHandler를 구현해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package com.dukcode.securityboilerplate.global.security.jwt.service;  
  
@RequiredArgsConstructor  
public class JwtService {  
  
  private static final String ACCESS_TOKEN_SUBJECT = "access-token";  
  private static final String REFRESH_TOKEN_SUBJECT = "refresh-token";  
  private static final String USERNAME_CLAIM = "email";  
  private static final String ROLES_CLAIM = "roles";  

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

  @Value("${jwt.properties.access-token-expiration-seconds}")  
  private Long accessTokenExpirationSeconds;  

  @Value("${jwt.properties.refresh-token-expiration-seconds}")  
  private Long refreshTokenExpirationSeconds;  

  @Value("${jwt.properties.token-prefix}")  
  private String tokenPrefix;  

  private Algorithm algorithm;  
  private JWTVerifier verifier;  
    
  @PostConstruct  
  public void init() {  
    algorithm = Algorithm.HMAC256(secret);  
    verifier = JWT.require(algorithm).build();  
  }
  
  public String createAccessTokenWithPrefix(Authentication authentication) {  
    UserDetails userDetails = (UserDetails) authentication.getPrincipal();  
    String username = userDetails.getUsername();  
    List<String> roles = userDetails.getAuthorities().stream()  
        .map(GrantedAuthority::toString)  
        .collect(Collectors.toList());  
  
    String accessToken = createAccessToken(username, roles);  
  
    return withTokenPrefix(accessToken);  
  }  
  
  private String createAccessToken(String username, List<String> roles) {  
    return JWT.create().withSubject(ACCESS_TOKEN_SUBJECT)  
        .withIssuedAt(new Date())  
        .withExpiresAt(new Date(System.currentTimeMillis() + accessTokenExpirationSeconds * 1000L))  
        .withClaim(USERNAME_CLAIM, username)  
        .withClaim(ROLES_CLAIM, roles)  
        .sign(algorithm);  
  }  
  
  
  private String withTokenPrefix(String token) {  
    return tokenPrefix + " " + token;  
  }  
  
}

이제 외부에서 createAccessTokenWithPrefix()메서드를 호출하면 JWT 토큰을 생성할 수 있게 된다. Bearer aaaaa.bbbbb.ccccc 형태로 반환된 것이다.

JwtLoginSuccessHandler

AuthetncationSuccessHandler를 상속하는 JwtLoginSuccessHandler를 구현해보자. Spring Security는 자격 증명에 성공 시 onAuthenticationSuccess()메서드를 호출하기 때문에 이 메서드에서 JWT 토큰을 생성해 응답 헤더에 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.dukcode.securityboilerplate.global.security.login.handler;  

@Slf4j  
@RequiredArgsConstructor  
public class JwtLoginSuccessHandler implements AuthenticationSuccessHandler {  
  
  private final JwtService jwtService;  

  @Value("${jwt.response.access-token-header-name}")  
  private String accessTokenHeaderName;  
  @Value("${jwt.response.refresh-token-header-name}")
  private String refreshTokenHeaderName;
  
  @Override  
  public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,  
      Authentication authentication) throws IOException, ServletException {  
  
    String accessTokenWithPrefix = jwtService.createAccessTokenWithPrefix(authentication);  
  
    response.setHeader(accessTokenHeaderName, accessTokenWithPrefix);  
    response.setStatus(HttpStatus.OK.value());  
  }  
  
}

로그인이 성공했을 때, JwtService를 통해 JWT토큰을 생성하고 Authorization헤더에 토큰을 실어보낼 수 있게 한다.

SecurityConfiguration

JwtServiceJwtLoginSuccessHandler를 빈으로 등록해보자. SecurityConfiguration에서 다음과 같은 메서드를 추가하면 된다.

1
2
3
4
5
6
7
8
9
10
  @Bean
  public JwtService jwtService() {
    return new JwtService();
  }

  @Bean
  public AuthenticationSuccessHandler authenticationSuccessHandler() {
    return new JwtLoginSuccessHandler(jwtService());
  }

실행

PostMan으로 실제 로그인 성공 시, JWT 토큰이 실려 오는지 확인해 보자.

응답 헤더에 우리가 설정한대로 토큰이 실려오는 것을 확인할 수 있다.

JWT 토큰 검증

이제 클라이언트는 받은 토큰을 요청이 실어보내면 서버에서 해당 토큰이 맞는지 검증하면 된다. JwtAuthenticationProcessingFilter를 작성하고 Spring Security 필터 체인에 끼워 넣어 요청의 JWT 토큰이 정상적인 토큰인지, 유효기간 안의 토큰인지 검사할 수 있도록 해보자.

JwtService

가장 먼저 JwtService에 메서드를 추가해 토큰이 정상 토큰인지 검사할 수 있도록 해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package com.dukcode.securityboilerplate.global.security.jwt.service;  
  
@RequiredArgsConstructor  
public class JwtService {  

  // ...
  
  public Authentication resolveAccessTokenWithPrefix(String accessTokenWithPrefix) {  
    String token = removePrefix(accessTokenWithPrefix);  
    DecodedJWT decodeToken = verifier.verify(token);  
  
    String username = decodeToken.getClaim(USERNAME_CLAIM).asString();  
    List<SimpleGrantedAuthority> roles = decodeToken.getClaim(ROLES_CLAIM)  
        .asList(String.class)  
        .stream()  
        .map(SimpleGrantedAuthority::new)  
        .toList();  
  
    return UsernamePasswordAuthenticationToken.authenticated(username, null, roles);  
  }  
  
  private String removePrefix(String token) {  
    if (!token.startsWith(tokenPrefix + " ")) {  
      throw new JWTVerificationException("Invalid Token Format");  
    }  
    return token.substring(tokenPrefix.length() + 1);  
  }  
  
}

클라이언트 객체는 prefix가 포함된 access token을 메서드로 전달하면 된다. JwtService는 토큰을 검증한다. 잘못된 토큰이거나 유효기간이 지난 토큰이라면 JWTVerificationException 및 하위 예외를 발생시킨다.

JwtAuthenticationProcessingFilter

이제 JWT 토큰을 검증하는 필터를 만들어 Spring Security 필터 체인에 끼워 넣어보자. 해당 필터의 역할은 인증이 필요한 요청에서 HttpServletRequest의 헤더에 실려온 JWT토큰을 검사해 유효한 토큰인지 검사하고 이를 SecurityContext에 저장하는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package com.dukcode.securityboilerplate.global.security.authentication.filter;  
  
@Slf4j  
@RequiredArgsConstructor  
public class JwtAuthenticationProcessingFilter extends OncePerRequestFilter {  
  
  private final JwtService jwtService;  
  
  @Value("${jwt.response.access-token-header-name}")  
  private String accessTokenHeaderName;  
  
  @Override  
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,  
      FilterChain filterChain) throws ServletException, IOException {  
  
    String accessTokenWithPrefix = extractAccessTokenWithPrefix(request);  
  
    // access-token 없으면 pass  
    if (!StringUtils.hasText(accessTokenWithPrefix)) {  
      filterChain.doFilter(request, response);  
      return;  
    }  
  
    try {  
      Authentication authentication  
          = jwtService.resolveAccessTokenWithPrefix(accessTokenWithPrefix);  
      // SecurityContext에 인증 정보를 저장
      SecurityContextHolder.getContext().setAuthentication(authentication);  
    } catch (JWTVerificationException e) {  
      // 인증이 실패했을 경우 request에 예외 저장  
      request.setAttribute("exception", e);  
    }  
  
    filterChain.doFilter(request, response);  
  }  
  
  
  private String extractAccessTokenWithPrefix(HttpServletRequest request) {  
    return request.getHeader(accessTokenHeaderName);  
  }  
}

헤더에 JWT 토큰이 존재하지 않으면 다음 필터로 지나가도록 한다. 토큰이 존재하면 JwtService를 통해 검증한다. 검증이 성공하면 SecurityContext에 인증된 Authentication객체를 저장하고 다음 필터로 진행한다.

검증이 어떤 이유로 실패할 수 있다. 이 때 JWTVerifier는 원인에 따라 JWTVerificationException 밑 하위 예외를 뱉는다. 예를 들어 토큰 기간이 만료되었다면 JWTVerificationException의 자식 예외인 TokenExpiredException을 뱉는다.

예외를 처리하는 방법은 여러가지로 고려해 볼 수 있다.

  • 검증 예외 발생 시, 바로 해당 필터에서 예외 상황에 대한 ErrorResponse를 전송하는 방법
  • 검증 예외 발생 시, 해당 필터 앞에 예외에 대한 응답을 하는 JwtExceptionTranslationFilter를 추가하고 해당 필터가 예외를 던지면 앞의 필터가 받아서 처리
  • 검증 예외 발생 시, 예외를 request의 attribute에 저장해 놓고 추후 Spring Security가 인증되지 않는 요청을 처리할 때 사용

첫번째 방법과 두번째 방법은 직관적이지만 단점이 존재한다. 만약 해당 요청이 인증이 필요하지 않은 요청이었다고 가정하자. 하지만 요청에는 유효시간이 지났거나 검증에 실패하는 토큰이 포함되어 있었다고 하자.

첫번째 방법과 두번째 방법은 해당 요청이 인증이 필요한 요청인지 아닌지 판단하지 않고 예외가 발생하면 바로 응답을 낸다.

하지만 세번째 방법은 Spring Security가 해당 요청이 인증이 필요한 요청이라고 판단이 내려졌을 때 JWT 토큰과 관련된 예외를 처리한다. 따라서 인증이 필요하지 않은 요청이라면 JWT 토큰에 문제가 있어도 정상적인 응답을 낸다.

위와 같은 장점을 가져가기 위해서 request의 attribute를 활용했다.

JwtAuthententicationEntryPoint

Spring Security는 최종적인 인증/인가 요청을 AuthorizationFilter에서 판단한다. 여기서 인증이 필요한 요청인데 인증이 되어 있지 않다거나, 필요한 권한이 존재하지 않는 요청을 AuthorizationDecision이라는 클래스로 판단한다. 이때 요건이 충족되지 않았다고 판단이 되면 예외를 던지고 이는 AuthorizationFilter의 바로 앞 필터인 ExceptionTranslationFilter에서 처리하게 된다.

이 때, 인증이 필요한 요청에 인증이 되지 않은 요청이라면 ExceptionTranslactionFilterAuthenticationEntryPoint의 메서드를 호출한다.

AuthenticationEntryPoint의 역할은 다음과 같다. 인증이 필요한 자원에 접근할 때 인증이 되지 않으면 자격 증명을 요청하는 HTTP 응답을 보내는 데 사용된다. 즉, AuthenticationEntryPoint에 도달했다는 의미는 인증이 필요한 곳에 인증이 되지 않았다는 것을 의미한다.

이제 AuthenticationEntryPoint를 구현해보자. AuthenticationEntryPoint에 도달 했다는 의미는 인증이 필요한 요청에 인증이 이루어 지지 않았기 때문이다. 인증이 이루어 지지 않은 이유는 크게 두 가지로 생각해 볼 수 있다.

  • 토큰이 존재하지 않음 : request의 attribute에 exception 존재 X
  • 유효한 토큰이 아님 : request의 attribute에 exception 존재 O
    • 토큰이 만료됨
    • 그 외

유효한 토큰이 아닌 경우에는 request의 attribute에 JWTVerificationException이 들어가 있을 것이다. 이 때 실제 예외가 어떤 클래스의 인스턴스인지 파악해 상황별로 다른 응답을 보낼 수 있다.

request에 attribute가 존재하지 않는 경우에는 토큰이 존재하지 않는 경우이므로 Unauthroized 응답을 보낼 수있다. 코드를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package com.dukcode.securityboilerplate.global.security.jwt.entrypoint;  

@RequiredArgsConstructor  
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {  
  
  private final ObjectMapper objectMapper;  
  
  
  @Override  
  public void commence(HttpServletRequest request, HttpServletResponse response,  
      AuthenticationException authException) throws IOException, ServletException {  
    // attribute에서 exception을 꺼낸다.
    JWTVerificationException jwtVerificationException =  
        (JWTVerificationException) request.getAttribute("exception");  

    // 토큰 만료의 경우 다른 응답
    if (jwtVerificationException instanceof TokenExpiredException) {  
      sendErrorResponse(response, ErrorCode.TOKEN_EXPIRED, 499);  
      return;  
    }  

    // 유효한 토큰이 아닌 경우 다른 응답
    if (jwtVerificationException != null) {  
      sendErrorResponse(response, ErrorCode.INVALID_TOKEN, HttpServletResponse.SC_UNAUTHORIZED);  
      return;  
    }  

    // 토큰이 존재 하지 않는 경우 다른 응답
    sendErrorResponse(response, ErrorCode.UNAUTHORIZED, HttpServletResponse.SC_UNAUTHORIZED);  
  }  
  
  private void sendErrorResponse(HttpServletResponse response, ErrorCode errorCode, int status)  
      throws IOException {  
    ErrorResponse errorResponse = ErrorResponse.of(status, errorCode);  
  
    String body = objectMapper.writeValueAsString(errorResponse);  
    response.setStatus(status);  
    response.setContentType(MediaType.APPLICATION_JSON_VALUE);  
    response.setCharacterEncoding(StandardCharsets.UTF_8.name());  
    response.getWriter().write(body);  
  }  
  
}

위와 같이 구성하면 인증 실패 원인을 클라이언트에게 명확하게 전송할 수 있다. 따라서 클라이언트는 상황에 맞게 대처할 수 있게 된다.

SecurityConfiguration

이제 우리가 구성한 클래스들을 Spring Security 필터 체인에 추가해 보자. SecurityConfiguration에 다음과 같이 설정한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package com.dukcode.securityboilerplate.global.security.configuration;  
  
@RequiredArgsConstructor  
@EnableWebSecurity(debug = true)  
@Configuration  
public class SecurityConfiguration {  

  // ...
  
  @Bean  
  public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {  
    http.authorizeHttpRequests(  
            request -> request
                .requestMatchers(new AntPathRequestMatcher("/error")).permitAll()
                .requestMatchers(
                    new AntPathRequestMatcher("/member", HttpMethod.POST.name())).permitAll()
                .anyRequest().authenticated()

        )  
        .logout(AbstractHttpConfigurer::disable)  
        .csrf(AbstractHttpConfigurer::disable)  
        .requestCache(RequestCacheConfigurer::disable)  
        .sessionManagement(AbstractHttpConfigurer::disable);  
  
    http.addFilterAt(loginProcessingFilter(authenticationManager()),  
        UsernamePasswordAuthenticationFilter.class);  
    // 로그인 필터 앞에 JWT 인증 필터 추가
    http.addFilterAfter(jwtAuthenticationProcessingFilter(), JsonLoginProcessingFilter.class);  

    // authenticationEntryPoint 등록
    http.exceptionHandling(ex -> ex.authenticationEntryPoint(authenticationEntryPoint()));

  
    return http.build();  
  }  

  // ...

  // JWT 인증 필터 빈 등록
  @Bean  
  public JwtAuthenticationProcessingFilter jwtAuthenticationProcessingFilter() {  
    return new JwtAuthenticationProcessingFilter(jwtService());  
  }  

  // AuthenticationEntryPoint 등록
  @Bean
  public AuthenticationEntryPoint authenticationEntryPoint() {
    return new JwtAuthenticationEntryPoint(objectMapper);
  }

}

다음 포스트에서는 Refresh Token을 도입해 보안을 강화해 볼 것이다.

댓글남기기