Skip to content

Commit

Permalink
Merge pull request #74 from TeamACON/feat/#73
Browse files Browse the repository at this point in the history
[FEAT/#73] 토큰 재발급 / 로그아웃 / 회원 탈퇴 기능 구현
  • Loading branch information
gahyuun authored Feb 15, 2025
2 parents 1d3e024 + 5fa0dcc commit 6f5d031
Show file tree
Hide file tree
Showing 12 changed files with 243 additions and 5 deletions.
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 java.util.Collections;
import java.util.concurrent.TimeUnit;
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;

// TODO: 어노테이션을 활용하여 리팩
@Configuration
@EnableCaching
public class CacheConfig {

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

@Bean
public CacheManager cacheManager() {
// TODO: initialCapacity, maximumSize 설정
// 최대 용량 설정을 따로 진행하지 않음. 메모리 부족 문제 주의 필요
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,15 +54,18 @@ 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(
Expand Down Expand Up @@ -133,4 +140,29 @@ public Claims getTokenClaims(String token, PublicKey publicKey) {
.parseClaimsJws(token)
.getBody();
}
}

// TODO: cache token 관리 로직 클래스로 분리
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);
}

// TODO: 블랙리스트 설정 필요
public void deleteRefreshToken(String refreshToken) {
Cache cache = cacheManager.getCache("refreshTokenCache");
validateRefreshToken(refreshToken);
cache.evict(refreshToken);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +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입니다."),
INVALID_REFRESH_TOKEN_ERROR(HttpStatus.BAD_REQUEST, 40088, "유효하지 않은 refreshToken입니다."),
// TODO: NonNull 필드에 null 값이 입력되었을 때 발생하는 예외 처리 추가

/* 401 Unauthorized */
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.WithdrawalReasonRequest;
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 Down Expand Up @@ -117,4 +121,31 @@ public ResponseEntity<ProfileResponse> getProfile() {
memberService.fetchProfile()
);
}

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

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

@PostMapping(path = "/members/withdrawal", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> postWithdrawal(
@Valid @RequestBody WithdrawalReasonRequest request
) {
memberService.withdrawMember(request.reason(), request.refreshToken());
return ResponseEntity.ok().build();
}
}
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 LogoutRequest(
@NotBlank(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 ReissueTokenRequest(
@NotBlank(message = "refreshToken은 공백일 수 없습니다.")
String refreshToken
) {

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

import jakarta.validation.constraints.NotBlank;

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

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
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 @@ -52,6 +55,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 @@ -86,8 +90,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 @@ -228,5 +231,44 @@ 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);

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, String refreshToken) {
MemberEntity memberEntity = memberRepository.findByIdOrElseThrow(principalHandler.getUserIdFromPrincipal());

memberRepository.deleteById(memberEntity.getId());
jwtTokenProvider.deleteRefreshToken(refreshToken);
// TODO: 엑세스 토큰 블랙리스트

withdrawalReasonRepository.save(
WithdrawalReasonEntity.builder()
.reason(reason)
.build()
);

}

// TODO: 최근 길 안내 장소 지우는 스케줄러 추가
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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 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> {

}

0 comments on commit 6f5d031

Please sign in to comment.