기본적인 이론을 모두 정리했으니 직접 구현해봅시다.
Spring Security에서 제공하는 로그인 기능이 아닌
OAuth2를 사용하는 로그인 기능을 구현해보겠습니다.
( form 로그인 방법도 노션에 정리는 해뒀지만,,, OAuth2 보다 작성할 내용이 많아 추후에 하겠습니다. )
구현할 로그인 과정의 대략적인 순서는 다음과 같습니다.
- 클라이언트에서 Spring Boot 서버로 로그인을 요청합니다.
- 클라이언트에게 OAuth2 로그인 redirect url을 전달하여 로그인을 시도합니다.
- 로그인에 성공하면 OAuth2 리소스 서버에서 사용자의 정보를 가져와 Refresh JWT 를 발급합니다.
- 발급된 Refresh JWT를 Redis 에 저장하여 관리하고, 클라이언트에게 쿠키(Http only) 형태로 전달해줍니다.
- 클라이언트는 Refresh JWT를 통해 Access JWT를 발급받아 사용합니다.
이번 포스트에서는 클라이언트에서 로그인 요청부터
로그인에 성공해서 사용자의 정보를 가져오는 것 까지의 과정을 정리해보겠습니다.
OAuth2 등록 및 설정
원하는 OAuth2 제공 서비스의 문서를 참고하여 애플리케이션을 등록합니다.
아래 블로그는 구글 소셜 로그인을 설정하는 방법이 잘 정리되어 있어 참고하시면 좋을 것 같습니다!
application.properties 파일에 OAuth2 설정을 입력합니다.
#registration
spring.security.oauth2.client.registration.서비스명.client-name=서비스명
spring.security.oauth2.client.registration.서비스명.client-id=서비스에서 발급 받은 아이디
spring.security.oauth2.client.registration.서비스명.client-secret=서비스에서 발급 받은 비밀번호
spring.security.oauth2.client.registration.서비스명.redirect-uri=서비스에 등록한 우리쪽 로그인 성공 URI
spring.security.oauth2.client.registration.서비스명.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.서비스명.scope=리소스 서버에서 가져올 데이터 범위
#provider
spring.security.oauth2.client.provider.서비스명.authorization-uri=서비스 로그인 창 주소
spring.security.oauth2.client.provider.서비스명.token-uri=토큰 발급 서버 주소
spring.security.oauth2.client.provider.서비스명.user-info-uri=사용자 정보 획득 주소
spring.security.oauth2.client.provider.서비스명.user-name-attribute=응답 데이터 변수
SecurityConfig를 설정합니다.
이후 구현에 맞게 변경되는 설정들이 있습니다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
//csrf disable
http
.csrf((auth) -> auth.disable());
//From 로그인 방식 disable
http
.formLogin((auth) -> auth.disable());
//HTTP Basic 인증 방식 disable
http
.httpBasic((auth) -> auth.disable());
//세션 설정 : STATELESS
http
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
//oauth2
http
.oauth2Login(Customizer.withDefaults());
//경로별 인가 작업
http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/").permitAll()
.anyRequest().authenticated());
// 등등의 설정들 ...
return http.build();
}
}
구현
앞서 대략적인 로그인 과정을 말씀드렸는데,
이번에는 동작 하나씩 살펴보며 구현해보겠습니다.
사용 스택 : Vue 3, Spring Boot 3.xx, Spring Security 6.xx, JWT 0.12.3, OAuth 2.0 Client
- (Client) OAuth2 로그인 url 중에서 원하는 url을 선택해 하이퍼링크로 로그인을 시도합니다.
- Server로 HTTP 요청을 보내는 것이 아니라 하이퍼 링크를 사용하는 이유는 OAuth2 인증서버의 CORS 정책 때문입니다.
- 관습적으로 아래와 같은 url을 사용합니다.
window.location.href = "http://localhost:8080/oauth2/authorization/서비스명";
- (Server) OAuth2AuthorizationRequestRedirectFilter에 의해서 해당 url로 등록된 OAuth2 인증서버로 리디렉트 됩니다.
- application.properties 파일에서 provider 설정 중 authorization_uri
- (OAuth2 인증서버) 로그인을 성공하면 OAuth2 인증 서버에서 등록된 애플리케이션 서버의 URL로 권한부여 코드를 파라미터(혹은 바디)로 함께 리디렉션합니다.
-
- application.properties 파일에서 registration 설정 중 redirect-uri
-
- (Server) OAuth2LoginAuthenticationFilter가 요청을 가로채고 OAuth2LoginAuthenticationProvider에 의해서 OAuth2 인증 서버에 코드를 전달하여 OAuth2 리소스 서버로 접근할 수 있는 Access 토큰을 받아오고 Access 토큰을 통해 리소스 서버에 접근해서 해당 사용자의 정보를 받아옵니다.
- OAuth2 설정만 잘 해두면 자동적으로 동작됨.
- 이 Access 토큰을 저장하는 등의 커스텀이 가능하지만, Access 토큰을 클라이언트로 옮기는 등은 권장하지 않는 방법
- (Server) 리소스 서버에서 받아온 사용자의 정보는 OAuth2UserService의 구현체인 DefaultOAuth2UserService를 상속받은 클래스를 구현해 필요한 비즈니스 로직을 수행한 후 OAuth2User 의 구현체로 반환합니다.
저는 여기서 DB와 연동해 회원이 존재하는지를 파악하고 데이터를 추가하거나 업데이트 해줍니다.
-
@Service @RequiredArgsConstructor @Transactional(readOnly = true) public class CustomOAuth2UserService extends DefaultOAuth2UserService { private final UserRepository userRepository; @Transactional @Override public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { // 부모 클래스인 DefaultOAuth2UserService 를 통해 // 리소스 서버로부터 받아온 OAuth2User 객체를 불러옵니다. OAuth2User oAuth2User = super.loadUser(userRequest); // 서비스 명 획득 String registrationId = userRequest.getClientRegistration().getRegistrationId(); OAuth2Response oAuth2Response = null; // 각각의 리소스 서버가 반환해주는 데이터가 다르기 때문에 전처리 작업을 해줌. if (registrationId.equals("google")) { oAuth2Response = new GoogleResponse(oAuth2User.getAttributes()); } else { return null; } String oauthData = oAuth2Response.getProvider() + " " + oAuth2Response.getProviderId(); // DB 조회 및 생성 Users existData = userRepository.findByOauthData(oauthData); if (existData == null) { Users user = Users.createUserWithOAuth2(oAuth2Response.getName(), oAuth2Response.getEmail(), oauthData); userRepository.save(user); UserInfoDTO userInfo = new UserInfoDTO(Role.USER.value(), oAuth2Response.getName(), oauthData ); return new CustomOAuth2User(userInfo); } else { // 더티 체킹 existData.updateUserInfo(oAuth2Response.getName(), oAuth2Response.getEmail()); UserInfoDTO userInfo = new UserInfoDTO(existData.getRole(), oAuth2Response.getName(), oauthData); return new CustomOAuth2User(userInfo); } } }
-
@RequiredArgsConstructor public class CustomOAuth2User implements OAuth2User { private final UserInfoDTO userInfoDTO; @Override public Map<String, Object> getAttributes() { return null; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { Collection<GrantedAuthority> collection = new ArrayList<>(); collection.add(new GrantedAuthority() { @Override public String getAuthority() { return userInfoDTO.getRole(); } }); return collection; } @Override public String getName() { return userInfoDTO.getName(); } public String getOAuthData() { return userInfoDTO.getOauthData(); } public String getUsername(){ return userInfoDTO.getName(); } }
- 또한 기존의 OAuth2UserService를 대체하기 위해서 WebConfig 설정을 추가해줍니다.
@Configuration @EnableWebSecurity @RequiredArgsConstructor public class WebSecurityConfig { private final CustomOAuth2UserService customOAuth2UserService; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .oauth2Login((oauth2) -> oauth2 .userInfoEndpoint((userInfoEndpointConfig) -> userInfoEndpointConfig .userService(customOAuth2UserService)); // ... return http.build(); } }
-
- (Server) SimpleUrlAuthenticationSuccessHandler를 상속받아 성공적인 로그인 시 Refresh JWT를 발급합니다.
-
@Component @RequiredArgsConstructor public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { // 이 두 클래스는 편의를 위해 구현했습니다. // JWT 생성 등의 유틸 기능 private final JwtUtil jwtUtil; // JWT 를 데이터베이스와 연동하기 위한 비즈니스 로직을 포함하는 서비스 private final JWTService jwtService; @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { // Refresh 토큰 발급 CustomOAuth2User customUserDetails = (CustomOAuth2User) authentication.getPrincipal(); String oauthData = customUserDetails.getOAuthData(); String name = customUserDetails.getName(); Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities(); Iterator<? extends GrantedAuthority> iterator = authorities.iterator(); GrantedAuthority auth = iterator.next(); String role = auth.getAuthority(); String refresh = TokenType.REFRESH.value(); String refreshToken = jwtUtil.createJwt(refresh, role, name, oauthData, JwtExpiredMs.REFRESH.value()); // 생성된 Refresh 토큰을 Redis 데이터베이스에 저장함. jwtService.addRefreshToken(name, oauthData, refreshToken); response.addCookie(HttpHeaderUtil.createCookie(refresh, refreshToken)); response.setStatus(HttpStatus.PERMANENT_REDIRECT.value()); response.sendRedirect("http://localhost:3000/issue/accessToken"); } }
- 이 성공 핸들러도 WebConfig 파일에 추가합시다.
@Configuration @EnableWebSecurity @RequiredArgsConstructor public class WebSecurityConfig { private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; // 보안 필터 체인 @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http //== OAuth2 .oauth2Login((oauth2) -> oauth2 .userInfoEndpoint((userInfoEndpointConfig) -> userInfoEndpointConfig .userService(customOAuth2UserService)) .successHandler(oAuth2LoginSuccessHandler)); // ... return http.build(); } }
-
동작 원리
추가적으로 보다 정확하고 자세한 동작 원리를 알기 위해서는 빌드를 이용하셔도 좋습니다.
빌드를 통해 OAuth2AuthorizationRequestRedirectFilter와 OAuth2LoginAuthenticationFilter의 동작을 하나씩 살펴보면 됩니다!!
( 저도 처음에 보다 정확한 이해를 위해 클래스들을 타고타고 들어가 기능을 확인하니 이해가 빠르게 됐습니다. )
이 과정을 하게되면 훨씬 정확하게 OAuth2 로그인 과정을 이해하고 추가적인 커스텀 또한 가능합니다!
아래 블로그에 이해하기 쉽게 작성되어 있어서 이 글을 참고하셨으면 좋겠습니다!!
주의할 점
이번 포스트에서는 CORS 설정과 Http Header 설정 등을 하지 않았지만,
구현할 때 필요에 따라 해당 설정들을 추가할 필요가 있습니다.
Reference
OAuth2AuthorizationRequestRedirectFilter (spring-security-docs 6.2.3 API)
OAuth2LoginAuthenticationFilter (spring-security-docs 6.2.3 API)
https://substantial-park-a17.notion.site/Docs-002024551c294889863d0c7923590568
https://www.callicoder.com/spring-boot-security-oauth2-social-login-part-1/
'WEB > Spring' 카테고리의 다른 글
RestControllerAdvice로 전역에서 발생된 예외 처리하기. (0) | 2024.05.01 |
---|---|
Spring Security 프레임워크로 로그인에 성공하면 JWT 발급하기 (0) | 2024.04.17 |
Spring Security가 뭐죠? (0) | 2024.03.24 |
[Spring JPA] IncorrectResultSizeDataAccessException (0) | 2023.12.05 |
[Spring Boot] API Docs를 자동으로 만들어보자! (1) | 2023.11.25 |