[Spring] SpringBoot 3.X + OAuth2 Client + Spring Security + JWT 소셜로그인 구현(애플, 카카오, 구글) - 3

Chan_hee·2024년 5월 2일

이전 포스트까지는 OAuth2 Client를 활용하여 별도의 컨트롤러 구현없이 소셜로그인 기능을 구현하였습니다. 하지만 단지 로그인 기능만 구현한다면 사용자는 번거롭게 매번 로그인 해주어야 하는 불편함이 존재합니다. 지속적인 로그인 유지를 위해 사용되는 방식으로는 크게 세션방식과 JWT를 활용하는 방식 2가지가 있는데 저는 JWT를 활용하였습니다. 이번 포스트에서는 JWT 엑세스/리프레쉬 토큰 구현 및 어떻게 기능들이 동작하는지 알아보겠습니다.

💡 TokenDto

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TokenDto {
    private String accessToken;
    private String refreshToken;
}

💡 RefreshToken

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table(name = "refresh_token")
@Entity
public class RefreshToken {

    @Id
    private String memberId;
    private String value;

    public void updateValue(String token) {
        this.value = token;
    }
}

💡 RefreshTokenRepository

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, String> {
    Optional<RefreshToken> findByMemberId(String memberId);
}

💡 TokenProvider

@Slf4j
@Component
public class TokenProvider {
    private final MemberRepository memberRepository;

    private static final String AUTHORITIES_KEY = "auth";
    private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30;            // 30분
    private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7;  // 7일
    private final Key key;

    public TokenProvider(@Value("${jwt.secret}") String secretKey,
                         MemberRepository memberRepository) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
        this.memberRepository = memberRepository;
    }

    public TokenDto generateTokenDto(Authentication authentication) {
        // 권한들 가져오기
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();

        // Access Token 생성
        Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())       // payload "sub": "name"
                .claim(AUTHORITIES_KEY, authorities)        // payload "auth": "ROLE_USER"
                .setExpiration(accessTokenExpiresIn)        // payload "exp": 1516239022 (예시)
                .signWith(key, SignatureAlgorithm.HS512)    // header "alg": "HS512"
                .compact();

        // Refresh Token 생성
        String refreshToken = Jwts.builder()
                .setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();

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

    public Authentication getAuthentication(String accessToken) {
        // 토큰 복호화
        Claims claims = parseClaims(accessToken);

        if (claims.get(AUTHORITIES_KEY) == null) {
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }

        // 클레임에서 권한 정보 가져오기
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        // UserDetails 객체를 만들어서 Authentication 리턴
        Member member = memberRepository.findById(Long.parseLong(claims.getSubject())).orElse(null);
        if(member == null)
            throw new CustomRuntimeException(MEMBER_NOT_FOUND_ERREOR);
        UserDetails principal = new PrincipalDetails(member);
        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.info("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.info("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            log.info("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}

🔗 TokenProvider 생성자

🖊️ Secret_key

  • @Value("${jwt.secret}")으로 JWT의 secret_key를 주입합니다.
  • echo '문자열'|base64 을 터미널에 입력하여 인코딩된 secret_key를 application.yml에 아래와 같이 작성합니다.
jwt:
  secret: {secret_key}

🔗 generateTokenDto(Authentication authentication)

  • 현재 Authentication 객체에 있는 이전 포스트에서 구현했던 PrincipalDetails를 활용하여 access/refresh Token을 생성하는 매서드입니다. 이를 위해선 아래와 같이 build.gradle에 의존성을 추가해주어야 합니다.
 dependencies{
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
    }

🖊️ FLOW

  1. authentication의 권한을 가져옵니다.(ROLE_USER, ROLE_GUEST)
  2. 위에서 추가한 jwt 라이브러리를 활용하여 accessToken을 생성합니다.
  3. 그 후 refreshToken을 생성합니다.
  4. TokenDto에 위 두 토큰을 담아 반환합니다.

🔗 getAuthentication 매서드

  • 이 매서드는 매개변수로 받은 accessToken을 활용하여 Authentication 객체를 생성하고 반환하는 매서드입니다. 이 매서드는 추후 구현하는 JWT필터에서 활용됩니다.

🖊️ FLOW

  1. parseClaim 매서드를 활용하여 accessToken의 Claim들을 받아옵니다.
  2. 받아온 Claim에서 권한정보들을 가져옵니다.
  3. Claim에서 userId로 memberRepository에서 해당 멤버를 찾습니다.
  4. 찾은 멤버로 UserDetails의 구현체인 PrincipalDetails를 생성합니다.
  5. UsernamePasswordAuthenticationToken 매서드를 활용하여 Authentication객체를 생성한 후 반환합니다.

여기까지 우선 토큰을 발행하는 과정을 알아보았습니다. 이제 JWT필터를 만들고 Security필터에 우리가 만든 필터를 등록해야합니다. 지금부터는 필터를 구현하는 코드와 필터를 통과했을 경우와 그렇지 않을 경우의 핸들러를 구현해보겠습니다.

💡 JwtFilter

@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String BEARER_PREFIX = "Bearer ";

    private final TokenProvider tokenProvider;

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

        // 1. Request Header 에서 토큰을 꺼냄
        String jwt = resolveToken(request);

        // 2. validateToken 으로 토큰 유효성 검사
        // 정상 토큰이면 해당 토큰으로 Authentication 을 가져와서 SecurityContext 에 저장
        if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
            Authentication authentication = tokenProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    // Request Header 에서 토큰 정보를 꺼내오기
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(7);
        }
        return null;
    }
}
  • TokenProvider에서 구현한 매서드들을 활용하여 토큰의 유효성을 검사하는 JWT필터입니다. 주석처리한 설명부분 봐주시면 충분할 것 같습니다.

