Google, Kakao, Naver 등 다양한 환경에 확장성을 갖춘 형태로 소셜 로그인을 구현해보자.
일단 Kako, Naver 두 환경을 대상으로 하며, 각 Developers 홈페이지에서 Application을 생성한 상태임을 가정하고 진행할 것이다.
참고 사항
카카오 로그인의 경우 '개인정보 동의항목 심사 신청'이 완료되기 전까지는 닉네임, 카카오계정(이메일) 등의 데이터만 불러올 수 있다. 본 글에서는 일단 닉네임과 이메일을 불러오는 것으로 진행하겠다.
네이버 로그인의 경우 서비스 상태가 '개발중'인 경우, 테스터 ID 등록 후 해당 계정에 한해서만 소셜 로그인을 적용해볼 수 있다. Naver Developers/내 애플리케이션/멤버관리에서 테스터 ID를 등록하고 진행하자.
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
application.yml
spring:
security:
oauth2:
authorizedRedirectUri: https://surveasy-workspace.web.app
authorizedRedirectUri-local: http://localhost:8080
client:
registration:
kakao:
client-id: {{앱 설정/요약 정보/REST API KEY}}
client-secret: {{제품 설정/카카오 로그인/보안/Client Secret}}
redirect-uri: "{baseUrl}/oauth2/callback/{registrationId}"
authorization-grant-type: authorization_code
scope:
- nickname
- account_email
provider:
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize
token-uri: https://kauth.kakao.com/oauth/token
user-info-uri: https://kapi.kakao.com/v2/user/me
user-name-attribute: id
1. 사용자 관련 클래스
1-1. User.java
@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Where(clause = "deleted_at IS NULL")
@SQLDelete(sql = "UPDATE user SET deleted_at = CURRENT_TIMESTAMP where user_id = ?")
public class User {
@Id
@Column(name = "user_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotNull
@Size(max = 100)
private String email;
@NotNull
@Size(max = 20)
private String name;
@NotNull
@Size(max = 128)
@JsonIgnore
private String password;
@NotNull
@Column(length = 15)
private String phoneNum;
@NotNull
@Column(length = 10)
private String signedUpAt;
@Nullable
@Column(length = 10)
@JsonFormat(shape= JsonFormat.Shape.STRING, pattern="yyyy-MM-dd")
private LocalDate deletedAt;
@NotNull
@Enumerated(EnumType.STRING)
private UserRole role;
@Nullable
@Enumerated(EnumType.STRING)
private AuthProvider authProvider;
@Builder
private User(String email, String name, String password, String phoneNum,
AuthProvider authProvider) {
this.name = name;
this.email = email;
this.password = password;
this.phoneNum = phoneNum;
if(authProvider != null) this.authProvider = authProvider;
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
this.signedUpAt = simpleDateFormat.format(new Date());
this.role = UserRole.ROLE_ANONYMOUS;
}
public static User of(UserSignUpRequestDTO userSignUpRequestDTO, String encodedPassword) {
return User.builder()
.email(userSignUpRequestDTO.getUserVo().getEmail())
.name(userSignUpRequestDTO.getUserVo().getName())
.password(encodedPassword)
.phoneNum(userSignUpRequestDTO.getUserVo().getPhoneNum())
.build();
}
public static User ofOAuth(OAuth2UserInfo oAuth2UserInfo, AuthProvider authProvider) {
return User.builder()
.email(oAuth2UserInfo.getEmail())
.name(oAuth2UserInfo.getName())
.password(oAuth2UserInfo.getOAuth2Id())
.phoneNum(oAuth2UserInfo.getPhoneNum())
.authProvider(authProvider)
.build();
}
public User updateFrom(OAuth2UserInfo oAuth2UserInfo) {
this.password = oAuth2UserInfo.getOAuth2Id();
this.name = oAuth2UserInfo.getName();
this.email = oAuth2UserInfo.getEmail();
return this;
}
}
1-2. PrincipalDetails .java
@Data
public class PrincipalDetails implements UserDetails, OAuth2User {
private User user;
private boolean duplicateEmail = false; // 추가
@Setter
private Map<String, Object> attributes; // 추가
public PrincipalDetails(User user) {
this.user = user;
}
public Long getId() {
return user.getId();
}
@Override
public Map<String, Object> getAttributes() {
return null;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add((GrantedAuthority) () -> String.valueOf(user.getRole()));
return collection;
}
@Override
public String getPassword() {
return null;
}
@Override
public String getUsername() {
return user.getEmail();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
@Override
public String getName() {
return null;
}
}
2. OAuth2.0 데이터 관련 클래스
2-1. AuthProvider.java
import lombok.Getter;
@Getter
public enum AuthProvider {
KAKAO("카카오"),
NAVER("네이버");
private final String value;
AuthProvider(String value) {
this.value = value;
}
}
2-2. OAuth2UserInfo.java
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Map;
@Getter
@AllArgsConstructor
public abstract class OAuth2UserInfo {
protected Map<String, Object> attributes;
public abstract String getOAuth2Id();
public abstract String getEmail();
public abstract String getName();
public abstract String getPhoneNumber();
}
2-2-1. KakaoOAuth2User.java
import java.util.Map;
public class KakaoOAuth2User extends OAuth2UserInfo {
private Long id;
public KakaoOAuth2User(Map<String, Object> attributes) {
super((Map<String, Object>) attributes.get("kakao_account"));
this.id = (Long) attributes.get("id");
}
@Override
public String getOAuth2Id() {
return this.id.toString();
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getName() {
return "";
// 개인정보동의항목 승인 후 return (String) attributes.get("name");
}
@Override
public String getPhoneNum() {
return "";
// 개인정보동의항목 승인 후 return "0" + ((String) attributes.get("phone_number")).substring(4);
}
}
2-2-2. NaverOAuth2UserInfo.java
import java.util.Map;
public class NaverOAuth2User extends OAuth2UserInfo {
public NaverOAuth2User(Map<String, Object> attributes) {
super((Map<String, Object>) attributes.get("response"));
}
@Override
public String getOAuth2Id() {
return (String) attributes.get("id");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getName() {
return (String) attributes.get("name");
}
@Override
public String getPhoneNum() {
return (String) attributes.get("mobile");
}
}
2-3. OAuth2UserInfoFactory.java
import java.util.Map;
public class OAuth2UserInfoFactory {
public static OAuth2UserInfo getOAuth2UserInfo(AuthProvider authProvider, Map<String, Object> attributes) {
switch (authProvider) {
case KAKAO -> {
return new KakaoOAuth2User(attributes);
}
case NAVER -> {
return new NaverOAuth2User(attributes);
}
default -> throw new IllegalArgumentException("Invalid Provider Type");
}
}
}
2-4. CustomOAuth2UserService.java
@Component
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) throws OAuth2AuthenticationException {
OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService = new DefaultOAuth2UserService();
OAuth2User oAuth2User = oAuth2UserService.loadUser(oAuth2UserRequest);
return processOAuth2User(oAuth2UserRequest, oAuth2User);
}
protected OAuth2User processOAuth2User(OAuth2UserRequest oAuth2UserRequest, OAuth2User oAuth2User) {
AuthProvider authProvider = AuthProvider.valueOf(oAuth2UserRequest.getClientRegistration().getRegistrationId().toUpperCase());
OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(authProvider, oAuth2User.getAttributes());
if(!StringUtils.hasText(oAuth2UserInfo.getEmail())) {
throw new RuntimeException("Email Not Found From OAuth2 Provider");
}
User user = userRepository.findByEmail(oAuth2UserInfo.getEmail()).orElse(null);
// 이미 회원 가입된 이메일
if(user != null) {
// 이메일-PW 회원가입했던 메일 or 다른 provider에서 시도
if(user.getAuthProvider() == null || !user.getAuthProvider().equals(authProvider)) {
PrincipalDetails principalDetails = new PrincipalDetails(user);
principalDetails.setDuplicateEmail(true);
return principalDetails;
}
// 동일 provider에서 시도
else {
user = updateUser(user, oAuth2UserInfo);
}
}
// 신규 가입
else {
user = registerUser(oAuth2UserInfo, authProvider);
}
return new PrincipalDetails(user);
}
private User registerUser(OAuth2UserInfo oAuth2UserInfo, AuthProvider authProvider) {
User user = User.ofOAuth(oAuth2UserInfo, authProvider);
return userRepository.save(user);
}
private User updateUser(User user, OAuth2UserInfo oAuth2UserInfo) {
return userRepository.save(user.updateFrom(oAuth2UserInfo));
}
}
3. Cookie 관련 클래스
3-1. CookieUtils.java
public class CookieUtils {
public static Optional<Cookie> getCookie(HttpServletRequest request, String name) {
Cookie[] cookies = request.getCookies();
if(cookies != null && cookies.length > 0) {
for(Cookie cookie : cookies) {
if(cookie.getName().equals(name)) {
return Optional.of(cookie);
}
}
}
return Optional.empty();
}
public static Optional<String> readServletCookie(HttpServletRequest request, String name) {
return Arrays.stream(request.getCookies())
.filter(cookie -> name.equals(cookie.getName()))
.map(Cookie::getValue)
.findAny();
}
public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) {
Cookie cookie = new Cookie(name, value);
cookie.setPath("/");
cookie.setHttpOnly(true);
cookie.setMaxAge(maxAge);
response.addCookie(cookie);
}
public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) {
Cookie[] cookies = request.getCookies();
if(cookies != null && cookies.length > 0) {
for(Cookie cookie : cookies) {
if(cookie.getName().equals(name)) {
cookie.setValue("");
cookie.setPath("/");
cookie.setMaxAge(0);
response.addCookie(cookie);
}
}
}
}
public static String serialize(Object object) {
return Base64.getUrlEncoder().encodeToString(SerializationUtils.serialize(object));
}
public static <T> T deserialize(Cookie cookie, Class<T> tClass) {
return tClass.cast(SerializationUtils.deserialize(Base64.getUrlDecoder().decode(cookie.getValue())));
}
}
3-2. CookieAuthorizationRequestRepository.java
@Component
public class CookieAuthorizationRequestRepository implements AuthorizationRequestRepository {
public static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request";
public static final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri";
private static final int COOKIE_EXPIRE_SECONDS = 180;
@Override
public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
return CookieUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME)
.map(cookie -> CookieUtils.deserialize(cookie, OAuth2AuthorizationRequest.class))
.orElse(null);
}
@Override
public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {
if(authorizationRequest == null) {
CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
return;
}
CookieUtils.addCookie(
response,
OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME,
CookieUtils.serialize(authorizationRequest),
COOKIE_EXPIRE_SECONDS
);
String redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);
if(StringUtils.isNoneBlank(redirectUriAfterLogin)) {
CookieUtils.addCookie(
response,
REDIRECT_URI_PARAM_COOKIE_NAME,
redirectUriAfterLogin,
COOKIE_EXPIRE_SECONDS
);
}
}
@Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) {
return this.loadAuthorizationRequest(request);
}
public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) {
CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
}
}
4. Spring Security 관련 클래스
4-1. OAuth2AuthenticationSuccessHandler
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
@Value("${spring.security.oauth2.authorizedRedirectUri}")
private String redirectUri;
@Value("${spring.security.oauth2.authorizedRedirectUri-local}")
private String redirectUriLocal;
private final TokenProvider tokenProvider;
private final CookieAuthorizationRequestRepository cookieAuthorizationRequestRepository;
private final ObjectMapper objectMapper;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
String targetUrl;
if(((PrincipalDetails) authentication.getPrincipal()).isDuplicateEmail())
targetUrl = determineTargetUrlException(request, response);
else
targetUrl = determineTargetUrl(request, response, authentication);
if(response.isCommitted()) {
logger.debug("Response has already been committed");
return;
}
clearAuthenticationAttributes(request, response);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
protected String getRedirectUri(HttpServletRequest request, HttpServletResponse response) {
Optional<String> redirectUri = CookieUtils.getCookie(request, CookieAuthorizationRequestRepository.REDIRECT_URI_PARAM_COOKIE_NAME)
.map(Cookie::getValue);
if(redirectUri.isPresent() && !isAuthorizedRedirectUri(redirectUri.get())) {
throw new RuntimeException("Redirect URIs are not matched");
}
return redirectUri.orElse(getDefaultTargetUrl());
}
protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
String targetUrl = getRedirectUri(request, response);
TokenResponse tokenResponse = TokenResponse.of(
tokenProvider.createOAuth2AccessToken(authentication),
tokenProvider.createOAuth2RefreshToken(authentication)
);
String result = null;
try {
result = objectMapper.writeValueAsString(tokenResponse);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
return UriComponentsBuilder.fromHttpUrl(targetUrl)
.queryParam("result", result)
.build().toUriString();
}
protected String determineTargetUrlException(HttpServletRequest request, HttpServletResponse response) {
String targetUrl = getRedirectUri(request, response);
String error = null;
try {
error = objectMapper.writeValueAsString(Oauth2DuplicateUser.EXCEPTION.getErrorReason());
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
return UriComponentsBuilder.fromHttpUrl(targetUrl)
.queryParam("error", error)
.build().toUriString();
}
protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
super.clearAuthenticationAttributes(request);
cookieAuthorizationRequestRepository.removeAuthorizationRequest(request, response);
}
private boolean isAuthorizedRedirectUri(String uri) {
URI clientRedirectUri = URI.create(uri);
URI authorizedUri = URI.create(redirectUri);
URI authorizedUriLocal = URI.create(redirectUriLocal);
if((authorizedUri.getHost().equalsIgnoreCase(clientRedirectUri.getHost()) && authorizedUri.getPort() == clientRedirectUri.getPort())
|| ((authorizedUriLocal.getHost().equalsIgnoreCase(clientRedirectUri.getHost()) && authorizedUriLocal.getPort() == clientRedirectUri.getPort()))) {
return true;
}
return false;
}
}
4-2. OAuth2AuthenticationFailureHandler
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
private final CookieAuthorizationRequestRepository cookieAuthorizationRequestRepository;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authenticationException) throws IOException {
String targetUrl = CookieUtils.getCookie(request, CookieAuthorizationRequestRepository.REDIRECT_URI_PARAM_COOKIE_NAME)
.map(Cookie::getValue)
.orElse("/");
targetUrl = UriComponentsBuilder.fromUriString(targetUrl)
.queryParam("error", authenticationException.getLocalizedMessage())
.build().toUriString();
cookieAuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
}
4-3. WebSecurityConfig
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(configurationSource()))
.csrf(AbstractHttpConfigurer::disable)
.headers(headers -> {
headers.defaultsDisabled().frameOptions(Customizer.withDefaults());
})
.sessionManagement(sessionManagement -> {
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
})
.authorizeHttpRequests(request -> {
request.requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
.requestMatchers(SwaggerPatterns).permitAll()
.
.
.
.anyRequest().permitAll();
})
.oauth2Login(oauth2 -> {
oauth2.authorizationEndpoint(auth -> auth.baseUri("/oauth2/authorize")
.authorizationRequestRepository(cookieAuthorizationRequestRepository))
.redirectionEndpoint(redirect -> redirect.baseUri("/oauth2/callback/*"))
.userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService))
.successHandler(oAuth2AuthenticationSuccessHandler)
.failureHandler(oAuth2AuthenticationFailureHandler);
})
.logout(logout -> {
logout.clearAuthentication(true)
.deleteCookies("JSESSIONID");
})
.exceptionHandling(exceptionHandling -> {
exceptionHandling.accessDeniedHandler(jwtAccessDeniedHandler)
.authenticationEntryPoint(jwtAuthenticationEntryPoint);
})
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtExceptionHandlerFilter, JwtAuthenticationFilter.class)
.addFilterBefore(accessDeniedFilter, AuthorizationFilter.class);
return http.build();
}
5. 로그인 요청
5-1. 카카오 로그인
<a href="https://서버URL/oauth2/authorize/kakao?redirect_uri=http://localhost:3000/success(원하는redirect경로)">카카오 로그인</a>
5-2. 네이버 로그인
<a href="https://서버URL/oauth2/authorize/naver?redirect_uri=http://localhost:3000/success(원하는redirect경로)">네이버 로그인</a>
[참고 문서]
https://www.callicoder.com/spring-boot-security-oauth2-social-login-part-2
'Spring > Spring Boot' 카테고리의 다른 글
[Spring Boot - Zoom] 회의실 자동 생성 #2. REST API (0) | 2024.01.03 |
---|---|
[Spring Boot - Zoom] 회의실 자동 생성 #1. OAuth2.0 (0) | 2024.01.03 |
[Spring Boot - Firebase] 연동하기 (0) | 2023.10.28 |
[Spring Boot] 주기적 코드 실행 (0) | 2023.10.23 |
[Spring Boot - JPA] Entity의 Default Value 지정하기 (0) | 2023.08.21 |