소셜 로그인 구현
구현 요구사항 중 네이버, 카카오 소셜 로그인 구현이 있어
Spring 에서 제공하는 oauth2-client 모듈을 이용하여 구현했다.
환경
SpringBoot 3.1.5
Java 17
Gradle 8.4
준비사항
build.gradle에서 해당 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
인증 과정
소셜 로그인 인증 과정은 다음과 같습니다.
1, 2. 사용자가 서버에서 설정된 oauth2 login url을 통해 로그인 요청시 (ex. /api/auth/login/{provider})
서버에서 로그인페이지로 리다이렉트시킵니다.
3. 로그인을 완료하게되면 인증서버에서 받은 code 값을 서버에 전달합니다.
4. 사용자로 부터 받은 code 값으로 인증서버에서 사용자 정보를 요청합니다.
5, 6. 받은 사용자 정보로 DB에서 회원을 확인하고 회원정보를 저장 또는 업데이트를 합니다.
7. 사용자 정보를 기반으로 토큰 또는 세션/쿠키를 생성하여 사용자에게 반환합니다.
구현
구현단계는 크게 3가지입니다.
1. 인증서버에 어플리케이션 등록
2. 개발 환경 설정
3. 초기화 및 로그인 기능 구현하기
인증서버 어플리케이션 등록
구현할 로그인은 네이버, 카카오 이므로 각 개발자 센터에서 애플리케이션을 등록해야합니다.
신청방법은 아래 링크를 참고하시면 됩니다.
https://notspoon.tistory.com/41
https://notspoon.tistory.com/34
개발 환경설정
application.properties
1 에서 등록한 후 받은 API 키와 Redirect Url 을 application.properties에 설정해주어야합니다.
#Socal Login
# - Naver
# registration
spring.security.oauth2.client.registration.naver.client-id=${naverClientId} //클라이언트 ID
spring.security.oauth2.client.registration.naver.client-secret=${naverClientSecret} //클라이언트 Secret
//redirect url -> 어플리케이션 등록 때 설정한 redirect url
spring.security.oauth2.client.registration.naver.redirect-uri={baseUrl}/{action}/oauth2/code/{registrationId}
spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.naver.scope=name,email.profile_image //정보제공 항목
spring.security.oauth2.client.registration.naver.client-name=Naver
# provider
spring.security.oauth2.client.provider.naver.authorization-uri=https://nid.naver.com/oauth2.0/authorize
spring.security.oauth2.client.provider.naver.token-uri=https://nid.naver.com/oauth2.0/token
spring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver.com/v1/nid/me
spring.security.oauth2.client.provider.naver.user-name-attribute=response
# - Kakao
# registration
spring.security.oauth2.client.registration.kakao.client-id=${kakaoClientId} //어플리케이션 API Key
spring.security.oauth2.client.registration.kakao.redirect-uri={baseUrl}/{action}/oauth2/code/{registrationId}
spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.kakao.scope=profile_nickname,account_email,profile_nickname
spring.security.oauth2.client.registration.kakao.client-name=Kakao
# provider
spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize
spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token
spring.security.oauth2.client.provider.kakao.user-info-uri=https://kapi.kakao.com/v2/user/me
spring.security.oauth2.client.provider.kakao.user-name-attribute=id
securityConfig.java
스프링 시큐리티 설정에 oauth2-client 설정을 해야합니다.
각 Csutom 코드들은 뒤에서 설명하겠습니다.
http
.oauth2Login(oauth2 -> oauth2
//사용자가 소셜로그인을 하기위한 요청 base url (ex. 네이버로그인 : /api/auth/login/naver)
//해당 url로 요청시 application.properties에서 설정한 key, redirect-url을 가지고 로그인페이지로 리다이렉트합니다.
.authorizationEndpoint(endpointConfig -> endpointConfig.baseUri("/api/auth/login"))
//사용자가 로그인페이지에 로그인 후 전달한 code 값을 통해 인증서버에 사용자 정보를 요청하고 사용자 정보를 처리하는 custom service를 주입합니다.
.userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig.userService(oAuth2UserService))
//인증처리가 성공하면 해당 handler에서 후처리(jwt 생성 후 response에 세팅)를 합니다.
.successHandler(oauth2SuccessHandler)
//인증처리가 실패한 경우 해당 custom handler에서 후처리 (에러응답 세팅을 합니다.
.failureHandler(oauth2AuthenticationFailureHandler)
);
로그인 구현
User.java
소셜로그인 테이블을 따로 생성하지 않고 컬럼으로 각 소셜 id 식별자 컬럼을 추가했습니다.
//User Entity 구현 코드...
@Setter
@Column(name = "kakao_id")
private String kakaoId;
@Setter
@Column(name = "naver_id")
private String naverId;
UserRepository.java
인증서버로부터 받은 사용자의 id 값으로 회원DB에 회원정보를 찾기위한 쿼리메소드를 추가했습니다.
//UserRepository 구현코드..
Optional<User> findByNaverId(String naverId);
Optional<User> findByKakaoId(String kakaoId);
OAuth2UserDetails.java
인증서버로부터 사용자 정보를 받게되면 attributes 라는 객체에 보내지게 됩니다.
인증서버별로 key 값이 다르기 때문에 각각 해당 클래스를 상속받아 구현해주어야합니다.
ex. 네이버 : response, 카카오: properties
public abstract class OAuth2UserDetails {
protected Map<String, Object> attributes;
protected OAuth2UserDetails(Map<String, Object> attributes) {
this.attributes = attributes;
}
public Map<String, Object> getAttributes() {
return attributes;
}
//회원정보를 저장할 때 필요한 필드들 추가
public abstract String getId();
public abstract String getName();
NaverOAuth2UserDetails.java
public class NaverOAuth2UserDetails extends OAuth2UserDetails {
public NaverOAuth2UserDetails(Map<String, Object> attributes) {
super((Map<String, Object>)attributes.get("response"));
}
@Override
public String getId() {
return String.valueOf(attributes.get("id"));
}
@Override
public String getName() {
return String.valueOf(attributes.get("name"));
}
}
KakaoOAuth2UserDetails.java
public class KakaoOAuth2UserDetails extends OAuth2UserDetails {
private final Map<String, Object> properties;
public KakaoOAuth2UserDetails(Map<String, Object> attributes) {
super(attributes);
properties = (Map<String, Object>)attributes.get("properties");
}
@Override
public String getId() {
return String.valueOf(attributes.get("id"));
}
@Override
public String getName() {
return String.valueOf(properties.get("nickname"));
}
@Override
public String getEmail() {
return String.valueOf(properties.get("account_email"));
}
@Override
public String getImageUrl() {
return null;
}
}
OAuth2UserInfoFactory.java
인증서버별 UserDetails를 생성하기 위한 Factory 클래스입니다.
public class OAuth2UserInfoFactory {
private OAuth2UserInfoFactory() {
}
public static OAuth2UserDetails getOAuth2UserInfo(SocialType socialType, Map<String, Object> attributes) {
return switch (socialType) {
case NAVER -> new NaverOAuth2UserDetails(attributes);
case KAKAO -> new KakaoOAuth2UserDetails(attributes);
};
}
}
CustomOAuth2UserService.java
소셜로그인 인증처리를 위한 클래스입니다.
@Slf4j(topic = "oauth2 인증 서비스")
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
public CustomOAuth2UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public OAuth2User loadUser(OAuth2UserRequest oauth2Userrequest) throws
OAuth2AuthenticationException {
OAuth2User oauth2User = super.loadUser(oauth2Userrequest);
try {
return processOAuth2User(oauth2Userrequest, oauth2User);
} catch (Exception exception) {
log.error("OAuth2 Authentication process error");
throw new InternalAuthenticationServiceException(exception.getMessage(), exception.getCause());
}
}
//인증처리로직
private OAuth2User processOAuth2User(
OAuth2UserRequest oauth2Userrequest, OAuth2User oauth2User) {
//인증서버 제공회사명 (Naver, Kakao) -> application.properties에 name으로 설정되어있음
String registrationId = oauth2Userrequest.getClientRegistration().getRegistrationId().toUpperCase();
SocialType socialType = valueOf(registrationId);
//Factory에서 각 인증서버별 UserDetails 생성
OAuth2UserDetails oAuth2UserDetails = OAuth2UserInfoFactory.getOAuth2UserInfo(socialType,
oauth2User.getAttributes());
return new CustomUserDetails(findUser(socialType, oAuth2UserDetails), oauth2User.getAttributes());
}
private User findUser(SocialType socialType, OAuth2UserDetails oauth2Userdetails) {
Optional<User> userOptional = switch (socialType) {
case NAVER -> userRepository.findByNaverId(oauth2Userdetails.getId());
case KAKAO -> userRepository.findByKakaoId(oauth2Userdetails.getId());
};
return userOptional.orElseGet(() -> createUser(socialType, oauth2Userdetails));
}
//DB에 회원 정보가 없는경우 생성 후 저장
private User createUser(SocialType socialType, OAuth2UserDetails oauth2Userdetails) {
String username = switch (socialType) {
case KAKAO -> socialType.getCode() + oauth2Userdetails.getId();
case NAVER -> socialType.getCode() + oauth2Userdetails.getId().substring(0, 10);
};
User user = new User(
username,
"NO_PASSWORD",
UserRoleEnum.USER
);
switch (socialType) {
case KAKAO -> user.setKakaoId(oauth2Userdetails.getId());
case NAVER -> user.setNaverId((oauth2Userdetails.getId()));
}
return userRepository.save(user);
}
OAuth2AuthenticationSuccessHandler.java
위에 CustomOAuth2UserService 에서 인증처리가 모두 완료되는 경우 실행됩니다.
@Slf4j
@RequiredArgsConstructor
@Component
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtProvider jwtProvider;
private final RefreshTokenRepository refreshTokenRepository;
private final ObjectMapper objectMapper;
@Override
public void onAuthenticationSuccess(
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException, ServletException {
CustomUserDetails userDetails = (CustomUserDetails)authentication.getPrincipal();
User user = userDetails.getUser();
TokenDto tokenDto = jwtProvider.createToken(user.getUsername(), user.getRole());
// DB 저장
RefreshToken refreshToken = RefreshToken.of(user.getUsername(), tokenDto.getRefreshToken());
refreshTokenRepository.save(refreshToken);
//response 저장
jwtProvider.setTokenResponse(tokenDto, response);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setStatus(ResponseCode.LOGIN.getHttpStatus());
response.getWriter().write(
objectMapper.writeValueAsString(
BaseResponse.of(
ResponseCode.LOGIN.getMessage(),
ResponseCode.LOGIN.getHttpStatus(),
""
)
)
);
}
OAuth2AuthenticationFailuereHandler.java
위에 CustomOAuth2UserService 에서 인증처리가 실패할 경우 실행됩니다.
@Slf4j
@Component
public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
private final ObjectMapper objectMapper;
public OAuth2AuthenticationFailureHandler(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception)
throws IOException {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setStatus(INTERNAL_SERVER_ERROR.getHttpStatus());
response.getWriter().write(
objectMapper.writeValueAsString(
BaseResponse.of(
INTERNAL_SERVER_ERROR.getMessage(),
INTERNAL_SERVER_ERROR.getHttpStatus(),
""
)
)
);
}
}
테스트
http://localhost:8080/api/auth/login/naver 로 로그인 요청 <- Security Config에서 설정한 base url
로그인 후 인증 성공
로그인 후 인증 처리 실패
직접 RestTemplate을 사용하여 소셜로그인을 구현하지 않고 oauth2-client 모듈을 활용하여
소셜로그인을 구현해보았습니다.
궁금하신 점을 댓글로 달아주시면 아는 선까지 답변드리겠습니다
감사합니다!
'TIL' 카테고리의 다른 글
2023.12.11 TIL (3) | 2023.12.11 |
---|---|
2023.12.10 TIL - oauth2-client 인증 실패 핸들러 (0) | 2023.12.10 |
2023.12.07 TIL - Refresh Token 재발급 구현하기 (1) | 2023.12.08 |
2023.12.06 TIL - 스프링 시큐리티 RequestMatchers (1) | 2023.12.06 |
2023.12.05 TIL - 팀 프로젝트 시작 (1) | 2023.12.05 |