개발일기
React + Spring Oauth2 구글, 카카오 로그인 본문
build.gradle 설정을 해주었다.
//oauth2
implementation 'org.springframework.security:spring-security-oauth2-client'
implementation 'org.springframework.security:spring-security-oauth2-jose'
Oauth2.0 설정을 위하여 로그인 흐름을 그려보았다.
1. 로그인 요청
기본적으로 Spring Security에서 기본적으로 제공하는 URL이 있음.
http://{domain}/oauth2/authorization/{registrationId} 해당 URL을 시큐리티에서 구현해두었고, 따로 Controller를 제작하지 않는다.
2. 리다이렉트 URL
SpringSecurity에서 기본적으로 제공하는 URL이 있다. http://{domain}/login/oauth2/code/{registrationId}
Application.yml Oauth2설정
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope: profile, email
kakao:
client-id: ${KAKAO_CLIENT_ID}
client-secret: ${KAKAO_CLIENT_SECRET}
redirect-uri: https://photocard.site/login/oauth2/code/kakao
client-authentication-method: client_secret_post
authorization-grant-type: authorization_code
scope: profile_nickname, profile_image, account_email
client-name: Kakao
provider:
naver:
authorization-uri: https://nid.naver.com/oauth2.0/authorize
token-uri: https://nid.naver.com/oauth2.0/token
user-info-uri: https://openai.naver.com/v1/nid/me
user_name_attribute: response
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
해당 부분에 대한 설정 방법을 카카오 developer, google devleoper로 가서 설정해주면 된다.
이제 Oauth2.0 관련 클래스를 생성해주자.
내 서비스에서도 해당 회원에대한 useremail과 토큰을 저장해줘야 하기에 email, role필드를 따로 커스텀하여 추가해주자.
CustomOauth2.0을 구현은 추가정보들을 내서비스에서 가지고자 받게되었다.
CustomOauth2User.class
package com.realworld.feature.oauth.domain;
import com.realworld.feature.auth.Authority;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import java.util.Collection;
import java.util.Map;
public class CustomOAuth2User extends DefaultOAuth2User {
private final String email;
private final Authority authority;
public CustomOAuth2User(Collection<? extends GrantedAuthority> authorities, Map<String, Object> attributes, String nameAttributeKey, String email, Authority authority) {
super(authorities, attributes, nameAttributeKey);
this.email = email;
this.authority = authority;
}
}
OauthAttributes
각 소셜에서 받아오는 데이터가 다르므로, 소셜별로 받는 데이터를 분기처리하는 DTO 클래스이다.
package com.realworld.feature.oauth.domain;
import com.realworld.feature.auth.Authority;
import com.realworld.feature.member.domain.Member;
import com.realworld.feature.oauth.domain.user.GoogleOAuth2UserInfo;
import com.realworld.feature.oauth.domain.user.KakaoOAuth2UserInfo;
import com.realworld.feature.oauth.domain.user.OAuth2UserInfo;
import com.realworld.global.category.SocialType;
import lombok.Builder;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
/**
* 각 소셜에서 받아오는 데이터가 다르므로
* 소셜별로 데이터를 받는 데이터를 분기 처리하는 클래스
*/
@Getter
@Slf4j
public class OAuthAttributes {
private final String nameAttributeKey; // OAuth2 로그인 진행 시 키가 되는 필드 값, PK와 같은 의미
private final 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) {
log.info("Extracting OAuthAttributes for socialType: {}", socialType);
log.info("attributes :: {}", attributes);
if (socialType == SocialType.KAKAO) {
return ofKakao(userNameAttributeName, attributes);
}
return ofGoogle(userNameAttributeName, attributes);
}
public 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 Member toEntity(SocialType socialType, OAuth2UserInfo oAuth2UserInfo) {
return Member.builder()
.userId(oAuth2UserInfo.getId())
.userEmail(oAuth2UserInfo.getUserEmail())
.oauthImage(oAuth2UserInfo.getImageUrl())
.nickname(oAuth2UserInfo.getNickname())
.authority(Authority.ROLE_USER)
.build();
}
}
빌더 부분
public Member toEntity(SocialType socialType, OAuth2UserInfo oAuth2UserInfo) {
return Member.builder()
.userId(oAuth2UserInfo.getId())
.userEmail(oAuth2UserInfo.getUserEmail())
.oauthImage(oAuth2UserInfo.getImageUrl())
.nickname(oAuth2UserInfo.getNickname())
.authority(Authority.ROLE_USER)
.build();
}
Oauth2 로그인시 진행되는 값들로 OAuth2UserInfo에는 소셜 타입별 해당하는 유저 정보가 들어있다.
SocialType에 해당하는 of 메소드이다.
public static OAuthAttributes of(SocialType socialType, String userNameAttributeName, Map<String, Object> attributes) {
log.info("Extracting OAuthAttributes for socialType: {}", socialType);
log.info("attributes :: {}", attributes);
if (socialType == SocialType.KAKAO) {
return ofKakao(userNameAttributeName, attributes);
}
return ofGoogle(userNameAttributeName, attributes);
}
각각의 소셜 타입에 맞게 OAuth2Attributes를 생성해줍니다.
OAuth2UserInfo.class
package com.realworld.feature.oauth.domain.user;
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();
public abstract String getNickname();
public abstract String getUserEmail();
public abstract String getImageUrl();
}
추상 클래스를 상속 받는 클래스에서만 사용할 수 있도록 protected 제어자를 사용한다.
public GoogleOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
생성자로 소셜 타입별 유저 정보 attributes 주입받아서 각 소셜 타입별 유저 정보 attributes를 주입 받아서 각 소셜 타입별 유저 정보 클래스가 소셜 타입에 맞는 attributes를 주입받아 가지도록 하였습니다.
구글에 해당하는 GoogleOAuth2UserInfo 코드
package com.realworld.feature.oauth.domain.user;
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 getUserEmail() {
return (String) attributes.get("email");
}
@Override
public String getImageUrl() {
return (String) attributes.get("picture");
}
}
KakaoOauth2UserInfo.class
package com.realworld.feature.oauth.domain.user;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
@Slf4j
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 getUserEmail() {
Map<String, Object> account = (Map<String, Object>) attributes.get("kakao_account");
log.info("email :: {}", account.get("email"));
return (String) account.get("email");
}
@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");
}
}
CustomOauth2UserService.class
package com.realworld.feature.oauth.service;
import com.realworld.feature.member.domain.Member;
import com.realworld.feature.member.entity.MemberJpaEntity;
import com.realworld.feature.member.repository.MemberRepository;
import com.realworld.feature.oauth.domain.CustomOAuth2User;
import com.realworld.feature.oauth.domain.OAuthAttributes;
import com.realworld.global.category.SocialType;
import jakarta.transaction.Transactional;
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;
import static com.realworld.global.category.SocialType.KAKAO;
import static com.realworld.global.category.SocialType.NAVER;
@Slf4j
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final MemberRepository memberRepository;
@Transactional
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
log.info("로그인 요청 진입");
System.out.println("getClientRegistration: " + userRequest.getClientRegistration());
System.out.println("getAccessToken: " + userRequest.getAccessToken().getTokenValue());
/**
* 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();
log.info("registrationId :: {} ", registrationId);
SocialType socialType = getSocialType(registrationId);
log.info("socialType :: {}", socialType);
// 3. userNameAttributeName 가져오기
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(); // OAuth2 로그인 시 키(PK)가 되는 값
Map<String, Object> attributes = oAuth2User.getAttributes(); // 소셜 로그인에서 API가 제공하는 userInfo의 Json (유저 정보들)
log.info("registrationId = {}", registrationId);
log.info("userNameAttributeName = {}", userNameAttributeName);
// socialType에 따라 유저정보를 통해 OAuthAttributes 객체 생성
OAuthAttributes extractAttributes = OAuthAttributes.of(socialType, userNameAttributeName, attributes);
Member createMember = getMember(extractAttributes, socialType);
// DefaultOAuth2User를 구현한 CustomOAuth2User 객체를 생성해서 반환
return new CustomOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(createMember.getAuthority().toString())),
attributes,
extractAttributes.getNameAttributeKey(),
createMember.getUserEmail(),
createMember.getAuthority()
);
}
/**
* TODO: SocialType,
* SocialType과 attributes에 들어있는 소셜 로그인의 식별값 id를 통해 회원을 찾아 반환하는 메소드
* 만약 찾은 회원이 있다면, 그대로 반환하고 없다면 save를 통하여 회원을 저장한다.
*
* @param attributes
* @param socialType
* @return
*/
private Member getMember(OAuthAttributes attributes, SocialType socialType) {
log.info("attributes :: {}", attributes);
MemberJpaEntity findMemberEntity = memberRepository.findById(attributes.getOAuth2UserInfo().getId()).orElse(null);
if (findMemberEntity == null) {
return saveMember(attributes, socialType);
}
return findMemberEntity.toDomain();
}
private Member saveMember(OAuthAttributes attributes, SocialType socialType) {
Member createdMember = attributes.toEntity(socialType, attributes.getOAuth2UserInfo());
return memberRepository.save(createdMember.toEntity()).toDomain();
}
private SocialType getSocialType(String registrationId) {
if (NAVER.equals(registrationId.toUpperCase())) {
return SocialType.NAVER;
}
if (KAKAO.equals(registrationId.toUpperCase())) {
return SocialType.KAKAO;
}
return SocialType.GOOGLE;
}
}
OAuth2UserService 에 해당하는 코드입니다.
OAuth2LoginSuccessHandler 전체 코드
package com.realworld.feature.oauth.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.realworld.feature.token.domain.Token;
import com.realworld.feature.token.service.TokenCommandService;
import com.realworld.global.config.jwt.JwtTokenProvider;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
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 org.springframework.web.util.UriComponentsBuilder;
import java.io.IOException;
@Slf4j
@RequiredArgsConstructor
@Component
public class OAuth2SuccessHandler implements AuthenticationSuccessHandler {
private static final String URI = "/";
private final JwtTokenProvider tokenProvider;
private final TokenCommandService tokenCommandService;
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
log.info("OAuth2 login 성공!");
log.info("{}", authentication.getName());
log.info("{}", authentication);
Token token = tokenProvider.createToken(authentication);
token.setUserId(authentication.getName());
tokenCommandService.saveToken(token);
String redirectUrl = UriComponentsBuilder.fromUriString(URI)
.build().toUriString();
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
String result = objectMapper.writeValueAsString(token);
response.getWriter().write(result);
response.sendRedirect(redirectUrl);
}
}
Oauth2.0FailureHandler 코드
package com.realworld.feature.oauth.handler;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;
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());
}
}
시큐어리티 설정코드
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.cors(httpSecurityCorsConfigurer -> httpSecurityCorsConfigurer.configurationSource(corsConfigurationSource()))
//.headers(headers->
// headers.frameOptions(frameOptionsConfig -> frameOptionsConfig.sameOrigin()))
//.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(authenticationManager -> authenticationManager
.authenticationEntryPoint(new JwtAuthenticationEntryPoint())
.accessDeniedHandler(new JwtAccessDeniedHandler()))
.sessionManagement((httpSecuritySessionManagementConfigurer ->
httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)))
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, getExcludeDevURI).permitAll()
.requestMatchers(HttpMethod.GET, getExcludeLocalURI).permitAll()
.requestMatchers(excludeDevURI).permitAll()
.requestMatchers(excludeLocalURI).permitAll()
.anyRequest()
.authenticated()
)
.oauth2Login(oauth ->
oauth.userInfoEndpoint(c -> c.userService(oAuth2UserService))
.successHandler(oAuth2SuccessHandler)
.failureHandler(oAuth2LoginFailureHandler)
)
.apply(new JwtSecurityConfig(jwtTokenProvider));
return http.build();
}
.oauth2Login(oauth ->
oauth.userInfoEndpoint(c -> c.userService(oAuth2UserService))
.successHandler(oAuth2SuccessHandler)
.failureHandler(oAuth2LoginFailureHandler)
)
Security 코드에 해당 부분을 추가해줍니다.
'photocard backend server 개발일기' 카테고리의 다른 글
STOMP 뿌셔버리기 (0) | 2024.07.13 |
---|---|
Web Socket 파헤쳐보기 (8) | 2024.07.13 |
Spring Github Action CI/CD docker-compose 자동화 배포 구축-완- (0) | 2024.07.01 |
Docker-compose Next.js spring-boot 배포하기 (0) | 2024.06.09 |
docker spring build 하여 배포하기 (1) | 2024.06.06 |