개인 프로젝트를 진행 중 로그인 기능이 필요해서 JWT 토큰을 사용한 로그인 기능을 구현해 보겠다.

(해당 프로젝트는 Spring Boot 3.x 버전의 프로젝트로 6.x 버전의 Spring Security를 사용한다.)

 

1. Spring Security, JWT dependency를 추가한다.

// spring security
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'

// jwt
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'

 

2. JWT 토큰 관련된 설정을 application.yml에 설정한다.

  • Jwt Token을 HMAC-SHA 알고리즘을 사용하여 생성할 것이기 때문에 256비트 이상의 키를 사용해야 한다.
    • (만약 256비트 미만의 키를 사용한다면 이 오류를 만나게 될 것이다.)
    • (랜덤한 secret-key가 필요하다면 여기에서 생성하면 된다.)
  • 로그인 시 사용자에게 전달할 액세스 토큰과 리프레시 토큰의 만료기간을 설정한다.
    • 액세스 토큰과 리프레시 토큰은 추후 포스팅 할 예정이다.
jwt:
  secret-key: U9f4wG5wrk1cOhtBh0T2l4BRY8B0/j6H7vRvXmvqMhOcUQstYjAb648Nkr2dDMFn
  expiration-time: 3600000 # 1시간: 1 * 60 * 60 * 1000 = 3600000
  refresh-expiration-time: 10800000 # 3시간: 3 * 60 * 60 * 1000 = 3600000

 

3. 토큰 생성, 검증, 권한 추출 등 토큰 관련된 메소드들을 포함하는 TokenProivder를 구현한다.

@Slf4j
@Component
public class TokenProvider {
    private SecretKey secretKey;
    private String expirationTime;
    private String refreshExpirationTime;

    public TokenProvider(
            @Value("${jwt.secret-key}") String key,
            @Value("${jwt.expiration-time}") String expirationTime,
            @Value("${jwt.refresh-expiration-time}") String refreshExpirationTime) {
        String secret = Base64.getEncoder().encodeToString(key.getBytes());
        this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        this.expirationTime = expirationTime;
        this.refreshExpirationTime = refreshExpirationTime;
    }

    public TokenDto generateToken(Authentication authentication) {
        Date now = new Date();

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

        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())
                .claim("role", authorities)
                .signWith(this.secretKey, SignatureAlgorithm.HS512)
                .setExpiration(new Date(now.getTime() + Long.parseLong(this.expirationTime)))
                .compact();

        String refreshToken = Jwts.builder()
                .signWith(this.secretKey, SignatureAlgorithm.HS512)
                .setExpiration(new Date(now.getTime() + Long.parseLong(this.refreshExpirationTime)))
                .compact();

        return TokenDto.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .build();
    }

    public String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token);
            return true;
        } catch (SecurityException | MalformedJwtException e) {
            log.info("Invalid JWT signature.");
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT token.");
        } catch (UnsupportedJwtException e) {
            log.info("JWT token not supported.");
        } catch (IllegalArgumentException e) {
            log.info("JWT token is invalid");
        }
        return false;
    }

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

        Object authoritiesCliam = claims.get("role");

        Collection<? extends GrantedAuthority> authorities = (authoritiesCliam == null) ?
                AuthorityUtils.NO_AUTHORITIES : AuthorityUtils.commaSeparatedStringToAuthorityList(claims.get("role").toString());

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

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

}

TokenProvider 생성자 : application.yml에 정의한 secretKey, expirationTime을 설정한다.

generateToken() : Authentication 인증 객체에서 사용자 이름, 권한 등 사용자 정보를 토큰에 넣어 생성한다.

(토큰이 탈취될 경우를 고려하여 중요한 정보는 넣지 않는다.)

resolveToken() : API 요청 시 헤더의 Bearer 토큰을 가져와서 액세스 토큰을 추출한다.

validateToken() : 액세스 토큰 유효성 검증 (만료된 토큰, 잘못된 토큰 등)

getAuthentication() : 유효한 토큰으로 API 요청한 경우 토큰에 담긴 사용자 이름, 권한을 포함한 인증객체를 만들어 반환한다.

 

4. API 요청 시 적용할 Jwt 필터를 구현한다.

@RequiredArgsConstructor
public class JwtFilter extends GenericFilterBean {

