소셜 로그인 서비스 레이어 구현
이전 글에서 컨트롤러 호출 방법과 컨트롤러까지 구현했다.
컨트롤러에서 Provider 이름과 인증 코드를 받아오기까지는 했으니 이제 아래 그림에서 토큰 발급 부터 진행한다.
1. 필요한 DTO 구현
1-1. 토큰 발급 과정에 필요한 DTO 구현
OauthTokenResponse
프론트에서 받은 인증 코드와 Provider 이름으로 Provider에 토큰 발급을 요청하고, 발급 받은 토큰으로 유저 정보를 요청한다.
토큰 발급 시 토큰 정보를 받을 DTO가 아래 OAuthTokenResponse 이다.
@Getter
public class OAuthTokenResponse {
@JsonProperty("access_token")
private String accessToken;
@JsonProperty("token_type")
private String tokenType;
@Builder
public OAuthTokenResponse(String accessToken, String tokenType) {
this.accessToken = accessToken;
this.tokenType = tokenType;
}
}
1-2. 유저 정보 요청에 필요한 DTO 구현
OAuthAttributes
위에서 발급 받은 토큰으로 Provider에 요청하여 제공 받은 유저 정보를 담는 DTO가 OAuthAttributes 이다.
Provider가 제공하는 유저 정보에 temp_nickname 이라는 값을 추가해서 사용하였다.
Provider마다 제공하는 정보 형식이 달라서 팩토리 메서드로 구현했다.
만약 깃허브나 페이스북 로그인이 추가 된다면 OAuthAttributes 클래스와 서비스 레이어의 검증 로직 정도만 수정하면 될것이다.
@Getter
public class OAuthAttributes {
private String nickname;
private String email;
private String profileImage;
private String provider;
@Builder
public OAuthAttributes(String nickname, String email, String profileImage, String provider) {
this.nickname = nickname;
this.email = email;
this.profileImage = profileImage;
this.provider = provider;
}
public static OAuthAttributes of(String providerName, Map<String, Object> attributes) throws IllegalArgumentException {
switch (providerName) {
case "카카오":
return ofKakao(providerName, attributes);
case "구글":
return ofGoogle(providerName, attributes);
case "네이버":
return ofNaver(providerName, attributes);
default:
throw new IllegalArgumentException("허용되지 않은 접근입니다.");
}
}
public User toUser() {
return User.builder()
.nickname(nickname)
.email(email)
.password("oauth2")
.profileImage(profileImage)
.type(LoginType.fromString(provider))
.build();
}
private static OAuthAttributes ofKakao(String providerName, Map<String, Object> attributes) {
Map<String, Object> account = (Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> profile = (Map<String, Object>) account.get("profile");
return OAuthAttributes.builder()
.nickname((String) attributes.get("temp_nickname"))
.email(account.get("email") + "/" + providerName)
.profileImage((String) profile.get("profile_image_url"))
.provider(providerName)
.build();
}
private static OAuthAttributes ofGoogle(String providerName, Map<String, Object> attributes) {
return OAuthAttributes.builder()
.nickname((String) attributes.get("temp_nickname"))
.email(attributes.get("email") + "/" + providerName)
.profileImage((String) attributes.get("picture"))
.provider(providerName)
.build();
}
private static OAuthAttributes ofNaver(String providerName, Map<String, Object> attributes) {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return OAuthAttributes.builder()
.nickname((String) attributes.get("temp_nickname"))
.email(response.get("email") + "/" + providerName)
.profileImage((String) response.get("profile_image"))
.provider(providerName)
.build();
}
}
2. 서비스 레이어 구현
과정은 아래와 같다.
- 우선 우리 서비스에서 제공하는 소셜 로그인 종류를 검증한다. (네이버, 카카오, 구글)
- oauth2-client에서 제공하는 ClientRegistrationRepository에서 ClientRegistration 객체를 조회한다.
ClientRegistration 객체는 기본 설정 + yml 파일에서 설정한 값을 토대로 oauth2 과정에 필요한 정보를 제공한다. - 인증 코드와 ClientRegistration 객체로 Provider에 액세스 토큰을 요청한다.
- 발급 받은 액세스 토큰으로 Provider에 유저 정보를 요청한다.
- 제공 받은 유저 정보로 회원가입 / 로그인을 진행한다.
AuthService
AuthService의 전문은 아래와 같다.
4번 까지는 소셜 로그인 공통 로직이다.
5번부터는 각자 서비스에 맞는 형태로 구현하면 된다.
본인은 4번에서 제공받은 유저 정보인 OAuthAttributes 에 임시 닉네임 값을 추가하여 사용하였다.
이후 소셜 로그인 시 변경이 있으면 수정하고, 가입되지 않은 유저면 회원가입을 진행하는 로직을 작성했다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AuthService {
private static final String TEMP_NICKNAME_PREFIX = "임시닉네임";
private final ClientRegistrationRepository clientRegistrationRepository;
private final UserRepository userRepository;
@Transactional
public User oauth2Login(String code, String provider) {
validateProvider(provider);
ClientRegistration registration = clientRegistrationRepository.findByRegistrationId(provider);
OAuthTokenResponse tokenResponse = requestAccessToken(code, registration);
User uerProfile = getUerProfile(tokenResponse.getAccessToken(), registration);
return updateOrSave(uerProfile);
}
private void validateProvider(String provider) {
List<String> validProvider = List.of("kakao", "naver", "google");
if (!validProvider.contains(provider)) {
throw new IllegalArgumentException("허용되지 않은 접근입니다.");
}
}
private OAuthTokenResponse requestAccessToken(String code, ClientRegistration registration) {
return WebClient.create()
.post()
.uri(registration.getProviderDetails().getTokenUri())
.headers(header -> {
header.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
header.setAcceptCharset(Collections.singletonList(StandardCharsets.UTF_8));
})
.bodyValue(createTokenRequest(code, registration))
.retrieve()
.bodyToMono(OAuthTokenResponse.class)
.block();
}
private MultiValueMap<String, String> createTokenRequest(String code, ClientRegistration registration) {
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("code", code);
formData.add("grant_type", "authorization_code");
formData.add("redirect_uri", registration.getRedirectUri());
formData.add("client_secret", registration.getClientSecret());
formData.add("client_id", registration.getClientId());
return formData;
}
private User getUerProfile(String accessToken, ClientRegistration registration) {
Map<String, Object> userAttributes = requestUserAttributes(registration, accessToken);
userAttributes.put("temp_nickname", createNonDuplicateNickname());
return OAuthAttributes.of(registration.getClientName(), userAttributes).toUser();
}
private Map<String, Object> requestUserAttributes(ClientRegistration registration, String accessToken) {
return WebClient.create()
.get()
.uri(registration.getProviderDetails().getUserInfoEndpoint().getUri())
.headers(header -> header.setBearerAuth(accessToken))
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.block();
}
private String createNonDuplicateNickname() {
String tempNickname = createRandomNickname();
while (isDuplicateNickname(tempNickname)) {
tempNickname = createRandomNickname();
}
return tempNickname;
}
private String createRandomNickname() {
Random random = new Random();
int randomNumber = random.nextInt(999999) + 1;
return TEMP_NICKNAME_PREFIX + String.format("%06d", randomNumber);
}
private boolean isDuplicateNickname(String nickname) {
return userRepository.findByNickname(nickname).isPresent();
}
private User updateOrSave(User uerProfile) {
User oauth2User = userRepository.findByEmail(uerProfile.getEmail())
.map(user -> user.updateOauth2Profile(uerProfile))
.orElse(uerProfile);
return userRepository.save(oauth2User);
}
}
3. 결과 확인
이전 글에서 네이버 로그인 링크가 아래와 같았다.
또한 컨트롤러 구현 쪽에서 소셜 로그인 성공 시 프론트 URL로 리다이렉트 시키도록 해두었다.
앱 등록을 안해서 등록한 멤버만 위 링크를 사용 가능하겠지만 링크를 눌러 소셜 로그인을 하면
백엔드 서버에 인증 코드가 가고, 백엔드 서버에서 Oauth2 과정을 거쳐 회원 정보를 얻어온 다음 프론트 페이지로 리다이렉트 한다.
우선은 프론트도 로컬에서 작업할거라 localhost:3000으로 리다이렉트 시켰다.
결과는 아래와 같이 소셜로그인 이후 쿠키로 JWT를 발급함을 알 수 있다.
'API 만들어 보기 > 소셜 로그인' 카테고리의 다른 글
소셜 로그인 2: 기본 설정 및 컨트롤러 구현 (0) | 2024.05.09 |
---|---|
소셜 로그인 1: 사전 작업 (0) | 2024.05.08 |