💡 JwtAccessDeniedHandler

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    private final ObjectMapper objectMapper = new ObjectMapper();
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        // 필요한 권한이 없이 접근하려 할때 403 FORBIDDEN 응답 생성
        ApiResponse<Object> apiResponse = ApiResponse.onFailure(FORBIDDEN_ERROR);

        // JSON 직렬화
        String jsonResponse = objectMapper.writeValueAsString(apiResponse);

        // 응답 설정
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        response.setStatus(HttpStatus.UNAUTHORIZED.value());

        // 응답 전송
        PrintWriter out = response.getWriter();
        out.println(jsonResponse);
        out.flush();

    }
}

💡 JwtAuthenticationEntryPoint

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper = new ObjectMapper();
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        // 유효한 자격증명을 제공하지 않고 접근하려 할때 401 Unauthorized 응답 생성
        ApiResponse<Object> apiResponse = ApiResponse.onFailure(UNAUTHORIZED_ERROR);

        // JSON 직렬화
        String jsonResponse = objectMapper.writeValueAsString(apiResponse);

        // 응답 설정
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        response.setStatus(HttpStatus.UNAUTHORIZED.value());

        // 응답 전송
        PrintWriter out = response.getWriter();
        out.println(jsonResponse);
        out.flush();
    }
}
  • JWTFilter를 통과하지 못한 경우 Client에게 응답하는 핸들러입니다. 토큰이 유효하지 않을 경우와 적절한 권한이 없을 경우로 나누어 각각 서버에서 만들어 놓은 APiResponse 객체 형식에 맞추어 응답하게 하였습니다.

💡 JwtSecurityConfig

@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    private final TokenProvider tokenProvider;

    // TokenProvider 를 주입받아서 JwtFilter 를 통해 Security 로직에 필터를 등록
    @Override
    public void configure(HttpSecurity http) {
        JwtFilter customFilter = new JwtFilter(tokenProvider);
        http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
    }
}
  • SecurityFilter보다 앞단에서 우리가 구현한 JWTFilter를 적용할 수 있게끔 JwtSecurityConfig클래스에서 등록해줍니다.

💡 OAuth2LoginResDto

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@JsonPropertyOrder({"isGuest", "accessToken", "refreshToken"})
@Builder
public class OAuth2LoginResDto {
    private String accessToken;
    private String refreshToken;
    private boolean isGuest;

    @JsonProperty("isGuest")
    public boolean isGuest(){
        return isGuest;
    }
    @JsonProperty("accessToken")
    public String getAccessToken(){
        return accessToken;
    }
    @JsonProperty("refreshToken")
    public String getRefreshToken(){
        return refreshToken;
    }
}

💡 SecurityConfig

