거누의 개발노트
Spring Boot OAuth2.0 적용기 본문
프로젝트가 진행되고 일주일이 지나서야 블로그를 적을 시간이 났다.
내가 맡은 기능은 로그인/회원가입, 소셜 로그인/회원가입 기능을 맡았다.
많은 구글페이지를 찾아봤지만, 아래만한 곳이 없었다.
해당 블로그를 클론 코딩한 Git 레포
https://github.com/geonoo/Oauth2
자세한 코드는 git에서 확인할 수 있습니다.
해당 내용들의 프로세스를 토대로 어떤 코드가 어떤 기능을 하고 삽질했던 것들을 정리해 보려고 한다.
1. 사용자가 소셜 로그인 버튼 클릭!
- http://localhost:8080/oauth2/authorization/{provider-id}?redirect_uri=http://localhost:3000
- 위 URL로 버튼 만들기
2. Redirect : Authorization Code Request with Back-End Redirect Url
리다이렉트와 인가코드를 요청한다.
3. 소셜로그인 할 수 있는 Redirect Url과 인코드를 반환해준다.
4. 소셜로그인을 할 수 있는 로그인 페이지를 제공해준다. ( 해당 로그인 페이지는 카카오, 구글, 네이버에서 열리는 페이지이다. )
카카오라면 이러한 페이지를 볼 수 있다.
5. 사용자는 로그인 및 동의를 진행하면 소셜 인가서버로 내용이 전달이 된다.
6. 소셜 인가서버에서는 인가 코드를 반환해준다.
7. 받은 인가코드와 함께 Access-Token을 요청한다.
8. Access-Token을 받는다.
9. Access-Token와 함께 사용자 정보를 요청한다.
10. 사용자 정보를 받는다.
- Back-End 서버에서는 받은 유저정보를 DB에 저장한다.
- JWT 토큰과 Refresh 토큰을 만든다.
- Refresh토큰을 DB에 저장한다.
11. 마지막으로 다시 프론트로 Redirect 시키면서 만들었던 토큰들도 반환해준다. Refresh토큰을 쿠키에 넣고, JWT토큰은 URL 파라미터로 주는 방식이다.
위 방식이 얼추 이해가 된다면 아래 전체 프로세스를 봤을때, 이해가 될 것이다.
각 개발 사이트의 설정 부터 사용자 정보를 가져오는 부분까지 이해하는데, 많은 시간이 걸렸던 것 같다.
1. 구글 정보는 전부 받아지는데, 카카오, 네이버는 User정보를 보내주는 형식이 달라서 Json을 읽어주는 곳을 수정해야했다.
public class GoogleOAuth2UserInfo extends OAuth2UserInfo {
public GoogleOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return (String) attributes.get("sub");
}
@Override
public String getName() {
return (String) attributes.get("name");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getImageUrl() {
return (String) attributes.get("picture");
}
}
public class KakaoOAuth2UserInfo extends OAuth2UserInfo {
public KakaoOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return attributes.get("id").toString();
}
@Override
public String getName() {
Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");
if (properties == null) {
return null;
}
return (String) properties.get("nickname");
}
@Override
public String getEmail() {
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
return (String) kakaoAccount.get("email");
}
@Override
public String getImageUrl() {
Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");
if (properties == null) {
return null;
}
return (String) properties.get("thumbnail_image");
}
}
public class NaverOAuth2UserInfo extends OAuth2UserInfo {
public NaverOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
if (response == null) {
return null;
}
return (String) response.get("id");
}
@Override
public String getName() {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
if (response == null) {
return null;
}
return (String) response.get("name");
}
@Override
public String getEmail() {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
if (response == null) {
return null;
}
return (String) response.get("email");
}
@Override
public String getImageUrl() {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
if (response == null) {
return null;
}
return (String) response.get("profile_image");
}
}
2. 우리 사이트에서 Email인증을 통해 가입한 유저가 만약 소셜로그인을 하려고 한다면, 같은 이메일 주소라면 바로 소셜로그인이 될 수 있게 하는 로직도 필요했다.
@Service
@RequiredArgsConstructor
@Slf4j
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
@Override
@Transactional
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());
User savedUser = userRepository.findByUserId(userInfo.getId());
//이미 가입된 회원
if (savedUser != null) {
if (providerType != savedUser.getProviderType()) {
throw new OAuthProviderMissMatchException(
"Looks like you're signed up with " + providerType +
" account. Please use your " + savedUser.getProviderType() + " account to login."
);
}
} else {
//이미 로컬로 가입한 회원
Optional<User> optionalUser = userRepository.findByEmail(userInfo.getEmail());
if (optionalUser.isPresent()) {
//소셜로그인 할 수 있게 변경
savedUser = optionalUser.get();
savedUser.updateSocialId(userInfo.getId(), providerType);
}else{
//최초 가입
savedUser = createUser(userInfo, providerType);
}
}
return UserPrincipal.create(savedUser, user.getAttributes());
}
private User createUser(OAuth2UserInfo userInfo, ProviderType providerType) {
User user = new User(
userInfo.getId(),
userInfo.getEmail(),
"Y",
userInfo.getImageUrl(),
providerType,
RoleType.USER
);
log.info("before saveAndFlush");
return userRepository.saveAndFlush(user);
}
}
3. OAuth2AuthenticationSuccessHandler에서 jwt토큰과 refresh토큰을 함께 보내주는 방식을 선택했다. 또한 가입한 유저의 닉네임은 우리 서비스에서 중복을 허용되면 안되기 때문에, 닉네임을 입력할 수 있게 구분해주는 flag를 보내주었다.
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("Sorry! We've got an Unauthorized Redirect URI and can't proceed with the authentication");
}
String targetUrl = redirectUri.orElse(getDefaultTargetUrl());
OAuth2AuthenticationToken authToken = (OAuth2AuthenticationToken) authentication;
ProviderType providerType = ProviderType.valueOf(authToken.getAuthorizedClientRegistrationId().toUpperCase());
OidcUser user = ((OidcUser) authentication.getPrincipal());
OAuth2UserInfo userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(providerType, user.getAttributes());
if (userInfo.getEmail() == null){
throw new CustomException(ErrorCode.NEED_EMAIL);
}
Collection<? extends GrantedAuthority> authorities = ((OidcUser) authentication.getPrincipal()).getAuthorities();
RoleType roleType = hasAuthority(authorities, RoleType.ADMIN.getCode()) ? RoleType.ADMIN : RoleType.USER;
String username = userRepository.findByUserId(userInfo.getId()).getUsername();
Date now = new Date();
AuthToken accessToken = tokenProvider.createAuthToken(
userInfo.getEmail(),
roleType.getCode(),
username,
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.findByEmail(userInfo.getEmail());
if (userRefreshToken != null) {
userRefreshToken.setRefreshToken(refreshToken.getToken());
} else {
userRefreshToken = new UserRefreshToken(userInfo.getEmail(), refreshToken.getToken());
userRefreshTokenRepository.saveAndFlush(userRefreshToken);
}
// 쿠키에 넣지 않고 파라미터로 넘겨주는 방식으로 변경
String nickCheck = (username == null) ? "N" : "Y";
return UriComponentsBuilder.fromUriString(targetUrl)
.queryParam("access", accessToken.getToken())
.queryParam("refresh", refreshToken.getToken())
.queryParam("nick", nickCheck)
.build().toUriString();
}
처음에 예전방식의 카카오 로그인으로 구현했다가, 해당 소스를 보고 작성했던 카카오 로그인은 걷어 내게 되었다.
위 삽질이 없었으면 조금더 빨리 구현할 수 있지 않았을까 라고 생각하지만, 처음 삽질이 있어서 이 로직을 이해하는데, 어느정도 도움이 되었다고 생각한다.
프로젝트를 시작하고 벌써 일주일이 지났다. 앞으로도 이러한 문제들을 해결하면서 꾸준히 작성해 나가야겠다.
'Spring' 카테고리의 다른 글
[Spring] 게시물 검색/조회 리펙토링 하기 - Querydsl, MySQL Full Text Search (0) | 2022.07.20 |
---|---|
Spring Boot - Github Action, S3, EC2, CodeDeploy 연동 (0) | 2022.06.23 |
[Spring] 스프링 컨테이너와 빈 (0) | 2022.06.09 |
[Spring] DI(Dependency Injection)를 사용하는 이유 (1) | 2022.06.07 |
Controller, Service, Repository (0) | 2022.06.07 |