Spring Security - OAuth 로그인 시 AuthenticationSuccessHandler를 활용한 JWT 발급 및 반환 ( OAuth-1 )

TopOfTheHead·2026년 5월 25일

Spring OAuth

목록 보기
9/11

서버 접속OAuth Provider로그인 페이지리디렉션하는 컨트롤러 정의하기

@Override
    @GetMapping("/google/login")
    public void redirectToGoogle(
          @RequestParam(required = true) String redirect_uri,
          @RequestParam(required = true) String state,
          HttpServletResponse httpServletResponse
    ) throws IOException {
        OauthHolderSingleton instance = OauthHolderSingleton.getInstance();
        instance.setRedirect_uri(redirect_uri);
        instance.setState(state);
        httpServletResponse.sendRedirect("/oauth2/authorization/google");
    }

이벤트 핸들러 ( Event Hanlder )
이벤트 발생 시 이벤트에 대응하는 로직을 수행하는 객체
ex ) OAuth 로그인 성공 시 성공 이벤트 / 실패 이벤트를 발생

SimpleUrlAuthenticationSuccessHandler
로그인 성공사용자를 특정 URL리다이렉트하는 역할을 수행하는 Spring Security클래스
▶ 가장 자주 사용되는 AuthenticationSuccessHandler 중 하나.

。해당 이벤트 핸들러상속하는 이벤트 핸들러 클래스 정의 및 로그인토큰클라이언트에게 반환하거나 실패를 알리는 로직을 작성

public class Oauth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        super.onAuthenticationSuccess(request, response, authentication);
    }
}
  • onAuthenticationSuccess( HttpServletRequest, HttpServletResponse, Authentication )
    사용자로그인이 성공한 순간 자동으로 실행되는 콜백 메서드

    DefaultOAuth2UserService.loadUser()가 호출되어 인증된 이후에 동작하며, 이를 통해 Security Context에 등록된 Authentication 구현체매개변수로 전달
    ▶ 해당 Authentication 구현체Principal인증된 사용자UserDetails 구현체가 포함되어있음.
@Override
    public void onAuthenticationSuccess(
            HttpServletRequest request,
            HttpServletResponse response,
            Authentication authentication
    ) throws IOException, ServletException {
        CurrentUser principal = (CurrentUser) authentication.getPrincipal();
    }

▶ 해당 [Authentication 구현체]

。 주로 해당 메서드에서 로그인 성공JWT 토큰을 발급하여 브라우저응답하는 로직을 작성

JWT 토큰을 반환하는 경우 super.onAuthenticationSuccess(request, response, authentication);를 정의하지 말아야한다.
▶ 이를 호출 시 특정 URL리디렉션을 수행하므로, JWT 토큰 반환 시 문제가 발생할 수 있음.

OAuth 로그인 기능 , JWT 토큰 발급 기능을 활용하여 사용자가 OAuth 로그인사용자에게 백엔드 서버에서 JWT 토큰 발급 후 반환하는 로직 작성

@Repository
public interface MemberRepository extends JpaRepository<Member,Long> {
    Optional<Member> findById(Long id);
    default Member findByIdOrThrow(Long id){
        return findById(id).orElseThrow(
                () -> new RuntimeException("해당 계정을 찾을 수 없습니다.")
        );
    }
    Optional<Member> findByEmail(String email);
    default Member findByEmailOrThrow(String email){
        return findByEmail(email).orElseThrow(
                () -> new RuntimeException("적합하지 않은 이메일입니다.")
        );
    }
}
  • UserDetails 역할의 OAuth2User 구현체 정의
    OAuth2User를 상속한 DTO 클래스 정의

    로그인 이후 OAuth Provider에서 전송한 데이터를 포함하도록 구성

    정적 팩토리 메서드 패턴을 사용하여 Entity 수신 시 UserDetails 구현체매핑
    객체 생성을 도메인이 주도함으로써 사용자Entity 간 매핑을 고려하지 않도록 설정이 가능
    객체 변환이므로, 메서드명 : from()을 지정
