본문 바로가기
부트캠프 개발일기/Main-Project

99일차: Main-Project Day 6 (Refresh Token, Cookie, 인증 에러 처리)

by shyun00 2023. 7. 5.

현재 로그인, 인증부분을 구현하는중이다.

 

인증과정에서 토큰시간만료, 잘못된 토큰 등 에러가발생하면 OncePerRequestFilter를 상속받은 필터를 통해 에러를 기록하고

AuthenticationEntryPoint를 구현한 클래스에서 에러 처리를 해주었다. (이전글 참고)

확실히 한번 해봤던 부분을 다시 해보니 개념이 좀 더 명확해지기도 했고, 중복되는 부분을 제거하고 더 간결하게 코드를 작성할 수 있었다.

2023.06.22 - [부트캠프 개발일기/Pre-Project] - 90일차 - Pre-Project Day 10-1 (인증 에러 처리: AuthenticationFailureHandler, AuthenticationEntryPoint)

 

그리고 오후에는 프론트엔드 담당자분이 보안과 관련해서 어떻게 하는게 좋을지 얘기해보자고 했다.

이때까지는 수업에서 배운대로 로그인에 성공하면 액세스 토큰, 리프레시 토큰을 발급해 Response Header에 넣어서 전송했었다.

 

그러면 응답 헤더에서 해당 내용을 바로 확인할 수 있으므로 토큰 탈취 등 보안 이슈가 생길 수 있다.

따라서 유효기간이 짧은 Access Token은 그대로 헤더에 넣어 전송하고, Refresh Token은 쿠키에 넣어서 전송하는걸 고려했다.

옵션이 없는 쿠키는 XSS, CSRF 공격에 취약하다. 하지만 HttpOnly, secure 설정을 통해 보안을 강화할 수 있다.

 

HttpOnly는 스크립트상에서는 접근이 불가능하도록 해준다.

secure는 Https 통신시에만 해당 쿠키를 사용할 수 있도록 해준다.

SameSite 옵션은 Strict, Lax 모드가 있어 도메인 범위 혹은 페이지 범위에 따라 쿠키 사용여부가 달라진다.

 

로그인이 완료된 후 Refresh 토큰을 전달하는 방식을 cookie를 사용한 방식으로 변경하였다.

+ 추가(7.7.): 프론트 서버와 통신하는 과정에서 쿠키가 저장되지 않는 오류가 생겨 ResponseCookie로 타입을 변경하고 일부 설정 변경.

// 변경 전 코드
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    ...

    @Override
    protected void successfulAuthentication(HttpServletRequest request,
                                            HttpServletResponse response,
                                            FilterChain chain,
                                            Authentication authResult) {
        Member member = (Member) authResult.getPrincipal();

        String accessToken = delegateAccessToken(member);

        response.setHeader("Authorization", "Bearer " + accessToken);
        response.addCookie(createCookie(member)); // Refresh 토큰을 쿠키에 전달한다.
    }

    ...

    private String delegateRefreshToken(Member member) {
        String subject = member.getLoginId();
        Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());

        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());

        String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);

        return refreshToken;
    }
    
    private Cookie createCookie(Member member){
        String cookieName = "RefreshToken";
        String cookieValue = delegateRefreshToken(member);

        Cookie cookie = new Cookie(cookieName, cookieValue);

        cookie.setHttpOnly(true);
        cookie.setSecure(true);
        cookie.setPath("/");
        cookie.setMaxAge(60*60*24); // 유효기간을 1일로 설정하였다.
        return cookie;
    }
}
// 변경 후 코드
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    ...

    @Override
    protected void successfulAuthentication(HttpServletRequest request,
                                            HttpServletResponse response,
                                            FilterChain chain,
                                            Authentication authResult) {
        Member member = (Member) authResult.getPrincipal();

        String accessToken = jwtTokenizer.delegateAccessToken(member);

        response.setHeader("Authorization", "Bearer " + accessToken);
        response.addHeader("Set-Cookie", createCookie(member).toString()); // 수정
    }

    private ResponseCookie createCookie(Member member) { // 수정
        String cookieName = "RefreshToken";
        String cookieValue = jwtTokenizer.delegateRefreshToken(member);

        ResponseCookie cookie = ResponseCookie.from(cookieName, cookieValue)
                .httpOnly(true)
                .secure(true)
                .path("/")
                .maxAge(Duration.ofDays(1))
                .sameSite("None")
                .build();

        return cookie;
    }
}
// CORS 설정
@Bean
CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration configuration = new CorsConfiguration();
    configuration.setAllowedOriginPatterns(Arrays.asList("*"));;
    configuration.setAllowedMethods(Arrays.asList("GET","POST", "PATCH", "DELETE", "OPTIONS"));
    configuration.setAllowedHeaders(Arrays.asList("*"));
    configuration.addExposedHeader("Authorization");
    configuration.addExposedHeader("Set-Cookie"); // 추가
    configuration.setAllowCredentials(true); // 추가

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

[결과확인]

로그인 요청을 보냈을때 Refresh토큰이 헤더에 저장되는것이 아니라 set-cookie로 되어있는것을 확인할 수 있다.

 

참고자료

JWT의 개념, 프론트엔드에서 해야할일

[Spring] jwt 토큰을 더 안전하게!(Refresh Token, Cookie)

(Spring Security + JWT) Refresh Token을 통한 토큰 재발급에 대해서

Spring Boot Servlet Filter에서 에러 코드 변경하기

Spring Security Jwt - 예외처리 커스터마이징