@Configuration
@RequiredArgsConstructor
@EnableMethodSecurity
public class SecurityConfig {
    private final TokenProvider tokenProvider;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
    private final PrincipalOauth2UserService principalOauth2UserService;
    private final RefreshTokenRepository refreshTokenRepository;
    private final AppleProperties appleProperties;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    @Bean
    public OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> accessTokenResponseClient(CustomRequestEntityConverter customRequestEntityConverter) {
        DefaultAuthorizationCodeTokenResponseClient accessTokenResponseClient = new DefaultAuthorizationCodeTokenResponseClient();
        accessTokenResponseClient.setRequestEntityConverter(customRequestEntityConverter);

        return accessTokenResponseClient;
    }
    @Bean
    public CustomRequestEntityConverter customRequestEntityConverter() {
        return new CustomRequestEntityConverter(appleProperties);
    }

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

        http
                .csrf((auth) -> auth.disable())
                .headers(h -> h.frameOptions(f -> f.sameOrigin()))
                .cors((co)->co.configurationSource(configurationSource()))
                .formLogin((auth) -> auth.disable())
                .httpBasic((auth)->auth.disable())
                .authorizeHttpRequests((auth) -> auth
                        .requestMatchers("/swagger", "/swagger-ui.html", "/swagger-ui/**", "/api-docs", "/api-docs/**", "/v3/api-docs/**").permitAll()
                        .requestMatchers("/api/auth/**").permitAll()
                        .anyRequest().authenticated())
                .oauth2Login(oauth2Login -> oauth2Login
                        .tokenEndpoint(tokenEndpointConfig -> tokenEndpointConfig.accessTokenResponseClient(accessTokenResponseClient(customRequestEntityConverter())))
                        .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig.userService(principalOauth2UserService))
                        .successHandler(successHandler()))
                .exceptionHandling((auth)->
                        auth.authenticationEntryPoint(jwtAuthenticationEntryPoint).accessDeniedHandler(jwtAccessDeniedHandler))
                .with(new JwtSecurityConfig(tokenProvider), c-> c.getClass())
                .sessionManagement(sm->sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        return http.build();
    }
    @Bean
    public AuthenticationSuccessHandler successHandler() {
        return (request, response, authentication) -> {
            // PrincipalDetails로 캐스팅하여 인증된 사용자 정보를 가져온다.
            PrincipalDetails principal = (PrincipalDetails) authentication.getPrincipal();

            boolean isGuest = false;
            if(principal.getMember().getAuthority().equals(Authority.ROLE_GUEST))
                isGuest = true;

            // jwt token 발행을 시작한다.
            TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);

            RefreshToken refreshToken = RefreshToken.builder()
                    .memberId(principal.getUsername())
                    .value(tokenDto.getRefreshToken())
                    .build();
            RefreshToken existRefreshToken = refreshTokenRepository.findByMemberId(principal.getUsername()).orElse(null);
            if(existRefreshToken == null)
                refreshTokenRepository.save(refreshToken);
            else {
                existRefreshToken.updateValue(tokenDto.getRefreshToken());
                refreshTokenRepository.save(existRefreshToken);
            }

            OAuth2LoginResDto oAuth2LoginResDto = OAuth2LoginResDto.builder()
                    .accessToken(tokenDto.getAccessToken())
                    .refreshToken(tokenDto.getRefreshToken())
                    .isGuest(isGuest)
                    .build();

            ObjectMapper objectMapper = new ObjectMapper();
            ApiResponse<OAuth2LoginResDto> apiResponse = ApiResponse.onSuccess(SuccessStatus._OK, oAuth2LoginResDto);
            // JSON 직렬화
            String jsonResponse = objectMapper.writeValueAsString(apiResponse);

            // 응답 설정
            response.setContentType("application/json");
            response.setCharacterEncoding("UTF-8");
            response.setStatus(HttpStatus.UNAUTHORIZED.value());

            // 응답 전송
            PrintWriter out = response.getWriter();
            out.println(jsonResponse);
            out.flush();
        };
    }
    @Bean
    public CorsConfigurationSource configurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.addAllowedOriginPattern("*"); // 모든 IP 주소 허용
        configuration.addAllowedHeader("*");
        configuration.addAllowedMethod("*"); // GET, POST, PUT, DELETE (Javascript 요청 허용)
        configuration.setAllowCredentials(true); // 클라이언트에서 쿠키 요청 허용
        configuration.addExposedHeader("Authorization");
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

}
  • 최종적인 SecurityConfig 클래스입니다. 이전 포스트와 비교하여 달라진 점은 filterChain부분에 JWT 핸들러 등록부분 추가와 OAuth2 성공 핸들러가 추가된 부분입니다. JWT 핸들러와 OAuth2 핸들러를 구분을 확실히 할 필요가 있습니다.