@Accessors(chain = true)
@Getter
public class CurrentUser implements OAuth2User {
    @Setter
    private Long id;
    private String email;
    private String name;
    @Setter
    private Role role;
    private Map<String,Object> attributes;
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(
                new SimpleGrantedAuthority(this.role.getValue())
        );
    }
    @Builder
    private CurrentUser(String email, String name, Map<String, Object> attributes){
        this.email = email;
        this.name = name;
        this.attributes = attributes;
    }
    public static CurrentUser from(Member member){
        if(member == null) throw new IllegalArgumentException("MEMBER_NOT_FOUND");
        return CurrentUser.builder()
                .name(member.getName())
                .email(member.getEmail())
                .build()
                // MemberDetails 내 @Accessor 을 통해
                // setter에서 객체를 반환하도록 설정.
                .setId(member.getId())
                .setRole(member.getRole());
    }
}
  • Service Layer 정의 및 회원가입 로직 작성
    Service Layer에서 DefaultOAuth2UserService 상속 및 OAuth 로그인 성공 시 로직에서 loadUser() 메서드 참고하여 구현

    Factory 패턴 클래스를 통해 각 OAuth Provider에서 반환하는 자원 응답 구조에 대해 대응하면서 커스텀 OAuth2User 구현체를 생성하도록 설정

    。추가로 ID를 기반으로 계정을 찾아 CurrentUser를 생성하여 반환하는 메서드를 작성
    JWT 검증 필터에서 검증 이후 Authentcation 구현체를 생성하여 Security Context Holder에 등록 시 활용됨
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService extends DefaultOAuth2UserService {
    private final MemberRepository memberRepository;
    @Override
    @Transactional
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);
        String provider = userRequest.getClientRegistration().getRegistrationId();
        CurrentUser currentUser = MemberServiceFactory.currentUser(provider, oAuth2User);
        Member foundedMember;
        try{
            foundedMember = memberRepository.findByEmailOrThrow(currentUser.getEmail());
        } catch ( RuntimeException e ){
            // email에 해당하는 Member가 없는 경우 회원가입
            foundedMember = memberRepository.save(
                    Member.builder()
                            .name(currentUser.getName())
                            .email(currentUser.getEmail())
                            .provider(provider)
                            .build()
            );
        }
        if (foundedMember.getProvider().equals(provider)){
            currentUser.setId(foundedMember.getId());
            currentUser.setRole(foundedMember.getRole());
            return currentUser;
        } else{
            throw new RuntimeException("중복 이메일입니다.");
        }
    }
	public CurrentUser loadCurrentUserById(Long id){
        return CurrentUser.from(
                memberRepository.findByIdOrThrow(id)
        );
    }
}

。 반환된 OAuth2User 구현체 ( = UserDetails )는 이후 Authentication 구현체Principal에 포함되어 SecurityContextHolder의 현재 스레드SecurityContext에 저장됨
▶ 이후 SimpleUrlAuthenticationSuccessHandler에 의해 조회되어 토큰 발급 시 활용됨

  • SecurityConfiguration 정의
    JWT 기반 인증을 수행하므로, Http BasicForm based Login을 비활성화

    JWT 기반 인증Session을 사용하지 않으므로, 필터체인에서 sessionManagement를 통해 세션 관리 전략STATELESS로 설정하여 세션이 생성되지 않도록 설정

    CORS 관련 설정을 위해 auth.requestMatchers(CorsUtils::isPreFlightRequest).permitAll()를 통해 모든 Preflight Request에 대해 허용하도록 설정
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
    private final Oauth2SuccessHandler oauth2SuccessHandler;
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity){
        return httpSecurity
                .csrf(csrf->csrf.disable())
                .cors(Customizer.withDefaults())
                .httpBasic(httpBasic -> httpBasic.disable())
                .formLogin(form -> form.disable())
                .sessionManagement(
                        config -> config.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .authorizeHttpRequests(
                        auth -> auth
                                .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
 								.requestMatchers("/admin/**").hasAuthority("ADMIN")
                                .requestMatchers("/member/**").hasAuthority("MEMBER")
                                .anyRequest().authenticated()
                )
                .build();
    }
}