    private final TokenProvider tokenProvider;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        String accessToken = tokenProvider.resolveToken((HttpServletRequest) request);

        if (StringUtils.hasText(accessToken) && tokenProvider.validateToken(accessToken)) {
            Authentication authentication = tokenProvider.getAuthentication(accessToken);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        chain.doFilter(request, response);
    }
}

1. Spring Security의 WebSecurityConfig에서 Jwt필터를 추가하면 모든 요청은 이 필터를 거치게 된다.

2. RequestHeader에서 액세스 토큰을 추출해 검증을 진행한다.

3. 토큰이 유효한 경우 Authentication 객체를 만들어 SecurityContextHolder에 수동으로 설정해 준다.

(인증 객체를 설정하게 되면 다음 필터인 UsernamePasswordAuthenticationFilter는 인증을 수행하지 않는다.)

 

5. SecurityConfig 작성

@Configuration
@RequiredArgsConstructor
public class WebSecurityConfig {

    private final TokenProvider tokenProvider;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .formLogin(FormLoginConfigurer::disable)
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(
                        session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .authorizeHttpRequests(
                        authorize -> authorize
                                .requestMatchers("/api/v1/login/**").permitAll()
                                .requestMatchers("/api/v1/member/**").permitAll()
                                .anyRequest().authenticated()
                )
                .addFilterBefore(new JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class)
                .exceptionHandling(
                        exception -> exception
                                .accessDeniedHandler(new JwtAccessDeniedHandler())
                                .authenticationEntryPoint(new JwtAuthenticationEntryPoint())
                )
                .build();
    }

}

1. formLogin(FormLoginConfigurer::disable) : 로그인 API를 제공할 것이므로 formLogin을 사용하지 않는다.

2. csrf(AbstractHttpConfigurer::disable) : 세션 쿠키 기반이 아닌 토큰 기반 인증을 구현하는 것이므로 CSRF를 사용하지 않는다.

3. sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

    : 토큰 기반 인증이므로 세션 쿠키 방식의 인증 방식으로 인증을 처리하지 않겠다는 설정이다. STATELESS로 설정 시 Spring Security는 세션을 생성하지 않는다.

4. authorizeHttpRequests(
                        authorize -> authorize
                                .requestMatchers("/api/v1/login/**").permitAll()
                                .requestMatchers("/api/v1/member/**").permitAll()
                                .anyRequest().authenticated()
    )

    : /api/v1/login/**, /api/v1/member/** 엔드포인트로 들어오는 요청은 인증 없이 허용하고 이외의 요청은 인증이 필요하다.

5. addFilterBefore(new JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class)

    : JwtFilter를 UsernamePasswordAuthenticationFilter 앞에 추가한다.

6. exceptionHandling(
                        exception -> exception
                                .accessDeniedHandler(new JwtAccessDeniedHandler())
                                .authenticationEntryPoint(new JwtAuthenticationEntryPoint())
    )

    :  커스텀한 AuthenticationEntryPoint, AccessDeniedHandler 인터페이스를 적용한다.

 

AuthenticationEntryPoint

    : 인증 중 예외 발생 시 AuthenticationException이 발생, AuthenticationEntryPoint에서 예외 처리를 커스텀한다.

AccessDeniedHandler

    : 인가 중 예외 발생 시 AccessDeniedException이 발생, AccessDeniedHandler 인터페이스를 구현하여 예외 처리를 커스텀한다.

 

6. 커스텀 JwtAuthenticationEntryPoint, JwtAccessDeniedHandler 구현

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
            AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.getWriter().write("접근 권한이 없습니다.");
    }
}
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
            AuthenticationException authException) throws IOException, ServletException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        if (authException instanceof UsernameNotFoundException || authException instanceof BadCredentialsException) {
            response.getWriter().write(authException.getMessage());
        }
        else {
            response.getWriter().write("로그인이 필요합니다.");
        }
    }
}

인증, 인가 처리 중 예외 발생 시 HttpServletResponse의 ContentType, Status, 응답 본문을 설정하여 에러 정보를 응답한다.

 

 

여기까지 인증 관련하여 토큰 발급 및 예외 처리가 완료되었다. 다음 포스트에서 로그인 요청 시 인증 수행을 알아보도록 하겠다.

 

끝~