서버 접속시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 Layer작성
。Member 정보 참고@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 Basic과Form 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() 구현 콜백 메서드를 구현
。인증이 완료된 이후SecurityContext의Authentication 구현체의Principal에DefaultOAuth2UserService.loadUser()를 통해 등록된OAuth2User 구현체를 가져와서 활용
1.로그인성공 시URL에Access Token과Refresh 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 구현체를 가져와서토큰을 발급
▶ 해당UserDetails는loadUser()에 의해Authentication 구현체에 포함되어SecurityContextHolder에 저장 된 것
。getRedirectStrategy().sendRedirect(request,response,URL);을 통해URL리디렉션을 수행
▶URL의Query 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
로그인성공 시Access Token은Authorization Header/Refresh Token은HttpOnly과Secure 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가 포함됨을 확인 가능
Spring Filter Chain에이벤트핸들러 클래스등록
oauth2.successHandler(핸들러클래스)를 통해필터체인에핸들러 클래스를 등록한다.@Configuration @RequiredArgsConstructor public class SecurityConfig { private final Oauth2SuccessHandler oauth2SuccessHandler; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity){ return httpSecurity .oauth2Login( oauth2 -> oauth2.successHandler(oauth2SuccessHandler) ) /// }
Http Response의Response Body에Token을 담아서 반환하는 경우
。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" ) ) ); } }