프로젝트 전체 코드: https://github.com/kcm02/JWT_OAuth_Login.git
OAuth
로그인이 관련된 클래스들이 많은 만큼, 게시물이 너무 길어져 전편과 후편으로 나누었다. 전편에서는 SecurityConfig
, User
등 기존에 있던 클래스에서 추가된 내용이나 CustomOAuth2User
, CustomOAuth2UserService
에 대해 다루었으니 참고 바란다.
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
OAuth2LoginFailureHandler.java
& OAuth2LoginSuccessHandler.java
OAuth2UserInfo
클래스들
GoogleOAuth2UserInfo
, KakaoOAuth2UserInfo
, NaverOAuth2UserInfo
OAuth2UserInfoFactory.java
OAuth2UserInfo
객체를 생성하는 팩토리 클래스 추가이번 게시물에서는 바로 이곳, oauth
패키지 안 클래스들에 대해 설명하고 최종적인 테스트 방법과 결과를 서술할 것이다. 이제 구현해 보자.
OAuth2UserInfo
OAuth2UserInfo
클래스는 OAuth 2.0 인증을 통해 얻은 사용자 정보를 추상화한 클래스로, 다양한 소셜 로그인 제공자(Google
, Kakao
, Naver
등)에서 사용자 정보를 통일된 형태로 다루기 위해 설계되었다.
이 클래스는 각 소셜 로그인 제공자에 대한 구체적인 구현 클래스들이 공통적으로 상속받아 사용하는 기본 구조를 정의한다.
public abstract class OAuth2UserInfo {
protected Map<String, Object> attributes;
public OAuth2UserInfo(Map<String, Object> attributes) {
this.attributes = attributes;
}
public abstract String getId();
public abstract String getName();
public abstract String getEmail();
}
id
, name
, email
)를 각 제공자별로 적절히 추출하여 제공하는 메서드를 정의한다.protected Map<String, Object> attributes
Map<String, Object>
public OAuth2UserInfo(Map<String, Object> attributes)
attributes
매개변수로 받아 attributes
필드를 초기화한다. 하위 클래스는 이 생성자를 통해 전달된 정보를 기반으로 사용자 정보를 처리한다.public abstract String get❓()
각 소셜 로그인 제공자(Google
, Kakao
, Naver
)에서 전달받은 사용자 정보를 처리하기 위해, OAuth2UserInfo
클래스를 상속받아 구체적인 사용자 정보 추출 로직을 구현하는 하위 클래스를 정의하여 코드를 작성한다.
GoogleOAuth2UserInfo
KakaoOAuth2UserInfo
NaverOAuth2UserInfo
이러한 하위 클래스들은 OAuth2UserInfo
의 추상 메서드를 구현하여 각 제공자에 맞는 사용자 정보를 추출하고 반환한다.
GoogleOAuth2UserInfo
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");
}
}
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"
}
저번 게시물에서도 설명했지만, 이해를 돕기 위해 다시 한 번 Json
응답 예시를 가져와 보았다. 여기서 sub
은 id
를 말한다.
Google
같은 경우 모두 바깥에 위치했기 때문에, attributes
에서 바로 get()
으로 각 정보를 가져오면 된다.
NaverOAuth2UserInfo
public class NaverOAuth2UserInfo extends OAuth2UserInfo {
public NaverOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
// 네이버 응답에서 'response' 속성 내부의 'id' 값을 추출
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return (String) response.get("id");
}
@Override
public String getName() {
// 네이버 응답에서 'response' 속성 내부의 'name' 값을 추출
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return (String) response.get("name");
}
@Override
public String getEmail() {
// 네이버 응답에서 'response' 속성 내부의 'email' 값을 추출
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return (String) response.get("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"
}
}
// 네이버 응답에서 'response' 속성 내부의 'id' 값을 추출
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return (String) response.get("id");
Naver
같은 경우 attributes
속, response
라는 곳 안에 정보들이 위치했다. 따라서 값을 추출할 때에도 먼저 response
를 정의한 다음, response
에서 정보를 추출해야 한다.
여기서 추출하는 값은 id
, name
, email
총 세 개이다. 모두 String
값으로 변환 후 리턴한다.
KakaoOAuth2UserInfo
public class KakaoOAuth2UserInfo extends OAuth2UserInfo {
public KakaoOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
Object id = attributes.get("id");
return id != null ? id.toString() : null; // Long 타입을 String으로 변환
}
@Override
public String getName() {
Map<String, Object> account = (Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> profile = (Map<String, Object>) account.get("profile");
return (String) profile.get("nickname");
}
@Override
public String getEmail() {
Map<String, Object> account = (Map<String, Object>) attributes.get("kakao_account");
return (String) account.get("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
는 가장 복잡한 형태를 띄고 있따. 먼저 id
의 경우 가장 바깥에 위치했지만, nickname
은 kakao_account
속 profile
에, email
은 kakao_account
에 위치했다.
따라서 값을 가져올 때도 각 위치에 알맞게 get()
을 해주어야 한다. 특히 카카오의 id
는 Long
타입인 만큼, toString()
을 통해 String
타입으로 변환해야 한다.
OAuth2UserInfoFactory
OAuth2UserInfoFactory
클래스는 다양한 소셜 로그인 제공자에 대한 사용자 정보를 처리하기 위해 OAuth2UserInfo
의 구체적인 구현체를 생성하는 팩토리 클래스이다.
이 클래스는 클라이언트가 소셜 로그인 제공자에 따라 올바른 OAuth2UserInfo
구현체를 받을 수 있도록 한다.
public class OAuth2UserInfoFactory {
public static OAuth2UserInfo getOAuth2UserInfo(String registrationId, Map<String, Object> attributes) {
if ("google".equalsIgnoreCase(registrationId)) {
return new GoogleOAuth2UserInfo(attributes);
} else if ("kakao".equalsIgnoreCase(registrationId)) {
return new KakaoOAuth2UserInfo(attributes);
} else if ("naver".equalsIgnoreCase(registrationId)) {
return new NaverOAuth2UserInfo(attributes);
} else {
throw new IllegalArgumentException("Invalid registration ID");
}
}
}
구현체 생성:
OAuth2UserInfo
구현체를 생성한다.객체 제공:
OAuth2UserInfo
객체를 제공한다.소셜 로그인 제공자별 로직
public static OAuth2UserInfo getOAuth2UserInfo(String registrationId, Map<String, Object> attributes)
설명: 소셜 로그인 제공자(registrationId
)와 사용자 정보(attributes
)를 기반으로 적절한 OAuth2UserInfo
구현체를 반환하는 정적 메서드
매개변수:
registrationId
: 소셜 로그인 제공자의 식별자 (예: "google"
, "kakao"
, "naver"
등). 이를 통해 어떤 소셜 로그인 제공자로부터 사용자 정보를 받았는지를 판단한다.attributes
: 소셜 로그인 제공자로부터 받은 사용자 정보를 담고 있는 Map<String, Object>
이다. 이 정보를 사용하여 사용자 ID, 이름, 이메일 등을 추출한다.동작:
"google"
이면 GoogleOAuth2UserInfo
객체를 생성하여 반환"kakao"
이면 KakaoOAuth2UserInfo
객체를 생성하여 반환"naver"
이면 NaverOAuth2UserInfo
객체를 생성하여 반환IllegalArgumentException
예외를 발생시킴반환값:
OAuth2UserInfo
구현체 객체OAuth2LoginSuccessHandler
OAuth2LoginSuccessHandler
클래스는 사용자가 OAuth2 로그인에 성공했을 때의 작업을 정의하는 로그인 성공 핸들러이다.
SimpleUrlAuthenticationSuccessHandler
를 확장하여 로그인 성공 후의 커스터마이즈된 처리를 지원한다. 주로 JWT 토큰 발급, 사용자 정보 응답 반환 등을 처리한다.
@Component
@RequiredArgsConstructor
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtTokenProvider jwtTokenProvider;
private final CustomOAuth2UserService oAuth2UserService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException {
// CustomOAuth2User 객체를 생성
CustomOAuth2User customOAuth2User = (CustomOAuth2User) authentication.getPrincipal();
String socialType = customOAuth2User.getSocialType();
// OAuth2UserInfo를 사용하여 사용자 정보를 가져옴
OAuth2UserInfo userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(socialType, customOAuth2User.getAttributes());
User user = oAuth2UserService.getUserByOAuth2UserInfo(userInfo, socialType);
CustomUserDetails userDetails = new CustomUserDetails(user);
// CustomUserDetails를 Authentication으로 변환
Authentication auth = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth);
String accessToken = jwtTokenProvider.createToken(auth);
String refreshToken = jwtTokenProvider.createRefreshToken(auth);
response.setHeader("Authorization", "Bearer " + accessToken);
response.setHeader("Refresh-Token", refreshToken);
JsonResponse jsonResponse = new JsonResponse(
HttpServletResponse.SC_OK,
"로그인 성공",
new UserResponseDTO(customOAuth2User.getName(), customOAuth2User.getEmail())
);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(jsonResponse.toJson());
}
}
JWT 토큰 생성:
CustomUserDetails 생성:
사용자 정보를 기반으로 CustomUserDetails
를 생성하여 Spring Security
의 컨텍스트에 설정한다.
JSON
형식의 응답 반환:
OAuth2LoginSuccessHandler
클래스는 OAuth2 로그인 성공 후 사용자 정보를 처리하고 JWT 토큰을 생성하여 클라이언트에 전달한다.
로그인 성공 시 CustomOAuth2User
객체를 기반으로 사용자 정보를 추출하고 데이터베이스에서 사용자 정보를 가져오거나 새로 생성한다.
CustomUserDetails
를 사용하여 Spring Security의 인증 컨텍스트를 업데이트하며, JwtTokenProvider
를 사용하여 액세스 토큰과 리프레시 토큰을 생성한다.
성공적인 로그인 응답을 JSON
형식으로 클라이언트에 반환하며, 응답 헤더에 JWT 토큰을 설정한다.
{
"statusCode": 200,
"message": "로그인 성공",
"data": {
"username": "홍길동",
"email": "abcd1234@gmail.com"
}
}
OAuth2LoginFailureHandler
사용자가 OAuth2 로그인 실패 후 수행할 작업을 정의하는 로그인 실패 핸들러이다. 실패 시, 실패 메시지와 HTTP 상태 코드를 포함한 JSON
응답을 작성하여 클라이언트에 전달한다.
@Component
public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception) throws IOException {
JsonResponse jsonResponse = new JsonResponse(
HttpServletResponse.SC_UNAUTHORIZED,
"로그인 실패",
null
);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write(jsonResponse.toString());
}
}
JSON
응답 반환:JsonResponse
객체를 생성하여 로그인 실패 메시지와 HTTP 상태 코드를 포함한 JSON 응답을 준비한다.
응답의 상태 코드를 HttpServletResponse.SC_UNAUTHORIZED
(401 Unauthorized
)로 설정한다.
응답의 콘텐츠 타입을 application/json
으로 설정한다.
JSON 응답을 클라이언트에 작성한다.
{
"statusCode": 401,
"message": "로그인 실패",
"data": {
null
}
}
Test
이제 정말 OAuth
로그인 구현이 끝났다. 지금까지 코드를 모두 작성했다면 문제 없이 잘 작동될 것이다.
Google
로그인 테스트링크로 들어가 실제 이메일을 입력하고, <다음> 버튼과 <계속> 버튼으로 로그인을 진행한다.
로그인이 끝나면 화면에 JSON
응답이 띄워진 것을 확인할 수 있다. 또한 DB에도 정상적으로 정보가 들어간다.
Naver
로그인 테스트
Naver
로그인 링크: http://localhost:9090/oauth2/authorization/naver
마찬가지로 링크에 들어가서 실제 네이버 아이디와 비밀번호로 로그인을 진행한다. 결과는 동일하다.
Kakao
로그인 테스트
Kakao
로그인 링크: http://localhost:9090/oauth2/authorization/kakao
카카오 또한 링크에 들어가서 로그인을 진행하면 정상적으로 처리된다.
User
조회OAuth
로그인 테스트 결과, social_id
와 social_type
, email
과 username
등에 알맞은 값이 들어오는 것을 확인할 수 있다.
이번 게시물을 마지막으로 프로젝트는 끝이 났다. 처음에 계획한 것들을 모두 이루었고, 중간에 필요하다 싶은 기능들도 모두 적절히 잘 추가한 것 같아 뿌듯하다.
중간에 다른 것들도 공부하고 준비하느라 3주 좀 넘게 걸려버렸지만 확실히 처음과 비교했을 때 각 기술 스택에 관한 지식도 늘었고, 무엇보다 기존에 부족했던 동작 방식또한 이해할 수 있게 되어 코드를 보는 눈이 넓어진 것 같다.
앞으로도 계속 발전하는 개발자가 되어 더 다양한 프로젝트들을 제작해 보고 싶다!