소셜 로그인 -> 로그아웃 & 회원 탈퇴 포스팅은 다음 링크로 들어가면 볼 수 있습니다.
[재능교환소] Spring Boot: 소셜 로그인 - 로그아웃 & 회원탈퇴(Kakao, Google)
직전 포스팅에서 Spring Boot와 React 사이에 카카오 로그인이 어떤 과정으로 이루어지는지 정리하였다.
[재능교환소] Spring Boot+React 카카오 로그인 흐름 (JWT)
이번 포스팅에서는 Spring Boot의 Spring Security와 OAuth2.0으로 카카오, 구글 로그인 기능을 만들어 보자.
사용자가 서버(스프링)에 접근하기 위해 카카오(혹은 구글) 로그인 창을 클릭한다.
로그인에 성공하면 카카오(혹은 구글)에 등록해 놓은 Redirect URL(http://{서버(스프링)}/login/oauth2/code/{registrationId}?{code} 경로로 요청이 들어온다.
서버(스프링)은 {code} 값을 이용해 카카오(혹은 구글)로 Access Token을 요청한다.
서버(스프링)는 Access Token으로 다시 카카오(혹은 구글)로 scope에 해당하는 사용자 정보를 요청한다. (email, profile 등)
사용자 정보를 받아 DB에 저장된 사용자라면 JWT로 Access Token 발급 & UUID로 Refresh Token를 만들어 반환하고, DB에 없는 사용자라면 회원가입을 진행한다.
만약 Access Token이 만료되면 Refresh Token로 카카오(혹은 구글)에게 Access Token의 재발행을 요청한다.
OAuth2 클라이언트 등록하는 과정은 생략하겠다.
구글과 카카오 각각 서비스를 생성하여 ClientId, ClientSecret, RedirectUrl 등 발급 받은 문자열을 기억해두자.
바로 프로젝트에 적용해보자.
spring:
security:
oauth2:
client:
registration:
google:
client-id: [client-id]
client-secret: [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]
redirect-uri: http://localhost/login/oauth2/code/google
scope: email
kakao:
client-id: [client-id] # 앱키 -> REST API 키
client-secret: [ej9ltR7Q0IA0g3hExhzpDZTMr74VTttN]
redirect-uri: http://localhost/login/oauth2/code/kakao
client-authentication-method: client_secret_post
authorization-grant-type: authorization_code
scope: account_email
client-name: Kakao
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
필자는 scope에 email만 받아 사용하였다.
그 외에 profile, openId는 개발 환경에 따라 추가시켜주면 되겠다.
사실 구현도 중요하지만, 설정이 제일 중요하다.
Spring Boot는 계속해서 버전이 업데이트 되고 있고, 필자도 3.X 버전을 사용하였는데 구현 과정에서 Spring Security에 적용해둔 exceptionHandling
의 authenticationEntryPoint
로 이동하며 계속해서 404 에러를 발생시켰다.
이유는 카카오 설정 부분의 client-authentication-method: client_secret_post
때문이었다.
초기에 Spring security 6 이전에 사용하던 client-authentication-method: POST
가 적용되지 않아 에러가 계속해서 발생했던 것이었고, 이를
client-authentication-method: client_secret_post
로 설정을 변경해주었더니 정상적으로 동작하였다.
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final AuthFilterService authFilterService;
private final AuthenticationProvider authenticationProvider;
private final CustomAuthenticationEntryPoint authenticationEntryPoint;
private final CustomAccessDeniedHandler accessDeniedHandler;
private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler;
private final UserOAuth2Service userOAuth2Service;
@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
//CorsConfigurationSource 인터페이스를 구현하는 익명 클래스 생성하여 getCorsConfiguration() 메소드 재정의
.cors(corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() {
//getCorsConfiguration() 메소드에서 CorsConfiguration 객체를 생성하고 필요한 설정들을 추가
@Override
public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
CorsConfiguration config = new CorsConfiguration();
//허용할 출처(도메인)를 설정
config.setAllowedOrigins(Collections.singletonList("http://localhost:3000"));
//허용할 HTTP 메소드를 설정
config.setAllowedMethods(Arrays.asList("GET", "POST", "DELETE", "PUT", "PATCH"));
//인증 정보 허용 여부를 설정
config.setAllowCredentials(true);
//허용할 헤더를 설정
config.setAllowedHeaders(List.of("*"));
config.setExposedHeaders(Arrays.asList("Authorization"));
//CORS 설정 캐시로 사용할 시간을 설정
config.setMaxAge(3600L);
return config;
}
}))
.csrf(AbstractHttpConfigurer::disable)
//DaoAuthenticationProvider의 세부 내역을 AuthenticationProvider 빈을 만들어 정의했으므로 인증을 구성해줘야 한다.
.authenticationProvider(authenticationProvider)
//.addFilterAfter(csrfCookieFilterService, BasicAuthenticationFilter.class)
.addFilterBefore(authFilterService, UsernamePasswordAuthenticationFilter.class)]
.exceptionHandling(configurer -> configurer
.accessDeniedHandler(accessDeniedHandler)
.authenticationEntryPoint(authenticationEntryPoint)
)
.authorizeHttpRequests((requests) -> requests
.requestMatchers(HttpMethod.PATCH, "/v1/notices/{noticeId}").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/v1/notices/{noticeId}").hasRole("ADMIN")
.requestMatchers("/myBalance").hasAnyRole("USER", "ADMIN")
.requestMatchers("/v1/notices/register").hasRole("ADMIN")
.requestMatchers("/v1/user/**", "/v1/file/**", "/v1/notices/{noticeId}", "/v1/comment/**", "/v1/subjectCategory/**", "/v1/place/**", "/v1/talent/**","/v1/profile/get","/profile", "/actuator/health","/health").permitAll())
.formLogin(Customizer.withDefaults())
.oauth2Login(oauth2 -> oauth2
.successHandler(oAuth2AuthenticationSuccessHandler)
.userInfoEndpoint(userInfoEndpointConfig
-> userInfoEndpointConfig.userService(userOAuth2Service)))
.httpBasic(Customizer.withDefaults());
return http.build();
}
}
위 Security 설정 파일에서 OAuth2를 적용한 부분은 다음과 같다.
.oauth2Login(oauth2 -> oauth2
.successHandler(oAuth2AuthenticationSuccessHandler)
.userInfoEndpoint(userInfoEndpointConfig
-> userInfoEndpointConfig.userService(userOAuth2Service)))
Security 설정에 .oauth2Login()
를 설정에 추가한 후 http://localhost/oauth2/authorization/kakao
로 접속하면 카카오 로그인 화면이 뜰 것이다.
동일하게 http://localhost/oauth2/authorization/google
로 접속하면 구글 로그인 화면이 뜬다.
그렇다면 Security 적용이 1차적으로 잘 된 것이다.
그리고 다음에 .successHandler(oAuth2AuthenticationSuccessHandler)
는 인증 프로세스에 따라 사용자 정의 로직을 실행하겠다는 의미이고,
.userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig.userService(userOAuth2Service)))
는 로그인이 성공하면 유저의 정보를 들고 userOAuth2Service
에서 후처리를 해주겠다는 의미이다.
UserOAuth2Service에 대해 더 자세히 알아보자.
OAuth2 인증을 완료 후 전달 받은 데이터로 서비스에 접근할 수 있는 인증 정보를 생성해주고 사용자 정보를 가져온다.
카카오, 구글 등이 보내주는 데이터가 다르기 때문에 OAuth2Attribute를 구현하여 처리해주었다.
loadUser 메서드는 사용자 정보를 요청할 수 있는 access token 을 얻고나서 실행된다. 회원 정보가 맞는지 확인하는 메서드라고 볼 수 있다.
마지막에 OAuth2User를 반환하면 Spring에서 알아서 Session에 저장해준다.
@Slf4j
@RequiredArgsConstructor
@Service
public class UserOAuth2Service implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService<OAuth2UserRequest, OAuth2User> service = new DefaultOAuth2UserService();
OAuth2User oAuth2User = service.loadUser(userRequest); // OAuth2 정보를 가져옵니다.
log.error("OAuth2User attributes: {}", oAuth2User.getAttributes());
Map<String, Object> originAttributes = oAuth2User.getAttributes(); // OAuth2User의 attribute
// OAuth2 서비스 id (kakao, google)
String registrationId = userRequest.getClientRegistration().getRegistrationId(); // 소셜 정보를 가져옵니다.
// OAuthAttributes: OAuth2User의 attribute를 서비스 유형에 맞게 담아줄 클래스
OAuthAttributes attributes = OAuthAttributes.of(registrationId, originAttributes);
if (!userRepository.findById(attributes.getEmail()).isPresent()) { //db에 해당 회원정보 없다면 저장
userRepository.save(attributes.toEntity());
}
List<GrantedAuthority> authorities = Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"));
return new OAuth2CustomUser(registrationId, originAttributes, authorities, attributes.getEmail());
}
}
@Getter
@ToString
public class OAuthAttributes {
private Map<String, Object> attributes; // OAuth2 반환하는 유저 정보
private String nameAttributesKey;
private String email;
private AuthProvider provider;
@Builder
public OAuthAttributes(Map<String, Object> attributes, String nameAttributesKey, String email,
AuthProvider provider) {
this.attributes = attributes;
this.nameAttributesKey = nameAttributesKey;
this.email = email;
this.provider = provider;
}
public static OAuthAttributes of(String socialName, Map<String, Object> attributes) {
if ("kakao".equals(socialName)) {
return ofKakao("id", attributes);
} else if ("google".equals(socialName)) {
return ofGoogle("sub", attributes);
}
return null;
}
private static OAuthAttributes ofGoogle(String userNameAttributeName, Map<String, Object> attributes) {
return OAuthAttributes.builder()
.email("google_" + String.valueOf(attributes.get("email")))
.attributes(attributes)
.nameAttributesKey(userNameAttributeName)
.provider(AuthProvider.google)
.build();
}
private static OAuthAttributes ofKakao(String userNameAttributeName, Map<String, Object> attributes) {
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
return OAuthAttributes.builder()
.email("kakao_"+ String.valueOf(kakaoAccount.get("email")))
.nameAttributesKey(userNameAttributeName)
.attributes(attributes)
.provider(AuthProvider.kakao)
.build();
}
public User toEntity() {
Authority authority = Authority.builder()
.authorityName("ROLE_USER")
.build();
return User.builder()
.id(email)
.authorities(Collections.singleton(authority))
.provider(provider)
.active(true)
.build();
}
}
소셜 로그인한 회원은 id 값을 소셜 로그인의 이메일을 가져와 kakao_
+email주소
, google_
+email주소
이런식으로 저장하였다.
일반 회원가입한 회원과 구별하기 위해 구분지어 놓은 것이다.
그리고 provider 필드는 소셜 로그인한 회원과 일반 로그인한 회원을 구분짓는 필드라고 볼 수 있다.
@AllArgsConstructor
public class OAuth2CustomUser implements OAuth2User, Serializable {
private String registrationId;
private Map<String, Object> attributes;
private List<GrantedAuthority> authorities;
private String id;
@Override
public Map<String, Object> getAttributes() {
return this.attributes;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getName() {
return this.registrationId;
}
public String getId() {
return id;
}
}
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
@Value("${custom.jwt.secretKey}")
private String secretKeyPlain;
private final RefreshRepository refreshRepository;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
// login 성공한 사용자 목록.
OAuth2CustomUser oAuth2User = (OAuth2CustomUser) authentication.getPrincipal();
String id = oAuth2User.getId(); // OAuth2User로부터 Resource Owner의 이메일 주소를 얻음 객체로부터
//accessToken 생성
String accessToken = generateAccessToken(id);
//refreshToken 생성
String refreshToken = generateRefreshToken(id);
String url = createURI(accessToken, refreshToken).toString();
getRedirectStrategy().sendRedirect(request, response, url);
}
// Redirect URI 생성. JWT를 쿼리 파라미터로 담아 전달한다.
private URI createURI(String accessToken, String refreshToken) {
MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
queryParams.add("access_token", accessToken);
queryParams.add("refresh_token", refreshToken);
return UriComponentsBuilder
.newInstance()
.scheme("http")
.host("localhost")
.port(3000)
.path("/oauth2/redirect")
.queryParams(queryParams)
.build()
.toUri();
}
//AccessToken 생성
private String generateAccessToken(String id) {
return Jwts
.builder().issuer("Skill Exchange").subject("JWT Access Token")
//claim(): 로그인된 유저의 ID, 권한을 채워줌
.claim("id", id)
.claim("authorities", "ROLE_USER")
//issuedAt(): 클라이언트에게 JWT 토큰이 발행시간 설정
.issuedAt(new Date())
//expiration(): 클라이언트에게 JWT 토큰이 만료시간 설정 (하루)
.expiration(new Date((new Date()).getTime() + /*1 * 60 * 1000*/ 24 * 60 * 60 * 1000))
//signWith(): JWT 토큰 속 모든 요청에 디지털 서명을 하는 것, 여기서 위에서 설정한 비밀키를 대입
.signWith(Keys.hmacShaKeyFor(secretKeyPlain.getBytes(StandardCharsets.UTF_8))).compact();
}
//RefreshToken 생성 (이미 있어도 덮어쓰기 가능)
private String generateRefreshToken(String id) {
Refresh redis = Refresh.builder()
.refreshToken(UUID.randomUUID().toString())
.userId(id)
.build();
refreshRepository.save(redis);
return redis.getRefreshToken();
}
}
로그인이 완료된 후 우리 서버에서 쓸 수 있는 jwt 토큰을 발급하는 부분이다.
OAuth2MemberSuccessHandler 에서 Access Token과 Refresh Token을 생성한 후 Redirect Uri에 쿼리 파라미터로 담아 보내주고 있다.
OAuth2MemberSuccessHandler 를 거쳐 URL이 http://localhost:3000/oauth2/redirect?access_token=eyJ...&refresh_token=7d0c5682-...
와 같은 형태로 브라우저에 응답한다. 리액트가 쿼리스트링의 Access_Token과 Refresh_Token을 적절하게 사용하면 된다.
참고로 RefreshToken은 레디스에 저장된다.
스프링부트x리액트 '카카오 로그인 하기' (JWT+OAuth2) [2]
Spring Security - OAuth2와 JWT로 로그인 구현하기(Kakao, Google, Naver)