[Spring] JWT 를 활용한 인증 시스템 구현 (3)

Sangwon·2023년 8월 30일

JWT 인증 과정

목록 보기
3/3

1편, 2편에서 이어지는 포스팅입니다.

이제 본격적으로 Spring boot에서 JWT 기반 Access Token, Refresh Token 활용 코드를 정리합니다.

참고로, 이 방법 외에도 다양한 구현 방법이 있을 수 있다는 점을 알립니다.

구현 과정

1. Build.gradle 의존성 추가

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

2. SecurityConfig 수정

Spring security 를 사용하고 있어 필요한 부분입니다.

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http.cors()
                .and()
     			.csrf().disable()
                .httpBasic().disable()
                .formLogin().disable()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .exceptionHandling()
                .authenticationEntryPoint(new CustomAuthenticationEntryPoint())
                .and()
                .addFilterBefore(
                        new JwtAuthenticationFilter(jwtTokenProvider, jwtService),
                        UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

몇 가지 설명을 남기면,

  • 세션 기반 인증을 사용하지 않으므로 sessionCreationPolicy 에 STATELESS 옵션을 주었다.
  • 유저 인증이 실패 시 동작을 정의한 CustomAuthenticationEntryPoint 클래스를 만들고 설정한다.
  • 유저 인증 필터인 JwtAuthenticationFilter 클래스를 만들고, 위치를 지정한다.

3. JWTTokenProvider

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {

    @Value("${JWT_SECRET_KEY}")
    private String secretKey;

    @Value("${ACCESS_TOKEN_EXPIRE_TIME}")
    private Long tokenValidTime;

    @Value("${REFRESH_TOKEN_EXPIRE_TIME}")
    private Long refreshTokenValidTime;

    private final UserDetailsService userDetailsService;

    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    public String createToken(String email) {
        Claims claims = Jwts.claims().setSubject(email);
        Date now = new Date();

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + tokenValidTime))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
    }

    public String createRefreshToken() {
        Date now = new Date();

        return Jwts.builder()
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + refreshTokenValidTime))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
    }

    public Authentication getAuthentication(String token) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(getMemberEmail(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    public String getMemberEmail(String token) {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(secretKey)
                    .build()
                    .parseClaimsJws(token)
                    .getBody()
                    .getSubject();
        } catch (ExpiredJwtException e) {
            return e.getClaims().getSubject();
        }
    }

    public String resolveToken(HttpServletRequest request) {
        return JwtHeaderUtils.getAccessToken(request);
    }

    public boolean validateTokenExpiration(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }
}
  • JWT Token을 발급, 유효성 검증 등을 수행하는 메서드들을 작성했습니다.
  • 이 경우 User email로 JWT 토큰을 생성하여 유저 식별 시 email 정보를 활용합니다.
  • getAuthentication()은 Spring security가 활용하는 인증 객체를 만드는 메서드.
  • 향후 Authentication 객체가 유저 식별 과정에서 활용되기 때문에 반드시 구현해야합니다.

4. JwtAuthenticationFilter

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilter {

    private final JwtTokenProvider jwtTokenProvider;

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

        if (token != null && jwtTokenProvider.validateTokenExpiration(token)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        chain.doFilter(request, response);
    }
}
  • SecurityConfig에서 설정한 유저 인증 필터
  • 요청으로 온 Access Token이 유효할 때 Authentication 객체를 SecurityContextHolder에 등록
  • Spring security는 이 Authentication 객체의 여부를 가지고 토큰 검증의 성공 / 실패를 판단
  • 따라서, Access Token이 만료된 경우 토큰 인증이 실패

5. CustomAuthenticationEntryPoint

토큰 인증이 실패할 때 불릴 코드이며 앞서 SpringSecurity에서 등록했던 것을 리마인드합시다.

@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(
            HttpServletRequest request,
            HttpServletResponse response,
            AuthenticationException authException)
            throws IOException, ServletException {
            //Access Token 만료 시 줄 응답에 대한 로직을 자유롭게 작성
    }
}
  • 커스텀하게 어떤 응답을 줄 것인지 로직을 작성하면 됩니다.

6. Access Token 재발급 (ReIssue)

프로젝트에서 사용한 코드는 범용성이 없어 생략하고, 로직의 흐름을 정리한다.

  • 요청으로 전달된 Refresh Token 만료되지 않은 경우, 액세스 토큰을 발급
    (+ Refresh Token 만료 12시간 전인 경우 Refresh Token도 재발급 및 DB 업데이트 수행)
  • 요청으로 전달된 Refresh Token이 만료된 경우, 기존 Refresh Token 무효화 및
    프론트에 적절한 응답 주어 사용자가 다시 로그인 할 수 있도록 함.

결론

2편에서 살펴본 토큰 인증 플로우를 코드로 적용하였습니다.

이 과정에서 spring security를 활용했을 때, 배운 점은 다음과 같습니다.

  • 인증이 언제 이루어지는지? -> JwtAuthenticationFilter 필터 적용
  • 인증을 어떻게 하는지? -> getAuthentication()을 통한 인증 객체 생성 여부로 판단
  • 인증 실패인 경우 어떻게 하는지 -> CustomAuthenticationEntryPoint 객체 적용

추가로, 이후 유저 인증이 끝나면 식별을 어떻게 할 수 있을까요?

  • SecurityContextHolder에 Authentication 객체를 등록했던 것이 기억날 겁니다.
  • 여기서 Authentication객체를 가지고 유저 정보를 받아오면 됩니다.
  • 이 때, PrincipalDetails라는 Spring security에서 유저 정보를 관리하는 객체를 이용하면 됩니다.

부록 - Spring security를 통한 인증 후 유저 식별하는 방법

아래는 프로젝트에서 활용한 ArgumentResolver에서 유저 식별에 사용한 코드입니다.

public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean hasLoginAnnotation = parameter.hasParameterAnnotation(Login.class);
        boolean hasMemberType = User.class.isAssignableFrom(parameter.getParameterType());

        return hasLoginAnnotation && hasMemberType;
    }

    @Override
    public Object resolveArgument(
            MethodParameter parameter,
            ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest,
            WebDataBinderFactory binderFactory)
            throws Exception {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        PrincipalDetails principal = (PrincipalDetails) authentication.getPrincipal();
        return principal.getUser();
    }
}
  • @Login 애노테이션으로 필요한 곳에서 인증된 유저 객체를 가져올 수 있습니다.
  • 중요한 점은 resolveArgument의 코드 처럼 인증된 유저를 가져올 수 있다는 점입니다.
profile
컴퓨터공학을 전공하였고, 현재는 금융업에 종사하며 투자에 관심이 많습니다.

0개의 댓글