SimpleUrlAuthenticationSuccessHandler를 정의하여 로그인 성공 시 JWT 토큰을 반환하는 이벤트 핸들러 클래스 정의하기
。해당 클래스SimpleUrlAuthenticationSuccessHandler를 상속 및 onAuthenticationSuccess() 구현 콜백 메서드를 구현

인증이 완료된 이후 SecurityContextAuthentication 구현체PrincipalDefaultOAuth2UserService.loadUser()를 통해 등록된 OAuth2User 구현체를 가져와서 활용


1. 로그인 성공 시 URLAccess TokenRefresh Token을 포함 후 리디렉션을 통해 클라이언트에게 응답하는 경우

  • YML 파일클라이언트 URL 정의
custom:
  jwt:
    redirection:
      base: http://localhost:3000/auth
  • 이벤트 핸들러 클래스 정의
@Component
@RequiredArgsConstructor
public class Oauth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    private final TokenProvider tokenProvider;
    @Value("${custom.jwt.redirection.base}")
    private String BASE_URL;
    @Override
    public void onAuthenticationSuccess(
            HttpServletRequest request,
            HttpServletResponse response,
            Authentication authentication
    ) throws IOException, ServletException {
        //  인증이 완료된 현재 사용자의 Authentication 구현체에서 Pringcipal를 추출 후 OAuth2User 구현체로 캐스팅
        CurrentUser currentUser = ( authentication.getPrincipal() instanceof CurrentUser c )? c : null ;
        if(currentUser == null) throw new RuntimeException("알 수 없는 사용자 정보입니다.");
        // Access Token / Refresh Token 발급
        KeyPair keyPair = tokenProvider.issueKeyPair(
                currentUser.getId(),
                currentUser.getRole()
        );
        // Header에 각각 Access Token과 Refresh Token을 포함 및
        // Client의 Base URL에 QueryString으로서 AccessToken과 RefreshToken을 포함한 후 리디렉션을 수행
        response.setHeader("X-Access-Token", keyPair.accessToken());
        response.setHeader("X-Refresh-Token", keyPair.refreshToken());
        getRedirectStrategy().sendRedirect(request,response,getUrlStr(keyPair));
    }
    private String getUrlStr(KeyPair keyPair){
        return UriComponentsBuilder.fromUriString(BASE_URL)
                .queryParam("access", keyPair.accessToken()) // QueryString
                .queryParam("refresh",keyPair.refreshToken())
                .build()
                .toUri()
                .toString();
    }
}

CurrentUser currentUser = (CurrentUser) authentication.getPrincipal();를 통해 현재 로그인한 사용자Authentication 구현체에서 UserDetails 구현체를 가져와서 토큰을 발급
▶ 해당 UserDetailsloadUser()에 의해 Authentication 구현체에 포함되어 SecurityContextHolder에 저장 된 것

getRedirectStrategy().sendRedirect(request,response,URL);을 통해 URL 리디렉션을 수행
URLQuery Parameter에 발급한 JWT 토큰Query String으로 포함 후 클라이언트 URL로 반환하여 리디렉션

http://localhost:3000/auth?
access=eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwicm9sZSI6Ik1FTUJFUiIsImlhdCI6MTc4MDE0MzY1NCwiZXhwIjoxNzgwMTQ1NDU0fQ.aJt_cmpCXLK_seje2A9q8MyQWjD_N8MROvA4qMN46CQv1NAF-Ef94yt-2YTa21fc5rbvcAq8jrRlCOnxIgMMbw
&
refresh=eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwicm9sZSI6Ik5PTkUiLCJpYXQiOjE3ODAxNDM2NTQsImV4cCI6MTc4MDc0ODQ1NH0.C-LBrSA8c73eB2ByHGvX7EVlgf6poGcHlAifm36STL7_IHYKIkwIfU8id3ym2QXJBdeWP5_5TZCYpKyAwuHLUA
  1. 로그인 성공 시 Access TokenAuthorization Header / Refresh TokenHttpOnlySecure flagf가 적용된 Cookie에 저장 후 클라이언트에게 리디렉션하는 경우
    JWT 토큰 발급 및 쿠키로 반환하는 경우