❗️ JWT handler vs OAuth2 handler

  • JWT 핸들러는 SecurityFilter와 관련이있습니다. 필터를 성공적으로 통과하지 못하였을때의 로직을 작성한 부분입니다.
  • OAuth2 핸들러는 소셜로그인이 성공한 후의 엔드포인트라고 생각하시면 됩니다.

🔗 successHandler 매서드

  • SecurityConfig 클래스에 작성된 매서드는 OAuth2 success 핸들러입니다.

🖊️ FLOW

  1. PrincipalDetails로 캐스팅하여 Authentication 객체에서 인증된 사용자 정보를 가져온다.
  2. 사용자 정보를 통해 ROLE을 확인하고 적용한다.
  3. 엑세스토큰과 리프레쉬토큰을 TokenProvider 클래스로 생성한다.
  4. 만약 이미 refreshTokenRepository에 해당 멤버Id에 대한 토큰이 존재하지 않는다면 발급한 refreshToken을 저장한다.
  5. 만약 해당 멤버Id에 대한 토큰이 존재하면 발급한 refreshToken을 업데이트 한 후 저장한다.
  6. OAuth2LoginResDto에 토큰정보및 게스트여부를 담아 json형식으로 Client에게 응답한다.

💡 JwtController

@Tag(name = "Jwt 토큰 재발급 API")
@RestController
@RequestMapping("api/auth")
@RequiredArgsConstructor
public class JwtController {
    private final JwtService jwtService;

    @Operation(summary = "토큰 재발급 API", description = "리프레쉬 토큰을 검증한 후 액세스 토큰을 재발급합니다.")
    @PostMapping("/reissue")
    public ResponseEntity<ApiResponse<?>> reissue(@RequestHeader(value = "Authorization") String accessToken, @RequestHeader(value = "refreshToken") String refreshToken){
        //Bearer 접두사 삭제
        accessToken = accessToken.substring(7);
        return jwtService.reissue(accessToken,refreshToken);
    }
}

💡 JwtService

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class JwtService {
    private final TokenProvider tokenProvider;
    private final RefreshTokenRepository refreshTokenRepository;
    @Transactional
    public ResponseEntity<ApiResponse<?>> reissue(String accessToken, String refreshToken){
        // 1. Refresh Token 검증
        if (!tokenProvider.validateToken(refreshToken))
            throw new CustomRuntimeException(BADREQUEST_ERROR);

        // 2. Access Token 에서 Member ID 가져오기
        Authentication authentication = tokenProvider.getAuthentication(accessToken);
        PrincipalDetails principalDetails = (PrincipalDetails)authentication.getPrincipal();

        String memberId = principalDetails.getUsername();
        RefreshToken existRefreshToken = refreshTokenRepository.findByMemberId(memberId).orElse(null);
        if(existRefreshToken == null)
            throw new CustomRuntimeException(BADREQUEST_ERROR);
        // 3. DB에 매핑 되어있는 Member ID(key)와 Vaule값이 같지않으면 에러 리턴
        if(!refreshToken.equals(existRefreshToken.getValue()))
            throw new CustomRuntimeException(BADREQUEST_ERROR);

        // 4. Vaule값이 같다면 토큰 재발급 진행
        TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);

        OAuth2LoginResDto oAuth2LoginResDto = OAuth2LoginResDto.builder()
                .accessToken(tokenDto.getAccessToken())
                .refreshToken(refreshToken)
                .isGuest(principalDetails.getMember().getAuthority().equals(Authority.ROLE_GUEST)?true:false)
                .build();

        return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, oAuth2LoginResDto));
    }
}
  • 위 두 클래스는 accessToken 재발급과 관련된 클래스들입니다. 컨트롤러단에서 accessToken의 접두사를 전처리 해주고 서비스단에서 refreshToken 검증 및 accessToken 재발행을 합니다.

✍🏻 마무리

이로써 길고 긴 Spring Oauth2 Client + JWT를 이용한 소셜로그인 구현이 끝이 났습니다. 처음에는 정리하기 막막하고 귀찮은 과정이었지만 막상 끝내고 나니 두고두고 다시 보면서 정리할 수 있을것 같아 뿌듯합니다.... 읽어주셔서 감사합니다.

0개의 댓글