diff --git a/src/main/java/clap/server/adapter/inbound/web/auth/ReissueTokenController.java b/src/main/java/clap/server/adapter/inbound/web/auth/ReissueTokenController.java new file mode 100644 index 00000000..8deff65a --- /dev/null +++ b/src/main/java/clap/server/adapter/inbound/web/auth/ReissueTokenController.java @@ -0,0 +1,26 @@ +package clap.server.adapter.inbound.web.auth; + +import clap.server.adapter.inbound.web.dto.auth.ReissueTokenResponse; +import clap.server.application.port.inbound.auth.ReissueTokenUsecase; +import clap.server.common.annotation.architecture.WebAdapter; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; + +@Tag(name = "토큰 재발급") +@WebAdapter +@RequiredArgsConstructor +@RequestMapping("/api/auths") +public class ReissueTokenController { + private final ReissueTokenUsecase reissueTokenUsecase; + + @Operation(summary = "토큰 재발급 API") + @PostMapping("/reissuance") + public ResponseEntity login(@RequestHeader String refreshToken) { + return ResponseEntity.ok(reissueTokenUsecase.reissueToken(refreshToken)); + } +} diff --git a/src/main/java/clap/server/adapter/inbound/web/dto/auth/ReissueTokenResponse.java b/src/main/java/clap/server/adapter/inbound/web/dto/auth/ReissueTokenResponse.java new file mode 100644 index 00000000..128abc3f --- /dev/null +++ b/src/main/java/clap/server/adapter/inbound/web/dto/auth/ReissueTokenResponse.java @@ -0,0 +1,7 @@ +package clap.server.adapter.inbound.web.dto.auth; + +public record ReissueTokenResponse( + String accessToken, + String refreshToken +) { +} diff --git a/src/main/java/clap/server/adapter/outbound/infrastructure/redis/refresh/RefreshTokenEntity.java b/src/main/java/clap/server/adapter/outbound/infrastructure/redis/refresh/RefreshTokenEntity.java index 2479d079..c71200ac 100644 --- a/src/main/java/clap/server/adapter/outbound/infrastructure/redis/refresh/RefreshTokenEntity.java +++ b/src/main/java/clap/server/adapter/outbound/infrastructure/redis/refresh/RefreshTokenEntity.java @@ -1,5 +1,6 @@ package clap.server.adapter.outbound.infrastructure.redis.refresh; +import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.ToString; @@ -8,6 +9,7 @@ @Getter @RedisHash("refreshToken") +@Builder @ToString(of = {"memberId", "token", "ttl"}) @EqualsAndHashCode(of = {"memberId", "token"}) public class RefreshTokenEntity { diff --git a/src/main/java/clap/server/adapter/outbound/infrastructure/redis/refresh/RefreshTokenMapper.java b/src/main/java/clap/server/adapter/outbound/infrastructure/redis/refresh/RefreshTokenMapper.java index 52115015..c9a95792 100644 --- a/src/main/java/clap/server/adapter/outbound/infrastructure/redis/refresh/RefreshTokenMapper.java +++ b/src/main/java/clap/server/adapter/outbound/infrastructure/redis/refresh/RefreshTokenMapper.java @@ -1,12 +1,10 @@ package clap.server.adapter.outbound.infrastructure.redis.refresh; - -import clap.server.adapter.outbound.persistense.mapper.MemberPersistenceMapper; import clap.server.domain.model.auth.RefreshToken; import org.mapstruct.InheritInverseConfiguration; import org.mapstruct.Mapper; -@Mapper(componentModel = "spring", uses = {MemberPersistenceMapper.class}) +@Mapper(componentModel = "spring") public interface RefreshTokenMapper { @InheritInverseConfiguration RefreshToken toDomain(final RefreshTokenEntity entity); diff --git a/src/main/java/clap/server/application/mapper/response/AuthResponseMapper.java b/src/main/java/clap/server/application/mapper/response/AuthResponseMapper.java index b8c393e3..4435ef4e 100644 --- a/src/main/java/clap/server/application/mapper/response/AuthResponseMapper.java +++ b/src/main/java/clap/server/application/mapper/response/AuthResponseMapper.java @@ -2,6 +2,8 @@ import clap.server.adapter.inbound.web.dto.auth.LoginResponse; import clap.server.adapter.inbound.web.dto.auth.MemberInfoResponse; +import clap.server.adapter.inbound.web.dto.auth.ReissueTokenResponse; +import clap.server.domain.model.auth.CustomJwts; import clap.server.domain.model.member.Member; public class AuthResponseMapper { @@ -26,4 +28,11 @@ public static MemberInfoResponse toMemberInfoResponse(Member member) { member.getStatus() ); } + + public static ReissueTokenResponse toReissueTokenResponse(final CustomJwts jwtTokens) { + return new ReissueTokenResponse( + jwtTokens.accessToken(), + jwtTokens.refreshToken() + ); + } } diff --git a/src/main/java/clap/server/application/port/inbound/auth/ReissueTokenUsecase.java b/src/main/java/clap/server/application/port/inbound/auth/ReissueTokenUsecase.java new file mode 100644 index 00000000..a9df09e2 --- /dev/null +++ b/src/main/java/clap/server/application/port/inbound/auth/ReissueTokenUsecase.java @@ -0,0 +1,7 @@ +package clap.server.application.port.inbound.auth; + +import clap.server.adapter.inbound.web.dto.auth.ReissueTokenResponse; + +public interface ReissueTokenUsecase { + ReissueTokenResponse reissueToken(String oldRefreshToken); +} diff --git a/src/main/java/clap/server/application/service/auth/AuthService.java b/src/main/java/clap/server/application/service/auth/AuthService.java index ee960017..7015a047 100644 --- a/src/main/java/clap/server/application/service/auth/AuthService.java +++ b/src/main/java/clap/server/application/service/auth/AuthService.java @@ -4,6 +4,7 @@ import clap.server.adapter.outbound.persistense.entity.member.constant.MemberStatus; import clap.server.application.mapper.response.AuthResponseMapper; import clap.server.application.port.inbound.auth.AuthUsecase; +import clap.server.application.port.outbound.auth.CommandRefreshTokenPort; import clap.server.application.port.outbound.member.LoadMemberPort; import clap.server.common.annotation.architecture.ApplicationService; import clap.server.domain.model.auth.CustomJwts; @@ -18,6 +19,7 @@ @RequiredArgsConstructor class AuthService implements AuthUsecase { private final LoadMemberPort loadMemberPort; + private final CommandRefreshTokenPort commandRefreshTokenPort; private final IssueTokenService issueTokenService; private final PasswordEncoder passwordEncoder; @@ -29,22 +31,25 @@ public LoginResponse login(String nickname, String password) { validatePassword(password, member.getPassword()); if (member.getStatus().equals(MemberStatus.APPROVAL_REQUEST)) { - String temporaryToken = issueTokenService.createTemporaryToken(member); + String temporaryToken = issueTokenService.issueTemporaryToken(member.getMemberId()); return AuthResponseMapper.toLoginResponse( temporaryToken, null, member ); } else { - CustomJwts jwtTokens = issueTokenService.createToken(member); + CustomJwts jwtTokens = issueTokenService.issueTokens(member); + commandRefreshTokenPort.save( + issueTokenService.issueRefreshToken(member.getMemberId()) + ); return AuthResponseMapper.toLoginResponse( jwtTokens.accessToken(), jwtTokens.refreshToken(), member ); } } - private void validatePassword(String inputPassword, String encodedPassword) { if (!passwordEncoder.matches(inputPassword, encodedPassword)) { throw new ApplicationException(MemberErrorCode.MEMBER_NOT_FOUND); } } + } diff --git a/src/main/java/clap/server/application/service/auth/IssueTokenService.java b/src/main/java/clap/server/application/service/auth/IssueTokenService.java index 42b9d348..2b8770dd 100644 --- a/src/main/java/clap/server/application/service/auth/IssueTokenService.java +++ b/src/main/java/clap/server/application/service/auth/IssueTokenService.java @@ -1,22 +1,21 @@ package clap.server.application.service.auth; +import clap.server.adapter.outbound.jwt.JwtClaims; import clap.server.adapter.outbound.jwt.access.AccessTokenClaim; import clap.server.adapter.outbound.jwt.access.temporary.TemporaryTokenClaim; import clap.server.adapter.outbound.jwt.refresh.RefreshTokenClaim; -import clap.server.application.port.outbound.auth.CommandRefreshTokenPort; +import clap.server.adapter.outbound.jwt.refresh.RefreshTokenClaimKeys; import clap.server.application.port.outbound.auth.JwtProvider; -import clap.server.application.port.outbound.auth.LoadRefreshTokenPort; import clap.server.domain.model.auth.CustomJwts; import clap.server.domain.model.auth.RefreshToken; import clap.server.domain.model.member.Member; -import clap.server.exception.AuthException; -import clap.server.exception.code.AuthErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import java.time.Duration; import java.time.LocalDateTime; +import java.util.function.Function; @RequiredArgsConstructor @Component @@ -25,56 +24,49 @@ class IssueTokenService { private final JwtProvider accessTokenProvider; private final JwtProvider refreshTokenProvider; private final JwtProvider temporaryTokenProvider; - private final LoadRefreshTokenPort loadRefreshTokenPort; - private final CommandRefreshTokenPort commandRefreshTokenPort; - public CustomJwts createToken(Member member) { + public CustomJwts issueTokens(Member member) { String accessToken = accessTokenProvider.createToken(AccessTokenClaim.of(member.getMemberId())); String refreshToken = refreshTokenProvider.createToken(RefreshTokenClaim.of(member.getMemberId())); - - commandRefreshTokenPort.save( - RefreshToken.of( - member.getMemberId(), refreshToken, - toSeconds(refreshTokenProvider.getExpiredDate(refreshToken)) - ) - ); - return CustomJwts.of(accessToken, refreshToken); } - public String createTemporaryToken(Member member) { - return temporaryTokenProvider.createToken(TemporaryTokenClaim.of(member.getMemberId())); + public String issueAccessToken(Long memberId) { + return accessTokenProvider.createToken(AccessTokenClaim.of(memberId)); } - private long toSeconds(LocalDateTime expiredDate) { - return Duration.between(LocalDateTime.now(), expiredDate).getSeconds(); - } - public RefreshToken refresh( - Long memberId, - String oldRefreshToken, - String newRefreshToken - ) throws IllegalArgumentException, IllegalStateException { - RefreshToken refreshToken = loadRefreshTokenPort.findByMemberId(memberId).orElseThrow( - ()-> new AuthException(AuthErrorCode.REFRESH_TOKEN_NOT_FOUND) + public RefreshToken issueRefreshToken(Long memberId) { + String refreshToken = refreshTokenProvider.createToken(RefreshTokenClaim.of(memberId)); + return RefreshToken.of( + memberId, refreshToken, + toSeconds(refreshTokenProvider.getExpiredDate(refreshToken)) ); - validateToken(oldRefreshToken, refreshToken); + } + + public String issueTemporaryToken(Long memberId) { + return temporaryTokenProvider.createToken(TemporaryTokenClaim.of(memberId)); + } - refreshToken.rotation(newRefreshToken); - commandRefreshTokenPort.save(refreshToken); - return refreshToken; + public Long resolveRefreshToken(String refreshToken) { + JwtClaims claims = refreshTokenProvider.parseJwtClaimsFromToken(refreshToken); + + return getClaimValue(claims, + RefreshTokenClaimKeys.USER_ID.getValue(), + Long::parseLong); } - private void validateToken(String oldRefreshToken, RefreshToken refreshToken) { - if (isTakenAway(oldRefreshToken, refreshToken.getToken())) { - commandRefreshTokenPort.delete(refreshToken); - throw new AuthException(AuthErrorCode.REFRESH_TOKEN_MISMATCHED); - } + private long toSeconds(LocalDateTime expiredDate) { + return Duration.between(LocalDateTime.now(), expiredDate).getSeconds(); } - private boolean isTakenAway(String requestRefreshToken, String expectedRefreshToken) { - return !requestRefreshToken.equals(expectedRefreshToken); + private static T getClaimValue(JwtClaims jwtClaims, String key, Function converter) { + Object value = jwtClaims.getClaims().get(key); + if (value != null) { + return converter.apply(value.toString()); + } + return null; } } diff --git a/src/main/java/clap/server/application/service/auth/ReissueTokenService.java b/src/main/java/clap/server/application/service/auth/ReissueTokenService.java new file mode 100644 index 00000000..022dd9e2 --- /dev/null +++ b/src/main/java/clap/server/application/service/auth/ReissueTokenService.java @@ -0,0 +1,68 @@ +package clap.server.application.service.auth; + +import clap.server.adapter.inbound.web.dto.auth.ReissueTokenResponse; +import clap.server.application.port.inbound.auth.ReissueTokenUsecase; +import clap.server.application.port.outbound.auth.CommandRefreshTokenPort; +import clap.server.application.port.outbound.auth.LoadRefreshTokenPort; +import clap.server.common.annotation.architecture.ApplicationService; +import clap.server.domain.model.auth.CustomJwts; +import clap.server.domain.model.auth.RefreshToken; +import clap.server.exception.AuthException; +import clap.server.exception.code.AuthErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.transaction.annotation.Transactional; + +import static clap.server.application.mapper.response.AuthResponseMapper.toReissueTokenResponse; + +@ApplicationService +@RequiredArgsConstructor +class ReissueTokenService implements ReissueTokenUsecase { + private final IssueTokenService issueTokenService; + private final LoadRefreshTokenPort loadRefreshTokenPort; + private final CommandRefreshTokenPort commandRefreshTokenPort; + + @Transactional + public ReissueTokenResponse reissueToken(String oldRefreshToken) { + Long memberId = issueTokenService.resolveRefreshToken(oldRefreshToken); + RefreshToken newRefreshToken; + try { + newRefreshToken = refresh(memberId, oldRefreshToken, + issueTokenService.issueRefreshToken(memberId).getToken()); + } catch (IllegalArgumentException e) { + throw new AuthException(AuthErrorCode.EXPIRED_TOKEN); + } catch (IllegalStateException e) { + throw new AuthException(AuthErrorCode.TAKEN_AWAY_TOKEN); + } + + String newAccessToken = issueTokenService.issueAccessToken(memberId); + CustomJwts tokens = CustomJwts.of(newAccessToken, newRefreshToken.getToken()); + return toReissueTokenResponse(tokens); + } + + private RefreshToken refresh( + Long memberId, + String oldRefreshToken, + String newRefreshToken + ) throws IllegalArgumentException, IllegalStateException { + RefreshToken refreshToken = loadRefreshTokenPort.findByMemberId(memberId).orElseThrow( + () -> new AuthException(AuthErrorCode.REFRESH_TOKEN_NOT_FOUND) + ); + validateToken(oldRefreshToken, refreshToken); + + refreshToken.rotation(newRefreshToken); + commandRefreshTokenPort.save(refreshToken); + return refreshToken; + } + + private void validateToken(String oldRefreshToken, RefreshToken refreshToken) { + if (isTakenAway(oldRefreshToken, refreshToken.getToken())) { + commandRefreshTokenPort.delete(refreshToken); + throw new AuthException(AuthErrorCode.REFRESH_TOKEN_MISMATCHED); + } + } + + private boolean isTakenAway(String requestRefreshToken, String expectedRefreshToken) { + return !requestRefreshToken.equals(expectedRefreshToken); + } + +}