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 #26] JWT 관리(저장/재발급) 및 로그아웃 API #28

Merged
merged 42 commits into from
Aug 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
2fbef2f
[fix] : TokenService 삭제
dudxo Aug 9, 2024
d3ece77
[feat] : 소셜 로그인 성공 시 토큰 생성 로직 수정
dudxo Aug 9, 2024
c5baa49
[feat] : Token 관련 에러코드 추가
dudxo Aug 9, 2024
0fe6a83
[feat] : 남은 만료시간 반환 메서드 추가
dudxo Aug 9, 2024
db8ea1c
[feat] : 로그아웃 로직 추가
dudxo Aug 9, 2024
0526c5d
[feat] : DTO Valid 추가
dudxo Aug 9, 2024
b2bd960
[fix] : logout HttpMethod 변경 및 RequestBody 추가
dudxo Aug 9, 2024
8da544b
[feat] : accessToken logout 확인 로직 추가
dudxo Aug 9, 2024
23f5214
[feat] : 토큰 재발급 로직 추가
dudxo Aug 9, 2024
0de33c0
[refactor] : 변경감지를 이용한 ResponseDTO 생성
dudxo Aug 10, 2024
5f77c6e
[test] : Mocking을 이용한 Service 테스트 수정
dudxo Aug 10, 2024
840550e
[feat] : 로그아웃 실패 검증 로직 추가
dudxo Aug 10, 2024
4866b23
[feat] : 로그아웃 에러코드 추가
dudxo Aug 10, 2024
cfaa693
[fix] : 토큰 재발급 반환 타입 DTO 오류 수정
dudxo Aug 10, 2024
ccbccf3
[fix] : API '/' 누락 수정
dudxo Aug 10, 2024
f3f2c81
[feat] : 토큰 재발급 시 생성을 위한 인증 객체 추가
dudxo Aug 10, 2024
427ea4b
[fix] : MeberService.class <-> TokenProvider.class 순환참조 해결
dudxo Aug 10, 2024
21043a1
[test] : 로그아웃, 토큰 재발급 단위테스트 추가
dudxo Aug 10, 2024
128daa1
Merge branch 'dev' into feat/26/JWT-Logout
dudxo Aug 10, 2024
5c1942e
[fix] : @AuthenticationPrincipal를 통해 획득한 인증 객체 타입 변경
dudxo Aug 10, 2024
0ad361f
[test] : setUp시 refreshToken 생성 및 저장 추가
dudxo Aug 10, 2024
cb40730
[test] : MemberController 통합테스트 추가
dudxo Aug 10, 2024
4b6dfdc
[test] : 각 테스트 후 redis 초기화 추가
dudxo Aug 10, 2024
7e22abf
[feat] : 로그아웃/토큰 재발급 요청 DTO Valid Message 수정
dudxo Aug 11, 2024
ff4d80d
[feat] : 로그아웃 에러 코드 message 수정
dudxo Aug 11, 2024
129cd36
[feat] : 로그아웃 에러 코드 message 수정
dudxo Aug 11, 2024
8f77330
[style] : isOfficialEmailExists() 인라인 수정
dudxo Aug 11, 2024
3631a96
[refactor] : 메일 본문 내용 상수 선언을 통한 리팩토링
dudxo Aug 11, 2024
2123399
[fix] : MailService <-> MemberService 순환 참조 위험성 제거를 위한 변경
dudxo Aug 11, 2024
0d18502
[fix] : throw 누락 수정
dudxo Aug 11, 2024
7f0ff1a
[fix] : updateAdditionalInfo() 변경 감지에 의한 save() 로직 삭제
dudxo Aug 11, 2024
1da2ced
[style] : 반환 변수명 수정(findMember -> foundMember)
dudxo Aug 11, 2024
4fa5850
[refactor] : Enum 재사용성을 위한 parseProviderFromSocialEmail() 로직 리팩토링
dudxo Aug 11, 2024
4ea9fed
[test] : TokenProviderTest 추가
dudxo Aug 11, 2024
8d729a1
[rename] : isDuplicatedNickname() 닉네임 존재 여부 변수명 변경
dudxo Aug 11, 2024
0cc7d08
[test] : 실제 로직 변경으로 불필요해진 stub 제거
dudxo Aug 11, 2024
c69440b
[test] : 회원가입 API 통합 테스트를 위한 신규 회원 저장 로직 추가
dudxo Aug 11, 2024
cd5a73f
[test] : 실제 로직 변경으로 stub 변경 및 불필요한 mock 제거
dudxo Aug 11, 2024
1162d88
[test]: MemberFixture 객체 생성 메서드 추가
dudxo Aug 11, 2024
04b04e0
[refactor] : 소셜 이메일을 통한 Provider 찾는 로직 변경
dudxo Aug 11, 2024
930152d
[test] : parseProviderFromSocialEmail() 실제 로직 변경으로 인한 staticMock 추가
dudxo Aug 11, 2024
d40766d
[test] : Provider Test 작성
dudxo Aug 11, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading