프로젝트 전체 코드: https://github.com/kcm02/JWT_OAuth_Login.git
User
와의 통합이번 OAuth
로그인 구현에서 가장 신경쓴 부분은 바로 기존 User
정보와의 통합이다. 물론 대형 서비스에서는 각각 엔티티를 구별해 구현하는 경우도 많지만, 나는 OAuth
로그인에도 JWT
토큰 기반 로그아웃을 적용하고 싶었기에 최대한 자체 로그인과 OAuth
로그인이 통합되도록 코드를 작성해 보았다.
User
: 자체 로그인 + OAuth
로그인?우선 같은 User
엔티티 안에서 가장 효율적으로 두 로그인을 함께 저장할 수 있는 방법에 대해 생각했다.
SocialType
SocialId
이 두 필드를 만들어 OAuth
로그인 시에만 값이 들어갈 수 있게 하고, 비밀번호 같은 경우는 자체 로그인만 사용하도록 코드를 구현했다.
그 밖에 JWT
토큰이나 가입 시간, 업데이트 시간같은 경우 @TimeStamp
어노테이션을 사용했기에 크게 문제될 것은 없었다. 결과, 엔티티 통합은 성공적으로 끝났다.
또한 같은 방식으로 JWT
의 AccessToken
과 RefreshToken
을 발급했기에 기존 로그아웃 방식을 동일하게 적용할 수 있게 되었다.
Json
응답에 맞춰 추출저번 게시물에서 설명했듯, 이번 프로젝트에서 사용할 외부 플랫폼은 총 3개이다.
Google
Kakao
Naver
아무래도 국내에서 가장 자주 쓰이는 대표 플랫폼들인 만큼 자료가 방대했다. 그러나 여러 개를 동시에 구현할 경우, 각 플랫폼의 응답 형태에 맞게 구현해야 한다.
그래서 본격적인 구현 전에, 미리 Google
, Kakao
, Naver
의 로그인 성공 후 액세스 토큰을 이용해 사용자 정보를 요청할 때 반환되는 JSON 응답 예시를 알아보려고 한다.
Google
의 Response
형태{
"sub": "1234567890",
"name": "John Doe",
"given_name": "John",
"family_name": "Doe",
"picture": "https://lh3.googleusercontent.com/a-/AOh14GExample",
"email": "johndoe@example.com",
"email_verified": true,
"locale": "en"
}
Google
은 보다시피 매우 간단하게 구성되어 있다. 여기서 보통 사이트에서 사용되는 특성은 다음과 같다.
sub
: 소셜 로그인의 IDname
: 해당 사용자의 이름email
: 사용자의 이메일 주소Naver
의 Response
형태{
"resultcode": "00",
"message": "success",
"response": {
"id": "1234567890abcdefg",
"nickname": "홍길동",
"profile_image": "https://example.com/profile.jpg",
"email": "johndoe@example.com",
"name": "홍길동",
"age": "20-29",
"gender": "M",
"birthday": "01-01",
"birthyear": "1990",
"mobile": "010-1234-5678"
}
}
네이버는 이렇게 id
와 name
, email
등의 정보가 response
라는 곳 안에 담겨져 있다. 따라서 정보를 가져올 때도 response
에서 가져와야 한다는 Google
과의 차이점이 존재한다.
id
: 소셜 로그인의 IDname
: 해당 사용자의 이름email
: 사용자의 이메일 주소Kakao
의 Response
형태{
"id": 123456789,
"connected_at": "2023-01-01T00:00:00Z",
"properties": {
"nickname": "홍길동",
"profile_image": "http://k.kakaocdn.net/dn/ExampleProfileImage.jpg",
"thumbnail_image": "http://k.kakaocdn.net/dn/ExampleThumbnailImage.jpg"
},
"kakao_account": {
"profile_needs_agreement": false,
"profile": {
"nickname": "홍길동",
"thumbnail_image_url": "http://k.kakaocdn.net/dn/ExampleThumbnailImage.jpg",
"profile_image_url": "http://k.kakaocdn.net/dn/ExampleProfileImage.jpg",
"is_default_image": false
},
"email_needs_agreement": false,
"is_email_valid": true,
"is_email_verified": true,
"email": "johndoe@example.com"
}
}
Kakao
는 가장 복잡한 Json
응답 형태를 가지고 있다. 먼저 id
의 경우 Google
과 같이 가장 바깥쪽에 위치했지만, email
은 kakao_account
안에, nickname
은 properties
에 위치해있다.
아무래도 각각 다른 위치에 있기 때문에 구현할 때에도 좀 더 신경써야 한다. 참고로 nickname
은 kakao_account
의 profile
안에도 존재한다.
id
: 소셜 로그인의 IDnickname
: 해당 사용자의 이름email
: 사용자의 이메일 주소이제 이 구조를 기억하고 OAuth 2.0
로그인 구현을 시작해 보자.
com.project.securelogin
├── config
│ └── RedisConfig.java
│ └── SecurityConfig.java ✔️
├── controller
│ └── AuthController.java
│ └── UserController.java
├── domain
│ └── CustomOAuth2User.java ✔️
│ └── CustomUserDetails.java
│ └── User.java ✔️
├── dto
│ └── JsonResponse.java
│ └── UserRequestDTO.java
│ └── UserResponseDTO.java
├── exception
│ └── UserAccountLockedException.java
│ └── UserNotEnabledException.java
├── jwt
│ └── JwtAuthenticationFilter.java
│ └── JwtTokenProvider.java
├── oauth
│ └── handler
│ └── OAuth2LoginFailureHandler.java ✔️
│ └── OAuth2LoginSuccessHandler.java ✔️
│ └── info
│ └── OAuth2UserInfo.java ✔️
│ └── GoogleOAuth2UserInfo.java ✔️
│ └── KakaoOAuth2UserInfo.java ✔️
│ └── NaverOAuth2UserInfo.java ✔️
│ └── OAuth2UserInfoFactory.java ✔️
├── repository
│ └── JwtTokenRedisRepository.java
│ └── UserRepository.java ✔️
└── service
└── AuthService.java
└── CustomOAuth2UserService.java ✔️
└── CustomUserDetailsService.java
└── MailService.java
└── UserService.java
SecurityConfig.java
CustomOAuth2User.java
User.java
OAuth2LoginFailureHandler.java
& OAuth2LoginSuccessHandler.java
CustomOAuth2UserService.java
OAuth2UserInfo
클래스들
GoogleOAuth2UserInfo
, KakaoOAuth2UserInfo
, NaverOAuth2UserInfo
OAuth2UserInfoFactory.java
OAuth2UserInfo
객체를 생성하는 팩토리 클래스 추가UserRepository.java
SecurityConfig
private final CustomOAuth2UserService customOAuth2UserService;
private final OAuth2LoginSuccessHandler oauth2LoginSuccessHandler;
private final OAuth2LoginFailureHandler oauth2LoginFailureHandler;
@Bean // SecurityFilterChain 설정
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
··· 생략 ···
// OAuth 로그인 설정
.oauth2Login(oauth2 -> oauth2
.loginPage("/login")
.failureHandler(oauth2LoginFailureHandler)
.successHandler(oauth2LoginSuccessHandler)
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService)
)
)
··· 생략 ···
return httpSecurity.build();
}
OAuth
로그인 설정.oauth2Login(oauth2 -> oauth2...)
oauth2Login
메서드는 Spring Security에서 OAuth2 로그인을 구성하기 위해 사용된다. 이 블록 안에서 OAuth2 로그인과 관련된 다양한 설정을 할 수 있다..loginPage("/login")
loginPage
메서드는 사용자가 인증되지 않았을 때 리디렉션할 로그인 페이지를 지정한다. 이 경우, /login
URL로 리디렉션된다..failureHandler(oauth2LoginFailureHandler)
failureHandler
메서드는 OAuth2 인증이 실패했을 때 실행될 핸들러를 지정한다. oauth2LoginFailureHandler
는 인증 실패 시 수행할 동작을 정의한 클래스이다. 일반적으로 실패 이유를 로깅하거나 사용자에게 오류 메시지를 보여주는 역할을 한다..successHandler(oauth2LoginSuccessHandler)
successHandler
메서드는 OAuth2 인증이 성공했을 때 실행될 핸들러를 지정한다. oauth2LoginSuccessHandler
는 인증 성공 시 수행할 동작을 정의한 클래스이다. 일반적으로 사용자 정보를 로드하고 세션을 설정하거나 JWT 토큰을 발급하는 역할을 한다..userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService))
userInfoEndpoint
메서드는 OAuth2 인증 과정에서 사용자 정보를 가져올 엔드포인트를 설정한다.userService
메서드는 사용자 정보를 로드하기 위한 서비스를 지정한다. 여기서 customOAuth2UserService
는 OAuth2 공급자로부터 사용자 정보를 가져와 애플리케이션의 사용자 정보로 변환하는 역할을 한다.이 설정은 Spring Security
에서 OAuth2
로그인을 처리하기 위해 필요한 설정을 구성한다. 사용자가 로그인 페이지를 방문하면, 지정된 OAuth2
공급자(Google
, Kakao
, Naver
등)를 통해 인증을 시도한다.
인증 과정에서 발생할 수 있는 성공 및 실패 상황에 대해 각각의 핸들러를 정의해 적절한 처리를 수행하고, customOAuth2UserService
를 사용하여 OAuth2
공급자로부터 받은 정보를 애플리케이션의 사용자 정보로 변환한다.
User
private String socialType; // 소셜 타입 (자체 로그인의 경우 Null)
private String socialId; // 소셜 ID (자체 로그인의 경우 Null)
public void updateOAuthUser(String username, String email, String socialType, String socialId) {
this.username = username;
if (email != null) {
this.email = email;
}
this.socialType = socialType;
this.socialId = socialId;
this.enabled = true; // OAuth 로그인 후 사용자는 활성화 상태
}
socialType
, socialId
컬럼 추가 (자체 로그인의 경우 null
로 설정)
socialType
: google
, kakao
, naver
등 provider
값을 설정한다.socialId
: OAuth2
플랫폼에서 주어지는 고유한 ID
값을 설정한다.updateOAuthUser
메서드
OAuth
유저의 정보를 업데이트하는 메서드username
, email
, socialType
, socialId
를 받아와 업데이트한다.OAuth
로그인 후 사용자는 활성화 상태이므로 enabled
를 true
로 설정한다.CustomOAuth2User
이 클래스는 OAuth2 사용자 정보를 처리하며, Google
, Kakao
, Naver
와 같은 소셜 로그인 사용자 정보를 통합하여 관리한다.
@Getter
public class CustomOAuth2User implements OAuth2User {
private final OAuth2User oAuth2User;
private final String socialType;
private final String id;
private final String name;
private final String email;
public CustomOAuth2User(OAuth2User oAuth2User, String socialType) {
this.oAuth2User = oAuth2User;
this.socialType = socialType;
this.id = extractId(oAuth2User, socialType);
this.name = extractName(oAuth2User, socialType);
this.email = extractEmail(oAuth2User, socialType);
}
private String extractId(OAuth2User oAuth2User, String socialType) {
switch (socialType) {
case "google":
return (String) oAuth2User.getAttribute("sub");
case "kakao":
Object id = oAuth2User.getAttribute("id");
return id != null ? id.toString() : null; // Long 타입을 String으로 변환
case "naver":
Map<String, Object> response = (Map<String, Object>) oAuth2User.getAttribute("response");
return (String) response.get("id");
default:
throw new IllegalArgumentException("Unsupported social type: " + socialType);
}
}
private String extractName(OAuth2User oAuth2User, String socialType) {
switch (socialType) {
case "google":
return (String) oAuth2User.getAttribute("name");
case "kakao":
Map<String, Object> account = (Map<String, Object>) oAuth2User.getAttribute("kakao_account");
Map<String, Object> profile = (Map<String, Object>) account.get("profile");
return (String) profile.get("nickname");
case "naver":
Map<String, Object> response = (Map<String, Object>) oAuth2User.getAttribute("response");
return (String) response.get("name");
default:
throw new IllegalArgumentException("Unsupported social type: " + socialType);
}
}
private String extractEmail(OAuth2User oAuth2User, String socialType) {
switch (socialType) {
case "google":
return (String) oAuth2User.getAttribute("email");
case "kakao":
Map<String, Object> account = (Map<String, Object>) oAuth2User.getAttribute("kakao_account");
return (String) account.get("email");
case "naver":
Map<String, Object> response = (Map<String, Object>) oAuth2User.getAttribute("response");
return (String) response.get("email");
default:
throw new IllegalArgumentException("Unsupported social type: " + socialType);
}
}
··· 생략 ···
}
oAuth2User
: OAuth2 사용자 정보를 담고 있는 객체socialType
: 소셜 로그인 타입 (Google, Kakao, Naver)id
: 소셜 플랫폼에서 제공하는 사용자 IDname
: 소셜 플랫폼에서 제공하는 사용자 이름email
: 소셜 플랫폼에서 제공하는 사용자 이메일public CustomOAuth2User(OAuth2User oAuth2User, String socialType) {
this.oAuth2User = oAuth2User;
this.socialType = socialType;
this.id = extractId(oAuth2User, socialType);
this.name = extractName(oAuth2User, socialType);
this.email = extractEmail(oAuth2User, socialType);
}
oAuth2User
: OAuth2 사용자 정보 객체socialType
: 소셜 로그인 타입 (google, kakao, naver)extractId(OAuth2User oAuth2User, String socialType)
oAuth2User
: OAuth2 사용자 정보 객체socialType
: 소셜 로그인 타입sub
속성에서 ID 추출id
속성에서 ID 추출response
객체에서 id
속성 추출extractName(OAuth2User oAuth2User, String socialType)
oAuth2User
: OAuth2 사용자 정보 객체socialType
: 소셜 로그인 타입name
속성에서 이름 추출kakao_account
객체의 profile
객체에서 nickname
속성 추출response
객체에서 name
속성 추출extractEmail(OAuth2User oAuth2User, String socialType)
oAuth2User
: OAuth2 사용자 정보 객체socialType
: 소셜 로그인 타입email
속성에서 이메일 추출kakao_account
객체에서 email
속성 추출response
객체에서 email
속성 추출UserRepository
Optional<User> findBySocialId(String socialId);
UserRepository
에서 소셜 ID로 사용자를 조회하는 메서드를 추가한다. 이 메서드는 향후 CustomOAuth2UserService
에서 소셜 로그인 사용자 정보를 검색하고 가져올 때 쓰인다.
CustomOAuth2UserService
이 클래스는 OAuth2
사용자 정보를 로드하고, 사용자 정보를 저장하거나 업데이트하는 역할을 담당한다. 또한 인증이 원활하게 작동되도록 지원하며, 기존 사용자 정보와의 통합을 담당한다.
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
@Override
@Transactional
public OAuth2User loadUser(OAuth2UserRequest userRequest) {
String registrationId = userRequest.getClientRegistration().getRegistrationId();
OAuth2User oauth2User = getOAuth2User(userRequest);
OAuth2UserInfo userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(registrationId, oauth2User.getAttributes());
saveOrUpdateUser(userInfo, registrationId);
return new CustomOAuth2User(oauth2User, registrationId);
}
private OAuth2User getOAuth2User(OAuth2UserRequest userRequest) {
// 기본 OAuth2UserService를 사용하여 사용자 정보를 가져온다.
return new DefaultOAuth2UserService().loadUser(userRequest);
}
private User saveOrUpdateUser(OAuth2UserInfo userInfo, String registrationId) {
User user = userRepository.findBySocialId(userInfo.getId())
.orElseGet(() -> createNewUser(userInfo, registrationId));
// 업데이트할 필요가 있는 경우 필드 업데이트
user.updateOAuthUser(userInfo.getName(), userInfo.getEmail(), registrationId, userInfo.getId());
return userRepository.save(user);
}
private User createNewUser(OAuth2UserInfo userInfo, String registrationId) {
return User.builder()
.username(userInfo.getName())
.email(userInfo.getEmail()) // 이메일 추가
.socialType(registrationId)
.socialId(userInfo.getId())
.password("") // OAuth 로그인에서는 비밀번호가 필요 없으므로 빈 문자열로 설정
.enabled(true)
.build();
}
public User getUserByOAuth2UserInfo(OAuth2UserInfo userInfo, String registrationId) {
return userRepository.findBySocialId(userInfo.getId())
.orElseGet(() -> createNewUser(userInfo, registrationId));
}
}
userRepository
: 사용자 정보를 저장하고 조회하는 리포지토리loadUser(OAuth2UserRequest userRequest)
userRequest
: OAuth2 로그인 요청 정보registrationId
를 통해 로그인 제공자를 식별한다.getOAuth2User
메서드를 호출하여 기본 OAuth2 사용자 정보를 로드한다.OAuth2UserInfoFactory
를 사용하여 로그인 제공자에 따라 적절한 OAuth2UserInfo
객체를 생성한다.saveOrUpdateUser
메서드를 호출하여 사용자 정보를 저장하거나 업데이트한다.CustomOAuth2User
객체를 생성하여 반환한다.CustomOAuth2User
객체getOAuth2User(OAuth2UserRequest userRequest)
userRequest
: OAuth2 로그인 요청 정보DefaultOAuth2UserService
를 사용하여 사용자 정보를 가져온다.OAuth2User
객체saveOrUpdateUser(OAuth2UserInfo userInfo, String registrationId)
userInfo
: OAuth2 사용자 정보 객체registrationId
: 로그인 제공자 IDuserRepository
를 사용하여 소셜 ID로 사용자를 조회한다.createNewUser
메서드를 호출하여 새 사용자를 생성한다.User
객체createNewUser(OAuth2UserInfo userInfo, String registrationId)
userInfo
: OAuth2 사용자 정보 객체registrationId
: 로그인 제공자 IDUser
객체를 생성하고, 필수 정보를 설정한다.User
객체getUserByOAuth2UserInfo(OAuth2UserInfo userInfo, String registrationId)
userInfo
: OAuth2 사용자 정보 객체registrationId
: 로그인 제공자 IDuserRepository
를 사용하여 소셜 ID로 사용자를 조회한다createNewUser
메서드를 호출하여 새 사용자를 생성한다.User
객체Continue
✈️이제 OAuth
패키지의 핸들러, UserInfo
관련 클래스들을 제외한 설정은 모두 끝났다. 다음 게시물에서는 이어서 OAuth
패키지 안에 있는 클래스들을 소개하고, 테스트 방법과 결과를 설명하도록 하겠다.