Post

Spring 3.0 Jwt 로그인 구현하기 with. Spring Security

JWT 로그인을 구현하면서 우여곡절이 많았습니다…

이 글에서는 현재까지 제가 이해한 JWT 구현 방법을 설명하고자 합니다!

JWT 작동 구조

alt text

사용자 검증은 총 2단계로 이루어져 있습니다.

  1. Access Token 검증

  2. ID 및 Password 검증

Access Token에 대한 검증은 JWT Filter에서, ID 및 Password에 대한 처리는 UsernamePasswordAuthenticationFilter 클래스에서 처리됩니다.

Spring Security의 filter에 생성한 JWT Filter를 추가하는 방식으로 Filter를 적용 시킵니다.

[ Spring boot 및 JAVA 버전 ] Spring 3.3.3 & Java 17


build.gradle 및 환경 변수 설정

build.gradle

1
2
3
4
5
6
7
// jwt
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'

// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'

application.yml

1
2
3
4
5
6
7
8
jwt:
  secret: ${jwt.secret}
  access:
    expiration: ${jwt.access.expiration}
    header: ${jwt.access.header}
  refresh:
    expiration: ${jwt.refresh.expiration}
    header: ${jwt.refresh.header}

application.properties

1
2
3
4
5
jwt.secret=(대충 긴 암호 코드)
jwt.access.expiration=86400 (24 * 60 * 60 -> 1시간)
jwt.refresh.expiration=1209600000 (24 * 60 * 60 * 1000 * 14 -> 14일)
jwt.access.header=Authorization
jwt.refresh.header=Authorization-refresh

암호 코드는 터미널에

1
openssl rand -hex 64

을 입력하거나, https://randomkeygen.com/ 에 접속해서 암호키를 복사/붙여넣기 합니다!

.yml 과 .properties 파일 모두 /src/main/resource 폴더 밑에 만들어주면 됩니다. (모르는 사람은 없겠지만..)


JWT 로그인 구현하기

JWT 로그인을 구현하는데 필요한 파일입니다.

  • JwtToken: Access Token과 Refresh Token을 저장할 DTO 클래스입니다.
  • JwtProvider: JWT 기능을 구현할 Service 클래스입니다
  • JwtAuthenticationFilter: Spring Security의 filter가 실행되기 전 이 filter 클래스가 실행되어 클라이언트가 보낸 Header로부터 Access Token을 추출하여 사용자를 검증하고 그에 맞는 권한을 부여합니다.
  • JwtAuthentication: 사용자 권한 및 정보에 대한 Service 클래스입니다. Authentication 클래스를 상속받습니다.
  • JwtAuthEntryPoint: 권한이 없는 사용자가 접근할 시, 예외를 발생시키는 클래스입니다. AuthenticationEntryPoint 클래스를 상속받습니다. (이 때, 403 Forbidden 오류가 발생하게 되고, 이를 커스텀 하기 위해서는 AccessDeniedHandler를 상속받는 클래스에서 Override를 해주면 됩니다. 이 글에서는 다루지 않았습니다.)

JwtToken.class

1
2
3
4
5
6
7
@Getter
@Builder
@AllArgsConstructor
public class JwtToken {
    private String accessToken;
    private String refreshToken;
}

JwtProvider.class

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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
@Component
@Slf4j
@RequiredArgsConstructor
public class JwtProvider implements AuthenticationProvider {
    @Value("${jwt.secret}")
    private String secret;
    @Value("${jwt.access.expiration}")
    private long accessTokenValidityInSeconds;
    @Value("${jwt.refresh.expiration}")
    private long refreshTokenValidityInSeconds;
    @Value("${jwt.access.header}")
    private String accessHeader;
    @Value("${jwt.refresh.header}")
    private String refreshHeader;

    private static final String ACCESS_TOKEN_SUBJECT = "AccessToken";
    private static final String REFRESH_TOKEN_SUBJECT = "RefreshToken";
    private static final String USERNAME_CLAIM = "email";
    private static final String BEARER = "bearer ";
    public static final String AUTHORIZATION = "Authorization";

    private final UserRepository userRepository;
    private final ObjectMapper objectMapper;

    public JwtToken issue(User user){
        return JwtToken.builder()
                .accessToken(createAccessToken(user.getId().toString(), user.getUserRole()))
                .refreshToken(createRefreshToken())
                .build();
    }

    public JwtAuthentication getAuthentication(String accessToken) {
        Jws<Claims> claimsJws = validateAccessToken(accessToken);

        Claims body = claimsJws.getBody();
        Long userId = Long.parseLong((String) body.get("userId"));
        UserRole userRole = UserRole.of((String) body.get("userRole"));

        return new JwtAuthentication(userId, userRole);
    }

