프로젝트에 적용 된 소셜 로그인 FLOW
1️⃣ from
Frontend to
Authorization Server [ 로그인 URL 요청 ]
• redirect_uri : OAuth2 provider가 성공적으로 인증을 완료했을 때 redirect 할 URI를 지정합니다.
(OAuth2의 redirectUri 와는 다름!)
<div onclick="location.href='/oauth2/authorization/naver'">네이버로 시작하기</div>
<div onclick="location.href='/oauth2/authorization/kakao'">카카오로 시작하기</div>
<div onclick="location.href='/oauth2/authorization/google'">구글로 시작하기</div>
2️⃣ endpoint로 인증 요청을 받으면, Spring Security의 OAuth2 클라이언트는 user를 provider가 제공하는 AuthorizationUrl로 redirect 합니다.
http://localhost:8080/oauth2/callback/{provider}
) 그리고 이때 사용자 인증코드 (authroization code) 도 함께 갖고있습니다.@Configuration
@RequiredArgsConstructor
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final CorsProperties corsProperties;
private final AppProperties appProperties;
private final AuthTokenProvider tokenProvider;
private final CustomOAuth2UserService oAuth2UserService;
private final TokenAccessDeniedHandler tokenAccessDeniedHandler;
private final UserRefreshTokenRepository userRefreshTokenRepository;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) //JWT 인증에는 기본적으로 session을 사용하지 않기 때문에 STATELESS
.and()
.csrf().disable()
.httpBasic().disable()
.formLogin().disable()
.exceptionHandling()
.authenticationEntryPoint(new RestAuthenticationEntryPoint())
.accessDeniedHandler(tokenAccessDeniedHandler)
.and()
.authorizeRequests()
.requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
.antMatchers("/", "/favicon.ico", "/**/*.png", "/**/*.gif", "/**/*.svg", "/**/*.jpg", "/**/*.html", "/**/*.css", "/**/*.js").permitAll()
.antMatchers("/register/**").hasAuthority(RoleType.NORMAL.getCode())
.antMatchers("/login").permitAll()
.antMatchers("/userLogout").permitAll()
.antMatchers("/events/**").permitAll()
.antMatchers("/cafes/**").permitAll()
.antMatchers("/posts/**").permitAll()
.antMatchers("/api/**").permitAll()
.antMatchers("/swagger-resources/**").permitAll()
.anyRequest().authenticated() //설정된 값 이외의 나머지 URL, 인증된 사용자, 로그인한 사용자만 볼 수 있음
.and()
.oauth2Login() //Oauth2 로그인 기능에대한 여러가지 설정의 진입점
.authorizationEndpoint()
.baseUri("/oauth2/authorization")
.authorizationRequestRepository(oAuth2AuthorizationRequestbasedOnCookieRepository()) //Authorization request와 관련된 state가 저장됨
.and()
.redirectionEndpoint()//endpoint로 인증요청을 받으면, Spring security의 Oauth2 사용자를 provider가 제공하는 AuthorizationUri로 Redirect
.baseUri("/*/oauth2/code/*") // 이 때, 사용자 인증코드 (authorization code)를 함께 갖고감
.and()
.userInfoEndpoint() //Oauth2 로그인 성공 이후 사용자 정보를 가져올때의 설정 담당
.userService(oAuth2UserService) // 소셜 로그인 성공 시 후속조치를 진행할 UserService인터페이스의 구현체 등록
.and()
.successHandler(oAuth2AuthenticationSuccessHandler()) // JWT authentication token을 만들고, client가 정의한 redirect로 token을 갖고 넘어감
.failureHandler(oAuth2AuthenticationFailureHandler()); // 인증이 실패하면 error코드를 담은 uri를 넘겨줌
http.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/static/css/**, /static/js/**, *.ico");
// swagger
web.ignoring().antMatchers(
"/v2/api-docs", "/configuration/ui",
"/swagger-resources", "/configuration/security",
"/swagger-ui.html", "/webjars/**","/swagger/**", "/swagger-ui/index.html");
}
//auth 매니저 설정
@Override
@Bean(BeanIds.AUTHENTICATION_MANAGER)
protected AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}
//토큰 필터 설정
@Bean
public TokenAuthenticationFilter tokenAuthenticationFilter() {
return new TokenAuthenticationFilter(tokenProvider);
}
//Oauth 인증 실패 핸들러
@Bean
public OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler() {
return new OAuth2AuthenticationFailureHandler(oAuth2AuthorizationRequestbasedOnCookieRepository());
}
//Oauth 인증 성공 핸들러
@Bean
public OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler() {
return new OAuth2AuthenticationSuccessHandler(
tokenProvider,
appProperties,
userRefreshTokenRepository,
oAuth2AuthorizationRequestbasedOnCookieRepository()
);
}
//쿠키 기반 인가 repository, 인가 응답을 연계 하고 검증할 때 사용
@Bean
public OAuth2AuthorizationRequestBasedOnCookieRepository oAuth2AuthorizationRequestbasedOnCookieRepository() {
return new OAuth2AuthorizationRequestBasedOnCookieRepository();
}
//Security 설정 시, 사용할 인코더 설정
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
//Cors 설정
@Bean
public UrlBasedCorsConfigurationSource corsConfigurationSource() {
UrlBasedCorsConfigurationSource corsConfigSource = new UrlBasedCorsConfigurationSource();
CorsConfiguration corsConfig = new CorsConfiguration();
corsConfig.setAllowedHeaders(Arrays.asList(corsProperties.getAllowedHeaders().split(",")));
corsConfig.setAllowedMethods(Arrays.asList(corsProperties.getAllowedMethods().split(",")));
corsConfig.setAllowedOrigins(Arrays.asList(corsProperties.getAllowedOrigins().split(",")));
corsConfig.setAllowCredentials(true);
corsConfig.setMaxAge(corsConfig.getMaxAge());
corsConfigSource.registerCorsConfiguration("/**", corsConfig);
return corsConfigSource;
}
}
3️⃣ Oauth2 에서의 콜백 결과가 에러이면 Spring Security는 OAuth2AuthenticationFailureHanlder 를 호출합니다. (Security Config에 정의함)
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
private final OAuth2AuthorizationRequestBasedOnCookieRepository authorizationRequestRepository;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
String targetUrl = CookieUtil.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
.map(Cookie::getValue)
.orElse(("/"));
exception.printStackTrace();
targetUrl = UriComponentsBuilder.fromUriString(targetUrl)
.queryParam("error", exception.getLocalizedMessage())
.build().toUriString();
authorizationRequestRepository.removeAuthorizationRequest(request, response);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
}
4️⃣ Oauth2 에서의 콜백 결과가 성공이고 사용자 인증코드 (authorization code)도 포함하고 있다면 Spring Security는 access_token
에 대한 authroization code를 교환하고, customOAuth2UserService
를 호출합니다 (Security Config에 정의)
customOAuth2UserService
는 인증된 사용자의 세부사항을 검색한 후에 데이터베이스에 Create를 하거나 동일 Email로 Update 하는 로직을 작성합니다.@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User user = super.loadUser(userRequest);
try {
return this.process(userRequest, user);
} catch (AuthenticationException ex) {
throw ex;
} catch (Exception ex) {
ex.printStackTrace();
throw new InternalAuthenticationServiceException(ex.getMessage(), ex.getCause());
}
}
//인증을 요청하는 사용자에 따라서 없는 회원이면 회원가입, 이미 존재하는 회원이면 업데이트를 실행
private OAuth2User process(OAuth2UserRequest userRequest, OAuth2User user) {
//현재 진행중인 서비스를 구분하기 위해 문자열을 받음
ProviderType providerType = ProviderType.valueOf(userRequest.getClientRegistration().getRegistrationId().toUpperCase());
OAuth2UserInfo userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(providerType, user.getAttributes());
Optional<User> checkUser = userRepository.findByUserEmail(userInfo.getEmail());
User savedUser = checkUser.isEmpty() ? createUser(userInfo, providerType) : checkUser.get();
if (providerType != savedUser.getUserRegPath()) {
throw new OAuthProviderMissMatchException(
"가입 경로가 잘못 되었습니다. " + savedUser.getUserRegPath() + "로 다시 로그인해주세요"
);
}
updateUser(savedUser, userInfo);
return UserPrincipal.create(savedUser, user.getAttributes());
}
//가져온 사용자 정보에 변경이 있다면 업데이트를 실행
private User updateUser(User user, OAuth2UserInfo userInfo) {
if (userInfo.getNickname() != null && !user.getUserNickname().equals(userInfo.getNickname())) {
user.setUserNickname(userInfo.getNickname());
}
if (userInfo.getUserImage() != null && !user.getUserImage().equals(userInfo.getUserImage())) {
user.setUserImage(userInfo.getUserImage());
}
return user;
}
//가져온 사용자 정보를 통해서 회원가입 실행
private User createUser(OAuth2UserInfo userInfo, ProviderType providerType) {
User user = User.builder()
.userEmail(userInfo.getEmail())
.userNickname(userInfo.getNickname())
// .userGender(userInfo.getGender())
.userImage(userInfo.getUserImage())
.userRegPath(providerType)
.userStatus(StatusType.ACTIVATE)
.role(RoleType.NORMAL)
.build();
return userRepository.saveAndFlush(user);
}
}
5️⃣ 마지막으로 oAuth2AuthenticationSuccessHandler
이 불리고 그것이 JWT authentication token을 만들고, access_token과 refresh_token을 cookie에 저장합니다.
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final AuthTokenProvider tokenProvider;
private final AppProperties appProperties;
private final UserRefreshTokenRepository userRefreshTokenRepository;
private final OAuth2AuthorizationRequestBasedOnCookieRepository authorizationRequestRepository;
//oauth2 인증이 성공적으로 이뤄졌을 때 실행됨
//token을 포함한 uri를 생성 후 인증요청 쿠키를 비워주고 redirect 함
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
String targetUrl = determineTargetUrl(request, response, authentication);
if (response.isCommitted()) {
logger.debug("Response has already been committed. Unable to redirect to " + targetUrl);
return;
}
clearAuthenticationAttributes(request, response);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
//token을 생성하고 이를 포함한 프론트엔드의 URI를 생성한다.
protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
Optional<String> redirectUri = CookieUtil.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
.map(Cookie::getValue);
if (redirectUri.isPresent() && !isAuthorizedRedirectUri(redirectUri.get())) {
throw new IllegalArgumentException("잘못된 리다이렉트 경로입니다. 인증을 진행할 수 없습니다.");
}
String targetUrl = redirectUri.orElse("/");
OAuth2AuthenticationToken authToken = (OAuth2AuthenticationToken) authentication;
ProviderType providerType = ProviderType.valueOf(authToken.getAuthorizedClientRegistrationId().toUpperCase());
OidcUser user = ((OidcUser) authentication.getPrincipal());
OAuth2UserInfo userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(providerType, user.getAttributes());
Collection<? extends GrantedAuthority> authorities = ((OidcUser) authentication.getPrincipal()).getAuthorities();
RoleType roleType = hasAuthority(authorities);
Date now = new Date();
AuthToken accessToken = tokenProvider.createAuthToken(
userInfo.getEmail(),
userInfo.getNickname(),
roleType.getCode(),
new Date(now.getTime() + appProperties.getAuth().getTokenExpiry())
);
// refresh 토큰 설정
long refreshTokenExpiry = appProperties.getAuth().getRefreshTokenExpiry();
AuthToken refreshToken = tokenProvider.createAuthToken(
appProperties.getAuth().getTokenSecret(),
new Date(now.getTime() + refreshTokenExpiry)
);
// DB 저장
UserRefreshToken userRefreshToken = userRefreshTokenRepository.findByUserEmail(userInfo.getEmail());
if (userRefreshToken != null) {
userRefreshToken.setRefreshToken(refreshToken.getToken());
} else {
userRefreshToken = new UserRefreshToken(userInfo.getEmail(), refreshToken.getToken());
userRefreshTokenRepository.saveAndFlush(userRefreshToken);
}
int cookieMaxAge = (int) refreshTokenExpiry / 60;
int cookieMaxAgeForAccess = (int) appProperties.getAuth().getTokenExpiry() / 1000;
/*
Access Token 저장
*/
CookieUtil.deleteCookie(request, response, ACCESS_TOKEN);
CookieUtil.addCookieForAccess(response, ACCESS_TOKEN, accessToken.getToken(), cookieMaxAgeForAccess);
/*
Refresh Token 저장
*/
CookieUtil.deleteCookie(request, response, REFRESH_TOKEN);
CookieUtil.addCookie(response, REFRESH_TOKEN, refreshToken.getToken(), cookieMaxAge);
return UriComponentsBuilder.fromUriString(targetUrl)
.build().toUriString();
}
//인증정보 요청 내역에서 쿠키를 삭제
protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
super.clearAuthenticationAttributes(request);
authorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
}
private RoleType hasAuthority(Collection<? extends GrantedAuthority> authorities) {
if (authorities == null) {
return RoleType.NORMAL;
}
for (GrantedAuthority grantedAuthority : authorities) {
if (RoleType.HOST.getCode().equals(grantedAuthority.getAuthority())) {
return RoleType.HOST;
} else if (RoleType.ADMIN.getCode().equals(grantedAuthority.getAuthority())) {
return RoleType.ADMIN;
}
}
return RoleType.NORMAL;
}
//application.oauth.yml을 통해서 등록해놓은 Redirect uri가 맞는지 확인한다.
private boolean isAuthorizedRedirectUri(String uri) {
URI clientRedirectUri = URI.create(uri);
return appProperties.getOauth2().getAuthorizedRedirectUris()
.stream()
.anyMatch(authorizedRedirectUri -> {
// Only validate host and port. Let the clients use different paths if they want to
URI authorizedURI = URI.create(authorizedRedirectUri);
return authorizedURI.getHost().equalsIgnoreCase(clientRedirectUri.getHost())
&& authorizedURI.getPort() == clientRedirectUri.getPort();
});
}
}
- 단, refresh_token의 경우 프론트엔드에서 조작할 수 없도록 httpOnly 설정을 넣어주었고,
access_token은 로그아웃 과정 시, 프론트엔드에서 쿠키에서 토큰을 지워주는 작업을 할 수 있도록 httpOnly 설정을 뺐습니다.
- 또한, www가 붙고 안붙고를 다른 도메인으로 인식하는 문제를 해결하기 위해 쿠키의 도메인을 일괄적으로 지정받을 수 있도록 setDomain을 사용하여 맞춰주었고, 이에 따라 로그아웃 과정도 프론트에서 쿠키를 지우는 것이 아닌 백엔드에서 쿠키 시간을 0로 설정하여 새로 만들 수 있도록 로직을 바꿔주었습니다.
- **CookieUtil.java**
```java
public class CookieUtil {
/*
쿠키에서 access_token을 가져오는 메소드
*/
public static String getAccessToken(HttpServletRequest request) {
Optional<Cookie> cookie = getCookie(request, "access_token");
if (cookie.isEmpty()) {
return null;
}
return cookie.get().getValue();
}
public static Optional<Cookie> getCookie(HttpServletRequest request, String name) {
Cookie[] cookies = request.getCookies();
if (cookies != null && cookies.length > 0) {
for (Cookie cookie : cookies) {
if (name.equals(cookie.getName())) {
return Optional.of(cookie);
}
}
}
return Optional.empty();
}
/*
httpOnly 권한을 풀고 access 토큰을 저장하기 위함
*/
public static void addCookieForAccess(HttpServletResponse response, String name, String value, int maxAge) {
Cookie cookie = new Cookie(name, value);
cookie.setPath("/");
cookie.setHttpOnly(false);
cookie.setMaxAge(maxAge);
response.addCookie(cookie);
}
public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) {
Cookie cookie = new Cookie(name, value);
cookie.setPath("/");
cookie.setHttpOnly(true);
cookie.setMaxAge(maxAge);
response.addCookie(cookie);
}
public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) {
Cookie[] cookies = request.getCookies();
if (cookies != null && cookies.length > 0) {
for (Cookie cookie : cookies) {
if (name.equals(cookie.getName())) {
cookie.setValue("");
cookie.setPath("/");
cookie.setMaxAge(0);
response.addCookie(cookie);
}
}
}
}
public static String serialize(Object obj) {
return Base64.getUrlEncoder()
.encodeToString(SerializationUtils.serialize(obj));
}
public static <T> T deserialize(Cookie cookie, Class<T> cls) {
return cls.cast(
SerializationUtils.deserialize(
Base64.getUrlDecoder().decode(cookie.getValue())
)
);
}
}
public class CookieUtil {
/*
httpOnly 권한을 풀고 access 토큰을 저장하기 위함
*/
public static void addCookieForAccess(HttpServletResponse response, String name, String value, int maxAge) {
Cookie cookie = new Cookie(name, value);
cookie.setPath("/");
cookie.setHttpOnly(false);
cookie.setMaxAge(maxAge);
cookie.setDomain("eventcafecloud.com");
response.addCookie(cookie);
}
public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) {
Cookie cookie = new Cookie(name, value);
cookie.setPath("/");
cookie.setHttpOnly(true);
cookie.setMaxAge(maxAge);
cookie.setDomain("eventcafecloud.com");
response.addCookie(cookie);
}
public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) {
Cookie[] cookies = request.getCookies();
if (cookies != null && cookies.length > 0) {
for (Cookie cookie : cookies) {
if (name.equals(cookie.getName())) {
cookie.setValue("");
cookie.setPath("/");
cookie.setMaxAge(0);
cookie.setDomain("eventcafecloud.com");
response.addCookie(cookie);
}
}
}
}
}