diff --git a/src/main/java/mocacong/server/controller/AuthController.java b/src/main/java/mocacong/server/controller/AuthController.java index c08b55a7..2623503e 100644 --- a/src/main/java/mocacong/server/controller/AuthController.java +++ b/src/main/java/mocacong/server/controller/AuthController.java @@ -2,12 +2,13 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import javax.validation.Valid; import lombok.RequiredArgsConstructor; import mocacong.server.dto.request.AppleLoginRequest; import mocacong.server.dto.request.AuthLoginRequest; import mocacong.server.dto.request.KakaoLoginRequest; +import mocacong.server.dto.request.RefreshTokenRequest; import mocacong.server.dto.response.OAuthTokenResponse; +import mocacong.server.dto.response.ReissueTokenResponse; import mocacong.server.dto.response.TokenResponse; import mocacong.server.service.AuthService; import org.springframework.http.ResponseEntity; @@ -16,6 +17,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import javax.validation.Valid; + @Tag(name = "Login", description = "인증") @RestController @RequiredArgsConstructor @@ -44,4 +47,11 @@ public ResponseEntity loginKakao(@RequestBody @Valid KakaoLo OAuthTokenResponse response = authService.kakaoOAuthLogin(request); return ResponseEntity.ok(response); } + + @Operation(summary = "토큰 재발급") + @PostMapping("/reissue") + public ResponseEntity refreshAccessToken(@RequestBody @Valid RefreshTokenRequest request) { + ReissueTokenResponse response = authService.reissueAccessToken(request); + return ResponseEntity.ok(response); + } } diff --git a/src/main/java/mocacong/server/domain/Token.java b/src/main/java/mocacong/server/domain/Token.java new file mode 100644 index 00000000..c950bf7a --- /dev/null +++ b/src/main/java/mocacong/server/domain/Token.java @@ -0,0 +1,36 @@ +package mocacong.server.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.redis.core.TimeToLive; + +import javax.persistence.Id; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class Token { + + @Id + private Long id; + + private String refreshToken; + + private String accessToken; + + @TimeToLive(unit = TimeUnit.MILLISECONDS) + private long expiration; + + public void setAccessToken(String newAccessToken) { + this.accessToken = newAccessToken; + } + + public static String createRefreshToken() { + return UUID.randomUUID().toString(); + } +} diff --git a/src/main/java/mocacong/server/dto/request/RefreshTokenRequest.java b/src/main/java/mocacong/server/dto/request/RefreshTokenRequest.java new file mode 100644 index 00000000..1608fb7e --- /dev/null +++ b/src/main/java/mocacong/server/dto/request/RefreshTokenRequest.java @@ -0,0 +1,15 @@ +package mocacong.server.dto.request; + +import lombok.*; + +import javax.validation.constraints.NotBlank; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +@ToString +public class RefreshTokenRequest { + + @NotBlank(message = "1012:공백일 수 없습니다.") + private String refreshToken; +} diff --git a/src/main/java/mocacong/server/dto/response/CafeImageReportResponse.java b/src/main/java/mocacong/server/dto/response/CafeImageReportResponse.java index a78b0881..aeea3425 100644 --- a/src/main/java/mocacong/server/dto/response/CafeImageReportResponse.java +++ b/src/main/java/mocacong/server/dto/response/CafeImageReportResponse.java @@ -11,6 +11,4 @@ public class CafeImageReportResponse { private int cafeImageReportCount; - - private int userReportCount; } diff --git a/src/main/java/mocacong/server/dto/response/CafeReviewResponse.java b/src/main/java/mocacong/server/dto/response/CafeReviewResponse.java index 04927697..da1042fb 100644 --- a/src/main/java/mocacong/server/dto/response/CafeReviewResponse.java +++ b/src/main/java/mocacong/server/dto/response/CafeReviewResponse.java @@ -20,12 +20,10 @@ public class CafeReviewResponse { private String sound; private String desk; private int reviewsCount; - private int userReportCount; public static CafeReviewResponse of(double score, Cafe cafe, Member member) { CafeDetail cafeDetail = cafe.getCafeDetail(); int reviewsCount = cafe.getReviews().size(); - int userReportCount = member.getReportCount(); return new CafeReviewResponse( score, @@ -36,8 +34,7 @@ public static CafeReviewResponse of(double score, Cafe cafe, Member member) { cafeDetail.getPowerValue(), cafeDetail.getSoundValue(), cafeDetail.getDeskValue(), - reviewsCount, - userReportCount + reviewsCount ); } } diff --git a/src/main/java/mocacong/server/dto/response/CommentReportResponse.java b/src/main/java/mocacong/server/dto/response/CommentReportResponse.java index 573bf4be..d7a6f775 100644 --- a/src/main/java/mocacong/server/dto/response/CommentReportResponse.java +++ b/src/main/java/mocacong/server/dto/response/CommentReportResponse.java @@ -11,6 +11,4 @@ public class CommentReportResponse { private int commentReportCount; - - private int userReportCount; } diff --git a/src/main/java/mocacong/server/dto/response/CommentSaveResponse.java b/src/main/java/mocacong/server/dto/response/CommentSaveResponse.java index 44c45e4d..3d65bb7f 100644 --- a/src/main/java/mocacong/server/dto/response/CommentSaveResponse.java +++ b/src/main/java/mocacong/server/dto/response/CommentSaveResponse.java @@ -9,6 +9,4 @@ public class CommentSaveResponse { private Long id; - - private int userReportCount; } diff --git a/src/main/java/mocacong/server/dto/response/FavoriteSaveResponse.java b/src/main/java/mocacong/server/dto/response/FavoriteSaveResponse.java index b5a7caf9..9460d462 100644 --- a/src/main/java/mocacong/server/dto/response/FavoriteSaveResponse.java +++ b/src/main/java/mocacong/server/dto/response/FavoriteSaveResponse.java @@ -9,6 +9,4 @@ public class FavoriteSaveResponse { private Long favoriteId; - - private int userReportCount; } diff --git a/src/main/java/mocacong/server/dto/response/OAuthTokenResponse.java b/src/main/java/mocacong/server/dto/response/OAuthTokenResponse.java index 2e515483..ba9a3486 100644 --- a/src/main/java/mocacong/server/dto/response/OAuthTokenResponse.java +++ b/src/main/java/mocacong/server/dto/response/OAuthTokenResponse.java @@ -8,7 +8,8 @@ @ToString public class OAuthTokenResponse { - private String token; + private String accessToken; + private String refreshToken; private String email; private Boolean isRegistered; private String platformId; diff --git a/src/main/java/mocacong/server/dto/response/ReissueTokenResponse.java b/src/main/java/mocacong/server/dto/response/ReissueTokenResponse.java new file mode 100644 index 00000000..b34b1027 --- /dev/null +++ b/src/main/java/mocacong/server/dto/response/ReissueTokenResponse.java @@ -0,0 +1,20 @@ +package mocacong.server.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@ToString +public class ReissueTokenResponse { + private String accessToken; + + private int userReportCount; + + public static ReissueTokenResponse from(final String accessToken, int userReportCount) { + return new ReissueTokenResponse(accessToken, userReportCount); + } +} diff --git a/src/main/java/mocacong/server/dto/response/TokenResponse.java b/src/main/java/mocacong/server/dto/response/TokenResponse.java index fe22164a..44200ea6 100644 --- a/src/main/java/mocacong/server/dto/response/TokenResponse.java +++ b/src/main/java/mocacong/server/dto/response/TokenResponse.java @@ -10,11 +10,13 @@ @AllArgsConstructor @ToString public class TokenResponse { - private String token; + private String accessToken; + + private String refreshToken; private int userReportCount; - public static TokenResponse from(final String token, int userReportCount) { - return new TokenResponse(token, userReportCount); + public static TokenResponse from(final String accessToken, final String refreshToken, int userReportCount) { + return new TokenResponse(accessToken, refreshToken, userReportCount); } } diff --git a/src/main/java/mocacong/server/exception/badrequest/NotExpiredAccessTokenException.java b/src/main/java/mocacong/server/exception/badrequest/NotExpiredAccessTokenException.java new file mode 100644 index 00000000..a3b2ed40 --- /dev/null +++ b/src/main/java/mocacong/server/exception/badrequest/NotExpiredAccessTokenException.java @@ -0,0 +1,8 @@ +package mocacong.server.exception.badrequest; + +public class NotExpiredAccessTokenException extends BadRequestException { + + public NotExpiredAccessTokenException() { + super("아직 만료되지 않은 액세스 토큰입니다", 1022); + } +} diff --git a/src/main/java/mocacong/server/exception/unauthorized/AccessTokenExpiredException.java b/src/main/java/mocacong/server/exception/unauthorized/AccessTokenExpiredException.java new file mode 100644 index 00000000..09db56cd --- /dev/null +++ b/src/main/java/mocacong/server/exception/unauthorized/AccessTokenExpiredException.java @@ -0,0 +1,15 @@ +package mocacong.server.exception.unauthorized; + +import lombok.Getter; + +@Getter +public class AccessTokenExpiredException extends UnauthorizedException { + + public AccessTokenExpiredException() { + super("Access Token 유효기간이 만료되었습니다.", 1014); + } + + public AccessTokenExpiredException(String message) { + super(message, 1014); + } +} diff --git a/src/main/java/mocacong/server/exception/unauthorized/InvalidAccessTokenException.java b/src/main/java/mocacong/server/exception/unauthorized/InvalidAccessTokenException.java new file mode 100644 index 00000000..ee68ff31 --- /dev/null +++ b/src/main/java/mocacong/server/exception/unauthorized/InvalidAccessTokenException.java @@ -0,0 +1,12 @@ +package mocacong.server.exception.unauthorized; + +public class InvalidAccessTokenException extends UnauthorizedException { + + public InvalidAccessTokenException() { + super("올바르지 않은 Access Token 입니다. 다시 로그인해주세요.", 1015); + } + + public InvalidAccessTokenException(String message) { + super(message, 1015); + } +} diff --git a/src/main/java/mocacong/server/exception/unauthorized/InvalidRefreshTokenException.java b/src/main/java/mocacong/server/exception/unauthorized/InvalidRefreshTokenException.java new file mode 100644 index 00000000..4980d687 --- /dev/null +++ b/src/main/java/mocacong/server/exception/unauthorized/InvalidRefreshTokenException.java @@ -0,0 +1,11 @@ +package mocacong.server.exception.unauthorized; + +import lombok.Getter; + +@Getter +public class InvalidRefreshTokenException extends UnauthorizedException { + + public InvalidRefreshTokenException() { + super("올바르지 않은 Refresh Token 입니다. 다시 로그인해주세요.", 1021); + } +} diff --git a/src/main/java/mocacong/server/exception/unauthorized/InvalidTokenException.java b/src/main/java/mocacong/server/exception/unauthorized/InvalidTokenException.java deleted file mode 100644 index ff0f897f..00000000 --- a/src/main/java/mocacong/server/exception/unauthorized/InvalidTokenException.java +++ /dev/null @@ -1,12 +0,0 @@ -package mocacong.server.exception.unauthorized; - -public class InvalidTokenException extends UnauthorizedException { - - public InvalidTokenException() { - super("올바르지 않은 토큰입니다. 다시 로그인해주세요.", 1015); - } - - public InvalidTokenException(String message) { - super(message, 1015); - } -} diff --git a/src/main/java/mocacong/server/exception/unauthorized/TokenExpiredException.java b/src/main/java/mocacong/server/exception/unauthorized/TokenExpiredException.java deleted file mode 100644 index 702da112..00000000 --- a/src/main/java/mocacong/server/exception/unauthorized/TokenExpiredException.java +++ /dev/null @@ -1,12 +0,0 @@ -package mocacong.server.exception.unauthorized; - -public class TokenExpiredException extends UnauthorizedException { - - public TokenExpiredException() { - super("로그인 인증 유효기간이 만료되었습니다. 다시 로그인 해주세요.", 1014); - } - - public TokenExpiredException(String message) { - super(message, 1014); - } -} diff --git a/src/main/java/mocacong/server/security/auth/JwtTokenProvider.java b/src/main/java/mocacong/server/security/auth/JwtTokenProvider.java index 2ceb331f..ebd3c7b3 100644 --- a/src/main/java/mocacong/server/security/auth/JwtTokenProvider.java +++ b/src/main/java/mocacong/server/security/auth/JwtTokenProvider.java @@ -1,29 +1,31 @@ package mocacong.server.security.auth; -import mocacong.server.exception.unauthorized.InvalidTokenException; -import mocacong.server.exception.unauthorized.TokenExpiredException; +import io.jsonwebtoken.*; +import mocacong.server.exception.unauthorized.AccessTokenExpiredException; +import mocacong.server.exception.unauthorized.InvalidAccessTokenException; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -import io.jsonwebtoken.*; import java.util.Date; @Component public class JwtTokenProvider { private final String secretKey; - private final long validityInMilliseconds; + private final long validityAccessTokenInMilliseconds; + private final JwtParser jwtParser; public JwtTokenProvider(@Value("${security.jwt.token.secret-key}") String secretKey, - @Value("${security.jwt.token.expire-length}") long validityInMilliseconds) { + @Value("${security.jwt.token.access-key-expire-length}") + long validityAccessTokenInMilliseconds) { this.secretKey = secretKey; - this.validityInMilliseconds = validityInMilliseconds; + this.validityAccessTokenInMilliseconds = validityAccessTokenInMilliseconds; this.jwtParser = Jwts.parser().setSigningKey(secretKey); } - public String createToken(Long memberId) { + public String createAccessToken(Long memberId) { Date now = new Date(); - Date validity = new Date(now.getTime() + validityInMilliseconds); + Date validity = new Date(now.getTime() + validityAccessTokenInMilliseconds); return Jwts.builder() .setSubject(String.valueOf(memberId)) @@ -33,23 +35,32 @@ public String createToken(Long memberId) { .compact(); } - public void validateToken(String token) { + public void validateAccessToken(String token) { try { jwtParser.parseClaimsJws(token); } catch (ExpiredJwtException e) { - throw new TokenExpiredException(); + throw new AccessTokenExpiredException(); } catch (JwtException e) { - throw new InvalidTokenException(); + throw new InvalidAccessTokenException(); + } + } + + public boolean isExpiredAccessToken(String token) { + try { + jwtParser.parseClaimsJws(token); + } catch (ExpiredJwtException e) { + return true; } + return false; } public String getPayload(String token) { try { return jwtParser.parseClaimsJws(token).getBody().getSubject(); } catch (ExpiredJwtException e) { - throw new TokenExpiredException(); + throw new AccessTokenExpiredException(); } catch (JwtException e) { - throw new InvalidTokenException(); + throw new InvalidAccessTokenException(); } } } diff --git a/src/main/java/mocacong/server/security/auth/LoginInterceptor.java b/src/main/java/mocacong/server/security/auth/LoginInterceptor.java index 7634812e..1dd255b2 100644 --- a/src/main/java/mocacong/server/security/auth/LoginInterceptor.java +++ b/src/main/java/mocacong/server/security/auth/LoginInterceptor.java @@ -1,11 +1,12 @@ package mocacong.server.security.auth; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; import org.springframework.http.HttpMethod; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + @Component public class LoginInterceptor implements HandlerInterceptor { private final JwtTokenProvider jwtTokenProvider; @@ -20,8 +21,8 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons return true; } - String token = AuthorizationExtractor.extractAccessToken(request); - jwtTokenProvider.validateToken(token); + String accessToken = AuthorizationExtractor.extractAccessToken(request); + jwtTokenProvider.validateAccessToken(accessToken); return true; } diff --git a/src/main/java/mocacong/server/security/auth/apple/AppleJwtParser.java b/src/main/java/mocacong/server/security/auth/apple/AppleJwtParser.java index 03245a1f..77bc9ee8 100644 --- a/src/main/java/mocacong/server/security/auth/apple/AppleJwtParser.java +++ b/src/main/java/mocacong/server/security/auth/apple/AppleJwtParser.java @@ -3,13 +3,14 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import io.jsonwebtoken.*; -import java.security.PublicKey; -import java.util.Map; -import mocacong.server.exception.unauthorized.InvalidTokenException; -import mocacong.server.exception.unauthorized.TokenExpiredException; +import mocacong.server.exception.unauthorized.InvalidAccessTokenException; +import mocacong.server.exception.unauthorized.AccessTokenExpiredException; import org.springframework.stereotype.Component; import org.springframework.util.Base64Utils; +import java.security.PublicKey; +import java.util.Map; + @Component public class AppleJwtParser { @@ -24,7 +25,7 @@ public Map parseHeaders(String identityToken) { String decodedHeader = new String(Base64Utils.decodeFromUrlSafeString(encodedHeader)); return OBJECT_MAPPER.readValue(decodedHeader, Map.class); } catch (JsonProcessingException | ArrayIndexOutOfBoundsException e) { - throw new InvalidTokenException("Apple OAuth Identity Token 형식이 올바르지 않습니다."); + throw new InvalidAccessTokenException("Apple OAuth Identity Token 형식이 올바르지 않습니다."); } } @@ -35,9 +36,9 @@ public Claims parsePublicKeyAndGetClaims(String idToken, PublicKey publicKey) { .parseClaimsJws(idToken) .getBody(); } catch (ExpiredJwtException e) { - throw new TokenExpiredException("Apple OAuth 로그인 중 Identity Token 유효기간이 만료됐습니다."); + throw new AccessTokenExpiredException("Apple OAuth 로그인 중 Identity Token 유효기간이 만료됐습니다."); } catch (UnsupportedJwtException | MalformedJwtException | SignatureException | IllegalArgumentException e) { - throw new InvalidTokenException("Apple OAuth Identity Token 값이 올바르지 않습니다."); + throw new InvalidAccessTokenException("Apple OAuth Identity Token 값이 올바르지 않습니다."); } } } diff --git a/src/main/java/mocacong/server/security/auth/apple/AppleOAuthUserProvider.java b/src/main/java/mocacong/server/security/auth/apple/AppleOAuthUserProvider.java index 28b95460..49a47c44 100644 --- a/src/main/java/mocacong/server/security/auth/apple/AppleOAuthUserProvider.java +++ b/src/main/java/mocacong/server/security/auth/apple/AppleOAuthUserProvider.java @@ -2,7 +2,7 @@ import io.jsonwebtoken.Claims; import lombok.RequiredArgsConstructor; -import mocacong.server.exception.unauthorized.InvalidTokenException; +import mocacong.server.exception.unauthorized.InvalidAccessTokenException; import mocacong.server.security.auth.OAuthPlatformMemberResponse; import org.springframework.stereotype.Component; @@ -31,7 +31,7 @@ public OAuthPlatformMemberResponse getApplePlatformMember(String identityToken) private void validateClaims(Claims claims) { if (!appleClaimsValidator.isValid(claims)) { - throw new InvalidTokenException("Apple OAuth Claims 값이 올바르지 않습니다."); + throw new InvalidAccessTokenException("Apple OAuth Claims 값이 올바르지 않습니다."); } } } diff --git a/src/main/java/mocacong/server/service/AuthService.java b/src/main/java/mocacong/server/service/AuthService.java index 9898cd55..874f2cea 100644 --- a/src/main/java/mocacong/server/service/AuthService.java +++ b/src/main/java/mocacong/server/service/AuthService.java @@ -4,11 +4,15 @@ import mocacong.server.domain.Member; import mocacong.server.domain.Platform; import mocacong.server.domain.Status; +import mocacong.server.domain.Token; import mocacong.server.dto.request.AppleLoginRequest; import mocacong.server.dto.request.AuthLoginRequest; import mocacong.server.dto.request.KakaoLoginRequest; +import mocacong.server.dto.request.RefreshTokenRequest; import mocacong.server.dto.response.OAuthTokenResponse; +import mocacong.server.dto.response.ReissueTokenResponse; import mocacong.server.dto.response.TokenResponse; +import mocacong.server.exception.badrequest.NotExpiredAccessTokenException; import mocacong.server.exception.badrequest.PasswordMismatchException; import mocacong.server.exception.notfound.NotFoundMemberException; import mocacong.server.exception.unauthorized.InactiveMemberException; @@ -19,11 +23,14 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import javax.transaction.Transactional; + @Service @RequiredArgsConstructor public class AuthService { private final MemberRepository memberRepository; + private final RefreshTokenService refreshTokenService; private final JwtTokenProvider jwtTokenProvider; private final PasswordEncoder passwordEncoder; private final AppleOAuthUserProvider appleOAuthUserProvider; @@ -34,10 +41,14 @@ public TokenResponse login(AuthLoginRequest request) { validatePassword(findMember, request.getPassword()); validateStatus(findMember); - String token = issueToken(findMember); + String accessToken = issueAccessToken(findMember); + String refreshToken = issueRefreshToken(); + + // Redis에 refresh 토큰 저장 (사용자 기본키 Id, refresh 토큰, access 토큰) + refreshTokenService.saveTokenInfo(findMember.getId(), refreshToken, accessToken); int userReportCount = findMember.getReportCount(); - return TokenResponse.from(token, userReportCount); + return TokenResponse.from(accessToken, refreshToken, userReportCount); } public OAuthTokenResponse appleOAuthLogin(AppleLoginRequest request) { @@ -66,26 +77,37 @@ private OAuthTokenResponse generateOAuthTokenResponse(Platform platform, String .orElseThrow(NotFoundMemberException::new); validateStatus(findMember); int userReportCount = findMember.getReportCount(); - String token = issueToken(findMember); + String accessToken = issueAccessToken(findMember); + String refreshToken = issueRefreshToken(); + + refreshTokenService.saveTokenInfo(findMember.getId(), refreshToken, accessToken); + // OAuth 로그인은 성공했지만 회원가입에 실패한 경우 if (!findMember.isRegisteredOAuthMember()) { - return new OAuthTokenResponse(token, findMember.getEmail(), false, platformId, - userReportCount); + return new OAuthTokenResponse(accessToken, refreshToken, findMember.getEmail(), + false, platformId, userReportCount); } - return new OAuthTokenResponse(token, findMember.getEmail(), true, platformId, - userReportCount); + return new OAuthTokenResponse(accessToken, refreshToken, findMember.getEmail(), + true, platformId, userReportCount); }) .orElseGet(() -> { Member oauthMember = new Member(email, platform, platformId, Status.ACTIVE); Member savedMember = memberRepository.save(oauthMember); - String token = issueToken(savedMember); - return new OAuthTokenResponse(token, email, false, platformId, + String accessToken = issueAccessToken(savedMember); + String refreshToken = issueRefreshToken(); + + refreshTokenService.saveTokenInfo(savedMember.getId(), refreshToken, accessToken); + return new OAuthTokenResponse(accessToken, refreshToken, email, false, platformId, savedMember.getReportCount()); }); } - private String issueToken(final Member findMember) { - return jwtTokenProvider.createToken(findMember.getId()); + private String issueAccessToken(final Member findMember) { + return jwtTokenProvider.createAccessToken(findMember.getId()); + } + + private String issueRefreshToken() { + return Token.createRefreshToken(); } private void validatePassword(final Member findMember, final String password) { @@ -99,4 +121,21 @@ private void validateStatus(final Member findMember) { throw new InactiveMemberException(); } } + + @Transactional + public ReissueTokenResponse reissueAccessToken(RefreshTokenRequest request) { + String refreshToken = request.getRefreshToken(); + Member member = refreshTokenService.getMemberFromRefreshToken(refreshToken); + Token token = refreshTokenService.findTokenByRefreshToken(refreshToken); + String oldAccessToken = token.getAccessToken(); + + // 이전에 발급된 액세스 토큰이 만료가 되어야 새로운 액세스 토큰 발급 + if (jwtTokenProvider.isExpiredAccessToken(oldAccessToken)) { + String newAccessToken = issueAccessToken(member); + token.setAccessToken(newAccessToken); + refreshTokenService.updateToken(token); + return ReissueTokenResponse.from(newAccessToken, member.getReportCount()); + } + throw new NotExpiredAccessTokenException(); + } } diff --git a/src/main/java/mocacong/server/service/CommentService.java b/src/main/java/mocacong/server/service/CommentService.java index c3ac6832..f3ae64d8 100644 --- a/src/main/java/mocacong/server/service/CommentService.java +++ b/src/main/java/mocacong/server/service/CommentService.java @@ -46,7 +46,7 @@ public CommentSaveResponse save(Long memberId, String mapId, String content) { .orElseThrow(NotFoundMemberException::new); Comment comment = new Comment(cafe, member, content); - return new CommentSaveResponse(commentRepository.save(comment).getId(), member.getReportCount()); + return new CommentSaveResponse(commentRepository.save(comment).getId()); } @Transactional(readOnly = true) diff --git a/src/main/java/mocacong/server/service/FavoriteService.java b/src/main/java/mocacong/server/service/FavoriteService.java index 1fb2fca2..35b16f22 100644 --- a/src/main/java/mocacong/server/service/FavoriteService.java +++ b/src/main/java/mocacong/server/service/FavoriteService.java @@ -41,7 +41,7 @@ public FavoriteSaveResponse save(Long memberId, String mapId) { try { Favorite favorite = new Favorite(member, cafe); - return new FavoriteSaveResponse(favoriteRepository.save(favorite).getId(), member.getReportCount()); + return new FavoriteSaveResponse(favoriteRepository.save(favorite).getId()); } catch (DataIntegrityViolationException e) { throw new AlreadyExistsFavorite(); } diff --git a/src/main/java/mocacong/server/service/MemberService.java b/src/main/java/mocacong/server/service/MemberService.java index 2dce9893..e90e2d6b 100644 --- a/src/main/java/mocacong/server/service/MemberService.java +++ b/src/main/java/mocacong/server/service/MemberService.java @@ -127,8 +127,8 @@ public EmailVerifyCodeResponse sendEmailVerifyCode(EmailVerifyCodeRequest reques int randomNumber = random.nextInt(EMAIL_VERIFY_CODE_MAXIMUM_NUMBER + 1); String code = String.format("%04d", randomNumber); awsSESSender.sendToVerifyEmail(requestEmail, code); - String token = jwtTokenProvider.createToken(member.getId()); - return new EmailVerifyCodeResponse(token, code); + String accessToken = jwtTokenProvider.createAccessToken(member.getId()); + return new EmailVerifyCodeResponse(accessToken, code); } @Transactional diff --git a/src/main/java/mocacong/server/service/RefreshTokenService.java b/src/main/java/mocacong/server/service/RefreshTokenService.java new file mode 100644 index 00000000..d0c07a2c --- /dev/null +++ b/src/main/java/mocacong/server/service/RefreshTokenService.java @@ -0,0 +1,62 @@ +package mocacong.server.service; + +import mocacong.server.domain.Member; +import mocacong.server.domain.Token; +import mocacong.server.exception.notfound.NotFoundMemberException; +import mocacong.server.exception.unauthorized.InvalidRefreshTokenException; +import mocacong.server.repository.MemberRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +@Service +public class RefreshTokenService { + + private final long validityRefreshTokenInMilliseconds; + private final MemberRepository memberRepository; + private final RedisTemplate redisTemplate; + + public RefreshTokenService(@Value("${security.jwt.token.refresh-key-expire-length}") + long validityRefreshTokenInMilliseconds, + MemberRepository memberRepository, + RedisTemplate redisTemplate) { + this.validityRefreshTokenInMilliseconds = validityRefreshTokenInMilliseconds; + this.memberRepository = memberRepository; + this.redisTemplate = redisTemplate; + } + + public void saveTokenInfo(Long memberId, String refreshToken, String accessToken) { + Token token = Token.builder() + .id(memberId) + .refreshToken(refreshToken) + .accessToken(accessToken) + .expiration(validityRefreshTokenInMilliseconds) // 리프레시 토큰 유효기간 + .build(); + + redisTemplate.opsForValue().set(refreshToken, token, validityRefreshTokenInMilliseconds, TimeUnit.SECONDS); + } + + public Member getMemberFromRefreshToken(String refreshToken) { + Token token = findTokenByRefreshToken(refreshToken); + if (token.getExpiration() > 0) { + Long memberId = token.getId(); + return memberRepository.findById(memberId) + .orElseThrow(NotFoundMemberException::new); + } + throw new InvalidRefreshTokenException(); + } + + public Token findTokenByRefreshToken(String refreshToken) { + Token token = (Token) redisTemplate.opsForValue().get(refreshToken); + if (token != null) { + return token; + } + throw new InvalidRefreshTokenException(); + } + + public void updateToken(Token token) { + redisTemplate.opsForValue().set(token.getRefreshToken(), token, token.getExpiration(), TimeUnit.MILLISECONDS); + } +} diff --git a/src/main/java/mocacong/server/service/ReportService.java b/src/main/java/mocacong/server/service/ReportService.java index c3aeb616..a283d761 100644 --- a/src/main/java/mocacong/server/service/ReportService.java +++ b/src/main/java/mocacong/server/service/ReportService.java @@ -56,7 +56,7 @@ public CommentReportResponse reportComment(Long memberId, Long commentId, String } catch (DataIntegrityViolationException e) { throw new DuplicateReportCommentException(); } - return new CommentReportResponse(comment.getReportsCount(), reporter.getReportCount()); + return new CommentReportResponse(comment.getReportsCount()); } private void createCommentReport(Comment comment, Member reporter, String reportReason) { @@ -114,7 +114,7 @@ public CafeImageReportResponse reportCafeImage(Long memberId, Long cafeImageId, } catch (DataIntegrityViolationException e) { throw new DuplicateReportCafeImageException(); } - return new CafeImageReportResponse(cafeImage.getReportsCount(), reporter.getReportCount()); + return new CafeImageReportResponse(cafeImage.getReportsCount()); } private void createCafeImageReport(CafeImage cafeImage, Member reporter, String reportReason) { diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index e7eb022e..e9a6cf9e 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -31,7 +31,8 @@ spring: security.jwt.token: secret-key: ${JWT_SECRET_KEY} - expire-length: ${JWT_EXPIRE_LENGTH} + access-key-expire-length: ${JWT_ACCESS_EXPIRE_LENGTH} + refresh-key-expire-length: ${JWT_REFRESH_EXPIRE_LENGTH} springdoc: default-consumes-media-type: application/json diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 106dba86..c4ebb647 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -35,7 +35,8 @@ spring: security.jwt.token: secret-key: testtesttesttesttesttesttesttesttesttest - expire-length: 864000 + access-key-expire-length: 10000 + refresh-key-expire-length: 1728000 springdoc: default-consumes-media-type: application/json diff --git a/src/test/java/mocacong/server/acceptance/AcceptanceFixtures.java b/src/test/java/mocacong/server/acceptance/AcceptanceFixtures.java index c3d77ca3..0f956416 100644 --- a/src/test/java/mocacong/server/acceptance/AcceptanceFixtures.java +++ b/src/test/java/mocacong/server/acceptance/AcceptanceFixtures.java @@ -28,7 +28,7 @@ public class AcceptanceFixtures { .statusCode(HttpStatus.OK.value()) .extract() .as(TokenResponse.class) - .getToken(); + .getAccessToken(); } public static ExtractableResponse 카페_등록(CafeRegisterRequest request) { diff --git a/src/test/java/mocacong/server/acceptance/AuthAcceptanceTest.java b/src/test/java/mocacong/server/acceptance/AuthAcceptanceTest.java index f385dc11..6a3252f2 100644 --- a/src/test/java/mocacong/server/acceptance/AuthAcceptanceTest.java +++ b/src/test/java/mocacong/server/acceptance/AuthAcceptanceTest.java @@ -15,7 +15,9 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; + import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; @@ -47,8 +49,11 @@ void login() { .extract() .as(TokenResponse.class); - assertNotNull(tokenResponse); - assertNotNull(tokenResponse.getToken()); + assertAll( + () -> assertNotNull(tokenResponse), + () -> assertNotNull(tokenResponse.getAccessToken()), + () -> assertNotNull(tokenResponse.getRefreshToken()) + ); } @Test diff --git a/src/test/java/mocacong/server/security/auth/JwtTokenProviderTest.java b/src/test/java/mocacong/server/security/auth/JwtTokenProviderTest.java index c9cc1f77..be59dbc6 100644 --- a/src/test/java/mocacong/server/security/auth/JwtTokenProviderTest.java +++ b/src/test/java/mocacong/server/security/auth/JwtTokenProviderTest.java @@ -1,40 +1,51 @@ package mocacong.server.security.auth; -import mocacong.server.exception.unauthorized.InvalidTokenException; -import mocacong.server.exception.unauthorized.TokenExpiredException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import mocacong.server.exception.unauthorized.AccessTokenExpiredException; +import mocacong.server.exception.unauthorized.InvalidAccessTokenException; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.context.SpringBootTest; +import java.util.Date; + import static org.assertj.core.api.Assertions.*; @SpringBootTest class JwtTokenProviderTest { @Autowired private JwtTokenProvider jwtTokenProvider; - private String token; + + @Value("${security.jwt.token.secret-key}") + private String secretKey; + + private String accessToken; @DisplayName("payload 정보를 통해 유효한 JWT 토큰을 생성한다") @Test public void createToken() { Long payload = 1L; - String token = jwtTokenProvider.createToken(payload); + accessToken = jwtTokenProvider.createAccessToken(payload); - Assertions.assertNotNull(token); - Assertions.assertTrue(token.length() > 0); + Assertions.assertAll( + () -> Assertions.assertNotNull(accessToken), + () -> Assertions.assertTrue(accessToken.length() > 0) + ); } @DisplayName("올바른 토큰 정보로 payload를 조회한다") @Test void getPayload() { - token = jwtTokenProvider.createToken(1L); + accessToken = jwtTokenProvider.createAccessToken(1L); - String payload = jwtTokenProvider.getPayload(token); + String payload = jwtTokenProvider.getPayload(accessToken); - assertThat(jwtTokenProvider.getPayload(token)).isEqualTo(payload); + assertThat(jwtTokenProvider.getPayload(accessToken)).isEqualTo(payload); } @DisplayName("유효하지 않은 토큰 형식의 토큰으로 payload를 조회할 경우 예외를 발생시킨다") @@ -42,18 +53,19 @@ void getPayload() { void getPayloadByInvalidToken() { String invalidToken = "invalid-token"; - assertThatThrownBy(() -> jwtTokenProvider.validateToken(invalidToken)) - .isInstanceOf(InvalidTokenException.class); + assertThatThrownBy(() -> jwtTokenProvider.validateAccessToken(invalidToken)) + .isInstanceOf(InvalidAccessTokenException.class); } @DisplayName("만료된 토큰으로 payload를 조회할 경우 예외를 발생시킨다") @Test void getPayloadByExpiredToken() { long expirationMillis = 1L; - JwtTokenProvider jwtTokenProvider = new JwtTokenProvider("secret-key", expirationMillis); + JwtTokenProvider jwtTokenProvider = new JwtTokenProvider("secret-key", + expirationMillis); Long expiredPayload = 1L; - String expiredToken = jwtTokenProvider.createToken(expiredPayload); + String expiredToken = jwtTokenProvider.createAccessToken(expiredPayload); try { Thread.sleep(expirationMillis); } catch (InterruptedException e) { @@ -61,7 +73,7 @@ void getPayloadByExpiredToken() { } assertThatThrownBy(() -> jwtTokenProvider.getPayload(expiredToken)) - .isInstanceOf(TokenExpiredException.class); + .isInstanceOf(AccessTokenExpiredException.class); } @DisplayName("시크릿 키가 틀린 토큰 정보로 payload를 조회할 경우 예외를 발생시킨다") @@ -71,13 +83,36 @@ void getPayloadByWrongSecretKeyToken() { String correctSecretKey = "correct-secret-key"; String wrongSecretKey = "wrong-secret-key"; - JwtTokenProvider tokenProvider = new JwtTokenProvider(correctSecretKey, 3600000L); - String token = tokenProvider.createToken(payload); + JwtTokenProvider tokenProvider = new JwtTokenProvider(correctSecretKey, + 3600000L); + String token = tokenProvider.createAccessToken(payload); - assertThatExceptionOfType(InvalidTokenException.class) + assertThatExceptionOfType(InvalidAccessTokenException.class) .isThrownBy(() -> { - JwtTokenProvider wrongTokenProvider = new JwtTokenProvider(wrongSecretKey, 3600000L); + JwtTokenProvider wrongTokenProvider = new JwtTokenProvider(wrongSecretKey, + 3600000L); wrongTokenProvider.getPayload(token); }); } + + @DisplayName("새로운 액세스 토큰을 발급한다") + @Test + void renewAccessToken() { + Long memberId = 1L; + Date now = new Date(); + long expiredValidityInMilliseconds = 0L; + String expiredAccessToken = Jwts.builder() + .setExpiration(new Date(now.getTime() - expiredValidityInMilliseconds)) + .signWith(SignatureAlgorithm.HS256, secretKey) + .compact(); + + // 새로운 액세스 토큰 및 리프레시 토큰 발급 + String newAccessToken = jwtTokenProvider.createAccessToken(memberId); + + Assertions.assertAll( + () -> assertThatThrownBy(() -> jwtTokenProvider.validateAccessToken(expiredAccessToken)) + .isInstanceOf(AccessTokenExpiredException.class), + () -> assertThat(newAccessToken).isNotEmpty() + ); + } } diff --git a/src/test/java/mocacong/server/security/auth/apple/AppleJwtParserTest.java b/src/test/java/mocacong/server/security/auth/apple/AppleJwtParserTest.java index c5adef58..f2f63e38 100644 --- a/src/test/java/mocacong/server/security/auth/apple/AppleJwtParserTest.java +++ b/src/test/java/mocacong/server/security/auth/apple/AppleJwtParserTest.java @@ -3,16 +3,18 @@ import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; +import mocacong.server.exception.unauthorized.AccessTokenExpiredException; +import mocacong.server.exception.unauthorized.InvalidAccessTokenException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + import java.security.*; import java.util.Date; import java.util.Map; -import mocacong.server.exception.unauthorized.InvalidTokenException; -import mocacong.server.exception.unauthorized.TokenExpiredException; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; class AppleJwtParserTest { @@ -45,7 +47,7 @@ void parseHeaders() throws NoSuchAlgorithmException { @DisplayName("올바르지 않은 형식의 Apple identity token으로 헤더를 파싱하면 예외를 반환한다") void parseHeadersWithInvalidToken() { assertThatThrownBy(() -> appleJwtParser.parseHeaders("invalidToken")) - .isInstanceOf(InvalidTokenException.class); + .isInstanceOf(InvalidAccessTokenException.class); } @Test @@ -97,7 +99,7 @@ void parseExpiredTokenAndGetClaims() throws NoSuchAlgorithmException { .compact(); assertThatThrownBy(() -> appleJwtParser.parsePublicKeyAndGetClaims(identityToken, publicKey)) - .isInstanceOf(TokenExpiredException.class); + .isInstanceOf(AccessTokenExpiredException.class); } @Test @@ -122,6 +124,6 @@ void parseInvalidPublicKeyAndGetClaims() throws NoSuchAlgorithmException { .compact(); assertThatThrownBy(() -> appleJwtParser.parsePublicKeyAndGetClaims(identityToken, differentPublicKey)) - .isInstanceOf(InvalidTokenException.class); + .isInstanceOf(InvalidAccessTokenException.class); } } diff --git a/src/test/java/mocacong/server/security/auth/apple/AppleOAuthUserProviderTest.java b/src/test/java/mocacong/server/security/auth/apple/AppleOAuthUserProviderTest.java index f2e3d3c6..4b3cfb53 100644 --- a/src/test/java/mocacong/server/security/auth/apple/AppleOAuthUserProviderTest.java +++ b/src/test/java/mocacong/server/security/auth/apple/AppleOAuthUserProviderTest.java @@ -4,7 +4,7 @@ import io.jsonwebtoken.SignatureAlgorithm; import java.security.*; import java.util.Date; -import mocacong.server.exception.unauthorized.InvalidTokenException; +import mocacong.server.exception.unauthorized.InvalidAccessTokenException; import mocacong.server.security.auth.OAuthPlatformMemberResponse; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -87,6 +87,6 @@ void invalidClaims() throws NoSuchAlgorithmException { when(appleClaimsValidator.isValid(any())).thenReturn(false); assertThatThrownBy(() -> appleOAuthUserProvider.getApplePlatformMember(identityToken)) - .isInstanceOf(InvalidTokenException.class); + .isInstanceOf(InvalidAccessTokenException.class); } } diff --git a/src/test/java/mocacong/server/service/AuthServiceTest.java b/src/test/java/mocacong/server/service/AuthServiceTest.java index cc564705..b452525f 100644 --- a/src/test/java/mocacong/server/service/AuthServiceTest.java +++ b/src/test/java/mocacong/server/service/AuthServiceTest.java @@ -1,23 +1,34 @@ package mocacong.server.service; +import groovy.util.logging.Slf4j; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; import mocacong.server.domain.Member; import mocacong.server.domain.Platform; import mocacong.server.domain.Status; +import mocacong.server.domain.Token; import mocacong.server.dto.request.AppleLoginRequest; import mocacong.server.dto.request.AuthLoginRequest; +import mocacong.server.dto.request.RefreshTokenRequest; import mocacong.server.dto.response.OAuthTokenResponse; +import mocacong.server.dto.response.ReissueTokenResponse; import mocacong.server.dto.response.TokenResponse; +import mocacong.server.exception.badrequest.NotExpiredAccessTokenException; import mocacong.server.exception.badrequest.PasswordMismatchException; import mocacong.server.exception.unauthorized.InactiveMemberException; import mocacong.server.repository.MemberRepository; import mocacong.server.security.auth.OAuthPlatformMemberResponse; import mocacong.server.security.auth.apple.AppleOAuthUserProvider; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.security.crypto.password.PasswordEncoder; +import java.util.Date; + import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.anyString; @@ -32,9 +43,13 @@ class AuthServiceTest { private PasswordEncoder passwordEncoder; @Autowired private AuthService authService; + @Value("${security.jwt.token.secret-key}") + private String secretKey; @MockBean private AppleOAuthUserProvider appleOAuthUserProvider; + @MockBean + private RefreshTokenService refreshTokenService; @Test @DisplayName("회원 로그인 요청이 옳다면 토큰을 발급하고 상태는 ACTIVE로 반환한다") @@ -50,7 +65,8 @@ void login() { assertAll( () -> assertThat(member.getStatus()).isEqualTo(Status.ACTIVE), - () -> assertNotNull(tokenResponse.getToken()), + () -> assertNotNull(tokenResponse.getAccessToken()), + () -> assertNotNull(tokenResponse.getRefreshToken()), () -> assertThat(tokenResponse.getUserReportCount()).isEqualTo(0) ); } @@ -96,7 +112,8 @@ void loginOAuthNotRegistered() { OAuthTokenResponse actual = authService.appleOAuthLogin(new AppleLoginRequest("token")); assertAll( - () -> assertThat(actual.getToken()).isNotNull(), + () -> assertThat(actual.getAccessToken()).isNotNull(), + () -> assertThat(actual.getRefreshToken()).isNotNull(), () -> assertThat(actual.getEmail()).isEqualTo(expected), () -> assertThat(actual.getIsRegistered()).isFalse(), () -> assertThat(actual.getPlatformId()).isEqualTo(platformId) @@ -123,7 +140,8 @@ void loginOAuthRegisteredAndMocacongMember() { OAuthTokenResponse actual = authService.appleOAuthLogin(new AppleLoginRequest("token")); assertAll( - () -> assertThat(actual.getToken()).isNotNull(), + () -> assertThat(actual.getAccessToken()).isNotNull(), + () -> assertThat(actual.getRefreshToken()).isNotNull(), () -> assertThat(actual.getEmail()).isEqualTo(expected), () -> assertThat(actual.getIsRegistered()).isTrue(), () -> assertThat(actual.getPlatformId()).isEqualTo(platformId) @@ -143,7 +161,8 @@ void loginOAuthRegisteredButNotMocacongMember() { OAuthTokenResponse actual = authService.appleOAuthLogin(new AppleLoginRequest("token")); assertAll( - () -> assertThat(actual.getToken()).isNotNull(), + () -> assertThat(actual.getAccessToken()).isNotNull(), + () -> assertThat(actual.getRefreshToken()).isNotNull(), () -> assertThat(actual.getEmail()).isEqualTo(expected), () -> assertThat(actual.getIsRegistered()).isFalse(), () -> assertThat(actual.getPlatformId()).isEqualTo(platformId) @@ -164,7 +183,8 @@ void loginOAuthWithMocacongEmail() { OAuthTokenResponse actual = authService.appleOAuthLogin(new AppleLoginRequest("token")); assertAll( - () -> assertThat(actual.getToken()).isNotNull(), + () -> assertThat(actual.getAccessToken()).isNotNull(), + () -> assertThat(actual.getRefreshToken()).isNotNull(), () -> assertThat(actual.getEmail()).isEqualTo(email), () -> assertThat(actual.getIsRegistered()).isFalse(), () -> assertThat(actual.getPlatformId()).isEqualTo(platformId) @@ -185,7 +205,8 @@ void signUpWithAppleEmail() { memberRepository.save(member); assertAll( - () -> assertThat(response.getToken()).isNotNull(), + () -> assertThat(response.getAccessToken()).isNotNull(), + () -> assertThat(response.getRefreshToken()).isNotNull(), () -> assertThat(response.getEmail()).isEqualTo(member.getEmail()) ); } @@ -211,4 +232,53 @@ void loginOAuthWithInactive() { assertThrows(InactiveMemberException.class, () -> authService.appleOAuthLogin(new AppleLoginRequest("token"))); } + + @Test + @DisplayName("액세스 토큰 재발급 요청이 올바르다면 액세스 토큰을 재발급한다") + void reissueAccessToken() { + String refreshToken = "valid-refresh-token"; + Date now = new Date(); + long expiredValidityInMilliseconds = 0L; + String expiredAccessToken = Jwts.builder() + .setExpiration(new Date(now.getTime() + expiredValidityInMilliseconds)) + .signWith(SignatureAlgorithm.HS256, secretKey) + .compact(); + String encodedPassword = passwordEncoder.encode("a1b2c3d4"); + Member member = new Member("kth990303@naver.com", encodedPassword, "케이"); + + Token token = new Token(member.getId(), refreshToken, expiredAccessToken, 0); + when(refreshTokenService.getMemberFromRefreshToken(refreshToken)).thenReturn(member); + when(refreshTokenService.findTokenByRefreshToken(refreshToken)).thenReturn(token); + + RefreshTokenRequest request = new RefreshTokenRequest(refreshToken); + ReissueTokenResponse response = authService.reissueAccessToken(request); + + Assertions.assertAll( + () -> assertNotNull(response), + () -> assertEquals(member.getReportCount(), response.getUserReportCount()) + ); + } + + @Test + @DisplayName("만료되지 않은 액세스 토큰을 가지고 재발급 요청 시 예외 발생") + void reissueAccessTokenFailsWhenNotExpired() { + String refreshToken = "valid-refresh-token"; + Date now = new Date(); + long futureValidityInMilliseconds = 3600000L; + String validAccessToken = Jwts.builder() + .setExpiration(new Date(now.getTime() + futureValidityInMilliseconds)) + .signWith(SignatureAlgorithm.HS256, secretKey) + .compact(); + String encodedPassword = passwordEncoder.encode("a1b2c3d4"); + Member member = new Member("kth990303@naver.com", encodedPassword, "케이"); + + Token token = new Token(member.getId(), refreshToken, validAccessToken, 999); + when(refreshTokenService.getMemberFromRefreshToken(refreshToken)).thenReturn(member); + when(refreshTokenService.findTokenByRefreshToken(refreshToken)).thenReturn(token); + + RefreshTokenRequest request = new RefreshTokenRequest(refreshToken); + + assertThrows(NotExpiredAccessTokenException.class, + () -> authService.reissueAccessToken(request)); + } } diff --git a/src/test/java/mocacong/server/service/RefreshTokenServiceTest.java b/src/test/java/mocacong/server/service/RefreshTokenServiceTest.java new file mode 100644 index 00000000..30b253c3 --- /dev/null +++ b/src/test/java/mocacong/server/service/RefreshTokenServiceTest.java @@ -0,0 +1,54 @@ +package mocacong.server.service; + +import mocacong.server.domain.Member; +import mocacong.server.domain.Token; +import mocacong.server.exception.unauthorized.InvalidRefreshTokenException; +import mocacong.server.repository.MemberRepository; +import mocacong.server.security.auth.JwtTokenProvider; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@ServiceTest +class RefreshTokenServiceTest { + + @Autowired + private JwtTokenProvider jwtTokenProvider; + @Autowired + private MemberRepository memberRepository; + @Autowired + private RefreshTokenService refreshTokenService; + + @DisplayName("올바른 refresh token 을 가지고 회원 정보를 얻는다") + @Test + public void getMemberFromRefreshToken() { + String email = "dlawotn3@naver.com"; + Member member = memberRepository.save(new Member(email, "abcd1234", "메리")); + Long payload = 1L; + + String refreshToken = Token.createRefreshToken(); + String accessToken = jwtTokenProvider.createAccessToken(payload); + refreshTokenService.saveTokenInfo(member.getId(), refreshToken, accessToken); + Member findMember = refreshTokenService.getMemberFromRefreshToken(refreshToken); + + Assertions.assertAll( + () -> Assertions.assertNotNull(refreshToken), + () -> Assertions.assertTrue(refreshToken.length() > 0), + () -> assertThat(findMember.getId()).isEqualTo(payload) + ); + } + + @DisplayName("올바르지 않은 refresh token 을 가지고 검증하면 예외를 발생시킨다") + @Test + public void validateWrongRefreshToken() { + String refreshToken = "wrong-refresh-token"; + + assertThrows(InvalidRefreshTokenException.class, + () -> refreshTokenService.getMemberFromRefreshToken(refreshToken) + ); + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 6d20ba56..ef94956c 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -25,7 +25,8 @@ spring: security.jwt.token: secret-key: testtesttesttesttesttesttesttesttesttest - expire-length: 864000 + access-key-expire-length: 864000 + refresh-key-expire-length: 1728000 cloud: aws: