본격적으로, 프로젝트에 사용된 OAuth 2.0 로그인 관련 클래스 코드를 설명드리고자 합니다!
들어가기 전
OAuth 관련 패키지 구조는 다음과 같습니다.
OAuth2Service를 생성하기 위해 spring-boot-starter-oauth2-client 라이브러리를 사용합니다.
build.gradle에 다음과 같이 의존성을 추가해줍시다!
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
📖 OAuth 2.0 관련 클래스 생성
1. DefalutOAuth2User를 상속한 User 클래스 - CustomOAuth2User
이후에 OAuth2UserService에서 사용할 OAuth2User 객체를 커스텀한 클래스입니다.
OAuth2UserService에서 기본으로 반환되는 OAuth2User 객체에서 추가할 필드가 있어서,
커스텀하여 OAuth2UserService에서 커스텀한 CustomOAuth2User를 반환하도록 할 예정입니다.
DefaultOAuth2User를 상속받고, email과 role 필드를 추가로 가집니다.
email과 role 필드를 따로 커스텀하여 추가하는 이유는 다음과 같습니다.
※ email : OAuth 로그인 시 처음 로그인일 경우, 내 서비스에서 Resource Server가 제공하지 않는
정보가 필요할 경우에, Resource Server가 아닌 내 서비스에서 해당 정보를 사용자에게 입력 받아야합니다.
(ex - 사는 도시, 나이 등)
이때, 어떤 유저가 OAuth 로그인한지 내 서비스의 서버 입장에서는 알 수가 없으므로
OAuth 로그인 시 임의의 Email을 생성하여
AccessToken을 발급받아서 회원 식별용으로 AccessToken을 사용합니다.
(이후에 OAuth2LoginSuccessHandler에서 해당 이메일로 Token 발급 & 처리)
※ role : OAuth 로그인 시 위의 추가 정보(사는 도시, 나이 등)을 입력했는지 (처음 OAuth 로그인인지)를
판단하기 위해 필요합니다.
처음 로그인하는 유저를 Role.GUEST로 설정하고,
이후에 추가 정보를 입력해서 회원가입을 진행하면, Role.USER로 업데이트하는 식으로 설정했습니다.
이렇게 하면, OAuth 로그인 회원 중 Role.GUEST인 회원은 처음 로그인이므로
SuccessHandler에서 추가 정보(사는 도시, 나이 등)를 입력하는 URL로 리다이렉트합니다.
(이후에 OAuth2LoginSuccessHandler에서 해당 이메일로 Token 발급 & 처리)
※ 결국, CustomOAuth2User를 구현하는 이유는,
Resource Server에서 제공하지 않는 추가 정보들을 내 서비스에서 가지고 있기 위함입니다.
따라서, Resourc Server에서 제공하는 정보만 사용해도 된다면
굳이 CustomOAuth2User를 구현하지 않고,
일반 DefalutOAuth2User를 사용하면 됩니다!
🎯 CustomOAuth2User 전체 코드
/**
* DefaultOAuth2User를 상속하고, email과 role 필드를 추가로 가진다.
*/
@Getter
public class CustomOAuth2User extends DefaultOAuth2User {
private String email;
private Role role;
/**
* Constructs a {@code DefaultOAuth2User} using the provided parameters.
*
* @param authorities the authorities granted to the user
* @param attributes the attributes about the user
* @param nameAttributeKey the key used to access the user's "name" from
* {@link #getAttributes()}
*/
public CustomOAuth2User(Collection<? extends GrantedAuthority> authorities,
Map<String, Object> attributes, String nameAttributeKey,
String email, Role role) {
super(authorities, attributes, nameAttributeKey);
this.email = email;
this.role = role;
}
}
✅ CustomOAuth2User 생성자 부분
super()로 부모 객체인 DefaultOAuth2User를 생성하고,
email과 role 파라미터를 추가로 받아서, 주입하여 CustomOAuth2User를 생성합니다.
2. OAuth DTO 클래스 - OAuthAttributes
각 소셜에서 받아오는 데이터가 다르므로,
소셜별로 받는 데이터를 분기 처리하는 DTO 클래스입니다.
코드가 길기 때문에, 전체 코드를 먼저 보여드리고, 부분적으로 설명하도록 하겠습니다.
🎯 OAuthAttributes 전체 코드
package login.oauthtest4.global.oauth2;
import login.oauthtest4.domain.user.Role;
import login.oauthtest4.domain.user.SocialType;
import login.oauthtest4.domain.user.User;
import login.oauthtest4.global.oauth2.userinfo.GoogleOAuth2UserInfo;
import login.oauthtest4.global.oauth2.userinfo.KakaoOAuth2UserInfo;
import login.oauthtest4.global.oauth2.userinfo.NaverOAuth2UserInfo;
import login.oauthtest4.global.oauth2.userinfo.OAuth2UserInfo;
import lombok.Builder;
import lombok.Getter;
import java.util.Map;
import java.util.UUID;
/**
* 각 소셜에서 받아오는 데이터가 다르므로
* 소셜별로 데이터를 받는 데이터를 분기 처리하는 DTO 클래스
*/
@Getter
public class OAuthAttributes {
private String nameAttributeKey; // OAuth2 로그인 진행 시 키가 되는 필드 값, PK와 같은 의미
private OAuth2UserInfo oauth2UserInfo; // 소셜 타입별 로그인 유저 정보(닉네임, 이메일, 프로필 사진 등등)
@Builder
private OAuthAttributes(String nameAttributeKey, OAuth2UserInfo oauth2UserInfo) {
this.nameAttributeKey = nameAttributeKey;
this.oauth2UserInfo = oauth2UserInfo;
}
/**
* SocialType에 맞는 메소드 호출하여 OAuthAttributes 객체 반환
* 파라미터 : userNameAttributeName -> OAuth2 로그인 시 키(PK)가 되는 값 / attributes : OAuth 서비스의 유저 정보들
* 소셜별 of 메소드(ofGoogle, ofKaKao, ofNaver)들은 각각 소셜 로그인 API에서 제공하는
* 회원의 식별값(id), attributes, nameAttributeKey를 저장 후 build
*/
public static OAuthAttributes of(SocialType socialType,
String userNameAttributeName, Map<String, Object> attributes) {
if (socialType == SocialType.NAVER) {
return ofNaver(userNameAttributeName, attributes);
}
if (socialType == SocialType.KAKAO) {
return ofKakao(userNameAttributeName, attributes);
}
return ofGoogle(userNameAttributeName, attributes);
}
private static OAuthAttributes ofKakao(String userNameAttributeName, Map<String, Object> attributes) {
return OAuthAttributes.builder()
.nameAttributeKey(userNameAttributeName)
.oauth2UserInfo(new KakaoOAuth2UserInfo(attributes))
.build();
}
public static OAuthAttributes ofGoogle(String userNameAttributeName, Map<String, Object> attributes) {
return OAuthAttributes.builder()
.nameAttributeKey(userNameAttributeName)
.oauth2UserInfo(new GoogleOAuth2UserInfo(attributes))
.build();
}
public static OAuthAttributes ofNaver(String userNameAttributeName, Map<String, Object> attributes) {
return OAuthAttributes.builder()
.nameAttributeKey(userNameAttributeName)
.oauth2UserInfo(new NaverOAuth2UserInfo(attributes))
.build();
}
/**
* of메소드로 OAuthAttributes 객체가 생성되어, 유저 정보들이 담긴 OAuth2UserInfo가 소셜 타입별로 주입된 상태
* OAuth2UserInfo에서 socialId(식별값), nickname, imageUrl을 가져와서 build
* email에는 UUID로 중복 없는 랜덤 값 생성
* role은 GUEST로 설정
*/
public User toEntity(SocialType socialType, OAuth2UserInfo oauth2UserInfo) {
return User.builder()
.socialType(socialType)
.socialId(oauth2UserInfo.getId())
.email(UUID.randomUUID() + "@socialUser.com")
.nickname(oauth2UserInfo.getNickname())
.imageUrl(oauth2UserInfo.getImageUrl())
.role(Role.GUEST)
.build();
}
}
🎯 OAuthAttributes 부분 코드 설명
💻 필드, 빌더 부분
private String nameAttributeKey; // OAuth2 로그인 진행 시 키가 되는 필드 값, PK와 같은 의미
private OAuth2UserInfo oauth2UserInfo; // 소셜 타입별 로그인 유저 정보(닉네임, 이메일, 프로필 사진 등등)
@Builder
private OAuthAttributes(String nameAttributeKey, OAuth2UserInfo oauth2UserInfo) {
this.nameAttributeKey = nameAttributeKey;
this.oauth2UserInfo = oauth2UserInfo;
}
✅ String nameAttributeKey
OAuth2 로그인 진행 시 키가 되는 필드 값으로, PK와 같은 의미입니다.
(이후에 CustomOAuth2UserService에서 값을 얻어 빌더로 생성 후 반환합니다.)
✅ OAuth2UserInfo oauth2UserInfo
소셜 타입별 로그인 유저 정보를 가진 OAuth2UserInfo 인스턴스를 가집니다.
OAuth2UserInfo는 추상 클래스로, 밑에서 다루겠습니다.
(이후에 CustomOAuth2UserService에서 값을 얻어 빌더로 생성 후 반환합니다.)
💻 OAuthAttributes를 생성하는 of 메소드
/**
* SocialType에 맞는 메소드 호출하여 OAuthAttributes 객체 반환
* 파라미터 : userNameAttributeName -> OAuth2 로그인 시 키(PK)가 되는 값 / attributes : OAuth 서비스의 유저 정보들
* 소셜별 of 메소드(ofGoogle, ofKaKao, ofNaver)들은 각각 소셜 로그인 API에서 제공하는
* 회원의 식별값(id), attributes, nameAttributeKey를 저장 후 build
*/
public static OAuthAttributes of(SocialType socialType,
String userNameAttributeName, Map<String, Object> attributes) {
if (socialType == SocialType.NAVER) {
return ofNaver(userNameAttributeName, attributes);
}
if (socialType == SocialType.KAKAO) {
return ofKakao(userNameAttributeName, attributes);
}
return ofGoogle(userNameAttributeName, attributes);
}
private static OAuthAttributes ofKakao(String userNameAttributeName, Map<String, Object> attributes) {
return OAuthAttributes.builder()
.nameAttributeKey(userNameAttributeName)
.oauth2UserInfo(new KakaoOAuth2UserInfo(attributes))
.build();
}
public static OAuthAttributes ofGoogle(String userNameAttributeName, Map<String, Object> attributes) {
return OAuthAttributes.builder()
.nameAttributeKey(userNameAttributeName)
.oauth2UserInfo(new GoogleOAuth2UserInfo(attributes))
.build();
}
public static OAuthAttributes ofNaver(String userNameAttributeName, Map<String, Object> attributes) {
return OAuthAttributes.builder()
.nameAttributeKey(userNameAttributeName)
.oauth2UserInfo(new NaverOAuth2UserInfo(attributes))
.build();
}
✅ OAuthAttributes of(SocialType socialType,
String userNameAttributeName, Map<String, Object> attributes)
CustomOAuth2UserService에서 파라미터들을 주입해서 OAuthAttributes 객체를 생성하는 메소드입니다.
파라미터로 들어온 SocialType 별로 분기 처리하여 각 소셜 타입에 맞게 OAuthAttributes를 생성해줍니다.
✅ ofKakao, ofGoogle, ofNaver 메소드
소셜 타입 별로 나눠서 빌더로 OAuthAttributes 빌드 시
유저 정보 추상 클래스인 OAuth2UserInfo 필드에 각 소셜 타입의 OAuth2UserInfo를 생성하여 빌드합니다.
(KakaoOAuth2UserInfo, GoogleOAuth2UserInfo, NaverOAuth2UserInfo)
💻 OAuthAttributes의 정보로 내 서비스 User를 생성하는 toEntity 메소드
/**
* of메소드로 OAuthAttributes 객체가 생성되어, 유저 정보들이 담긴 OAuth2UserInfo가 소셜 타입별로 주입된 상태
* OAuth2UserInfo에서 socialId(식별값), nickname, imageUrl을 가져와서 build
* email에는 UUID로 중복 없는 랜덤 값 생성
* role은 GUEST로 설정
*/
public User toEntity(SocialType socialType, OAuth2UserInfo oauth2UserInfo) {
return User.builder()
.socialType(socialType)
.socialId(oauth2UserInfo.getId())
.email(UUID.randomUUID() + "@socialUser.com")
.nickname(oauth2UserInfo.getNickname())
.imageUrl(oauth2UserInfo.getImageUrl())
.role(Role.GUEST)
.build();
}
이후에 CustomOAuth2UserService에서 DB에 저장할 내 서비스 User를
OAuth2UserInfo의 정보를 사용하여 빌더로 빌드 후 반환합니다.
앞서 CustomOAuth2User에서 언급한대로 email과 role도 사용하기 위해 추가하여 빌드합니다.
email은 JWT Token을 발급하기 위한 용도뿐이므로 UUID를 사용하여 임의로 설정합니다.
3. 소셜 타입별 유저 정보를 가지는 OAuth2UserInfo 추상 클래스 및 자식 클래스
소셜 타입별로 유저 정보를 가지는 추상클래스입니다.
OAuth2UserInfo 추상클래스를 상속받아 각 소셜 타입의 유저 정보 클래스를 구현합니다.
🎯 OAuth2UserInfo 전체 코드
package login.oauthtest4.global.oauth2.userinfo;
import java.util.Map;
public abstract class OAuth2UserInfo {
protected Map<String, Object> attributes;
public OAuth2UserInfo(Map<String, Object> attributes) {
this.attributes = attributes;
}
public abstract String getId(); //소셜 식별 값 : 구글 - "sub", 카카오 - "id", 네이버 - "id"
public abstract String getNickname();
public abstract String getImageUrl();
}
✅ protected Map<String, Object> attributes
추상클래스를 상속받는 클래스에서만 사용할 수 있도록 protected 제어자를 사용했습니다.
✅ public OAuth2UserInfo(Map<String, Object> attributes)
생성자 파라미터로 각 소셜 타입별 유저 정보 attributes를 주입받아서
각 소셜 타입별 유저 정보 클래스가 소셜 타입에 맞는 attributes를 주입받아 가지도록 했습니다.
✅ abstract String getId() / getNickname() / getImageUrl()
제 서비스에 사용하고 싶은 유저 정보들을 가져오는 메소드를 생성했습니다.
각 소셜에서 제공하는 정보 중에 사용하고 싶은 정보가 있다면 더 추가해서 사용하면 됩니다.
이제 OAuth2UserInfo 추상 클래스를 상속받는 각 소셜 타입별 자식 클래스를 살펴봅시다.
(NaverOAuth2UserInfo, KakaoOAuth2UserInfo, GoogleOAuth2UserInfo)
살펴보기 전에, 각 소셜 타입별로 어떠한 JSON 형식으로 유저 정보(attributes)가 들어오는지 파악해야합니다.
네이버, 카카오, 구글의 유저 정보 JSON 예시를 살펴봅시다.
📖 네이버의 유저 정보 Response JSON 예시
{
"resultcode": "00",
"message": "success",
"response": {
"email": "openapi@naver.com",
"nickname": "OpenAPI",
"profile_image": "https://ssl.pstatic.net/static/pwe/address/nodata_33x33.gif",
"age": "40-49",
"gender": "F",
"id": "32742776",
"name": "오픈 API",
"birthday": "10-01"
}
}
📖 카카오의 유저 정보 Response JSON 예시
{
"id":123456789,
"connected_at": "2022-04-11T01:45:28Z",
"kakao_account": {
// 프로필 또는 닉네임 동의 항목 필요
"profile_nickname_needs_agreement": false,
// 프로필 또는 프로필 사진 동의 항목 필요
"profile_image_needs_agreement ": false,
"profile": {
// 프로필 또는 닉네임 동의 항목 필요
"nickname": "홍길동",
// 프로필 또는 프로필 사진 동의 항목 필요
"thumbnail_image_url": "http://yyy.kakao.com/.../img_110x110.jpg",
"profile_image_url": "http://yyy.kakao.com/dn/.../img_640x640.jpg",
"is_default_image":false
},
// 이름 동의 항목 필요
"name_needs_agreement":false,
"name":"홍길동",
// 카카오계정(이메일) 동의 항목 필요
"email_needs_agreement":false,
"is_email_valid": true,
"is_email_verified": true,
"email": "sample@sample.com",
// 연령대 동의 항목 필요
"age_range_needs_agreement":false,
"age_range":"20~29",
// 출생 연도 동의 항목 필요
"birthyear_needs_agreement": false,
"birthyear": "2002",
// 생일 동의 항목 필요
"birthday_needs_agreement":false,
"birthday":"1130",
"birthday_type":"SOLAR",
// 성별 동의 항목 필요
"gender_needs_agreement":false,
"gender":"female",
// 카카오계정(전화번호) 동의 항목 필요
"phone_number_needs_agreement": false,
"phone_number": "+82 010-1234-5678",
// CI(연계정보) 동의 항목 필요
"ci_needs_agreement": false,
"ci": "${CI}",
"ci_authenticated_at": "2019-03-11T11:25:22Z",
},
"properties":{
"${CUSTOM_PROPERTY_KEY}": "${CUSTOM_PROPERTY_VALUE}",
...
}
}
📖 구글의 유저 정보 Response JSON 예시
{
"sub": "식별값",
"name": "name",
"given_name": "given_name",
"picture": "https//lh3.googleusercontent.com/~~",
"email": "email",
"email_verified": true,
"locale": "ko"
}
이렇게 소셜 타입별로 가져오는 JSON 유저 정보에서, 사용하고 싶은 유저 정보만 Key로 꺼내어 사용하면 됩니다.
그럼 이제 OAuth2UserInfo 추상 클래스를 상속받는 소셜 타입별 자식 클래스를 살펴보겠습니다.
🎯 NaverOAuth2UserInfo 전체 코드
package login.oauthtest4.global.oauth2.userinfo;
import java.util.Map;
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 getNickname() {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
if (response == null) {
return null;
}
return (String) response.get("nickname");
}
@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");
}
}
네이버의 경우에는, attributes를 받았을 때 바로 유저 정보가 있는 것이 아니라
'response' Key로 한 번 감싸져있기 때문에, get("response")로 꺼낸 후
사용할 정보 Key로 꺼내서 사용해야 합니다.
또한, get으로 꺼내면 Object로 반환되기 때문에 String으로 캐스팅하여 반환해야합니다.
🎯 KakaoOAuth2UserInfo 전체 코드
package login.oauthtest4.global.oauth2.userinfo;
import java.util.Map;
public class KakaoOAuth2UserInfo extends OAuth2UserInfo {
public KakaoOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return String.valueOf(attributes.get("id"));
}
@Override
public String getNickname() {
Map<String, Object> account = (Map<String, Object>) attributes.get("kakao_account");
if (account == null) {
return null;
}
Map<String, Object> profile = (Map<String, Object>) account.get("profile");
if (profile == null) {
return null;
}
return (String) profile.get("nickname");
}
@Override
public String getImageUrl() {
Map<String, Object> account = (Map<String, Object>) attributes.get("kakao_account");
if (account == null) {
return null;
}
Map<String, Object> profile = (Map<String, Object>) account.get("profile");
if (profile == null) {
return null;
}
return (String) profile.get("thumbnail_image_url");
}
}
카카오는 네이버와는 또 다르게
유저 정보가 'kakao_account.profile'으로 2번 감싸져있는 구조입니다. ('kakao_account' -> 'profile')
따라서 get을 2번 사용하여 데이터를 꺼낸 후 사용하고 싶은 정보의 Key로 꺼내서 사용하면 됩니다.
이때, getId는 Long으로 반환되어 (String)으로 캐스팅될 수 없으므로
String.valueOf()를 사용하여 캐스팅해주었습니다.
🎯 GoogleOAuth2UserInfo 전체 코드
package login.oauthtest4.global.oauth2.userinfo;
import java.util.Map;
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 getNickname() {
return (String) attributes.get("name");
}
@Override
public String getImageUrl() {
return (String) attributes.get("picture");
}
}
구글은 앞의 네이버, 카카오와 달리 유저 정보가 감싸져 있지 않기 때문에
바로 get으로 유저 정보 Key를 사용해서 꺼내면 됩니다.
4. OAuth2UserService를 커스텀한 CustomOAuth2UserService
OAuth2UserService를 커스텀한 CustomOAuth2UserService입니다.
OAuth2 로그인의 로직을 담당합니다.
코드가 길기 때문에, 전체 코드를 먼저 보여드리고, 부분적으로 설명하도록 하겠습니다.
🎯 CustomOAuth2UserService 전체 코드
package login.oauthtest4.global.oauth2.service;
import login.oauthtest4.domain.user.SocialType;
import login.oauthtest4.domain.user.User;
import login.oauthtest4.domain.user.repository.UserRepository;
import login.oauthtest4.global.oauth2.CustomOAuth2User;
import login.oauthtest4.global.oauth2.OAuthAttributes;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.Map;
@Slf4j
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
private static final String NAVER = "naver";
private static final String KAKAO = "kakao";
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
log.info("CustomOAuth2UserService.loadUser() 실행 - OAuth2 로그인 요청 진입");
/**
* DefaultOAuth2UserService 객체를 생성하여, loadUser(userRequest)를 통해 DefaultOAuth2User 객체를 생성 후 반환
* DefaultOAuth2UserService의 loadUser()는 소셜 로그인 API의 사용자 정보 제공 URI로 요청을 보내서
* 사용자 정보를 얻은 후, 이를 통해 DefaultOAuth2User 객체를 생성 후 반환한다.
* 결과적으로, OAuth2User는 OAuth 서비스에서 가져온 유저 정보를 담고 있는 유저
*/
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
/**
* userRequest에서 registrationId 추출 후 registrationId으로 SocialType 저장
* http://localhost:8080/oauth2/authorization/kakao에서 kakao가 registrationId
* userNameAttributeName은 이후에 nameAttributeKey로 설정된다.
*/
String registrationId = userRequest.getClientRegistration().getRegistrationId();
SocialType socialType = getSocialType(registrationId);
String userNameAttributeName = userRequest.getClientRegistration()
.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(); // OAuth2 로그인 시 키(PK)가 되는 값
Map<String, Object> attributes = oAuth2User.getAttributes(); // 소셜 로그인에서 API가 제공하는 userInfo의 Json 값(유저 정보들)
// socialType에 따라 유저 정보를 통해 OAuthAttributes 객체 생성
OAuthAttributes extractAttributes = OAuthAttributes.of(socialType, userNameAttributeName, attributes);
User createdUser = getUser(extractAttributes, socialType); // getUser() 메소드로 User 객체 생성 후 반환
// DefaultOAuth2User를 구현한 CustomOAuth2User 객체를 생성해서 반환
return new CustomOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(createdUser.getRole().getKey())),
attributes,
extractAttributes.getNameAttributeKey(),
createdUser.getEmail(),
createdUser.getRole()
);
}
private SocialType getSocialType(String registrationId) {
if(NAVER.equals(registrationId)) {
return SocialType.NAVER;
}
if(KAKAO.equals(registrationId)) {
return SocialType.KAKAO;
}
return SocialType.GOOGLE;
}
/**
* SocialType과 attributes에 들어있는 소셜 로그인의 식별값 id를 통해 회원을 찾아 반환하는 메소드
* 만약 찾은 회원이 있다면, 그대로 반환하고 없다면 saveUser()를 호출하여 회원을 저장한다.
*/
private User getUser(OAuthAttributes attributes, SocialType socialType) {
User findUser = userRepository.findBySocialTypeAndSocialId(socialType,
attributes.getOauth2UserInfo().getId()).orElse(null);
if(findUser == null) {
return saveUser(attributes, socialType);
}
return findUser;
}
/**
* OAuthAttributes의 toEntity() 메소드를 통해 빌더로 User 객체 생성 후 반환
* 생성된 User 객체를 DB에 저장 : socialType, socialId, email, role 값만 있는 상태
*/
private User saveUser(OAuthAttributes attributes, SocialType socialType) {
User createdUser = attributes.toEntity(socialType, attributes.getOauth2UserInfo());
return userRepository.save(createdUser);
}
}
🎯 CustomOAuth2UserService 부분 코드 설명
💻 loadUser(UserRequest userRequest) 부분
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
log.info("CustomOAuth2UserService.loadUser() 실행 - OAuth2 로그인 요청 진입");
/**
* DefaultOAuth2UserService 객체를 생성하여, loadUser(userRequest)를 통해 DefaultOAuth2User 객체를 생성 후 반환
* DefaultOAuth2UserService의 loadUser()는 소셜 로그인 API의 사용자 정보 제공 URI로 요청을 보내서
* 사용자 정보를 얻은 후, 이를 통해 DefaultOAuth2User 객체를 생성 후 반환한다.
* 결과적으로, OAuth2User는 OAuth 서비스에서 가져온 유저 정보를 담고 있는 유저
*/
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
/**
* userRequest에서 registrationId 추출 후 registrationId으로 SocialType 저장
* http://localhost:8080/oauth2/authorization/kakao에서 kakao가 registrationId
* userNameAttributeName은 이후에 nameAttributeKey로 설정된다.
*/
String registrationId = userRequest.getClientRegistration().getRegistrationId();
SocialType socialType = getSocialType(registrationId);
String userNameAttributeName = userRequest.getClientRegistration()
.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(); // OAuth2 로그인 시 키(PK)가 되는 값
Map<String, Object> attributes = oAuth2User.getAttributes(); // 소셜 로그인에서 API가 제공하는 userInfo의 Json 값(유저 정보들)
// socialType에 따라 유저 정보를 통해 OAuthAttributes 객체 생성
OAuthAttributes extractAttributes = OAuthAttributes.of(socialType, userNameAttributeName, attributes);
User createdUser = getUser(extractAttributes, socialType); // getUser() 메소드로 User 객체 생성 후 반환
// DefaultOAuth2User를 구현한 CustomOAuth2User 객체를 생성해서 반환
return new CustomOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(createdUser.getRole().getKey())),
attributes,
extractAttributes.getNameAttributeKey(),
createdUser.getEmail(),
createdUser.getRole()
);
}
이 부분은 내용이 길어서
코드에 달린 주석으로 이해해주세요!
💻 getSocialType(String registrationId) 부분
private SocialType getSocialType(String registrationId) {
if(NAVER.equals(registrationId)) {
return SocialType.NAVER;
}
if(KAKAO.equals(registrationId)) {
return SocialType.KAKAO;
}
return SocialType.GOOGLE;
}
registrationId("naver", "kakao", "google")로 분기 처리하여 맞는 소셜 타입 반환하는 메소드입니다.
💻 getUser(OAuthAttributes attributes, SocialType socialType) 부분
/**
* SocialType과 attributes에 들어있는 소셜 로그인의 식별값 id를 통해 회원을 찾아 반환하는 메소드
* 만약 찾은 회원이 있다면, 그대로 반환하고 없다면 saveUser()를 호출하여 회원을 저장한다.
*/
private User getUser(OAuthAttributes attributes, SocialType socialType) {
User findUser = userRepository.findBySocialTypeAndSocialId(socialType,
attributes.getOauth2UserInfo().getId()).orElse(null);
if(findUser == null) {
return saveUser(attributes, socialType);
}
return findUser;
}
/**
* OAuthAttributes의 toEntity() 메소드를 통해 빌더로 User 객체 생성 후 반환
* 생성된 User 객체를 DB에 저장 : socialType, socialId, email, role 값만 있는 상태
*/
private User saveUser(OAuthAttributes attributes, SocialType socialType) {
User createdUser = attributes.toEntity(socialType, attributes.getOauth2UserInfo());
return userRepository.save(createdUser);
}
userRepository에서 SocialType과 SocialId로 조회해서,
해당 유저가 있다면 조회한 유저를 반환하고, 없다면 DB에 저장해서 반환합니다.
이때, User 객체는 OAuthAttributes의 toEntity() 메소드를 이용해서 생성한 후 save합니다.
5. OAuth2 로그인 성공 시 로직을 처리하는 OAuth2LoginSuccessHandler
OAuth2 로그인이 성공한다면, OAuth2LoginSuccessHandler의 로직이 실행됩니다.
🎯 OAuth2LoginSuccessHandler 전체 코드
package login.oauthtest4.global.oauth2.handler;
import login.oauthtest4.domain.user.Role;
import login.oauthtest4.domain.user.User;
import login.oauthtest4.domain.user.repository.UserRepository;
import login.oauthtest4.global.jwt.service.JwtService;
import login.oauthtest4.global.oauth2.CustomOAuth2User;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler {
private final JwtService jwtService;
private final UserRepository userRepository;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
log.info("OAuth2 Login 성공!");
try {
CustomOAuth2User oAuth2User = (CustomOAuth2User) authentication.getPrincipal();
// User의 Role이 GUEST일 경우 처음 요청한 회원이므로 회원가입 페이지로 리다이렉트
if(oAuth2User.getRole() == Role.GUEST) {
String accessToken = jwtService.createAccessToken(oAuth2User.getEmail());
response.addHeader(jwtService.getAccessHeader(), "Bearer " + accessToken);
response.sendRedirect("oauth2/sign-up"); // 프론트의 회원가입 추가 정보 입력 폼으로 리다이렉트
jwtService.sendAccessAndRefreshToken(response, accessToken, null);
// User findUser = userRepository.findByEmail(oAuth2User.getEmail())
// .orElseThrow(() -> new IllegalArgumentException("이메일에 해당하는 유저가 없습니다."));
// findUser.authorizeUser();
} else {
loginSuccess(response, oAuth2User); // 로그인에 성공한 경우 access, refresh 토큰 생성
}
} catch (Exception e) {
throw e;
}
}
// TODO : 소셜 로그인 시에도 무조건 토큰 생성하지 말고 JWT 인증 필터처럼 RefreshToken 유/무에 따라 다르게 처리해보기
private void loginSuccess(HttpServletResponse response, CustomOAuth2User oAuth2User) throws IOException {
String accessToken = jwtService.createAccessToken(oAuth2User.getEmail());
String refreshToken = jwtService.createRefreshToken();
response.addHeader(jwtService.getAccessHeader(), "Bearer " + accessToken);
response.addHeader(jwtService.getRefreshHeader(), "Bearer " + refreshToken);
jwtService.sendAccessAndRefreshToken(response, accessToken, refreshToken);
jwtService.updateRefreshToken(oAuth2User.getEmail(), refreshToken);
}
}
1. 처음 OAuth2 로그인한 유저일 때
authentication.getPrincipal()로 받아온 CustomOAuth2User의 getRole이 GUEST라면,
AccessToken을 발급하고 요청 헤더에 실어서 회원가입 추가 폼으로 리다이렉트 시킵니다.
주석 처리한 부분 - Role을 GUEST -> USER로 업데이트하는 로직입니다.
지금은 회원가입 추가 폼 입력 시 업데이트하는 컨트롤러를 만들지 않아서 저렇게 놔뒀습니다.
이후에 회원가입 추가 폼 입력 시 업데이트하는 컨트롤러, 서비스를 만들면
그 시점에 Role Update를 진행하면 될 것 같습니다.
2. 이미 한 번 이상 OAuth2 로그인했던 유저일 때
authentication.getPrincipal()로 받아온 CustomOAuth2User의 getRole이 GUEST가 아니라면,
추가 정보를 기입하고 이미 한 번 로그인 했던 유저이므로,
Token만 발급하여 헤더에 실어 보냅니다.
※ loginSuccess 메소드 TODO
현재는 이미 한 번 로그인했던 유저면 계속 토큰을 발급해주고 있는데,
JwtAuthenticationProcessingFilter처럼 RefreshToken의 유/무, 만기에 따라 다르게 처리하도록
나중에 시간이 되면 구현해보도록 하겠습니다.
6. OAuth2 로그인 실패 시 로직을 처리하는 OAuth2LoginFailureHandler
OAuth2 로그인이 실패한다면, OAuth2LoginFailureHandler의 로직이 실행됩니다.
🎯 OAuth2LoginFailureHandler 전체 코드
package login.oauthtest4.global.oauth2.handler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Slf4j
@Component
public class OAuth2LoginFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.getWriter().write("소셜 로그인 실패! 서버 로그를 확인해주세요.");
log.info("소셜 로그인에 실패했습니다. 에러 메시지 : {}", exception.getMessage());
}
}
400 에러를 Response에 설정해주고,
에러 메시지와 로그를 설정해줍니다.
📖 깃허브 링크 (전체 코드)
https://github.com/KSH-beginner/oauth2WithJwtLogin
📕 전체 로그인 구현 목차
Spring Security + JWT를 이용한 자체 Login & OAuth2 Login API 구현 (1) - 회원(User) 관련 클래스 생성
Spring Security + JWT를 이용한 자체 Login & OAuth2 Login API 구현 (2) - JWT란?
Spring Security + JWT를 이용한 자체 Login & OAuth2 Login API 구현 (3) - JWT 관련 클래스 생성 / JWT 인증 로직
Spring Security + JWT를 이용한 자체 Login & OAuth2 Login API 구현 (4) - 자체 JSON 로그인 커스텀하기
Spring Security + JWT를 이용한 자체 Login & OAuth2 Login API 구현 (5) - OAuth란? / OAuth 2.0 인증 과정 예시
Spring Security + JWT를 이용한 자체 Login & OAuth2 Login API 구현 (6) - OAuth 2.0 로그인 구현 사전 설정
Spring Security + JWT를 이용한 자체 Login & OAuth2 Login API 구현 (7) - OAuth 2.0 로그인 관련 클래스 생성
Spring Security + JWT를 이용한 자체 Login & OAuth2 Login API 구현 (8) - SecurityConfig 설정 클래스 생성
Spring Security + JWT를 이용한 자체 Login & OAuth2 Login API 구현 (9)- JWT 자체 로그인 & OAuth2 Login 테스트
Reference
https://ttl-blog.tistory.com/98