
Spring Boot 3.4.1
Spring Security 6.4.2
첨부한 코드는 생략된 부분이 많아 실제로 동작하지 않을 수 있습니다.
YouTube채널 개발자 유미님의 영상을 많이 참고하였습니다. 전반적인 내용을 쉽게 설명해주시니 시청하시길 추천드립니다.
소셜로그인 제공자(구글, 네이버 등)의 설정은 다루지 않습니다. 사용하고자 하는 프로바이더의 Oauth2.0 클라이언트 등록을 먼저 완료하시길 바랍니다.
Oauth2.0 프로토콜의 전반적인 흐름은 동일하지만 구현 방식에 따라 조금 차이가 있을 수 있습니다.
먼저 스프링 시큐리티 Oauth2의 기본적인 동작은 이러합니다.
사용자가 백엔드 서버(나의 API서버)에 Google 로그인 페이지를 요청하면 Google의 로그인 페이지로 사용자를 redirect 시킵니다.
사용자가 로그인에 성공하면 Google에 미리 등록해 두었던 Redirect URI(나의 API서버)로 브라우저를 redirect합니다. 이 때 쿼리파라미터로 code를 제공합니다.(Grant Type이 Authorization Code일 경우)
백엔드서버는 브라우저의 redirect된 요청을 받으면 code를 가지고 Google의 인증서버에서 액세스토큰을 발급 받습니다.
발급 받은 액세스토큰을 이용해 Google의 리소스서버에서 사용자의 정보를 가져오고 후처리를 진행합니다.
http
.oauth2Login(Customizer.withDefaults())
.build();
본격적인 도입에 앞서 SecurityFilterChain에 추가 되는 필터는 다음과 같습니다.
OAuth2AuthorizationRequestRedirectFilter
-> 사용자에게 Google 로그인 페이지를 제공해주기 위한 필터입니다.
-> {baseUrl}/oauth2/authorization/{registrationId}가 엔드포인트로 기본 설정되어 있고, 이 경로로 요청이 들어오면 Google 로그인 페이지로 redirect 해줍니다.
OAuth2LoginAuthenticationFilter
-> 사용자가 로그인을 하면 설정한 Redirect URI로 요청이 오고 이 필터가 실행됩니다. Redirect URI에 포함된 code로 인증서버에서 액세스토큰을 발급 받습니다.
-> {baseUrl}/login/oauth2/code/{registrationId}가 Redirect URI로 기본 설정되어 있습니다.
{registrationId}는 아래에 spring.security.oauth2.client.registration.{registrationId}로 설정된 값입니다. 제 경우에는 google입니다.
저는 application.yml에 다음과 같이 설정해 주었습니다.
spring:
security:
oauth2:
client:
registration:
google:
client-name: google
client-id: 클라이언트ID
client-secret: 클라이언트SECRET
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
authorization-grant-type: authorization_code
scope: profile, email
Google과 같이 유명한 서비스들은 별도로 provider 프로퍼티 쪽은 설정해주지 않아도 된다고 합니다.
다소 복잡한 과정들은 이미 구현되어 있어 제가 직접 구현할 부분은 리소스서버에서 유저정보를 획득하는 OAuth2UserService와 성공핸들러 밖에 없었습니다.
DefaultOAuth2UserService을 상속받으면 리소스서버에서 유저정보를 획득하는 부분도 이미 정의되어 있습니다.
//OAuth2UserService
@Service
@RequiredArgsConstructor
@Transactional
public class OAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
private final HttpServletRequest request;
@Value("${spring.frontend.base-url}")
private String defaultRedirectUrl;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
//로그인 전 페이지 저장
storeSessionRedirectUrl();
OAuth2User oAuth2User = super.loadUser(userRequest);
String registrationId = userRequest.getClientRegistration().getRegistrationId();
// 서비스마다 다르게 객체 생성
OAuth2UserInfo oAuth2UserInfo = null;
if (registrationId.equals("google")) {
oAuth2UserInfo = new GoogleUserInfo(oAuth2User.getAttributes());
} else if (registrationId.equals("naver")){
oAuth2UserInfo = new NaverUserInfo(oAuth2User.getAttributes());
} else {
return null;
}
User user = saveOrUpdateUser(oAuth2UserInfo);
PrincipalDto principal = PrincipalDto.builder()
.id(user.getId())
.username(user.getUsername())
.password(null)
.name(oAuth2UserInfo.getProvider() + "_" + oAuth2UserInfo.getProviderId())
.role(user.getRole().name())
.build();
return new PrincipalDetails(principal);
}
private User saveOrUpdateUser(OAuth2UserInfo oAuth2UserInfo) {
User exist = userRepository.findByEmail(oAuth2UserInfo.getEmail())
.orElse(null);
if (exist != null) {
exist.changeImageUrl(oAuth2UserInfo.getImageUrl());
return exist;
}
return userRepository.save(
User.builder()
.email(oAuth2UserInfo.getEmail())
.username(UUID.randomUUID().toString())
.role(Role.USERNAME_UNSET)
.state(UserState.ACTIVE)
.imageUrl(oAuth2UserInfo.getImageUrl())
.build()
);
}
private void storeSessionRedirectUrl() {
Cookie redirectCookie = CookieUtil.getCookieFromRequest(request, "redirectUrl").orElse(null);
String redirectUrl = redirectCookie == null ? defaultRedirectUrl : redirectCookie.getValue();
request.getSession().setAttribute("redirectUrl", redirectUrl);
}
}
loadUser()메소드를 오버라이드합니다.
부모의 loadUser()를 사용하면 DefaultOAuth2User객체에 유저 정보(attribute)를 담아 반환해줍니다.
여기서 주의해야할 부분은 Oauth2.0 서비스마다 응답으로 받는 attribute의 key, value가 다를 수 있다는 것입니다. 다형성을 활용해 인터페이스 OAuth2UserInfo를 서비스마다 다르게 생성하도록 했습니다.
public interface OAuth2UserInfo {
String getProvider();
String getProviderId();
String getEmail();
String getName();
String getImageUrl();
}
public class GoogleUserInfo implements OAuth2UserInfo {
private final Map<String, Object> attributes;
public GoogleUserInfo(Map<String, Object> attributes) {
this.attributes = attributes;
}
@Override
public String getProvider() {
return "google";
}
@Override
public String getProviderId() {
return attributes.get("sub").toString();
}
@Override
public String getEmail() {
return attributes.get("email").toString();
}
@Override
public String getName() {
return attributes.get("name").toString();
}
@Override
public String getImageUrl() {
return attributes.get("picture").toString();
}
}
마지막으로 DB에 저장/수정들의 작업을 하고 OAuth2User의 구현체 PrincipalDto를 만들어 반환해 주었습니다.
저는 UsernamePassword인증과 통합하기 위해 UserDetails와 OAuth2User를 모두 받아 구현했습니다. 이 글의 내용에서는 OAuth2User만 구현하시면 됩니다.
@RequiredArgsConstructor
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
@Value("${spring.frontend.base-url}")
private String defaultRedirectUrl;
private static final String REDIRECT_URL = "redirectUrl";
private final JwtProvider jwtProvider;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
// 로그인 성공 시 브라우저 쿠키 삭제
Cookie cookie = new Cookie(REDIRECT_URL, null);
cookie.setDomain("localhost");
cookie.setPath("/");
cookie.setMaxAge(0);
response.addCookie(cookie);
PrincipalDetails userDetails = (PrincipalDetails) authentication.getPrincipal();
String role = authentication.getAuthorities()
.iterator().next()
.getAuthority();
String accessToken = jwtProvider.generateToken(ACCESS, userDetails.getId(), role, 10 * 60 * 1000L);
String refreshToken = jwtProvider.generateToken(REFRESH, userDetails.getId(), role, 10 * 60 * 1000L);
response.addCookie(jwtProvider.createJwtCookie("access_token", accessToken));
response.addCookie(jwtProvider.createJwtCookie("refresh_token", refreshToken));
String redirectUrl = request.getSession().getAttribute(REDIRECT_URL).toString();
if (!StringUtils.hasText(redirectUrl)) {
redirectUrl = defaultRedirectUrl;
}
if (role.equals("USERNAME_UNSET")) {
response.sendRedirect("http://localhost:5173/set-username");
} else {
response.sendRedirect(redirectUrl);
request.getSession().removeAttribute(REDIRECT_URL);
}
}
}
성공핸들러를 작성해 등록해주었습니다.
저는 jwt토큰을 발급하고 사용자의 원래 요청으로 리다이렉트 시키는 작업 등을 추가했습니다.
SecurityFilterChain에 설정을 완료해주면 마무리됩니다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig{
private final OAuth2UserService oAuth2UserService;
private final JwtProvider jwtProvider;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig
.userService(oAuth2UserService))
.successHandler(oAuth2SuccessHandler())
)
return http.build();
}
@Bean
public OAuth2SuccessHandler oAuth2SuccessHandler() {
return new OAuth2SuccessHandler(jwtProvider);
}
}