@Component
@RequiredArgsConstructor
public class Oauth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    private final TokenProvider tokenProvider;
    private final JwtProperties jwtProperties;
    private static final String COOKIE_NAME = "RT";
    @Value("${custom.jwt.redirection.base}")
    private String BASE_URL;
    @Override
    public void onAuthenticationSuccess(
            HttpServletRequest request,
            HttpServletResponse response,
			// DefaultOAuth2UserService.loadUser() 가 호출되어 인증된 이후의 Authentication 구현체가 전달
            Authentication authentication
    ) throws IOException, ServletException {
        //  인증이 완료된 현재 사용자의 Authentication 구현체에서 Pringcipal를 추출 후 OAuth2User 구현체로 캐스팅
        CurrentUser currentUser = ( authentication.getPrincipal() instanceof CurrentUser c )? c : null ;
        if(currentUser == null) throw new RuntimeException("알 수 없는 사용자 정보입니다.");
        // Access Token / Refresh Token 발급
        KeyPair keyPair = tokenProvider.issueKeyPair(
                currentUser.getId(),
                currentUser.getRole()
        );
        // Header에 Access Token을 포함 및 쿠키에 Refresh Token을 포함한 이후 리디렉션 수행
        Cookie cookie = new Cookie(COOKIE_NAME, keyPair.refreshToken());
        cookie.setMaxAge(jwtProperties.getValidations().getRefresh());
        cookie.setSecure(true);
        cookie.setHttpOnly(true);
        cookie.setPath("/");
        response.addCookie(cookie);
        // Authorization Header에 AccessToken을 포함
        response.setHeader(
                HttpHeaders.AUTHORIZATION,
                keyPair.accessToken()
        );
        getRedirectStrategy().sendRedirect(request,response,BASE_URL);
    }

쿠키수명RT 수명과 동일하게 설정

쿠키RT가 포함됨을 확인 가능

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
    private final Oauth2SuccessHandler oauth2SuccessHandler;
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity){
        return httpSecurity
                .oauth2Login(
                        oauth2 -> oauth2.successHandler(oauth2SuccessHandler)
                )
			///
}

Http ResponseResponse BodyToken을 담아서 반환하는 경우
ObjectMapper를 활용해서 HttpServletResponse 객체JSON 직렬화 후 포함해서 응답

@Component
@RequiredArgsConstructor
public class OauthSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    private final TokenProvider tokenProvider;
    private final ObjectMapper objectMapper;
    private final JwtProperties jwtProperties;
    private static final String REFRESH_TOKEN_NAME = "RT";
    //
    @Override
    public void onAuthenticationSuccess(
            HttpServletRequest request,
            HttpServletResponse response,
            Authentication authentication
    ) throws IOException, ServletException {
        CurrentUser principal;
        if(authentication.getPrincipal() instanceof CurrentUser c) principal = c;
        else {
            throw new BusinessException(
                    ErrorCode.AUTHENTICATION_ERROR,
                    "Authentication 객체에서 형변환 중 오류가 발생했습니다."
            );
        }
        //
        KeyPair keyPair = tokenProvider.issueKeyPair(
                principal.getEmail(),
                principal.getRole()
        );
        //
        Cookie cookie = new Cookie(REFRESH_TOKEN_NAME, keyPair.refreshToken());
        cookie.setMaxAge(jwtProperties.getValidations().getRefresh());
        cookie.setHttpOnly(true);
        cookie.setSecure(true);
        cookie.setPath("/");
        response.addCookie(cookie);
        //  
        response.setStatus(HttpServletResponse.SC_OK);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        //
        objectMapper.writeValue(
                response.getWriter(),
                Map.of(
                    "success", "true",
                    "data", Map.of(
                            "accessToken", keyPair.accessToken(),
                                "tokenType", "Bearer"
                        )
                )
        );
    }
}
profile
공부기록 블로그

0개의 댓글