이번에는 소셜로그인 기능을 구현해보려고 합니다.
찾아보니까 크게 2가지 방법이 있는 것 같더라고요
1. 클라이언트에서 Authorization 서버에 API 요청 후 소셜로그인 성공 후 Access Token을 발급받아 백엔드에게 넘겨 백엔드에서 해당 Access Token 으로 Resource 서버에 API 요청을 해 사용자 정보를 받아 로그인 및 회원가입 처리.
2. 스프링 시큐리티 와 Oauth2를 사용하여 백엔드에서 소셜로그인 모든 것을 처리. (이 경우 클라이언트에서는 단순히 백엔드로 소셜로그인 요청만 하면 끝!)
그래서 2번 방법으로 소셜로그인을 진행하려고 합니다.
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
spring.security.oauth2.client.registration.google.client-id={client-id]
spring.security.oauth2.client.registration.google.client-secret={client-secret}
spring.security.oauth2.client.registration.google.scope=profile,email
소셜로그인 종류에 따라 내용이 조금 달라지는 것 같긴한데
Google proeprties는 이러합니다!
1. 클라이언트에서 소셜 로그인 요청
2. 백엔드로 GET /oauth2/authorization/{provider-id}? redirect_uri=http://localhost:3000/oauth/redirect
으로 OAuth 인가 요청
3. Provider 별로 Authorization Code 인증을 할 수 있도록 리다이렉트
Redirect : GET https://oauth.provider.com/oauth2.0/authorize?…
4. 리다이렉트 화면에서 provider 서비스에 로그인
5. 로그인이 완료된 후, Athorization server로부터 백엔드로 Authorization 코드 응답
6. 백엔드에서 인가 코드를 이용하여 Authorization Server에 엑세스 토큰 요청
7. 엑세스 토큰 획득
8. 엑세스 토큰을 이용하여 Resource Server에 유저 데이터 요청
9. 획득한 유저 데이터를 DB에 저장 후, JWT 엑세스 토큰과 리프레시 토큰을 생성
10. 리프레시 토큰은 수정 불가능한 쿠키에 저장하고, 엑세스 토큰은 프로트엔드 리다이렉트 URI 에 쿼리스트링에 토큰을 담아 리다이렉트 (Redirect: GET http://localhost:3000/oauth/redirect?token={jwt-token})
11. 이후 과정은 사용자 인증 과정과 동일
/oauth2/authorization
로 지정되기는 한데userService
: 소셜로그인 성공 이후 처리를 담당한 서비스를 등록합니다.successHandler
: 소셜로그인 성공 이후 처리를 담당한 HandlerfailureHandler
: 소셜 로그인 실패 시 처리를 담당한 Handler (여기 코드에서는 아직 만들기 전이라 없어영)@Service
@RequiredArgsConstructor
public class OAuth2UserService 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 e) {
throw e;
} catch (Exception e) {
e.printStackTrace();
throw new InternalAuthenticationServiceException(e.getMessage(), e.getCause());
}
}
// User 정보 처리 함수
private OAuth2User process(OAuth2UserRequest userRequest, OAuth2User user) {
ProviderType providerType = ProviderType.valueOf(userRequest.getClientRegistration().getRegistrationId().toUpperCase());
OAuth2UserInfo userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(providerType, user.getAttributes());
UserEntity savedUser = userRepository.findBySocialId(userInfo.getId());
// 소셜로그인 한 유저가 DB에 존재한다면 로그인 아니라면 회원가입 처리
if (savedUser != null) {
if (providerType != savedUser.getProviderType()) {
throw new // Exception 발생시켜주세영;
}
} else {
savedUser = createUser(userInfo, providerType);
}
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint()
.getUserNameAttributeName();
return new DefaultOAuth2User(Collections.singletonList(new SimpleGrantedAuthority(RoleType.USER.toString()))
,user.getAttributes(), userNameAttributeName);
}
// 유저 가입
private UserEntity createUser(OAuth2UserInfo userInfo, ProviderType providerType) {
UserEntity user = UserEntity.builder()
.socialId(userInfo.getId())
.providerType(providerType)
.nickname(userInfo.getName())
.build();
return userRepository.save(user);
}
}
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final TokenProvider tokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
private final UserRepository userRepository;
@Override
@Transactional
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
String targetUrl = determineTargetUrl(request, response, authentication);
if (response.isCommitted()) {
logger.debug("Response has already been committed. Unable to redirect to " + targetUrl);
return;
}
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
Optional<String> redirectUri = CookieUtil.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
.map(Cookie::getValue);
String targetUrl = redirectUri.orElse(getDefaultTargetUrl());
OAuth2AuthenticationToken authToken = (OAuth2AuthenticationToken) authentication;
ProviderType providerType = ProviderType.valueOf(authToken.getAuthorizedClientRegistrationId().toUpperCase());
DefaultOAuth2User user = ((DefaultOAuth2User) authentication.getPrincipal());
OAuth2UserInfo userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(providerType, user.getAttributes());
JwtTokenResponse jwtTokenResponse = createToken(userInfo.getId());
String accessToken = jwtTokenResponse.getAccessToken();
String refreshToken = jwtTokenResponse.getRefreshToken();
int cookieMaxAge = tokenProvider.getExpiration(refreshToken).intValue() / 60;
CookieUtil.deleteCookie(request, response, "refresh_token");
CookieUtil.addCookie(response, "refresh_token", refreshToken, cookieMaxAge);
return UriComponentsBuilder.fromUriString(targetUrl)
.queryParam("token", accessToken)
.build().toUriString();
}
private JwtTokenResponse createToken(String socialId) {
UserEntity user = userRepository.findBySocialId(socialId);
if(user == null)
throw new; // Excepion 발생 시켜주세영
JwtTokenResponse jwtTokenResponse = tokenProvider.generateJwtToken(Long.toString(user.getUserId()), RoleType.USER);
RefreshTokenEntity refreshToken = RefreshTokenEntity.builder()
.user(user)
.refreshToken(jwtTokenResponse.getRefreshToken())
.build();
refreshTokenRepository.save(refreshToken);
return jwtTokenResponse;
}
}
Oauth2 관련 코드들이 많지만 핵심적인 코드만 올렸습니다.
먼저 소셜로그인이 성공 했다는 가정하에 진행을 하자면
OAuth2UserService
에서 유저 정보를 가져오게됩니다.
이후 유저가 DB에 존재하는 지 확인 후 로그인 혹은 회원가입을 하게 됩니다.
OAuth2AuthenticationSuccessHandler
에서는 2가지 기능을 하고 있습니다.
1. 로그인 한 사용자를 위해 Access Token, Refresh Token 발급
2. 해당 Target 주소로 Redirect
처음에는 Oauth2 구조만 보고 유저 정보를 가져오기 위해서 Resource Server에 요청하는 별다른 로직을 짜야되나 싶었는데 별다른 구현 없이 바로 유저 정보를 가져올 수 있도록 구성이 되있더라고요!! 되게 좋았던 부분 이였습니다.
소셜로그인으로 회원가입 이후 추가정보를 입력받아 회원가입을 진행을 하는 곳들이 있는데
이것에 대해 많은 구글링을 하였지만 정보가 많이 없어서 ㅠㅠㅠㅠ
그나마 알아낸 정보로 구성을 해보자면
1. 소셜로그인 요청 - 클라이언트
2. userService
에서 회원가입 - 백엔드
3. successHandler
에서 Access Token, Refresh Token 발급 및 Redirect - 백엔드
4. 회원정보 입력 페이지로 이동 - 클라이언트
5. 사용자가 추가정보 입력후 (Access Token 과 함께) 제출 - 클라이언트
6. Access Token 을 이용해 이미 가입된 소셜유저 DB 조회 - 백엔드
7. 추가정보들 업데이트 및 추가 - 백엔드
이런식으로 구성을 하게 되면 사용자 입장에서는 추가정보를 입력하여 회원가입하는 것처럼 보이게됩니다. (실제로는 그 전에 회원가입이 돼있는 상태)
이제 소셜로그인 종류마다 사용자 정보를 불러올 때 실제 값의 key가 다 다르기때문에
소셜로그인 마다 대응해줘야 합니다.
따라서 Factory 패턴을 사용해서 일관적인 사용자 정보를 불러올 수 있게끔 구현해야 합니다.
일단 클라이언트에서 단순히 소셜로그인 요청만 하면 백엔드에서 알아서 다해주니까
크게 뭔가를 구현안해도 된다는것이 클라이언트 측에서는 편한 것 같습니다.
원래 소셜로그인을 인증, 인가 둘다 API 요청으로 하는 방식은 알고있었는데
스프링 시큐리티랑 Oauth2를 사용해서 구현하는 건 처음인 것 같습니다. (물론 이것도 결국에는 API 요청을 보내기는 하는데 ㅋㅋㅋ)
편한 점은 API 요청부분을 알아서 해줘가지고 비즈니스 로직에만 집중하여 구현을 할 수 있어서 되게 좋은 것 같습니다.