Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[feat #117] 회원 탈퇴 API #118

Open
wants to merge 12 commits into
base: dev
Choose a base branch
from
12 changes: 12 additions & 0 deletions src/main/java/com/dnd/gongmuin/auth/controller/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import com.dnd.gongmuin.auth.dto.request.TempSignUpRequest;
import com.dnd.gongmuin.auth.dto.request.ValidateNickNameRequest;
import com.dnd.gongmuin.auth.dto.response.LogoutResponse;
import com.dnd.gongmuin.auth.dto.response.MemberDeletionResponse;
import com.dnd.gongmuin.auth.dto.response.ReissueResponse;
import com.dnd.gongmuin.auth.dto.response.SignUpResponse;
import com.dnd.gongmuin.auth.dto.response.TempSignResponse;
Expand Down Expand Up @@ -93,5 +94,16 @@ public ResponseEntity<ReissueResponse> reissue(HttpServletRequest request, HttpS

return ResponseEntity.ok(reissueResponse);
}

@Operation(summary = "회원탈퇴 API", description = "회원 탈퇴한다.")
@ApiResponse(useReturnTypeSchema = true)
@PostMapping("/delete")
public ResponseEntity<MemberDeletionResponse> deleteMember(
HttpServletRequest request
) {
MemberDeletionResponse memberDeletionResponse = authService.deleteMember(request);

return ResponseEntity.ok(memberDeletionResponse);
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.dnd.gongmuin.auth.dto.response;

public record MemberDeletionResponse(

dudxo marked this conversation as resolved.
Show resolved Hide resolved
Long memberId
) {
}
41 changes: 41 additions & 0 deletions src/main/java/com/dnd/gongmuin/auth/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import com.dnd.gongmuin.auth.dto.request.TempSignUpRequest;
import com.dnd.gongmuin.auth.dto.request.ValidateNickNameRequest;
import com.dnd.gongmuin.auth.dto.response.LogoutResponse;
import com.dnd.gongmuin.auth.dto.response.MemberDeletionResponse;
import com.dnd.gongmuin.auth.dto.response.ReissueResponse;
import com.dnd.gongmuin.auth.dto.response.SignUpResponse;
import com.dnd.gongmuin.auth.dto.response.TempSignResponse;
Expand All @@ -30,6 +31,7 @@
import com.dnd.gongmuin.security.jwt.util.TokenProvider;
import com.dnd.gongmuin.security.oauth2.AuthInfo;
import com.dnd.gongmuin.security.oauth2.CustomOauth2User;
import com.dnd.gongmuin.security.service.OAuth2UnlinkService;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
Expand All @@ -40,10 +42,12 @@
public class AuthService {

private static final String LOGOUT = "logout";
private static final String DELETE = "delete";
private final TokenProvider tokenProvider;
private final MemberRepository memberRepository;
private final CookieUtil cookieUtil;
private final RedisUtil redisUtil;
private final OAuth2UnlinkService oAuth2UnlinkService;

@Transactional
public TempSignResponse tempSignUp(TempSignUpRequest tempSignUpRequest, HttpServletResponse response) {
Expand Down Expand Up @@ -181,4 +185,41 @@ private void updateAdditionalInfo(AdditionalInfoRequest request, Member findMemb
);
}

@Transactional
public MemberDeletionResponse deleteMember(HttpServletRequest request) {
String accessToken = cookieUtil.getCookieValue(request);

if (!tokenProvider.validateToken(accessToken, new Date())) {
throw new ValidationException(AuthErrorCode.UNAUTHORIZED_TOKEN);
}

Authentication authentication = tokenProvider.getAuthentication(accessToken);
Member member = (Member)authentication.getPrincipal();

// RefreshToken 삭제
if (!Objects.isNull(redisUtil.getValues("RT:" + member.getSocialEmail()))) {
redisUtil.deleteValues("RT:" + member.getSocialEmail());
}

// 현재 발급 되어 있는 AccessToken 블랙리스트 등록
Long expiration = tokenProvider.getExpiration(accessToken, new Date());
redisUtil.setValues(accessToken, DELETE, Duration.ofMillis(expiration));

// AccessToken 블랙리스트 등록 여부 검증
String values = redisUtil.getValues(accessToken);
if (!Objects.equals(values, DELETE)) {
throw new NotFoundException(MemberErrorCode.DELETE_FAILED);
}

// TODO: SOFT DELETE

// oauth2 서비스 연결 끊기
oAuth2UnlinkService.unlink(member.getSocialEmail());

// oauth2 access 토큰 삭제
if (redisUtil.getValues("AT(oauth):" + member.getSocialEmail()) != null) {
redisUtil.deleteValues("AT(oauth):" + member.getSocialEmail());
}
return new MemberDeletionResponse(member.getId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.dnd.gongmuin.common.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;

@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate() {
RestTemplate restTemplate = new RestTemplate();
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(5000);
requestFactory.setReadTimeout(5000);

restTemplate.setRequestFactory(requestFactory);

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 설정은 어디에 쓰이나요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OAuth2UnlinkService에서 사용하고 있습니다!

return restTemplate;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ public enum MemberErrorCode implements ErrorCode {
UPDATE_PROFILE_FAILED("프로필 수정에 실패했습니다.", "MEMBER_005"),
QUESTION_POSTS_BY_MEMBER_FAILED("마이페이지 게시글 목록을 불러오는데 실패했습니다", "MEMBER_006"),
NOT_FOUND_JOB_GROUP("직군을 올바르게 입력해주세요.", "MEMBER_007"),
NOT_FOUND_JOB_CATEGORY("직렬을 올바르게 입력해주세요.", "MEMBER_008");
NOT_FOUND_JOB_CATEGORY("직렬을 올바르게 입력해주세요.", "MEMBER_008"),
DELETE_FAILED("회원탈퇴를 실패했습니다.", "MEMBER_009");

private final String message;
private final String code;
Expand Down
25 changes: 24 additions & 1 deletion src/main/java/com/dnd/gongmuin/member/service/MemberService.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.dnd.gongmuin.member.service;

import java.time.Duration;

import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.stereotype.Service;
Expand All @@ -23,6 +25,7 @@
import com.dnd.gongmuin.member.dto.response.QuestionPostsResponse;
import com.dnd.gongmuin.member.exception.MemberErrorCode;
import com.dnd.gongmuin.member.repository.MemberRepository;
import com.dnd.gongmuin.redis.util.RedisUtil;
import com.dnd.gongmuin.security.oauth2.Oauth2Response;

import lombok.RequiredArgsConstructor;
Expand All @@ -31,26 +34,46 @@
@RequiredArgsConstructor
public class MemberService {

private static final long ACCESS_TOKEN_EXPIRATION = 3600 * 1000;
private final MemberRepository memberRepository;
private final RedisUtil redisUtil;

public Member saveOrUpdate(Oauth2Response oauth2Response) {
Member member = memberRepository.findBySocialEmail(oauth2Response.createSocialEmail())
.map(m -> {
m.updateSocialEmail(oauth2Response.createSocialEmail());
deleteExistingOauthAccessToken(m);
saveOauth2AccessToken(oauth2Response, m);
return m;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

두 개 메서드가 각각 무슨 역할인지 궁금합니다!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deleteExistingOauthAccessToken()는 이전 소셜 로그인을 통해 저장되어 있는 Oauth2AccessToken가 존재한다면 지운 다음
saveOauth2AccessToken()를 통해 새로운 Oauth2AccessToken()을 저장합니다

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

개인적으로 deleteOauthAccessTokenIfExists처럼 네이밍하면 더 명확해질 것 같습니다

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

감사합니다 ㅎㅎ 반영하겠습니다!

})
.orElseGet(() -> createMemberFromOauth2Response(oauth2Response));

return memberRepository.save(member);
}

private void saveOauth2AccessToken(Oauth2Response oauth2Response, Member m) {
redisUtil.setValues(
"AT(oauth):" + m.getSocialEmail(),
oauth2Response.getOauth2AccessToken(),
Duration.ofMillis(ACCESS_TOKEN_EXPIRATION)
);
}

private void deleteExistingOauthAccessToken(Member m) {
if (redisUtil.getValues("AT(oauth2):" + m.getSocialEmail()) != null) {
redisUtil.deleteValues("AT(oauth2):" + m.getSocialEmail());
}
}

public Provider parseProviderFromSocialEmail(Member member) {
String socialEmail = member.getSocialEmail();
return Provider.fromSocialEmail(socialEmail);
}

private Member createMemberFromOauth2Response(Oauth2Response oauth2Response) {
return Member.of(oauth2Response.getName(), oauth2Response.createSocialEmail(), 10000, "ROLE_GUEST");
Member member = Member.of(oauth2Response.getName(), oauth2Response.createSocialEmail(), 10000, "ROLE_GUEST");
saveOauth2AccessToken(oauth2Response, member);
return member;
}

public Member getMemberBySocialEmail(String socialEmail) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.dnd.gongmuin.security.exception;

import com.dnd.gongmuin.common.exception.ErrorCode;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum OAuth2ErrorCode implements ErrorCode {

INVALID_REQUEST("유효하지 않은 탈퇴 요청입니다.", "OAUTH2_001"),
EXPIRED_AUTH_TOKEN("만료된 OAuth2 토큰입니다.", "OAUTH2_002"),
INTERNAL_SERVER_ERROR("OAuth 서버 에러 발생입니다.", "OAUTH2_003");
private final String message;
private final String code;
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse

if (tokenProvider.validateToken(accessToken, new Date())) {
// accessToken logout 여부 확인
if (tokenProvider.verifyLogout(accessToken)) {
if (tokenProvider.verifyBlackList(accessToken)) {
saveAuthentication(accessToken);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package com.dnd.gongmuin.security.jwt.util;

import java.time.Duration;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

import javax.crypto.SecretKey;
Expand Down Expand Up @@ -40,6 +40,7 @@
public class TokenProvider {

private static final String ROLE_KEY = "ROLE";
private static final String[] BLACKLIST = new String[] {"false", "delete"};
private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 90L;
private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24L;
private final MemberRepository memberRepository;
Expand Down Expand Up @@ -136,9 +137,9 @@ public Long getExpiration(String token, Date date) {
return (expiration.getTime() - date.getTime());
}

public boolean verifyLogout(String accessToken) {
public boolean verifyBlackList(String accessToken) {
String value = redisUtil.getValues(accessToken);
return Objects.equals("false", value);
return Arrays.asList(BLACKLIST).contains(value);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ public class KakaoResponse implements Oauth2Response {

private final Map<String, Object> attribute;
private final Long id;
private final String oauth2AccessToken;

public KakaoResponse(Map<String, Object> attribute) {
public KakaoResponse(Map<String, Object> attribute, String oauth2AccessToken) {
this.attribute = (Map<String, Object>)attribute.get("kakao_account");
this.id = (Long)attribute.get("id");
this.oauth2AccessToken = oauth2AccessToken;
}

@Override
Expand Down Expand Up @@ -43,4 +45,9 @@ public String createSocialEmail() {
);
}

@Override
public String getOauth2AccessToken() {
return this.oauth2AccessToken;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
public class NaverResponse implements Oauth2Response {

private final Map<String, Object> attribute;
private final String oauth2AccessToken;

public NaverResponse(Map<String, Object> attribute) {
public NaverResponse(Map<String, Object> attribute, String oauth2AccessToken) {
this.attribute = (Map<String, Object>)attribute.get("response");
this.oauth2AccessToken = oauth2AccessToken;
}

@Override
Expand Down Expand Up @@ -40,4 +42,9 @@ public String createSocialEmail() {
this.getEmail()
);
}

@Override
public String getOauth2AccessToken() {
return this.oauth2AccessToken;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ public interface Oauth2Response {

String createSocialEmail();

String getOauth2AccessToken();
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,15 @@ public class CustomOauth2UserService extends DefaultOAuth2UserService {
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 (Objects.equals(registrationId, KAKAO.getLabel())) {
oauth2Response = new KakaoResponse(oAuth2User.getAttributes());
oauth2Response = new KakaoResponse(oAuth2User.getAttributes(), oauth2AccessToken);
} else if (Objects.equals(registrationId, NAVER.getLabel())) {
oauth2Response = new NaverResponse(oAuth2User.getAttributes());
oauth2Response = new NaverResponse(oAuth2User.getAttributes(), oauth2AccessToken);
} else {
throw new OAuth2AuthenticationException(
new OAuth2Error(UNSUPPORTED_SOCIAL_LOGIN.getCode()),
Expand All @@ -57,5 +59,6 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic
);
return new CustomOauth2User(authInfo);
}

}

Loading
Loading