From e9a53c5a478404b6dabcbedcfb2c5434bc5c2a27 Mon Sep 17 00:00:00 2001 From: inpink Date: Fri, 2 Aug 2024 19:34:56 +0900 Subject: [PATCH 1/9] feat: add createdAt to JWT claims --- .../auth/google/controller/GoogleAuthController.java | 8 +++++--- src/main/java/app/cardcapture/auth/jwt/domain/Claims.java | 5 ++++- .../app/cardcapture/auth/jwt/service/JwtComponent.java | 6 ++++-- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/main/java/app/cardcapture/auth/google/controller/GoogleAuthController.java b/src/main/java/app/cardcapture/auth/google/controller/GoogleAuthController.java index 6e5b4d0..ffe7a1a 100644 --- a/src/main/java/app/cardcapture/auth/google/controller/GoogleAuthController.java +++ b/src/main/java/app/cardcapture/auth/google/controller/GoogleAuthController.java @@ -12,6 +12,7 @@ import app.cardcapture.user.service.UserService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.transaction.Transactional; import lombok.AllArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -19,6 +20,8 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.time.ZoneId; +import java.util.Date; import java.util.Optional; @RestController @@ -42,7 +45,6 @@ public ResponseEntity> getGoogleLoginD .responseType(googleAuthConfig.getResponseType()) .clientId(googleAuthConfig.getClientId()) .build(); - SuccessResponseDto responseDto = SuccessResponseDto.create(googleLoginRequestDto); return ResponseEntity.ok(responseDto); @@ -51,13 +53,13 @@ public ResponseEntity> getGoogleLoginD @GetMapping("/redirect") // TODO: 만약 access token이 만료됐으면, 프론트에서 refreshtoken이 있으면 보내달라는 의미로 에러코드를 다른 걸로 보내줘야함(상황에 따라 프론트에서 기대되는 동작이 다르기 때문에 이를 사전에 정해놓는 것 필요) // 지금은 다 403보내고있는데, 이를 구분해주기위한 용도로 에러코드를 이용할 수 있다. @Operation(summary = "구글 리다이렉트 엔드포인트", description = "구글 리다이렉트를 통해 받은 auth code를 받습니다. auth code를 이용하여 유저 정보를 가져올 것입니다.") + @Transactional //TODO: 추후 serivce로 분리하면 service계층에만 transactional달기 public ResponseEntity> getGoogleRedirect(@RequestParam(name = "code") String authCode) { // TODO: Service로 좀 감추기 (테스트 관점에서도 복잡도 감소 가능) GoogleTokenResponseDto googleTokenResponseDto = googleAuthService.getGoogleToken(authCode); UserDto userDto = googleAuthService.getUserInfo(googleTokenResponseDto.getAccessToken()); - Optional existingUserOpt = userService.findByGoogleId(userDto.getGoogleId()); User user = existingUserOpt.orElseGet(() -> { @@ -65,7 +67,7 @@ public ResponseEntity> getGoogleRedirect(@RequestPara return userService.save(userDto); }); - String jwt = jwtComponent.create(user.getId(), "ROLE_USER"); + String jwt = jwtComponent.create(user.getId(), "ROLE_USER", Date.from(user.getCreatedAt().atZone(ZoneId.systemDefault()).toInstant())); JwtDto jwtDto = new JwtDto(jwt); SuccessResponseDto responseDto = SuccessResponseDto.create(jwtDto); diff --git a/src/main/java/app/cardcapture/auth/jwt/domain/Claims.java b/src/main/java/app/cardcapture/auth/jwt/domain/Claims.java index 60b95e5..d784f93 100644 --- a/src/main/java/app/cardcapture/auth/jwt/domain/Claims.java +++ b/src/main/java/app/cardcapture/auth/jwt/domain/Claims.java @@ -12,6 +12,7 @@ public class Claims { private Date issuedAt; private Date expiresAt; private String issuer; + private Date createdAt; private Claims() {} @@ -21,9 +22,10 @@ public Claims(DecodedJWT decodedJWT) { this.issuedAt = decodedJWT.getIssuedAt(); this.expiresAt = decodedJWT.getExpiresAt(); this.issuer = decodedJWT.getIssuer(); + this.createdAt = decodedJWT.getClaim("created_at").asDate(); } - public static Claims of(Long id, String role, String issuer) { + public static Claims of(Long id, String role, String issuer, Date createdAt) { Claims claims = new Claims(); claims.id = id; @@ -31,6 +33,7 @@ public static Claims of(Long id, String role, String issuer) { claims.issuedAt = new Date(); claims.expiresAt = null; claims.issuer = issuer; + claims.createdAt = createdAt; return claims; } diff --git a/src/main/java/app/cardcapture/auth/jwt/service/JwtComponent.java b/src/main/java/app/cardcapture/auth/jwt/service/JwtComponent.java index 1cf8ba9..47a1537 100644 --- a/src/main/java/app/cardcapture/auth/jwt/service/JwtComponent.java +++ b/src/main/java/app/cardcapture/auth/jwt/service/JwtComponent.java @@ -25,8 +25,8 @@ public JwtComponent(JwtConfig jwtConfig) { this.jwtVerifier = JWT.require(jwtHashAlgorithm).withIssuer(jwtConfig.getIssuer()).build(); } - public String create(Long userId, String role) { - return this.create(Claims.of(userId, role, jwtConfig.getIssuer())); + public String create(Long userId, String role, Date createdAt) { + return this.create(Claims.of(userId, role, jwtConfig.getIssuer(), createdAt)); } public String create(Claims claims) { @@ -38,11 +38,13 @@ public String create(Claims claims) { builder.withExpiresAt(jwtConfig.getExpirationDate(now)); builder.withClaim("id", claims.getId()); builder.withArrayClaim("roles", claims.getRoles()); + builder.withClaim("created_at", claims.getCreatedAt()); return builder.sign(jwtHashAlgorithm); } public Claims verify(String token) { return new Claims(jwtVerifier.verify(token)); + } } \ No newline at end of file From 54295319b40cdab5462347b2ee4b2712abf202ac Mon Sep 17 00:00:00 2001 From: inpink Date: Fri, 2 Aug 2024 22:49:56 +0900 Subject: [PATCH 2/9] feat: enable user logout functionality --- docs/README.md | 2 +- .../auth/jwt/controller/TokenController.java | 30 +++++++++++ .../jwt/domain/entity/TokenBlacklist.java | 36 +++++++++++++ .../repository/TokenBlacklistRepository.java | 10 ++++ .../auth/jwt/service/JwtComponent.java | 11 ++-- .../auth/jwt/service/JwtService.java | 12 ----- .../jwt/service/TokenBlacklistService.java | 25 +++++++++ .../auth/jwt/service/JwtComponentStub.java | 51 ------------------- 8 files changed, 110 insertions(+), 67 deletions(-) create mode 100644 src/main/java/app/cardcapture/auth/jwt/controller/TokenController.java create mode 100644 src/main/java/app/cardcapture/auth/jwt/domain/entity/TokenBlacklist.java create mode 100644 src/main/java/app/cardcapture/auth/jwt/repository/TokenBlacklistRepository.java delete mode 100644 src/main/java/app/cardcapture/auth/jwt/service/JwtService.java create mode 100644 src/main/java/app/cardcapture/auth/jwt/service/TokenBlacklistService.java delete mode 100644 src/test/java/app/cardcapture/auth/jwt/service/JwtComponentStub.java diff --git a/docs/README.md b/docs/README.md index 97c33c0..380ca8f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -48,6 +48,6 @@ grant_type=authorization_code&code=AUTH_CODE&redirect_uri=YOUR_REDIRECT_URI&clie - [X] http://localhost:8080/swagger-ui.html - 유저는 자동 로그인을 할 수 있다. -- 유저는 로그아웃을 할 수 있다. +- [X] 유저는 로그아웃을 할 수 있다. - 유저는 회원탈퇴를 할 수 있다. - (유저는 회원정보를 수정할 수 있다.) diff --git a/src/main/java/app/cardcapture/auth/jwt/controller/TokenController.java b/src/main/java/app/cardcapture/auth/jwt/controller/TokenController.java new file mode 100644 index 0000000..3879d3d --- /dev/null +++ b/src/main/java/app/cardcapture/auth/jwt/controller/TokenController.java @@ -0,0 +1,30 @@ +package app.cardcapture.auth.jwt.controller; + +import app.cardcapture.auth.jwt.service.TokenBlacklistService; +import app.cardcapture.common.dto.SuccessResponseDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Tag(name = "token", description = "The token API") +@RequestMapping("/api/v1/token") +@AllArgsConstructor +public class TokenController { + + private final TokenBlacklistService tokenBlacklistService; + + @GetMapping("/logout") + @Operation(summary = "로그아웃", description = "JWT 토큰을 블랙리스트에 추가하여 로그아웃 처리합니다.") + public ResponseEntity> logout( + @RequestHeader("Authorization") String authHeader + ) { + tokenBlacklistService.addToBlacklist(authHeader); + return ResponseEntity.ok(SuccessResponseDto.create("로그아웃 성공")); + } +} diff --git a/src/main/java/app/cardcapture/auth/jwt/domain/entity/TokenBlacklist.java b/src/main/java/app/cardcapture/auth/jwt/domain/entity/TokenBlacklist.java new file mode 100644 index 0000000..d3c3712 --- /dev/null +++ b/src/main/java/app/cardcapture/auth/jwt/domain/entity/TokenBlacklist.java @@ -0,0 +1,36 @@ +package app.cardcapture.auth.jwt.domain.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "token_blacklist") +@EntityListeners(AuditingEntityListener.class) +public class TokenBlacklist { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String token; + + @Column(nullable = false) + @CreatedDate + private LocalDateTime createdAt; +} // TODO: 배치로 만료된 토큰 삭제하기 diff --git a/src/main/java/app/cardcapture/auth/jwt/repository/TokenBlacklistRepository.java b/src/main/java/app/cardcapture/auth/jwt/repository/TokenBlacklistRepository.java new file mode 100644 index 0000000..7fca06c --- /dev/null +++ b/src/main/java/app/cardcapture/auth/jwt/repository/TokenBlacklistRepository.java @@ -0,0 +1,10 @@ +package app.cardcapture.auth.jwt.repository; + +import app.cardcapture.auth.jwt.domain.entity.TokenBlacklist; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface TokenBlacklistRepository extends JpaRepository { + boolean existsByToken(String token); +} \ No newline at end of file diff --git a/src/main/java/app/cardcapture/auth/jwt/service/JwtComponent.java b/src/main/java/app/cardcapture/auth/jwt/service/JwtComponent.java index 47a1537..45d259b 100644 --- a/src/main/java/app/cardcapture/auth/jwt/service/JwtComponent.java +++ b/src/main/java/app/cardcapture/auth/jwt/service/JwtComponent.java @@ -18,11 +18,13 @@ public class JwtComponent { private final JwtConfig jwtConfig; private final Algorithm jwtHashAlgorithm; private final JWTVerifier jwtVerifier; + private final TokenBlacklistService tokenBlacklistService; - public JwtComponent(JwtConfig jwtConfig) { + public JwtComponent(JwtConfig jwtConfig, TokenBlacklistService tokenBlacklistService) { this.jwtConfig = jwtConfig; this.jwtHashAlgorithm = Algorithm.HMAC256(jwtConfig.getSecret()); this.jwtVerifier = JWT.require(jwtHashAlgorithm).withIssuer(jwtConfig.getIssuer()).build(); + this.tokenBlacklistService = tokenBlacklistService; } public String create(Long userId, String role, Date createdAt) { @@ -44,7 +46,10 @@ public String create(Claims claims) { } public Claims verify(String token) { - return new Claims(jwtVerifier.verify(token)); - + if (tokenBlacklistService.isTokenBlacklisted(token)) { + throw new RuntimeException("Token is blacklisted"); + } + Claims claims = new Claims(jwtVerifier.verify(token)); + return claims; } } \ No newline at end of file diff --git a/src/main/java/app/cardcapture/auth/jwt/service/JwtService.java b/src/main/java/app/cardcapture/auth/jwt/service/JwtService.java deleted file mode 100644 index 0f31e74..0000000 --- a/src/main/java/app/cardcapture/auth/jwt/service/JwtService.java +++ /dev/null @@ -1,12 +0,0 @@ -package app.cardcapture.auth.jwt.service; - -import app.cardcapture.auth.jwt.dto.JwtDto; -import org.springframework.stereotype.Service; - -@Service -public class JwtService { - - public JwtDto publish(String id) { - return new JwtDto(id); - } -} diff --git a/src/main/java/app/cardcapture/auth/jwt/service/TokenBlacklistService.java b/src/main/java/app/cardcapture/auth/jwt/service/TokenBlacklistService.java new file mode 100644 index 0000000..b678c39 --- /dev/null +++ b/src/main/java/app/cardcapture/auth/jwt/service/TokenBlacklistService.java @@ -0,0 +1,25 @@ +package app.cardcapture.auth.jwt.service; + +import app.cardcapture.auth.jwt.domain.entity.TokenBlacklist; +import app.cardcapture.auth.jwt.repository.TokenBlacklistRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class TokenBlacklistService { + private final TokenBlacklistRepository tokenBlacklistRepository; + + public boolean isTokenBlacklisted(String token) { + return tokenBlacklistRepository.existsByToken(token); + } + + public void addToBlacklist(String token) { + String refinedToken = token.replace("Bearer ", ""); + + TokenBlacklist tokenBlacklist = new TokenBlacklist(); + tokenBlacklist.setToken(refinedToken); + + tokenBlacklistRepository.save(tokenBlacklist); + } +} \ No newline at end of file diff --git a/src/test/java/app/cardcapture/auth/jwt/service/JwtComponentStub.java b/src/test/java/app/cardcapture/auth/jwt/service/JwtComponentStub.java deleted file mode 100644 index 5a2a129..0000000 --- a/src/test/java/app/cardcapture/auth/jwt/service/JwtComponentStub.java +++ /dev/null @@ -1,51 +0,0 @@ -package app.cardcapture.auth.jwt.service; - -import app.cardcapture.auth.jwt.config.JwtConfig; -import app.cardcapture.auth.jwt.config.JwtConfigStub; -import com.auth0.jwt.JWT; -import com.auth0.jwt.algorithms.Algorithm; -import com.auth0.jwt.interfaces.DecodedJWT; -import com.auth0.jwt.interfaces.JWTVerifier; -import org.springframework.stereotype.Component; - -import static org.mockito.Mockito.*; - -@Component -public class JwtComponentStub extends JwtComponent { - - private final JWTVerifier jwtVerifier; - private final Algorithm jwtHashAlgorithm; - private final JwtConfig jwtConfig; - - private JwtComponentStub(JwtConfig jwtConfig) { - super(jwtConfig); - this.jwtConfig = jwtConfig; - this.jwtHashAlgorithm = Algorithm.HMAC256(jwtConfig.getSecret()); - - this.jwtVerifier = mock(JWTVerifier.class); - when(this.jwtVerifier.verify(anyString())).thenAnswer(invocation -> { - String token = invocation.getArgument(0); - DecodedJWT decodedJWT = JWT.decode(token); - if ("valid_token".equals(token)) { - return decodedJWT; - } else { - throw new RuntimeException("Invalid JWT token"); - } - }); - } - - public static JwtComponent createStub() { - JwtConfig jwtConfig = JwtConfigStub.createStub(); - return new JwtComponentStub(jwtConfig); - } - - @Override - public Algorithm getJwtHashAlgorithm() { - return this.jwtHashAlgorithm; - } - - @Override - public JWTVerifier getJwtVerifier() { - return this.jwtVerifier; - } -} From e3451d79df67f161d232b39661d38992c05257ed Mon Sep 17 00:00:00 2001 From: inpink Date: Fri, 2 Aug 2024 23:24:03 +0900 Subject: [PATCH 3/9] feat: implement blacklist token with expiration based on token validity Added functionality to set the expiration time of blacklist tokens based on their validity period during logout. This allows for scheduled batch deletion of expired tokens, ensuring efficient token management. --- .../app/cardcapture/auth/jwt/config/JwtConfig.java | 5 +++++ .../auth/jwt/domain/entity/TokenBlacklist.java | 5 +++++ .../auth/jwt/service/TokenBlacklistService.java | 14 +++++++++++++- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/main/java/app/cardcapture/auth/jwt/config/JwtConfig.java b/src/main/java/app/cardcapture/auth/jwt/config/JwtConfig.java index 325855b..42cbc68 100644 --- a/src/main/java/app/cardcapture/auth/jwt/config/JwtConfig.java +++ b/src/main/java/app/cardcapture/auth/jwt/config/JwtConfig.java @@ -7,6 +7,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Configuration; +import java.time.LocalDateTime; import java.util.Date; @Configuration @@ -26,4 +27,8 @@ public long getExpirationMillis(long current) { public Date getExpirationDate(Date current) { return new Date(getExpirationMillis(current.getTime())); } + + public LocalDateTime getExpirationDate(LocalDateTime current) { + return current.plusSeconds(expirationInSeconds.intValue()); + } } diff --git a/src/main/java/app/cardcapture/auth/jwt/domain/entity/TokenBlacklist.java b/src/main/java/app/cardcapture/auth/jwt/domain/entity/TokenBlacklist.java index d3c3712..18b4f82 100644 --- a/src/main/java/app/cardcapture/auth/jwt/domain/entity/TokenBlacklist.java +++ b/src/main/java/app/cardcapture/auth/jwt/domain/entity/TokenBlacklist.java @@ -7,6 +7,7 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; +import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -28,9 +29,13 @@ public class TokenBlacklist { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(nullable = false, unique = true) private String token; @Column(nullable = false) @CreatedDate private LocalDateTime createdAt; + + @Column(nullable = false) + private LocalDateTime expiryDate; } // TODO: 배치로 만료된 토큰 삭제하기 diff --git a/src/main/java/app/cardcapture/auth/jwt/service/TokenBlacklistService.java b/src/main/java/app/cardcapture/auth/jwt/service/TokenBlacklistService.java index b678c39..4029e38 100644 --- a/src/main/java/app/cardcapture/auth/jwt/service/TokenBlacklistService.java +++ b/src/main/java/app/cardcapture/auth/jwt/service/TokenBlacklistService.java @@ -1,14 +1,22 @@ package app.cardcapture.auth.jwt.service; +import app.cardcapture.auth.jwt.config.JwtConfig; import app.cardcapture.auth.jwt.domain.entity.TokenBlacklist; import app.cardcapture.auth.jwt.repository.TokenBlacklistRepository; +import app.cardcapture.common.exception.BusinessLogicException; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; +import java.time.LocalDateTime; + @Service @RequiredArgsConstructor public class TokenBlacklistService { + + private final static String TOKEN_ALREADY_BLACKLISTED = "이미 블랙리스트에 추가된 토큰입니다."; private final TokenBlacklistRepository tokenBlacklistRepository; + private final JwtConfig jwtConfig; public boolean isTokenBlacklisted(String token) { return tokenBlacklistRepository.existsByToken(token); @@ -17,9 +25,13 @@ public boolean isTokenBlacklisted(String token) { public void addToBlacklist(String token) { String refinedToken = token.replace("Bearer ", ""); + if (isTokenBlacklisted(refinedToken)) { + throw new BusinessLogicException(TOKEN_ALREADY_BLACKLISTED, HttpStatus.BAD_REQUEST); + } + TokenBlacklist tokenBlacklist = new TokenBlacklist(); tokenBlacklist.setToken(refinedToken); - + tokenBlacklist.setExpiryDate(jwtConfig.getExpirationDate(LocalDateTime.now())); tokenBlacklistRepository.save(tokenBlacklist); } } \ No newline at end of file From 821f4b493fd1c344d783d3e939aafb1b5e664833 Mon Sep 17 00:00:00 2001 From: inpink Date: Sat, 3 Aug 2024 00:04:19 +0900 Subject: [PATCH 4/9] feat: create utility functions for LocalDateTime and Date conversion Added utility methods to convert between LocalDateTime and Date for consistent and simplified date-time handling across the application. --- .../cardcapture/common/utils/TimeUtils.java | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/main/java/app/cardcapture/common/utils/TimeUtils.java diff --git a/src/main/java/app/cardcapture/common/utils/TimeUtils.java b/src/main/java/app/cardcapture/common/utils/TimeUtils.java new file mode 100644 index 0000000..b2a8204 --- /dev/null +++ b/src/main/java/app/cardcapture/common/utils/TimeUtils.java @@ -0,0 +1,34 @@ +package app.cardcapture.common.utils; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; + +public class TimeUtils { + private TimeUtils() { + } + + public static LocalDateTime toLocalDateTime(Date date) { + return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime(); + } + + public static Date toDate(LocalDateTime localDateTime) { + return Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant()); + } + + public static long toEpochMilli(LocalDateTime localDateTime) { + return localDateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + } + + public static long toEpochMilli(Date date) { + return date.toInstant().toEpochMilli(); + } + + public static long toEpochSecond(LocalDateTime localDateTime) { + return localDateTime.atZone(ZoneId.systemDefault()).toInstant().getEpochSecond(); + } + + public static long toEpochSecond(Date date) { + return date.toInstant().getEpochSecond(); + } +} From 541fb2ad6bc7cf341b03ff933a2db06ade2b7e3f Mon Sep 17 00:00:00 2001 From: inpink Date: Sat, 3 Aug 2024 00:56:11 +0900 Subject: [PATCH 5/9] refactor: use Spring Security Principal for member retrieval instead of directly calling JWT components --- .../cardcapture/user/controller/UserController.java | 8 ++++---- .../app/cardcapture/user/service/UserService.java | 12 ------------ 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/src/main/java/app/cardcapture/user/controller/UserController.java b/src/main/java/app/cardcapture/user/controller/UserController.java index 44eec6e..96356d1 100644 --- a/src/main/java/app/cardcapture/user/controller/UserController.java +++ b/src/main/java/app/cardcapture/user/controller/UserController.java @@ -2,6 +2,7 @@ import app.cardcapture.auth.jwt.dto.JwtAuthorizationDto; import app.cardcapture.common.dto.SuccessResponseDto; +import app.cardcapture.security.PrincipleDetails; import app.cardcapture.user.dto.UserDto; import app.cardcapture.user.service.UserService; import io.swagger.v3.oas.annotations.Operation; @@ -10,6 +11,7 @@ import jakarta.validation.Valid; import lombok.AllArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestHeader; @@ -28,11 +30,9 @@ public class UserController { "JWT를 통해 사용자를 식별합니다. " + "JWT가 유효하지 않으면 403을 반환합니다.") public ResponseEntity> getUserDetails( - @RequestHeader(value = "Authorization") - @Valid JwtAuthorizationDto jwtAuthorizationDto + @AuthenticationPrincipal PrincipleDetails principle ) { - String accessToken = jwtAuthorizationDto.getAceessToken(); - UserDto userDto = userService.findUserByAccessToken(accessToken); + UserDto userDto = UserDto.from(principle.getUser()); SuccessResponseDto response = SuccessResponseDto.create(userDto); return ResponseEntity.ok(response); diff --git a/src/main/java/app/cardcapture/user/service/UserService.java b/src/main/java/app/cardcapture/user/service/UserService.java index 60f7396..c8aa4d1 100644 --- a/src/main/java/app/cardcapture/user/service/UserService.java +++ b/src/main/java/app/cardcapture/user/service/UserService.java @@ -1,7 +1,5 @@ package app.cardcapture.user.service; - import app.cardcapture.auth.jwt.domain.Claims; - import app.cardcapture.auth.jwt.service.JwtComponent; import app.cardcapture.common.exception.BusinessLogicException; import app.cardcapture.user.domain.entity.User; import app.cardcapture.user.dto.UserDto; @@ -17,7 +15,6 @@ public class UserService { private static final String USER_INFO_RETRIEVAL_ERROR = "Failed to retrieve user info"; private final UserRepository userRepository; - private final JwtComponent jwtComponent; public UserDto findUserById(Long id) { User user = userRepository.findById(id) @@ -29,15 +26,6 @@ public User save(UserDto userDto) { return userRepository.save(userDto.toEntity()); } - public UserDto findUserByAccessToken(String accessToken) { - Claims claims = jwtComponent.verify(accessToken); - Long id = claims.getId(); - - User user = userRepository.findById(id) - .orElseThrow(() -> new BusinessLogicException(USER_INFO_RETRIEVAL_ERROR, HttpStatus.NOT_FOUND)); - return UserDto.from(user); - } - public Optional findByGoogleId(String googleId) { return userRepository.findByGoogleId(googleId); } From ee58a4e22c3499af0c3adb6dcd331a87e0b6bd19 Mon Sep 17 00:00:00 2001 From: inpink Date: Sat, 3 Aug 2024 00:56:38 +0900 Subject: [PATCH 6/9] refactor: change DTO classes to use records Converted DTO classes to Java records to simplify the code and leverage the benefits of immutable data structures. --- .../auth/jwt/dto/JwtAuthorizationDto.java | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/main/java/app/cardcapture/auth/jwt/dto/JwtAuthorizationDto.java b/src/main/java/app/cardcapture/auth/jwt/dto/JwtAuthorizationDto.java index 4f8c83a..273cf62 100644 --- a/src/main/java/app/cardcapture/auth/jwt/dto/JwtAuthorizationDto.java +++ b/src/main/java/app/cardcapture/auth/jwt/dto/JwtAuthorizationDto.java @@ -1,12 +1,8 @@ package app.cardcapture.auth.jwt.dto; import jakarta.validation.constraints.NotBlank; -import lombok.AllArgsConstructor; -import lombok.Getter; -@Getter -@AllArgsConstructor -public class JwtAuthorizationDto { - @NotBlank - private String aceessToken; -} \ No newline at end of file +public record JwtAuthorizationDto( + @NotBlank String aceessToken +) { +} From bca1e0e5da36bf238b21bcea22ab33d661c4e47f Mon Sep 17 00:00:00 2001 From: inpink Date: Sat, 3 Aug 2024 01:02:46 +0900 Subject: [PATCH 7/9] feat: define exceptions for handling specific access token scenarios - Defined exceptions for handling cases when access tokens are sent from the frontend: - When a 401 status is returned, do not send a refresh token (user-initiated logout or blacklist). - When a 403 status is returned, prompt the frontend to send a refresh token if available (token invalid for some reason, re-authentication required). Implemented these exceptions to ensure proper handling of token-related scenarios. --- docs/README.md | 7 +++ .../cardcapture/auth/jwt/domain/Claims.java | 6 +++ .../jwt/exception/InvalidTokenException.java | 11 +++++ .../exception/TokenBlacklistedException.java | 11 +++++ .../auth/jwt/service/JwtComponent.java | 49 +++++++++++++++++-- .../cardcapture/common/utils/TimeUtils.java | 20 ++++++++ .../security/PrincipleUserDetailsService.java | 1 - .../app/cardcapture/user/dto/UserDto.java | 8 ++- 8 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 src/main/java/app/cardcapture/auth/jwt/exception/InvalidTokenException.java create mode 100644 src/main/java/app/cardcapture/auth/jwt/exception/TokenBlacklistedException.java diff --git a/docs/README.md b/docs/README.md index 380ca8f..cd2bfa5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -48,6 +48,13 @@ grant_type=authorization_code&code=AUTH_CODE&redirect_uri=YOUR_REDIRECT_URI&clie - [X] http://localhost:8080/swagger-ui.html - 유저는 자동 로그인을 할 수 있다. + - refresh token을 발급받아 저장한다. + - refresh token을 통해 access token을 재발급 받는다. + - [X] 프론트에서 access token 보낼 때 401 => refresh token 안보냄(사용자가 직접 로그아웃/블랙리스트 등) + - [X] 프론트에서 access token 보낼 때 403 => refresh token이 있으면 보내주세욥(모종의 이유로 토큰이 올바르지 않음. 재인증 필요) + - [X] 유저는 로그아웃을 할 수 있다. - 유저는 회원탈퇴를 할 수 있다. - (유저는 회원정보를 수정할 수 있다.) + +- 블랙리스트를 직접 등록하여 차단할 수 있다. diff --git a/src/main/java/app/cardcapture/auth/jwt/domain/Claims.java b/src/main/java/app/cardcapture/auth/jwt/domain/Claims.java index d784f93..f676981 100644 --- a/src/main/java/app/cardcapture/auth/jwt/domain/Claims.java +++ b/src/main/java/app/cardcapture/auth/jwt/domain/Claims.java @@ -1,8 +1,10 @@ package app.cardcapture.auth.jwt.domain; +import app.cardcapture.common.utils.TimeUtils; import com.auth0.jwt.interfaces.DecodedJWT; import lombok.Getter; +import java.time.LocalDateTime; import java.util.Date; @Getter @@ -37,4 +39,8 @@ public static Claims of(Long id, String role, String issuer, Date createdAt) { return claims; } + + public LocalDateTime getLoalDateCreatedAt() { + return TimeUtils.toLocalDateTime(createdAt); + } } \ No newline at end of file diff --git a/src/main/java/app/cardcapture/auth/jwt/exception/InvalidTokenException.java b/src/main/java/app/cardcapture/auth/jwt/exception/InvalidTokenException.java new file mode 100644 index 0000000..af3e3f4 --- /dev/null +++ b/src/main/java/app/cardcapture/auth/jwt/exception/InvalidTokenException.java @@ -0,0 +1,11 @@ +package app.cardcapture.auth.jwt.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.FORBIDDEN) +public class InvalidTokenException extends RuntimeException { + public InvalidTokenException(String message) { + super(message); + } +} diff --git a/src/main/java/app/cardcapture/auth/jwt/exception/TokenBlacklistedException.java b/src/main/java/app/cardcapture/auth/jwt/exception/TokenBlacklistedException.java new file mode 100644 index 0000000..3107f73 --- /dev/null +++ b/src/main/java/app/cardcapture/auth/jwt/exception/TokenBlacklistedException.java @@ -0,0 +1,11 @@ +package app.cardcapture.auth.jwt.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.UNAUTHORIZED) +public class TokenBlacklistedException extends RuntimeException { + public TokenBlacklistedException(String message) { + super(message); + } +} diff --git a/src/main/java/app/cardcapture/auth/jwt/service/JwtComponent.java b/src/main/java/app/cardcapture/auth/jwt/service/JwtComponent.java index 45d259b..5bd7568 100644 --- a/src/main/java/app/cardcapture/auth/jwt/service/JwtComponent.java +++ b/src/main/java/app/cardcapture/auth/jwt/service/JwtComponent.java @@ -2,9 +2,16 @@ import app.cardcapture.auth.jwt.config.JwtConfig; import app.cardcapture.auth.jwt.domain.Claims; +import app.cardcapture.auth.jwt.exception.InvalidTokenException; +import app.cardcapture.auth.jwt.exception.TokenBlacklistedException; +import app.cardcapture.common.utils.TimeUtils; +import app.cardcapture.user.dto.UserDto; +import app.cardcapture.user.service.UserService; import com.auth0.jwt.JWT; import com.auth0.jwt.JWTCreator; import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.JWTVerificationException; +import com.auth0.jwt.interfaces.DecodedJWT; import com.auth0.jwt.interfaces.JWTVerifier; import lombok.Getter; import org.springframework.stereotype.Component; @@ -15,16 +22,21 @@ @Getter public class JwtComponent { + private static final String BLACKLISTED_TOKEN = "Token is blacklisted"; + private static final String INVALID_TOKEN = "Token is invalid"; + private static final String EXPIRED_TOKEN = "Token is expired"; private final JwtConfig jwtConfig; private final Algorithm jwtHashAlgorithm; private final JWTVerifier jwtVerifier; private final TokenBlacklistService tokenBlacklistService; + private final UserService userService; - public JwtComponent(JwtConfig jwtConfig, TokenBlacklistService tokenBlacklistService) { + public JwtComponent(JwtConfig jwtConfig, TokenBlacklistService tokenBlacklistService, UserService userService) { this.jwtConfig = jwtConfig; this.jwtHashAlgorithm = Algorithm.HMAC256(jwtConfig.getSecret()); this.jwtVerifier = JWT.require(jwtHashAlgorithm).withIssuer(jwtConfig.getIssuer()).build(); this.tokenBlacklistService = tokenBlacklistService; + this.userService = userService; } public String create(Long userId, String role, Date createdAt) { @@ -46,10 +58,39 @@ public String create(Claims claims) { } public Claims verify(String token) { + verifyBlacklisted(token); + + DecodedJWT decodedJWT = verifyJWT(token); + Claims claims = new Claims(decodedJWT); + + verifyActualUser(claims); + + return claims; + } + + private DecodedJWT verifyJWT(String token) { + DecodedJWT decodedJWT; + try { + decodedJWT = jwtVerifier.verify(token); + } catch (JWTVerificationException e) { + throw new InvalidTokenException(INVALID_TOKEN); + } + return decodedJWT; + } + + private void verifyBlacklisted(String token) { if (tokenBlacklistService.isTokenBlacklisted(token)) { - throw new RuntimeException("Token is blacklisted"); + throw new TokenBlacklistedException(BLACKLISTED_TOKEN); + } + } + + private void verifyActualUser(Claims claims) { + UserDto foundUserById = userService.findUserById(claims.getId()); + long foundUserSecond = TimeUtils.toEpochSecond(foundUserById.getCreatedAt()); + long claimsSecond = TimeUtils.toEpochSecond(claims.getLoalDateCreatedAt()); + + if (foundUserSecond != claimsSecond) { + throw new InvalidTokenException(INVALID_TOKEN); } - Claims claims = new Claims(jwtVerifier.verify(token)); - return claims; } } \ No newline at end of file diff --git a/src/main/java/app/cardcapture/common/utils/TimeUtils.java b/src/main/java/app/cardcapture/common/utils/TimeUtils.java index b2a8204..3b9a4b9 100644 --- a/src/main/java/app/cardcapture/common/utils/TimeUtils.java +++ b/src/main/java/app/cardcapture/common/utils/TimeUtils.java @@ -31,4 +31,24 @@ public static long toEpochSecond(LocalDateTime localDateTime) { public static long toEpochSecond(Date date) { return date.toInstant().getEpochSecond(); } + + public static boolean isCurrentTimeOver(LocalDateTime localDateTime) { + return LocalDateTime.now().isAfter(localDateTime); + } + + public static boolean isCurrentTimeOverInMilli(Date date) { + return System.currentTimeMillis() > toEpochMilli(date); + } + + public static boolean isCurrentTimeOverInMilli(LocalDateTime localDateTime) { + return System.currentTimeMillis() > toEpochMilli(localDateTime); + } + + public static boolean isCurrentTimeOverInSeconds(Date date) { + return System.currentTimeMillis() / 1000 > toEpochSecond(date); + } + + public static boolean isCurrentTimeOverInSeconds(LocalDateTime localDateTime) { + return System.currentTimeMillis() / 1000 > toEpochSecond(localDateTime); + } } diff --git a/src/main/java/app/cardcapture/security/PrincipleUserDetailsService.java b/src/main/java/app/cardcapture/security/PrincipleUserDetailsService.java index 22781c0..d77c698 100644 --- a/src/main/java/app/cardcapture/security/PrincipleUserDetailsService.java +++ b/src/main/java/app/cardcapture/security/PrincipleUserDetailsService.java @@ -17,7 +17,6 @@ public class PrincipleUserDetailsService implements UserDetailsService { public UserDetails loadUserByUsername(String id) throws UsernameNotFoundException { User principal = userRepository.findById(Long.valueOf(id)) .orElseThrow(() -> new UsernameNotFoundException("User not found")); - System.out.println("userfind"+principal.toString()); return new PrincipleDetails(principal); } } diff --git a/src/main/java/app/cardcapture/user/dto/UserDto.java b/src/main/java/app/cardcapture/user/dto/UserDto.java index 1f92047..1b41380 100644 --- a/src/main/java/app/cardcapture/user/dto/UserDto.java +++ b/src/main/java/app/cardcapture/user/dto/UserDto.java @@ -8,6 +8,8 @@ import lombok.Getter; import lombok.ToString; +import java.time.LocalDateTime; + @Getter @AllArgsConstructor @ToString @@ -21,6 +23,8 @@ public class UserDto { private String givenName; private String familyName; private String picture; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; public static UserDto from(User user) { return new UserDto( @@ -30,7 +34,9 @@ public static UserDto from(User user) { user.getName(), user.getGivenName(), user.getFamilyName(), - user.getPicture() + user.getPicture(), + user.getCreatedAt(), + user.getUpdatedAt() ); } From dc20698986311a5e9231949d2956f7f3150ffc55 Mon Sep 17 00:00:00 2001 From: inpink Date: Sat, 3 Aug 2024 01:03:22 +0900 Subject: [PATCH 8/9] feat: enable error logging in all GlobalExceptionHandlers Updated all GlobalExceptionHandlers to log error messages. This ensures that all exceptions are properly recorded in the error logs for better monitoring and debugging. --- .../common/exception/GlobalExceptionHandler.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/app/cardcapture/common/exception/GlobalExceptionHandler.java b/src/main/java/app/cardcapture/common/exception/GlobalExceptionHandler.java index 87dd045..18578ca 100644 --- a/src/main/java/app/cardcapture/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/app/cardcapture/common/exception/GlobalExceptionHandler.java @@ -13,14 +13,17 @@ public class GlobalExceptionHandler { @ExceptionHandler(BusinessLogicException.class) public ResponseEntity> handleBusinessLogicException(BusinessLogicException ex) { - ErrorResponseDto response = ErrorResponseDto.create("알 수 없는 에러", null); + log.error(ex.getMessage(), ex); + + ErrorResponseDto response = ErrorResponseDto.create(ex.getMessage(), null); return new ResponseEntity<>(response, ex.getStatus()); } @ExceptionHandler(Exception.class) public ResponseEntity> handleGeneralException(Exception ex) { - ErrorResponseDto response = ErrorResponseDto.create("Internal Server Error", null); - log.error("Internal Server Error", ex); + log.error(ex.getMessage(), ex); + + ErrorResponseDto response = ErrorResponseDto.create(ex.getMessage(), null); return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR); } } From 51688dfae3617545c994eab74f7a239830029e79 Mon Sep 17 00:00:00 2001 From: inpink Date: Sat, 3 Aug 2024 02:19:48 +0900 Subject: [PATCH 9/9] feat: implement auto-login and token management - Enabled automatic login for users. - Added functionality to issue refresh tokens. - Implemented access token reissuance using refresh tokens. --- docs/README.md | 6 +- .../controller/GoogleAuthController.java | 67 +++++++++++++------ .../auth/jwt/config/JwtConfig.java | 19 ++++-- .../cardcapture/auth/jwt/domain/Claims.java | 2 +- .../app/cardcapture/auth/jwt/dto/JwtDto.java | 12 ---- .../auth/jwt/dto/JwtResponseDto.java | 9 +++ .../auth/jwt/dto/RefreshTokenRequestDto.java | 8 +++ .../filter/JwtAuthenticationTokenFilter.java | 2 +- .../auth/jwt/service/JwtComponent.java | 29 ++++++-- .../jwt/service/TokenBlacklistService.java | 2 +- .../user/controller/UserController.java | 4 +- .../auth/jwt/config/JwtConfigStub.java | 6 +- .../auth/jwt/service/JwtComponentTest.java | 12 ++-- 13 files changed, 116 insertions(+), 62 deletions(-) delete mode 100644 src/main/java/app/cardcapture/auth/jwt/dto/JwtDto.java create mode 100644 src/main/java/app/cardcapture/auth/jwt/dto/JwtResponseDto.java create mode 100644 src/main/java/app/cardcapture/auth/jwt/dto/RefreshTokenRequestDto.java diff --git a/docs/README.md b/docs/README.md index cd2bfa5..d9674f3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -47,9 +47,9 @@ grant_type=authorization_code&code=AUTH_CODE&redirect_uri=YOUR_REDIRECT_URI&clie - [X] API는 Swagger에서 확인 가능하다. - [X] http://localhost:8080/swagger-ui.html -- 유저는 자동 로그인을 할 수 있다. - - refresh token을 발급받아 저장한다. - - refresh token을 통해 access token을 재발급 받는다. +- [X] 유저는 자동 로그인을 할 수 있다. + - [X] refresh token을 발급할 수 있다. + - [X] refresh token을 통해 access token을 재발급 받는다. - [X] 프론트에서 access token 보낼 때 401 => refresh token 안보냄(사용자가 직접 로그아웃/블랙리스트 등) - [X] 프론트에서 access token 보낼 때 403 => refresh token이 있으면 보내주세욥(모종의 이유로 토큰이 올바르지 않음. 재인증 필요) diff --git a/src/main/java/app/cardcapture/auth/google/controller/GoogleAuthController.java b/src/main/java/app/cardcapture/auth/google/controller/GoogleAuthController.java index ffe7a1a..879135a 100644 --- a/src/main/java/app/cardcapture/auth/google/controller/GoogleAuthController.java +++ b/src/main/java/app/cardcapture/auth/google/controller/GoogleAuthController.java @@ -4,18 +4,24 @@ import app.cardcapture.auth.google.dto.GoogleLoginRequestDto; import app.cardcapture.auth.google.dto.GoogleTokenResponseDto; import app.cardcapture.auth.google.service.GoogleAuthService; -import app.cardcapture.auth.jwt.dto.JwtDto; +import app.cardcapture.auth.jwt.domain.Claims; +import app.cardcapture.auth.jwt.dto.JwtResponseDto; +import app.cardcapture.auth.jwt.dto.RefreshTokenRequestDto; import app.cardcapture.auth.jwt.service.JwtComponent; import app.cardcapture.common.dto.SuccessResponseDto; +import app.cardcapture.common.utils.TimeUtils; import app.cardcapture.user.domain.entity.User; import app.cardcapture.user.dto.UserDto; import app.cardcapture.user.service.UserService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.transaction.Transactional; +import jakarta.validation.Valid; import lombok.AllArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -48,30 +54,51 @@ public ResponseEntity> getGoogleLoginD SuccessResponseDto responseDto = SuccessResponseDto.create(googleLoginRequestDto); return ResponseEntity.ok(responseDto); - } // TODO: 쿠키에 넣어주면 프론트가 편할 수 있다 고려해볼 것 + } - @GetMapping("/redirect") // TODO: 만약 access token이 만료됐으면, 프론트에서 refreshtoken이 있으면 보내달라는 의미로 에러코드를 다른 걸로 보내줘야함(상황에 따라 프론트에서 기대되는 동작이 다르기 때문에 이를 사전에 정해놓는 것 필요) - // 지금은 다 403보내고있는데, 이를 구분해주기위한 용도로 에러코드를 이용할 수 있다. + @GetMapping("/redirect") @Operation(summary = "구글 리다이렉트 엔드포인트", description = "구글 리다이렉트를 통해 받은 auth code를 받습니다. auth code를 이용하여 유저 정보를 가져올 것입니다.") - @Transactional //TODO: 추후 serivce로 분리하면 service계층에만 transactional달기 - public ResponseEntity> getGoogleRedirect(@RequestParam(name = "code") String authCode) - { - // TODO: Service로 좀 감추기 (테스트 관점에서도 복잡도 감소 가능) - GoogleTokenResponseDto googleTokenResponseDto = googleAuthService.getGoogleToken(authCode); - UserDto userDto = googleAuthService.getUserInfo(googleTokenResponseDto.getAccessToken()); + @Transactional + public ResponseEntity> getGoogleRedirect( + @RequestParam(name = "code") String authCode + ) { + GoogleTokenResponseDto googleTokenResponseDto = googleAuthService.getGoogleToken(authCode); + UserDto userDto = googleAuthService.getUserInfo(googleTokenResponseDto.getAccessToken()); + + Optional existingUserOpt = userService.findByGoogleId(userDto.getGoogleId()); + + User user = existingUserOpt.orElseGet(() -> { + return userService.save(userDto); + }); + + String jwt = jwtComponent.createAccessToken(user.getId(), "ROLE_USER", Date.from(user.getCreatedAt().atZone(ZoneId.systemDefault()).toInstant())); + String refreshToken = jwtComponent.createRefreshToken(user.getId()); + JwtResponseDto jwtResponseDto = new JwtResponseDto(jwt, refreshToken); + + SuccessResponseDto responseDto = SuccessResponseDto.create(jwtResponseDto); - Optional existingUserOpt = userService.findByGoogleId(userDto.getGoogleId()); + return ResponseEntity.ok(responseDto); + } + + @PostMapping("/refresh") + @Operation(summary = "JWT 갱신", description = "리프레시 토큰을 이용하여 새로운 JWT를 반환합니다.") + public ResponseEntity> refreshJwt( + @RequestBody @Valid RefreshTokenRequestDto refreshTokenRequest + ) { + String refreshToken = refreshTokenRequest.refreshToken(); - User user = existingUserOpt.orElseGet(() -> { - // 신규 유저라면 저장 - return userService.save(userDto); - }); + Claims claims = jwtComponent.verifyRefreshToken(refreshToken); - String jwt = jwtComponent.create(user.getId(), "ROLE_USER", Date.from(user.getCreatedAt().atZone(ZoneId.systemDefault()).toInstant())); - JwtDto jwtDto = new JwtDto(jwt); + Long userId = claims.getId(); + UserDto userDto = userService.findUserById(userId); - SuccessResponseDto responseDto = SuccessResponseDto.create(jwtDto); + Date userCreatedAt = TimeUtils.toDate(userDto.getCreatedAt()); + String newJwt = jwtComponent.createAccessToken(userId, "ROLE_USER", userCreatedAt); + String newRefreshToken = jwtComponent.createRefreshToken(userId); - return ResponseEntity.ok(responseDto); + JwtResponseDto jwtResponseDto = new JwtResponseDto(newJwt, newRefreshToken); + SuccessResponseDto responseDto = SuccessResponseDto.create(jwtResponseDto); + + return ResponseEntity.ok(responseDto); } -} +} \ No newline at end of file diff --git a/src/main/java/app/cardcapture/auth/jwt/config/JwtConfig.java b/src/main/java/app/cardcapture/auth/jwt/config/JwtConfig.java index 42cbc68..d2aa70b 100644 --- a/src/main/java/app/cardcapture/auth/jwt/config/JwtConfig.java +++ b/src/main/java/app/cardcapture/auth/jwt/config/JwtConfig.java @@ -18,17 +18,22 @@ public class JwtConfig { private String issuer; private String secret; - private Long expirationInSeconds; + private Long accessExpirationInSeconds; + private Long refreshExpirationInSeconds; - public long getExpirationMillis(long current) { - return current + expirationInSeconds * 1000L; + public long getAccessExpirationMillis(long current) { + return current + accessExpirationInSeconds * 1000L; } - public Date getExpirationDate(Date current) { - return new Date(getExpirationMillis(current.getTime())); + public Date getAccessExpirationDate(Date current) { + return new Date(getAccessExpirationMillis(current.getTime())); } - public LocalDateTime getExpirationDate(LocalDateTime current) { - return current.plusSeconds(expirationInSeconds.intValue()); + public LocalDateTime getAccessExpirationDate(LocalDateTime current) { + return current.plusSeconds(accessExpirationInSeconds.intValue()); + } + + public Date getRefreshExpirationDate(Date current) { + return new Date(current.getTime() + refreshExpirationInSeconds * 1000L); } } diff --git a/src/main/java/app/cardcapture/auth/jwt/domain/Claims.java b/src/main/java/app/cardcapture/auth/jwt/domain/Claims.java index f676981..2f14f1c 100644 --- a/src/main/java/app/cardcapture/auth/jwt/domain/Claims.java +++ b/src/main/java/app/cardcapture/auth/jwt/domain/Claims.java @@ -8,7 +8,7 @@ import java.util.Date; @Getter -public class Claims { +public class Claims { //TODO: Access Token, Refresh Token 용 Claims 구분하기(NullpointerException 방지) private Long id; private String[] roles; private Date issuedAt; diff --git a/src/main/java/app/cardcapture/auth/jwt/dto/JwtDto.java b/src/main/java/app/cardcapture/auth/jwt/dto/JwtDto.java deleted file mode 100644 index 5d50482..0000000 --- a/src/main/java/app/cardcapture/auth/jwt/dto/JwtDto.java +++ /dev/null @@ -1,12 +0,0 @@ -package app.cardcapture.auth.jwt.dto; - -import lombok.Getter; -import lombok.NonNull; -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -@Getter -public class JwtDto { - @NonNull - private String accessToken; -} diff --git a/src/main/java/app/cardcapture/auth/jwt/dto/JwtResponseDto.java b/src/main/java/app/cardcapture/auth/jwt/dto/JwtResponseDto.java new file mode 100644 index 0000000..593a49e --- /dev/null +++ b/src/main/java/app/cardcapture/auth/jwt/dto/JwtResponseDto.java @@ -0,0 +1,9 @@ +package app.cardcapture.auth.jwt.dto; + +import jakarta.validation.constraints.NotBlank; + +public record JwtResponseDto( + @NotBlank String accessToken, + @NotBlank String refreshToken +) { +} diff --git a/src/main/java/app/cardcapture/auth/jwt/dto/RefreshTokenRequestDto.java b/src/main/java/app/cardcapture/auth/jwt/dto/RefreshTokenRequestDto.java new file mode 100644 index 0000000..3bc1963 --- /dev/null +++ b/src/main/java/app/cardcapture/auth/jwt/dto/RefreshTokenRequestDto.java @@ -0,0 +1,8 @@ +package app.cardcapture.auth.jwt.dto; + +import jakarta.validation.constraints.NotBlank; + +public record RefreshTokenRequestDto( + @NotBlank String refreshToken +) { +} diff --git a/src/main/java/app/cardcapture/auth/jwt/filter/JwtAuthenticationTokenFilter.java b/src/main/java/app/cardcapture/auth/jwt/filter/JwtAuthenticationTokenFilter.java index 9ac2d41..a9454b1 100644 --- a/src/main/java/app/cardcapture/auth/jwt/filter/JwtAuthenticationTokenFilter.java +++ b/src/main/java/app/cardcapture/auth/jwt/filter/JwtAuthenticationTokenFilter.java @@ -36,7 +36,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse authHeader = authHeader.substring(7); } String authToken = authHeader; - Claims claims = jwtComponent.verify(authToken); + Claims claims = jwtComponent.verifyAccessToken(authToken); if (claims != null) { Long userId = claims.getId(); if (userId != null) { diff --git a/src/main/java/app/cardcapture/auth/jwt/service/JwtComponent.java b/src/main/java/app/cardcapture/auth/jwt/service/JwtComponent.java index 5bd7568..6f25793 100644 --- a/src/main/java/app/cardcapture/auth/jwt/service/JwtComponent.java +++ b/src/main/java/app/cardcapture/auth/jwt/service/JwtComponent.java @@ -39,17 +39,17 @@ public JwtComponent(JwtConfig jwtConfig, TokenBlacklistService tokenBlacklistSer this.userService = userService; } - public String create(Long userId, String role, Date createdAt) { - return this.create(Claims.of(userId, role, jwtConfig.getIssuer(), createdAt)); + public String createAccessToken(Long userId, String role, Date createdAt) { + return this.createAccessToken(Claims.of(userId, role, jwtConfig.getIssuer(), createdAt)); } - public String create(Claims claims) { + public String createAccessToken(Claims claims) { Date now = new Date(); JWTCreator.Builder builder = JWT.create(); builder.withIssuer(claims.getIssuer()); builder.withIssuedAt(now); - builder.withExpiresAt(jwtConfig.getExpirationDate(now)); + builder.withExpiresAt(jwtConfig.getAccessExpirationDate(now)); builder.withClaim("id", claims.getId()); builder.withArrayClaim("roles", claims.getRoles()); builder.withClaim("created_at", claims.getCreatedAt()); @@ -57,7 +57,19 @@ public String create(Claims claims) { return builder.sign(jwtHashAlgorithm); } - public Claims verify(String token) { + public String createRefreshToken(Long userId) { + Date now = new Date(); + Date expirationDate = jwtConfig.getRefreshExpirationDate(now); + + return JWT.create() + .withIssuer(jwtConfig.getIssuer()) + .withIssuedAt(now) + .withExpiresAt(expirationDate) + .withClaim("id", userId) + .sign(jwtHashAlgorithm); + } + + public Claims verifyAccessToken(String token) { verifyBlacklisted(token); DecodedJWT decodedJWT = verifyJWT(token); @@ -68,6 +80,13 @@ public Claims verify(String token) { return claims; } + public Claims verifyRefreshToken(String token) { + DecodedJWT decodedJWT = verifyJWT(token); + Claims claims = new Claims(decodedJWT); + + return claims; + } + private DecodedJWT verifyJWT(String token) { DecodedJWT decodedJWT; try { diff --git a/src/main/java/app/cardcapture/auth/jwt/service/TokenBlacklistService.java b/src/main/java/app/cardcapture/auth/jwt/service/TokenBlacklistService.java index 4029e38..d721bd3 100644 --- a/src/main/java/app/cardcapture/auth/jwt/service/TokenBlacklistService.java +++ b/src/main/java/app/cardcapture/auth/jwt/service/TokenBlacklistService.java @@ -31,7 +31,7 @@ public void addToBlacklist(String token) { TokenBlacklist tokenBlacklist = new TokenBlacklist(); tokenBlacklist.setToken(refinedToken); - tokenBlacklist.setExpiryDate(jwtConfig.getExpirationDate(LocalDateTime.now())); + tokenBlacklist.setExpiryDate(jwtConfig.getAccessExpirationDate(LocalDateTime.now())); tokenBlacklistRepository.save(tokenBlacklist); } } \ No newline at end of file diff --git a/src/main/java/app/cardcapture/user/controller/UserController.java b/src/main/java/app/cardcapture/user/controller/UserController.java index 96356d1..a7df4a7 100644 --- a/src/main/java/app/cardcapture/user/controller/UserController.java +++ b/src/main/java/app/cardcapture/user/controller/UserController.java @@ -26,9 +26,7 @@ public class UserController { @GetMapping("/me") @Operation(summary = "사용자 정보 조회", - description = "현재 로그인한 사용자의 정보를 조회합니다. " + - "JWT를 통해 사용자를 식별합니다. " + - "JWT가 유효하지 않으면 403을 반환합니다.") + description = "현재 로그인한 사용자의 정보를 조회합니다. JWT를 통해 사용자를 식별합니다. ") public ResponseEntity> getUserDetails( @AuthenticationPrincipal PrincipleDetails principle ) { diff --git a/src/test/java/app/cardcapture/auth/jwt/config/JwtConfigStub.java b/src/test/java/app/cardcapture/auth/jwt/config/JwtConfigStub.java index e133af5..7be4be0 100644 --- a/src/test/java/app/cardcapture/auth/jwt/config/JwtConfigStub.java +++ b/src/test/java/app/cardcapture/auth/jwt/config/JwtConfigStub.java @@ -23,12 +23,12 @@ public String getSecret() { } @Override - public Long getExpirationInSeconds() { + public Long getAccessExpirationInSeconds() { return 3600L; // 1시간 } @Override - public Date getExpirationDate(Date now) { - return new Date(now.getTime() + getExpirationInSeconds() * 1000); + public Date getAccessExpirationDate(Date now) { + return new Date(now.getTime() + getAccessExpirationInSeconds() * 1000); } } diff --git a/src/test/java/app/cardcapture/auth/jwt/service/JwtComponentTest.java b/src/test/java/app/cardcapture/auth/jwt/service/JwtComponentTest.java index 7c22d58..32786c4 100644 --- a/src/test/java/app/cardcapture/auth/jwt/service/JwtComponentTest.java +++ b/src/test/java/app/cardcapture/auth/jwt/service/JwtComponentTest.java @@ -26,20 +26,20 @@ void testCreateToken() { Date createdAt = new Date(); // When - String token = jwtComponent.create(userId, role, createdAt); + String token = jwtComponent.createAccessToken(userId, role, createdAt); // Then assertNotNull(token); } @Test - void testVerifyToken() { + void testVerifyAccessTokenToken() { // Given Claims claims = Claims.of(12345789L, "USER", "test-issuer", new Date()); - String token = jwtComponent.create(claims); + String token = jwtComponent.createAccessToken(claims); // When - Claims verifiedClaims = jwtComponent.verify(token); + Claims verifiedClaims = jwtComponent.verifyAccessToken(token); // Then assertAll("Verify decoded claims", @@ -50,12 +50,12 @@ void testVerifyToken() { } @Test - void testVerifyInvalidToken() { + void testVerifyAccessTokenInvalidToken() { // Given String invalidToken = "invalid-token"; // When & Then - assertThatThrownBy(() -> jwtComponent.verify(invalidToken)) + assertThatThrownBy(() -> jwtComponent.verifyAccessToken(invalidToken)) .isInstanceOf(com.auth0.jwt.exceptions.JWTVerificationException.class); } } \ No newline at end of file