Skip to content

Commit

Permalink
[Feat #26] JWT 관리(저장/재발급) 및 로그아웃 API (#28)
Browse files Browse the repository at this point in the history
* [fix] : TokenService 삭제

* [feat] : 소셜 로그인 성공 시 토큰 생성 로직 수정
- refreshToken 생성 추가
- refreshToken 생성 시 redis 저장 추가

* [feat] : Token 관련 에러코드 추가

* [feat] : 남은 만료시간 반환 메서드 추가

* [feat] : 로그아웃 로직 추가

* [feat] : DTO Valid 추가

* [fix] : logout HttpMethod 변경 및 RequestBody 추가

* [feat] : accessToken logout 확인 로직 추가

* [feat] : 토큰 재발급 로직 추가

* [refactor] : 변경감지를 이용한 ResponseDTO 생성

* [test] : Mocking을 이용한 Service 테스트 수정

* [feat] : 로그아웃 실패 검증 로직 추가

* [feat] : 로그아웃 에러코드 추가

* [fix] : 토큰 재발급 반환 타입 DTO 오류 수정

* [fix] : API '/' 누락 수정

* [feat] : 토큰 재발급 시 생성을 위한 인증 객체 추가

* [fix] : MeberService.class <-> TokenProvider.class 순환참조 해결

* [test] : 로그아웃, 토큰 재발급 단위테스트 추가

* [fix] : @AuthenticationPrincipal를 통해 획득한 인증 객체 타입 변경

* [test] : setUp시 refreshToken 생성 및 저장 추가

* [test] : MemberController 통합테스트 추가

* [test] : 각 테스트 후 redis 초기화 추가

* [feat] : 로그아웃/토큰 재발급 요청 DTO Valid Message 수정

* [feat] : 로그아웃 에러 코드 message 수정

* [feat] : 로그아웃 에러 코드 message 수정

* [style] : isOfficialEmailExists() 인라인 수정

* [refactor] : 메일 본문 내용 상수 선언을 통한 리팩토링

* [fix] : MailService <-> MemberService 순환 참조 위험성 제거를 위한 변경
- MailService가 MemberService가 아닌 MemberRepository를 의존하도록 변경
- 변경으로 인한 checkDuplicatedOfficialEmail() 로직 변경 및 ReadOnly 추가

* [fix] : throw 누락 수정

* [fix] : updateAdditionalInfo() 변경 감지에 의한 save() 로직 삭제

* [style] : 반환 변수명 수정(findMember -> foundMember)

* [refactor] : Enum 재사용성을 위한 parseProviderFromSocialEmail() 로직 리팩토링

* [test] : TokenProviderTest 추가

* [rename] : isDuplicatedNickname() 닉네임 존재 여부 변수명 변경

* [test] : 실제 로직 변경으로 불필요해진 stub 제거

* [test] : 회원가입 API 통합 테스트를 위한 신규 회원 저장 로직 추가

* [test] : 실제 로직 변경으로 stub 변경 및 불필요한 mock 제거

* [test]: MemberFixture 객체 생성 메서드 추가

* [refactor] : 소셜 이메일을 통한 Provider 찾는 로직 변경

* [test] : parseProviderFromSocialEmail() 실제 로직 변경으로 인한 staticMock 추가

* [test] : Provider Test 작성
  • Loading branch information
dudxo authored Aug 11, 2024
1 parent 0b2758e commit 402f213
Show file tree
Hide file tree
Showing 23 changed files with 499 additions and 126 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
@RequestMapping("/api/auth")
public class AuthController {

@GetMapping("signin/kakao")
@GetMapping("/signin/kakao")
public ResponseEntity<?> kakaoLoginRedirect() {
HttpHeaders httpHeaders = new HttpHeaders();
// 카카오 로그인 페이지로 리다이렉트
Expand Down
11 changes: 9 additions & 2 deletions src/main/java/com/dnd/gongmuin/auth/domain/Provider.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,18 @@ public enum Provider {
KAKAO("kakao"),
NAVER("naver");

private final String provider;
private final String label;

public static Provider fromProviderName(String providerName) {
return Arrays.stream(values())
.filter(provider -> provider.getProvider().equalsIgnoreCase(providerName))
.filter(provider -> provider.getLabel().equalsIgnoreCase(providerName))
.findFirst()
.orElseThrow(() -> new NotFoundException(AuthErrorCode.NOT_FOUND_PROVIDER));
}

public static Provider fromSocialEmail(String socialEmail) {
return Arrays.stream(values())
.filter(provider -> socialEmail.contains(provider.getLabel()))
.findFirst()
.orElseThrow(() -> new NotFoundException(AuthErrorCode.NOT_FOUND_PROVIDER));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ public enum AuthErrorCode implements ErrorCode {

UNSUPPORTED_SOCIAL_LOGIN("해당 소셜 로그인은 지원되지 않습니다.", "AUTH_001"),
NOT_FOUND_PROVIDER("알맞은 Provider를 찾을 수 없습니다.", "AUTH_002"),
NOT_FOUND_AUTH("회원의 AUTH를 찾을 수 없습니다.", "AUTH_003");
NOT_FOUND_AUTH("회원의 AUTH를 찾을 수 없습니다.", "AUTH_003"),
UNAUTHORIZED_TOKEN("잘못된 토큰입니다.", "AUTH_004");

private final String message;
private final String code;
Expand Down
3 changes: 1 addition & 2 deletions src/main/java/com/dnd/gongmuin/auth/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,7 @@ public boolean isAuthStatusOld(Member member) {
}

private Auth createAuth(Member savedMember) {
String providerName = memberService.parseProviderFromSocialEmail(savedMember);
Provider provider = Provider.fromProviderName(providerName);
Provider provider = memberService.parseProviderFromSocialEmail(savedMember);

return Auth.of(provider, AuthStatus.NEW, savedMember);
}
Expand Down
15 changes: 9 additions & 6 deletions src/main/java/com/dnd/gongmuin/mail/service/MailService.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.dnd.gongmuin.common.exception.runtime.NotFoundException;
import com.dnd.gongmuin.mail.dto.MailMapper;
Expand All @@ -16,7 +17,7 @@
import com.dnd.gongmuin.mail.dto.response.SendMailResponse;
import com.dnd.gongmuin.mail.exception.MailErrorCode;
import com.dnd.gongmuin.mail.util.AuthCodeGenerator;
import com.dnd.gongmuin.member.service.MemberService;
import com.dnd.gongmuin.member.repository.MemberRepository;
import com.dnd.gongmuin.redis.util.RedisUtil;

import jakarta.mail.internet.MimeMessage;
Expand All @@ -28,13 +29,14 @@ public class MailService {

@Value("${spring.mail.auth-code-expiration-millis}")
private long authCodeExpirationMillis;
private final String SUBJECT = "[공무인] 공무원 인증 메일입니다.";
private static final String SUBJECT = "[공무인] 공무원 인증 메일입니다.";
private static final String AUTH_CODE_PREFIX = "AuthCode ";
private static final String TEXT = "인증 코드는 다음과 같습니다.\n ";

private final JavaMailSender mailSender;
private final AuthCodeGenerator authCodeGenerator;
private final RedisUtil redisUtil;
private final MemberService memberService;
private final MemberRepository memberRepository;

public SendMailResponse sendEmail(SendMailRequest request) {
String targetEmail = request.targetEmail();
Expand Down Expand Up @@ -71,7 +73,7 @@ private MimeMessage createMail(String targetEmail) {
MimeMessageHelper messageHelper = new MimeMessageHelper(mimeMessage, false, "UTF-8");
messageHelper.setTo(targetEmail);
messageHelper.setSubject(SUBJECT);
messageHelper.setText("인증 코드는 다음과 같습니다.\n" + authCode);
messageHelper.setText(TEXT + authCode);

return mimeMessage;
} catch (IllegalArgumentException e) {
Expand All @@ -86,8 +88,9 @@ private void saveAuthCodeToRedis(String targetEmail, String authCode, long authC
redisUtil.setValues(key, authCode, Duration.ofMillis(authCodeExpirationMillis));
}

private void checkDuplicatedOfficialEmail(String officialEmail) {
if (memberService.isOfficialEmailExists(officialEmail)) {
@Transactional(readOnly = true)
public void checkDuplicatedOfficialEmail(String officialEmail) {
if (memberRepository.existsByOfficialEmail(officialEmail)) {
throw new NotFoundException(MailErrorCode.DUPLICATED_ERROR);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,18 @@
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.dnd.gongmuin.member.domain.Member;
import com.dnd.gongmuin.member.dto.request.AdditionalInfoRequest;
import com.dnd.gongmuin.member.dto.request.LogoutRequest;
import com.dnd.gongmuin.member.dto.request.ReissueRequest;
import com.dnd.gongmuin.member.dto.request.ValidateNickNameRequest;
import com.dnd.gongmuin.member.dto.response.LogoutResponse;
import com.dnd.gongmuin.member.dto.response.ReissueResponse;
import com.dnd.gongmuin.member.dto.response.SignUpResponse;
import com.dnd.gongmuin.member.dto.response.ValidateNickNameResponse;
import com.dnd.gongmuin.member.service.MemberService;
import com.dnd.gongmuin.security.oauth2.CustomOauth2User;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

@RestController
Expand All @@ -25,16 +30,28 @@ public class MemberController {

@PostMapping("/check-nickname")
public ResponseEntity<ValidateNickNameResponse> checkNickName(
@RequestBody ValidateNickNameRequest validateNickNameRequest) {
@RequestBody @Valid ValidateNickNameRequest validateNickNameRequest) {
return ResponseEntity.ok(memberService.isDuplicatedNickname(validateNickNameRequest));
}

@PostMapping("/member")
public ResponseEntity<SignUpResponse> signUp(@RequestBody AdditionalInfoRequest request,
@AuthenticationPrincipal CustomOauth2User loginMember) {
SignUpResponse response = memberService.signUp(request, loginMember.getEmail());
public ResponseEntity<SignUpResponse> signUp(
@RequestBody @Valid AdditionalInfoRequest request,
@AuthenticationPrincipal Member loginMember) {
SignUpResponse response = memberService.signUp(request, loginMember.getSocialEmail());

return ResponseEntity.ok(response);
}

@PostMapping("/logout")
public ResponseEntity<LogoutResponse> logout(@RequestBody @Valid LogoutRequest request) {
LogoutResponse response = memberService.logout(request);
return ResponseEntity.ok(response);
}

@PostMapping("/reissue/token")
public ResponseEntity<ReissueResponse> reissue(@RequestBody @Valid ReissueRequest request) {
ReissueResponse response = memberService.reissue(request);
return ResponseEntity.ok(response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.dnd.gongmuin.member.dto.request;

import jakarta.validation.constraints.NotEmpty;

public record LogoutRequest(
@NotEmpty(message = "AccessToken을 입력해주세요.")
String accessToken
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.dnd.gongmuin.member.dto.request;

import jakarta.validation.constraints.NotEmpty;

public record ReissueRequest(
@NotEmpty(message = "AccessToken을 입력해주세요.")
String accessToken
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.dnd.gongmuin.member.dto.response;

public record LogoutResponse(
boolean result
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.dnd.gongmuin.member.dto.response;

public record ReissueResponse(
String accessToken
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ public enum MemberErrorCode implements ErrorCode {

NOT_FOUND_MEMBER("특정 회원을 찾을 수 없습니다.", "MEMBER_001"),
NOT_FOUND_NEW_MEMBER("신규 회원이 아닙니다.", "MEMBER_002"),
NOT_ENOUGH_CREDIT("보유한 크레딧이 부족합니다.", "MEMBER_003");
LOGOUT_FAILED("로그아웃을 실패했습니다.", "MEMBER_003"),
NOT_ENOUGH_CREDIT("보유한 크레딧이 부족합니다.", "MEMBER_004");

private final String message;
private final String code;
Expand Down
103 changes: 83 additions & 20 deletions src/main/java/com/dnd/gongmuin/member/service/MemberService.java
Original file line number Diff line number Diff line change
@@ -1,21 +1,34 @@
package com.dnd.gongmuin.member.service;

import java.time.Duration;
import java.util.Date;
import java.util.Objects;

import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.dnd.gongmuin.auth.domain.Provider;
import com.dnd.gongmuin.auth.exception.AuthErrorCode;
import com.dnd.gongmuin.common.exception.runtime.NotFoundException;
import com.dnd.gongmuin.common.exception.runtime.ValidationException;
import com.dnd.gongmuin.member.domain.JobCategory;
import com.dnd.gongmuin.member.domain.JobGroup;
import com.dnd.gongmuin.member.domain.Member;
import com.dnd.gongmuin.member.dto.request.AdditionalInfoRequest;
import com.dnd.gongmuin.member.dto.request.LogoutRequest;
import com.dnd.gongmuin.member.dto.request.ReissueRequest;
import com.dnd.gongmuin.member.dto.request.ValidateNickNameRequest;
import com.dnd.gongmuin.member.dto.response.LogoutResponse;
import com.dnd.gongmuin.member.dto.response.ReissueResponse;
import com.dnd.gongmuin.member.dto.response.SignUpResponse;
import com.dnd.gongmuin.member.dto.response.ValidateNickNameResponse;
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.jwt.util.TokenProvider;
import com.dnd.gongmuin.security.oauth2.AuthInfo;
import com.dnd.gongmuin.security.oauth2.CustomOauth2User;
import com.dnd.gongmuin.security.oauth2.Oauth2Response;

import lombok.RequiredArgsConstructor;
Expand All @@ -24,7 +37,11 @@
@RequiredArgsConstructor
public class MemberService {

private static final String TOKEN_PREFIX = "Bearer ";
private static final String LOGOUT = "logout";
private final MemberRepository memberRepository;
private final TokenProvider tokenProvider;
private final RedisUtil redisUtil;

public Member saveOrUpdate(Oauth2Response oauth2Response) {
Member member = memberRepository.findBySocialEmail(oauth2Response.createSocialEmail())
Expand All @@ -37,14 +54,9 @@ public Member saveOrUpdate(Oauth2Response oauth2Response) {
return memberRepository.save(member);
}

public String parseProviderFromSocialEmail(Member member) {
String socialEmail = member.getSocialEmail().toUpperCase();
if (socialEmail.contains("KAKAO")) {
return "KAKAO";
} else if (socialEmail.contains("NAVER")) {
return "NAVER";
}
throw new NotFoundException(AuthErrorCode.NOT_FOUND_PROVIDER);
public Provider parseProviderFromSocialEmail(Member member) {
String socialEmail = member.getSocialEmail();
return Provider.fromSocialEmail(socialEmail);
}

private Member createMemberFromOauth2Response(Oauth2Response oauth2Response) {
Expand All @@ -57,45 +69,96 @@ public boolean isOfficialEmail(Member member) {

@Transactional(readOnly = true)
public ValidateNickNameResponse isDuplicatedNickname(ValidateNickNameRequest request) {
boolean isDuplicate = memberRepository.existsByNickname(request.nickname());
boolean isDuplicated = memberRepository.existsByNickname(request.nickname());

return new ValidateNickNameResponse(isDuplicate);
return new ValidateNickNameResponse(isDuplicated);
}

@Transactional
public SignUpResponse signUp(AdditionalInfoRequest request, String email) {
Member findMember = memberRepository.findBySocialEmail(email)
Member foundMember = memberRepository.findBySocialEmail(email)
.orElseThrow(() -> new NotFoundException(MemberErrorCode.NOT_FOUND_MEMBER));

if (!isOfficialEmail(findMember)) {
new NotFoundException(MemberErrorCode.NOT_FOUND_NEW_MEMBER);
if (!isOfficialEmail(foundMember)) {
throw new NotFoundException(MemberErrorCode.NOT_FOUND_NEW_MEMBER);
}

Member signUpMember = updateAdditionalInfo(request, findMember);
updateAdditionalInfo(request, foundMember);

return new SignUpResponse(signUpMember.getNickname());
return new SignUpResponse(foundMember.getNickname());
}

public Member getMemberBySocialEmail(String socialEmail) {
return memberRepository.findBySocialEmail(socialEmail)
.orElseThrow(() -> new NotFoundException(MemberErrorCode.NOT_FOUND_MEMBER));
}

private Member updateAdditionalInfo(AdditionalInfoRequest request, Member findMember) {
private void updateAdditionalInfo(AdditionalInfoRequest request, Member findMember) {
findMember.updateAdditionalInfo(
request.nickname(),
request.officialEmail(),
JobGroup.of(request.jobGroup()),
JobCategory.of(request.jobCategory())
);

return memberRepository.save(findMember);
}

@Transactional(readOnly = true)
public boolean isOfficialEmailExists(String officialEmail) {
boolean result = memberRepository.existsByOfficialEmail(officialEmail);
return memberRepository.existsByOfficialEmail(officialEmail);
}

public LogoutResponse logout(LogoutRequest request) {
String accessToken = request.accessToken().substring(TOKEN_PREFIX.length());

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

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

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

Long expiration = tokenProvider.getExpiration(accessToken, new Date());
redisUtil.setValues(accessToken, LOGOUT, Duration.ofMillis(expiration));

String values = redisUtil.getValues(accessToken);
if (!Objects.equals(values, LOGOUT)) {
throw new NotFoundException(MemberErrorCode.LOGOUT_FAILED);
}

return new LogoutResponse(true);
}

public ReissueResponse reissue(ReissueRequest request) {
String accessToken = request.accessToken().substring(TOKEN_PREFIX.length());

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

// 로그아웃 토큰 처리
if ("logout".equals(redisUtil.getValues(accessToken))) {
throw new ValidationException(AuthErrorCode.UNAUTHORIZED_TOKEN);
}

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

String refreshToken = redisUtil.getValues("RT:" + member.getSocialEmail());

// 로그아웃 또는 토큰 만료 경우 처리
if ("false".equals(refreshToken)) {
throw new ValidationException(AuthErrorCode.UNAUTHORIZED_TOKEN);
}

CustomOauth2User customUser = new CustomOauth2User(
AuthInfo.of(member.getSocialName(), member.getSocialEmail()));
String reissuedAccessToken = tokenProvider.generateAccessToken(customUser, new Date());
tokenProvider.generateRefreshToken(customUser, new Date());

return result;
return new ReissueResponse(reissuedAccessToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo
.orElseThrow(() -> new NotFoundException(MemberErrorCode.NOT_FOUND_MEMBER));

String token = tokenProvider.generateAccessToken(customOauth2User, new Date());
tokenProvider.generateRefreshToken(customOauth2User, new Date());

response.setHeader("Authorization", token);

if (!isAuthStatusOld(findmember)) {
Expand Down
Loading

0 comments on commit 402f213

Please sign in to comment.