[Spring Boot] Spring Security와 Jwt토큰 로그인 (feat. OAuth 2.0 카카오 로그인)

이상협·2023년 2월 9일
3

Spring Boot

목록 보기
11/13

이전 포스팅

현재 카카오 로그인 요청을 통해 카카오 사용자 정보를 조회하는 기능까지 구현이 된 상태이다.
지금부터는 SpringSecurity와 Jwt토큰을 통해 로그인 기능을 구현할 것이다.

1. build.gradle

implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation 'org.springframework.boot:spring-boot-starter-security'

2. JwtProvider.java

Jwt 생성 및 유효성 검증을 하는 컴포넌트이다.

@Slf4j
public class JwtProvider {

    private static final Long accessTokenValidTime = Duration.ofMinutes(30).toMillis(); // 만료시간 30분
    private static final Long refreshTokenValidTime = Duration.ofDays(14).toMillis(); // 만료시간 2주

    // 회원 정보 조회
    public static Long getUserId(String token, String secretKey) {
        return Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(token)
                .getBody()
                .get("userId", Long.class);
    }

    // 토큰 유효 및 만료 확인
    public static boolean isExpired(String token, String secretKey) {

//        try {
            Claims claims = Jwts.parser()
                    .setSigningKey(secretKey)
                    .parseClaimsJws(token)
                    .getBody()
                    ;
            return false;
    }

    // refresh 토큰 확인
    public static boolean isRefreshToken(String token, String secretKey) {

        Header header = Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(token)
                .getHeader();

        if (header.get("type").toString().equals("refresh")) {
            return true;
        }
        return false;
    }

    // access 토큰 확인
    public static boolean isAccessToken(String token, String secretKey) {

        Header header = Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(token)
                .getHeader();

        if (header.get("type").toString().equals("access")) {
            return true;
        }
        return false;
    }

	// access 토큰 생성
    public static String createAccessToken(Long userId, String secretKey) {
        return createJwt(userId, secretKey, "access", accessTokenValidTime);
    }

	// refresh 토큰 생성
    public static String createRefreshToken(Long userId, String secretKey) {
        return createJwt(userId, secretKey,"refresh", refreshTokenValidTime);
    }

    public static String createJwt(Long userId, String secretKey, String type, Long tokenValidTime) {
        Claims claims = Jwts.claims();
        claims.put("userId", userId);

        return Jwts.builder()
                .setHeaderParam("type", type)
                .setClaims(claims)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + tokenValidTime))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact()
                ;
    }
}

3. application.yml

jwt secret 값 저장

jwt:
  secret: {Secret Key}

4. JwtFilter.java

API 요청이 들어왔을때 컨트롤러로 넘어가기전 토큰을 확인해주는 필터함수를 작성한다.

@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends OncePerRequestFilter {

    private final String secretKey;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        String path = request.getServletPath();

        // 로그인일 경우 건너뛰기
        if (
                path.startsWith("로그인 요청 API")
        ) {
            filterChain.doFilter(request, response);
            return;
        }

        final String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
        log.info("authorization : {}", authorization);

        if (authorization == null || !authorization.startsWith("Bearer ")) {
            throw new AuthenticationException(AuthenticationErrorCode.Empty_Authentication);
        }

        // Token 꺼내기
        String token = authorization.split(" ")[1];

        // Token Expired 되었는지 여부
        if (JwtProvider.isExpired(token, secretKey)) {
            filterChain.doFilter(request, response);
            return;
        }

        // UserId Token에서 꺼내기
        Long userId = JwtProvider.getUserId(token, secretKey);
        log.info("userName: {}", userId);

        // 토큰 재발급일 경우 리프레쉬 토큰 확인
        // 위에서 만료됐는지 확인했기 때문에 따로 만료확인 필요 없음
        // 리프레쉬 토큰이 유효한지와 path 정보를 통해 확인이 끝났기 때문에 컨트롤러에서는 바로 토큰 재발행해주고 보내주면 됨
        if (
                !(
                        (path.startsWith("토큰 재발행 API") && JwtProvider.isRefreshToken(token, secretKey))
                        || JwtProvider.isAccessToken(token, secretKey)
                )
        ) {
            // 재발행 요청 api인데, access token을 전달했을 경우
            // 아니면 access token을 넣어줘야하는데, 다른 토큰을 넣었을 경우
            throw new JwtException("");
        }

        // 권한 부여
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userId, null, List.of(new SimpleGrantedAuthority("USER")));

        // Detail을 넣어줌
        authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        log.info("[+] Token in SecurityContextHolder");
        filterChain.doFilter(request, response);
    }
}