    @Override
    public String createAccessToken(String userId, UserRole userRole) {
        Claims claims = Jwts.claims();
        claims.put("userId", userId);
        claims.put("userRole", userRole);

        return Jwts.builder()
                .setSubject(ACCESS_TOKEN_SUBJECT)
                .setClaims(claims)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + accessTokenValidityInSeconds * 1000))
                .signWith(SignatureAlgorithm.HS256, secret)
                .compact();
    }

    @Override
    public String createRefreshToken() {
        return Jwts
                .builder()
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + refreshTokenValidityInSeconds * 1000))
                .signWith(SignatureAlgorithm.HS256, secret)
                .compact();
    }

    @Override
    public boolean isTokenValid(String token) {
        try {
            Jwts.parser()
                .setSigningKey(secret)
                .parseClaimsJws(token);
            return true;
        } catch (ExpiredJwtException e) {
            throw new RuntimeException("TOKEN EXPIRED");
        } catch (JwtException e) {
            throw new RuntimeException("TOKEN INVALID");
        }
    }

    public Jws<Claims> validateAccessToken(String token) {
        try {
            return Jwts.parser()
                    .setSigningKey(secret)
                    .parseClaimsJws(token);
        } catch (ExpiredJwtException e) {
            throw new RuntimeException("TOKEN EXPIRED");
        } catch (JwtException e) {
            throw new RuntimeException("TOKEN INVALID");
        }
    }

    public String getAccessTokenFromHeader(HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if (cookie.getName().equalsIgnoreCase("access-token")) {
                    return cookie.getValue();
                }
            }
        }

        String header = request.getHeader(AUTHORIZATION);
        if (header != null) {
            if (!header.toLowerCase().startsWith(BEARER)) {
                throw new RuntimeException();
            }
            return header.substring(7);
        }

        return null;
    }
}

JwtAuthenticationFilter.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Component
@Slf4j
@AllArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtProvider jwtProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request);
        ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response);

        String accessToken = jwtProvider.getAccessTokenFromHeader(request);
        if (accessToken != null) {
            Authentication authentication = jwtProvider.getAuthentication(accessToken);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(requestWrapper, responseWrapper);
        responseWrapper.copyBodyToResponse();
    }
}

JwtAuthentication.class

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
public class JwtAuthentication implements Authentication {
    @Getter
    private Long userId;
    private UserRole userRole;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        for (String authority : userRole.getName().split(",")) {
            authorities.add(() -> authority);
        }
        return authorities;
    }

    @Override
    public Object getCredentials() {
        return userId;
    }

    @Override
    public Object getDetails() {
        return userId;
    }

    @Override
    public Object getPrincipal() {
        return userId;
    }

    @Override
    public boolean isAuthenticated() {
        return true;
    }

    @Override
    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {

    }

    @Override
    public String getName() {
        return null;
    }

}

JwtAuthEntryPoint.class

1
2
3
4
5
6
7
8
9
@Component
public class JwtAuthEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
    }
}

Spring Security

JWT에 필요한 모든 클래스를 작성하였으니, 이제 Spring Security에서 JWT를 등록합니다.

SecurityConfig.class

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
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {
    private final JwtProvider jwtProvider;

    @Bean // filter 등록
    public JwtAuthenticationFilter jwtAuthenticationFilter() {
        return new JwtAuthenticationFilter(jwtProvider);
    }

    @Bean // 인증이 안 된 사용자 접근 시 Endpoint 예외 발생
    public JwtAuthEntryPoint jwtAuthEntryPoint() {
        return new JwtAuthEntryPoint();
    }

    @Bean // 사용자 비밀번호 암호화
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean // JWT와는 상관 없지만 front-end에서 back-end로 API 호출 시 발생하는 cors 에러를 해결하는 코드
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();

        config.setAllowCredentials(true);
        config.addAllowedOriginPattern("*");
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
        config.setAllowedHeaders(List.of("*"));

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http,
                                           HandlerMappingIntrospector introspector) throws Exception {
        return http
                .cors(httpSecurityCorsConfigurer -> corsConfigurationSource()) // cors error 해결
                .csrf(AbstractHttpConfigurer::disable) // url 공격에 취약하기 때문에 비활성화
                .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(
                        SessionCreationPolicy.STATELESS)) // JWT가 Stateless한 방식으로 작동하기 때문에 비활성화
                .formLogin(AbstractHttpConfigurer::disable) // 따로 form 로그인을 하지 않기 때문에 비활성화
                .httpBasic(AbstractHttpConfigurer::disable) // token을 사용할 것이기 때문에 비활성화

                ...

                .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) // UsernamePasswordAuthenticationFilter 전에 Jwt filter 추가
                .build();
    }



여기까지 Spring Security & JWT 로그인 구현 방법에 대해서 작성해 보았습니다.
처음 JWT를 접하고 이에 대한 개념을 이해하기까지 꽤 오랜 시간이 걸렸습니다.. 거의 완벽하게 이해했다고 생각했는데 글을 작성하면서 다시 한 번 개념이 부족했던 부분에 대해서 확실하게 알게 된 것 같아 역시 블로그 글을 작성하길 잘했다고 생각했습니다!

자세한 구현 코드가 궁금하신 분들은 여기를 클릭해주세요!

참고 링크

하나, ,

This post is licensed under CC BY 4.0 by the author.