백엔드에서
자체로그인 + 소셜로그인
을 모두 처리하는 경우 회원탈퇴에 어려움을 겪는 분들이 보여서 공유하고자 작성하는 글입니다. 저는Redis
를 통해서 처리하였고 다른DB
에테이블
혹은컬럼
을 지정해서 사용하셔도 될 것 같습니다.
필자는 소셜 회원 탈퇴시 엔드포인트를 여러개 만드는 것이 아닌
/users
하나의 엔드포인트를 통해 모두 처리하도록 구현하였습니다. (프론트단의 개발 피로도를 줄여 줍니다.
)
import './App.css';
const onNaverLogin = () => {
window.location.href = 'http://localhost:8080/oauth2/authorization/naver';
};
const onKakaoLogin = () => {
window.location.href = 'http://localhost:8080/oauth2/authorization/kakao';
};
const onGoogleLogin = () => {
window.location.href = 'http://localhost:8080/oauth2/authorization/google';
};
function App() {
return (
<>
<h1>소셜 로그인 테스트</h1>
<h1>OAuth2 Login & SignUp </h1>
<button onClick={onNaverLogin} className="naver">
Naver
</button>
<button onClick={onKakaoLogin} className="kakao">
Kakao
</button>
<button onClick={onGoogleLogin} className="google">
Google
</button>
</>
);
}
export default App;
CutomOAuth2UserService
에서 provider
추출 과 함께 accessToken Redis
에 저장 (소셜로그인 플랫폼 accessToken 시간은 3600초
이므로 redis의 값 유지 시간을 3600초
로 일관성 유지)로그아웃
시 accessToken
을 삭제 하여 1시간 이내의
로그인하는 경우의 redis 키 값 중복 방지를 위한 삭제
과정회원 탈퇴
시 redis에서 accessToken을 불러와 각 플랫폼에 전달하여 연결 끊기 진행
(소셜 프로바이더 값이 유니크 제약조건
이 걸린 컬럼이거나 깔끔하게 필요없는 값을 지우고 싶을 시 연결 끊기가 될 시 1시간이네 재가입 경우 일 때 redis 키 값 중복 방지를 위해 삭제
하는 과정 필요implementation 'org.springframework.boot:spring-boot-starter-data-redis'
package com.carumuch.capstone.global.config;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@RequiredArgsConstructor
@Configuration
@EnableRedisRepositories
public class RedisRepositoryConfig {
private final RedisProperties redisProperties;
/* Lettuce */
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort());
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
package com.carumuch.capstone.auth.service;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.concurrent.TimeUnit;
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class RedisService {
private final RedisTemplate<String, String> redisTemplate;
/**
* 만료 시간 없는 키 값 지정
*/
@Transactional
public void setValues(String key, String value){
redisTemplate.opsForValue().set(key, value);
}
/**
* 만료시간 설정 -> 자동삭제
*/
@Transactional
public void setValuesWithTimeout(String key, String value, long timeout){
redisTemplate.opsForValue().set(key, value, timeout, TimeUnit.MILLISECONDS);
}
/**
* 키를 이용한 값 확인
*/
public String getValues(String key){
return redisTemplate.opsForValue().get(key);
}
/**
* 키 삭제
*/
@Transactional
public void deleteValues(String key) {
redisTemplate.delete(key);
}
}
/**
* Delete: 회원 탈퇴
*/
@DeleteMapping
public ResponseEntity<?> delete(HttpServletRequest request) {
authService.delete(request.getHeader("Authorization"));
ResponseCookie responseCookie = ResponseCookie.from("refresh-token", null)
.maxAge(0)
.path("/")
.build();
return ResponseEntity
.status(OK)
.header(HttpHeaders.SET_COOKIE, responseCookie.toString())
.body(ResponseDto.success(OK,null));
}
/**
* DELETE
*/
@Transactional
public void delete(String requestAccessTokenInHeader) {
String requestAccessToken = resolveToken(requestAccessTokenInHeader);
String principal = getPrincipal(requestAccessToken);
/* Redis에 저장되어 있는 Refresh Token 삭제 */
String refreshTokenInRedis = redisService.getValues("RT(" + SERVER + "):" + principal);
if (refreshTokenInRedis != null) {
redisService.deleteValues("RT(" + SERVER + "):" + principal);
}
/* Redis에 회원탈퇴 처리한 Access Token 저장 */
long expiration = jwtTokenProvider.getTokenExpirationTime(requestAccessToken) - new Date().getTime();
redisService.setValuesWithTimeout(requestAccessToken,
"delete",
expiration);
/* DB에 저장 되어 있는 회원 삭제 */
userRepository.deleteByLoginId(principal);
if (principal.startsWith("kakao") || principal.startsWith("google") || principal.startsWith("naver")) {
oAuth2UnlinkService.unlink(principal);
}
/* oauth2 access 토큰 삭제 */
if (redisService.getValues("AT(oauth2):" + principal) != null) {
redisService.deleteValues("AT(oauth2):" + principal);
}
log.info(principal + " : " + "delete" + "(" + new Date() + ")");
}
if (principal.startsWith("kakao") || principal.startsWith("google") || principal.startsWith("naver")) {
oAuth2UnlinkService.unlink(principal);
}
/* oauth2 access 토큰 삭제 */
if (redisService.getValues("AT(oauth2):" + principal) != null) {
redisService.deleteValues("AT(oauth2):" + principal);
}
package com.carumuch.capstone.auth.service;
import com.carumuch.capstone.global.common.ErrorCode;
import com.carumuch.capstone.global.common.exception.CustomException;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
@Service
@RequiredArgsConstructor
public class OAuth2UnlinkService {
private static final String GOOGLE_URL = "https://oauth2.googleapis.com/revoke";
private static final String KAKAO_URL = "https://kapi.kakao.com/v1/user/unlink";
private static final String NAVER_URL = "https://nid.naver.com/oauth2.0/token";
private final RestTemplate restTemplate;
private final RedisService redisService;
@Value("${spring.security.oauth2.client.registration.naver.client-id}")
private String NAVER_CLIENT_ID;
@Value("${spring.security.oauth2.client.registration.naver.client-secret}")
private String NAVER_CLIENT_SECRET;
public void unlink(String provider) {
if (provider.startsWith("google")) {
googleUnlink(provider);
} else if (provider.startsWith("kakao")) {
kakaoUnlink(provider);
} else if (provider.startsWith("naver")) {
naverUnlink(provider);
} else {
throw new CustomException(ErrorCode.INVALID_REQUEST);
}
}
/**
* 구글 연결 해제
*/
public void googleUnlink(String provider) {
String accessToken = redisService.getValues("AT(oauth2):" + provider);
// oauth2 토큰이 만료 시 재 로그인
if (accessToken == null) {
throw new CustomException(ErrorCode.EXPIRED_AUTH_TOKEN);
}
// 바디 설정
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("token", accessToken);
restTemplate.postForObject(GOOGLE_URL, params, String.class);
}
/**
* 카카오 연결 해제
*/
public void kakaoUnlink(String provider) {
String accessToken = redisService.getValues("AT(oauth2):" + provider);
// oauth2 토큰이 만료 시 재 로그인
if (accessToken == null) {
throw new CustomException(ErrorCode.EXPIRED_AUTH_TOKEN);
}
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
HttpEntity<Object> entity = new HttpEntity<>("", headers);
restTemplate.exchange(KAKAO_URL, HttpMethod.POST, entity, String.class);
}
/**
* 네이버 연결 해제
*/
public void naverUnlink(String provider) {
String accessToken = redisService.getValues("AT(oauth2):" + provider);
// oauth2 토큰이 만료 시 재 로그인
if (accessToken == null) {
throw new CustomException(ErrorCode.EXPIRED_AUTH_TOKEN);
}
String url = NAVER_URL +
"?service_provider=NAVER" +
"&grant_type=delete" +
"&client_id=" +
NAVER_CLIENT_ID +
"&client_secret=" +
NAVER_CLIENT_SECRET +
"&access_token=" +
accessToken;
UnlinkResponse response = restTemplate.getForObject(url, UnlinkResponse.class);
if (response != null && !"success".equalsIgnoreCase(response.getResult())) {
throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR);
}
}
/**
* 네이버 응답 데이터
*/
@Getter
@RequiredArgsConstructor
public static class UnlinkResponse {
@JsonProperty("access_token")
private final String accessToken;
private final String result;
}
}
String oauth2AccessToken = userRequest.getAccessToken().getTokenValue();
/*레디스 소셜 로그인 토큰 저장*/
redisService.setValuesWithTimeout("AT(oauth2):" + loginId , oauth2AccessToken, ACCESS_TOKEN_EXPIRATION); -> 회원 가입과 함께 진행하는 부분
/* oauth2 토큰 중복 방지 */
if (redisService.getValues("AT(oauth2):" + loginId) != null) {
redisService.deleteValues("AT(oauth2):" + loginId);
}
/* 레디스 토큰 정보 */
redisService.setValuesWithTimeout("AT(oauth2):" + loginId ,oauth2AccessToken, ACCESS_TOKEN_EXPIRATION); -> 로그인만 진행 하는 경우
package com.carumuch.capstone.auth.service;
import com.carumuch.capstone.auth.dto.*;
import com.carumuch.capstone.user.domain.User;
import com.carumuch.capstone.user.domain.type.Role;
import com.carumuch.capstone.user.repository.UserRepository;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
private final RedisService redisService;
private final long ACCESS_TOKEN_EXPIRATION = 3600 * 1000;
public CustomOAuth2UserService(UserRepository userRepository, RedisService redisService) {
this.userRepository = userRepository;
this.redisService = redisService;
}
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
// 회원 탈퇴 토큰 추출
String oauth2AccessToken = userRequest.getAccessToken().getTokenValue();
String registrationId = userRequest.getClientRegistration().getRegistrationId();
OAuth2Response oAuth2Response = null;
if (registrationId.equals("naver")) {
oAuth2Response = new NaverResponse(oAuth2User.getAttributes());
}
else if (registrationId.equals("kakao")) {
oAuth2Response = new KakaoResponse(oAuth2User.getAttributes());
}
else if (registrationId.equals("google")) {
oAuth2Response = new GoogleResponse(oAuth2User.getAttributes());
}
else {
return null;
}
String loginId = oAuth2Response.getProvider()+" "+oAuth2Response.getProviderId();
if (!userRepository.existsByLoginId(loginId)) {
/* 유저 저장 */
userRepository.save(User.builder()
.loginId(loginId)
.name(oAuth2Response.getName())
.email(oAuth2Response.getEmail())
.role(Role.USER)
.build());
/*레디스 소셜 로그인 토큰 저장*/
redisService.setValuesWithTimeout("AT(oauth2):" + loginId , oauth2AccessToken, ACCESS_TOKEN_EXPIRATION);
/* 유저 정보 전달 */
return new CustomOAuth2User(UserDto.builder()
.loginId(loginId)
.name(oAuth2Response.getName())
.role(Role.USER)
.build());
}
else {
User user = userRepository.findOAuth2UserByLoginId(loginId);
user.updateOAuth2(oAuth2Response.getName(), oAuth2Response.getEmail());
/* oauth2 토큰 중복 방지 */
if (redisService.getValues("AT(oauth2):" + loginId) != null) {
redisService.deleteValues("AT(oauth2):" + loginId);
}
/* 레디스 토큰 정보 */
redisService.setValuesWithTimeout("AT(oauth2):" + loginId ,oauth2AccessToken, ACCESS_TOKEN_EXPIRATION);
return new CustomOAuth2User(UserDto.builder()
.loginId(user.getLoginId())
.name(oAuth2Response.getName()) // 받아온 값
.role(user.getRole())
.build());
}
}
}
CustomLogoutFilter
package com.carumuch.capstone.auth.jwt;
import com.carumuch.capstone.auth.service.AuthService;
import com.carumuch.capstone.global.common.ResponseDto;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.filter.GenericFilterBean;
import java.io.IOException;
public class CustomLogoutFilter extends GenericFilterBean {
private final AuthService authService;
private final ObjectMapper objectMapper;
public CustomLogoutFilter(AuthService authService, ObjectMapper objectMapper) {
this.authService = authService;
this.objectMapper = objectMapper;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
}
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
/* 경로, Http 메소드 */
String requestUri = request.getRequestURI();
if (!requestUri.matches("^\\/logout$")) {
filterChain.doFilter(request, response);
return;
}
String requestMethod = request.getMethod();
if (!requestMethod.equals("POST")) {
filterChain.doFilter(request, response);
return;
}
/* Refresh Token 추출 */
String refresh = null;
Cookie[] cookies = request.getCookies();
for (Cookie cookie : cookies) {
if (cookie.getName().equals("refresh-token")) {
refresh = cookie.getValue();
}
}
String accessToken = request.getHeader("Authorization");
authService.logout(accessToken);
/* 응답 설정 */
Cookie cookie = new Cookie("refresh-token", null);
cookie.setMaxAge(0);
cookie.setPath("/");
response.addCookie(cookie);
response.setStatus(HttpServletResponse.SC_OK);
response.setCharacterEncoding("utf-8");
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write(objectMapper.writeValueAsString(
ResponseDto.success(HttpStatus.OK,null)));
}
}
/* 소셜 로그인 유저 일 경우 oauth2 access 토큰 삭제 */
if (redisService.getValues("AT(oauth2):" + principal) != null) {
redisService.deleteValues("AT(oauth2):" + principal);
}
/**
* 로그아웃: Access Token 무효화
*/
@Transactional
public void logout(String requestAccessTokenInHeader) {
String requestAccessToken = resolveToken(requestAccessTokenInHeader);
String principal = getPrincipal(requestAccessToken);
/* Redis에 저장되어 있는 Refresh Token 삭제 */
String refreshTokenInRedis = redisService.getValues("RT(" + SERVER + "):" + principal);
if (refreshTokenInRedis != null) {
redisService.deleteValues("RT(" + SERVER + "):" + principal);
}
/* 소셜 로그인 유저 일 경우 oauth2 access 토큰 삭제 */
if (redisService.getValues("AT(oauth2):" + principal) != null) {
redisService.deleteValues("AT(oauth2):" + principal);
}
/* Redis에 로그아웃 처리한 Access Token 저장 */
long expiration = jwtTokenProvider.getTokenExpirationTime(requestAccessToken) - new Date().getTime();
redisService.setValuesWithTimeout(requestAccessToken,
"logout",
expiration);
log.info(principal + " : " + "logout" + "(" + new Date() + ")");
}
AT(oauth2) 라고 지정한 토큰이 저장 된 것을 볼 수 있습니다. (key *
)
토큰 값이 정확히 들어 갔는지 value를 확인 합니다. (GET {키이름}
)
postman
으로 테스트를 진행합니다.AT(oauth2)
토큰도 삭제된것을 확인 할 수 있습니다. (아래 토큰은 자체 로그인 시 발급한 accessToken 무효화를 위해 저장한 것이라 글의 목적과 무관
합니다.)