로그인 API 요청을 할 경우 토큰이 없어도 request 처리가 되어야 하기 때문에 if문으로 따로 빠져나가도록 한다. (다른 불필요한 로직을 넘기기 위함)

AuthenticationException을 새로 만들었는데, 구글링에 Exception 커스텀 방법 검색하면 나온다.

5. JwtExceptionFilter.java

JwtFilter을 통과하기전에 JwtExceptionFilter를 따로 만들어 토큰이 만료되었거나 토큰이 비었을 경우 Exception을 발생시켜 처리하도록 하였다.

@RequiredArgsConstructor
@Slf4j
public class JwtExceptionFilter extends OncePerRequestFilter {

    private final ObjectMapper objectMapper;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            filterChain.doFilter(request, response);
        } catch(JwtException e) {
            log.error("[-] Invalid Token");

            // 오류 내용 기입
            BaseResponse baseResponse = new BaseResponse(BaseResponseStatus.INVALID_JWT);

            Map<String, Object> errorDetails = setErrorDetails(baseResponse);

            sendErrorMessage(response, errorDetails);
        } catch(AuthenticationException e) {
            log.error(e.getDetailMessage());

            // 헤더에 토큰이 비어있거나 잘못된 정보가 기입되었을 경우
            if (e.getAuthenticationErrorCode().equals(AuthenticationErrorCode.Empty_Authentication)) {
                BaseResponse baseResponse = new BaseResponse(BaseResponseStatus.EMPTY_JWT);

                Map<String, Object> errorDetails = setErrorDetails(baseResponse);

                sendErrorMessage(response, errorDetails);
            }
        }
    }

    // Set Error Json
    private Map<String, Object> setErrorDetails(BaseResponse baseResponse) {
        Map<String, Object> errorDetails = new HashMap<>();

        errorDetails.put("isSuccess", baseResponse.getIsSuccess());
        errorDetails.put("code", baseResponse.getCode());
        errorDetails.put("message", baseResponse.getMessage());

        return errorDetails;
    }

    // Send Error Message to Client
    private void sendErrorMessage(HttpServletResponse response, Map<String, Object> errorDetails) throws IOException {

        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");

        objectMapper.writeValue(response.getWriter(), errorDetails);
    }

이렇게 작성하면 요청된 API 컨트롤러로 넘어가기 전에 바로 클라이언트에게 response를 보낼 수 있다.

6. AuthenticationConfig.java

@Configuration
@EnableWebSecurity
public class AuthenticationConfig {

    OauthService oAuthService;

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

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .httpBasic().disable()
                .csrf().disable()
                .cors().and()
                .authorizeRequests()
                .antMatchers("카카오 로그인 요청 API").permitAll()
                .antMatchers(HttpMethod.GET, "/api/*").authenticated()
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // jwt 사용하는 경우 사용
                .and()
                .addFilterBefore(new JwtFilter(secretKey), UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(new JwtExceptionFilter(objectMapper), JwtFilter.class)
                .build()
                ;
    }
}

여기서 이전에 만든 JwtFilter와 JwtExceptionFilter를 넣어준다.

JwtProvider에서 토큰을 만드는 함수까지 작성했으므로, 따로 토큰 발급 api는 올리지 않겠다.

결과

포스트맨으로 헤더에 토큰을 넣지 않고 요청을 해보았다.

다음과 같이 JwtFilterException에서 Exception을 catch하여 컨트롤러로 넘어가기전에 response보낸 것을 알 수 있다.


토큰을 넣고 API 요청 결과 제대로 잘 날아오는 것을 확인할 수 있다.

참고

2개의 댓글

comment-user-thumbnail
2023년 8월 19일

도움많이 받았습니다!! 혹시 BaseResponse는 따로 클래스를 만드신걸까요??

1개의 답글