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] 토큰 재발급 / 로그아웃 / 회원 탈퇴 기능 구현 #4

Open
wants to merge 10 commits into
base: feat/#1
Choose a base branch
from
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ dependencies {

// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'

// Caffeine Cache
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'com.github.ben-manes.caffeine:caffeine'
}

tasks.named('test') {
Expand Down
38 changes: 38 additions & 0 deletions src/main/java/com/acon/server/global/auth/CacheConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.acon.server.global.auth;


import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.cache.support.SimpleCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Collections;
import java.util.concurrent.TimeUnit;

@Configuration
@EnableCaching
public class CacheConfig {
@Value("${jwt.refresh-token-expire-time}")
private long REFRESH_TOKEN_EXPIRATION_TIME;

@Bean
public CacheManager cacheManager() {

// 최대 용량 설정을 따로 진행하지 않음. 메모리 부족 문제 주의 필요
Caffeine<Object, Object> caffeineBuilder = Caffeine.newBuilder()
.expireAfterWrite(REFRESH_TOKEN_EXPIRATION_TIME, TimeUnit.MILLISECONDS);

CaffeineCache refreshTokenCache = new CaffeineCache(
"refreshTokenCache",
caffeineBuilder.build()
);

SimpleCacheManager cacheManager = new SimpleCacheManager();
cacheManager.setCaches(Collections.singletonList(refreshTokenCache));
return cacheManager;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
import javax.crypto.SecretKey;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;

Expand All @@ -31,6 +33,8 @@ public class JwtTokenProvider {

private static final String MEMBER_ID = "memberId";

private final CacheManager cacheManager;

@Value("${jwt.access-token-expire-time}")
private long ACCESS_TOKEN_EXPIRATION_TIME;

Expand All @@ -50,17 +54,21 @@ public String issueAccessToken(final Authentication authentication) {
return generateToken(authentication, ACCESS_TOKEN_EXPIRATION_TIME);
}

public String issueRefreshToken() {
public String issueRefreshToken(Long memberId) {
final Date now = new Date();

return Jwts.builder()
final String refreshToken = Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + REFRESH_TOKEN_EXPIRATION_TIME))
.signWith(getSigningKey())
.compact();
storeRefreshToken(refreshToken,memberId);

return refreshToken;
}


public String generateToken(
Authentication authentication,
Long tokenExpirationTime
Expand All @@ -73,6 +81,7 @@ public String generateToken(

claims.put(MEMBER_ID, authentication.getPrincipal());


return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE) // Header
.setClaims(claims) // Claim
Expand Down Expand Up @@ -116,6 +125,7 @@ public Long getMemberIdFromJwt(String token) {
return Long.valueOf(claims.get(MEMBER_ID).toString());
}


public Map<String, String> parseHeaders(String token) {
String header = token.split("\\.")[0];

Expand All @@ -133,4 +143,29 @@ public Claims getTokenClaims(String token, PublicKey publicKey) {
.parseClaimsJws(token)
.getBody();
}

public void storeRefreshToken(String refreshToken,Long memberId){
Cache cache = cacheManager.getCache("refreshTokenCache");
// 존재 유무만 확인하므로 빈 값
// TODO: Refresh Token key 수정
cache.put(refreshToken,memberId);
}

public Long validateRefreshToken(String refreshToken) {
Cache cache = cacheManager.getCache("refreshTokenCache");
// Intellij는 해당 조건이 항상 true라고 메세지를 띄우지만 cache.get(memberId)의 반환값이 존재하지 않을 시 null입니다!
if(cache.get(refreshToken)!=null) return cache.get(refreshToken,Long.class);
throw new BusinessException(ErrorType.INVALID_REFRESH_TOKEN_ERROR);
}


public void deleteRefreshToken(String refreshToken){
Cache cache = cacheManager.getCache("refreshTokenCache");
validateRefreshToken(refreshToken);
System.out.println(cache.get(refreshToken,Long.class));
cache.evict(refreshToken);
System.out.println(cache.get(refreshToken,Long.class));
}


}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public enum ErrorType {
INVALID_REQUEST_BODY_ERROR(HttpStatus.BAD_REQUEST, 40006, "유효하지 않은 Request Body입니다. 요청 형식 또는 필드를 확인하세요."),
DATA_INTEGRITY_VIOLATION_ERROR(HttpStatus.BAD_REQUEST, 40007, "데이터 무결성 제약 조건을 위반했습니다."),
INVALID_ACCESS_TOKEN_ERROR(HttpStatus.BAD_REQUEST, 40008, "유효하지 않은 accessToken입니다."),
// TODO: NonNull 필드에 null 값이 입력되었을 때 발생하는 예외 처리 추가
INVALID_REFRESH_TOKEN_ERROR(HttpStatus.BAD_REQUEST, 40088, "유효하지 않은 refreshToken입니다."),

/* 401 Unauthorized */
EXPIRED_ACCESS_TOKEN_ERROR(HttpStatus.UNAUTHORIZED, 40101, "만료된 accessToken입니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@

import com.acon.server.member.api.request.GuidedSpotRequest;
import com.acon.server.member.api.request.LoginRequest;
import com.acon.server.member.api.request.LogoutRequest;
import com.acon.server.member.api.request.MemberAreaRequest;
import com.acon.server.member.api.request.PreferenceRequest;
import com.acon.server.member.api.request.ReissueTokenRequest;
import com.acon.server.member.api.request.WithdrawalRequest;
import com.acon.server.member.api.response.AcornCountResponse;
import com.acon.server.member.api.response.LoginResponse;
import com.acon.server.member.api.response.MemberAreaResponse;
import com.acon.server.member.api.response.ProfileResponse;
import com.acon.server.member.api.response.ReissueTokenResponse;
import com.acon.server.member.application.service.MemberService;
import com.acon.server.member.domain.enums.Cuisine;
import com.acon.server.member.domain.enums.DislikeFood;
Expand All @@ -19,7 +23,6 @@
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import java.util.List;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
Expand Down Expand Up @@ -112,10 +115,35 @@ public ResponseEntity<AcornCountResponse> getAcornCount() {
);
}

@GetMapping(path = "/member/profile", produces = MediaType.APPLICATION_JSON_VALUE)
@GetMapping(path = "/members/me", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<ProfileResponse> getProfile() {
return ResponseEntity.ok(
memberService.fetchProfile()
);
}

@PostMapping(path = "/auth/logout", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> logout(
@Valid @RequestBody LogoutRequest request
) {
memberService.logout(request.refreshToken());
return ResponseEntity.ok().build();
}

@PostMapping(path = "/auth/reissue", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<ReissueTokenResponse> reissueToken(
@Valid @RequestBody ReissueTokenRequest request
) {
return ResponseEntity.ok(
memberService.reissueToken(request.refreshToken())
);
}

@PostMapping(path = "/members/withdrawal", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> postWithdrawal(
@Valid @RequestBody WithdrawalRequest request
) {
memberService.withdrawMember(request.reason());
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.acon.server.member.api.request;

import jakarta.validation.constraints.NotNull;

public record LogoutRequest(
@NotNull(message = "refreshToken은 필수입니다.")
String refreshToken
) {

}

Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.acon.server.member.api.request;

import jakarta.validation.constraints.NotNull;

public record ReissueTokenRequest(

@NotNull(message = "refreshToken은 필수입니다.")
String refreshToken
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.acon.server.member.api.request;

import jakarta.validation.constraints.NotBlank;

public record WithdrawalRequest(
@NotBlank(message = "탈퇴 이유는 공백일 수 없습니다.")
String reason
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.acon.server.member.api.response;

public record ReissueTokenResponse(
String accessToken,
String refreshToken
) {
public static ReissueTokenResponse of(
final String accessToken,
final String refreshToken
) {
return new ReissueTokenResponse(accessToken, refreshToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import com.acon.server.member.api.response.AcornCountResponse;
import com.acon.server.member.api.response.LoginResponse;
import com.acon.server.member.api.response.ProfileResponse;
import com.acon.server.member.api.response.ReissueTokenResponse;
import com.acon.server.member.application.mapper.GuidedSpotMapper;
import com.acon.server.member.application.mapper.MemberMapper;
import com.acon.server.member.application.mapper.PreferenceMapper;
Expand All @@ -25,12 +26,14 @@
import com.acon.server.member.infra.entity.GuidedSpotEntity;
import com.acon.server.member.infra.entity.MemberEntity;
import com.acon.server.member.infra.entity.VerifiedAreaEntity;
import com.acon.server.member.infra.entity.WithdrawalReasonEntity;
import com.acon.server.member.infra.external.google.GoogleSocialService;
import com.acon.server.member.infra.external.ios.AppleAuthAdapter;
import com.acon.server.member.infra.repository.GuidedSpotRepository;
import com.acon.server.member.infra.repository.MemberRepository;
import com.acon.server.member.infra.repository.PreferenceRepository;
import com.acon.server.member.infra.repository.VerifiedAreaRepository;
import com.acon.server.member.infra.repository.WithdrawalReasonRepository;
import com.acon.server.spot.domain.enums.SpotType;
import com.acon.server.spot.infra.repository.SpotRepository;
import java.time.LocalDate;
Expand All @@ -53,6 +56,7 @@ public class MemberService {
private final PreferenceRepository preferenceRepository;
private final VerifiedAreaRepository verifiedAreaRepository;
private final SpotRepository spotRepository;
private final WithdrawalReasonRepository withdrawalReasonRepository;

private final GuidedSpotMapper guidedSpotMapper;
private final MemberMapper memberMapper;
Expand Down Expand Up @@ -87,8 +91,7 @@ public LoginResponse login(
Long memberId = fetchMemberId(socialType, socialId);
MemberAuthentication memberAuthentication = new MemberAuthentication(memberId, null, null);
String accessToken = jwtTokenProvider.issueAccessToken(memberAuthentication);
String refreshToken = jwtTokenProvider.issueRefreshToken();

String refreshToken = jwtTokenProvider.issueRefreshToken(memberId);
return LoginResponse.of(accessToken, refreshToken);
}

Expand Down Expand Up @@ -225,5 +228,47 @@ public ProfileResponse fetchProfile(){
.build();
}

@Transactional
public void logout(String refreshToken){
MemberEntity memberEntity = memberRepository.findByIdOrElseThrow(principalHandler.getUserIdFromPrincipal());
if(!memberEntity.getId().equals(jwtTokenProvider.validateRefreshToken(refreshToken))){
throw new BusinessException(ErrorType.INVALID_ACCESS_TOKEN_ERROR);
}
jwtTokenProvider.deleteRefreshToken(refreshToken);
}

@Transactional
public ReissueTokenResponse reissueToken(String refreshToken) {
// TODO: 리팩토링
Long memberId = jwtTokenProvider.validateRefreshToken(refreshToken);
memberRepository.findByIdOrElseThrow(memberId);

MemberEntity memberEntity = memberRepository.findByIdOrElseThrow(principalHandler.getUserIdFromPrincipal());
if(!memberEntity.getId().equals(jwtTokenProvider.validateRefreshToken(refreshToken))){
throw new BusinessException(ErrorType.INVALID_ACCESS_TOKEN_ERROR);
}

jwtTokenProvider.deleteRefreshToken(refreshToken);

MemberAuthentication memberAuthentication = new MemberAuthentication(memberId, null, null);
String newAccessToken = jwtTokenProvider.issueAccessToken(memberAuthentication);
String newRefreshToken = jwtTokenProvider.issueRefreshToken(memberId);
return ReissueTokenResponse.of(newAccessToken, newRefreshToken);
}

@Transactional
public void withdrawMember(String reason) {
MemberEntity memberEntity = memberRepository.findByIdOrElseThrow(principalHandler.getUserIdFromPrincipal());
memberRepository.findByIdOrElseThrow(memberEntity.getId());

memberRepository.deleteById(memberEntity.getId());
withdrawalReasonRepository.save(
WithdrawalReasonEntity.builder()
.reason(reason)
.build()
);

}

// TODO: 최근 길 안내 장소 지우는 스케줄러 추가
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.acon.server.member.infra.entity;


import com.acon.server.global.entity.BaseTimeEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "withdrawal_reason")
public class WithdrawalReasonEntity extends BaseTimeEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "reason", nullable = false)
private String reason;

@Builder
public WithdrawalReasonEntity(
Long id,
String reason
) {
this.id = id;
this.reason = reason;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.acon.server.member.infra.repository;

import com.acon.server.member.infra.entity.WithdrawalReasonEntity;
import org.springframework.data.jpa.repository.JpaRepository;

public interface WithdrawalReasonRepository extends JpaRepository<WithdrawalReasonEntity,Long> {

}