diff --git a/src/main/java/com/wayble/server/common/config/SecurityConfig.java b/src/main/java/com/wayble/server/common/config/SecurityConfig.java index 9600ebf1..4b4d723b 100644 --- a/src/main/java/com/wayble/server/common/config/SecurityConfig.java +++ b/src/main/java/com/wayble/server/common/config/SecurityConfig.java @@ -36,6 +36,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers( "/api/v1/users/signup", "/api/v1/users/login", + "/api/v1/users/reissue", + "/api/v1/users/logout", "/swagger-ui/**", "/v3/api-docs/**" ).permitAll() diff --git a/src/main/java/com/wayble/server/common/config/security/JwtProperties.java b/src/main/java/com/wayble/server/common/config/security/JwtProperties.java index a1efd248..4d47c445 100644 --- a/src/main/java/com/wayble/server/common/config/security/JwtProperties.java +++ b/src/main/java/com/wayble/server/common/config/security/JwtProperties.java @@ -9,6 +9,7 @@ public class JwtProperties { private String secret; private long accessExp; + private long refreshExp; public String getSecret() { @@ -26,4 +27,8 @@ public void setSecret(String secret) { public void setAccessExp(long accessExp) { this.accessExp = accessExp; } + + public long getRefreshExp() { return refreshExp; } + + public void setRefreshExp(long refreshExp) { this.refreshExp = refreshExp; } } diff --git a/src/main/java/com/wayble/server/common/config/security/jwt/JwtTokenProvider.java b/src/main/java/com/wayble/server/common/config/security/jwt/JwtTokenProvider.java index 25227b3c..033cc9ff 100644 --- a/src/main/java/com/wayble/server/common/config/security/jwt/JwtTokenProvider.java +++ b/src/main/java/com/wayble/server/common/config/security/jwt/JwtTokenProvider.java @@ -31,6 +31,15 @@ public String generateToken(Long userId, String role) { .compact(); } + public String generateRefreshToken(Long userId) { + return Jwts.builder() + .setSubject(String.valueOf(userId)) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + jwtProperties.getRefreshExp())) + .signWith(signingKey, SignatureAlgorithm.HS256) + .compact(); + } + public boolean validateToken(String token) { try { Jwts.parserBuilder().setSigningKey(signingKey).build().parseClaimsJws(token); @@ -53,4 +62,12 @@ public Long getUserId(String token) { .getSubject(); return Long.parseLong(subject); } + + public Long getTokenExpiry(String token) { + if (!validateToken(token)) { + throw new IllegalArgumentException("Invalid token"); + } + return Jwts.parserBuilder().setSigningKey(signingKey).build() + .parseClaimsJws(token).getBody().getExpiration().getTime(); + } } \ No newline at end of file diff --git a/src/main/java/com/wayble/server/user/controller/UserController.java b/src/main/java/com/wayble/server/user/controller/UserController.java index 01b7e6db..1d650fa5 100644 --- a/src/main/java/com/wayble/server/user/controller/UserController.java +++ b/src/main/java/com/wayble/server/user/controller/UserController.java @@ -1,12 +1,16 @@ package com.wayble.server.user.controller; +import com.wayble.server.common.config.security.jwt.JwtTokenProvider; +import com.wayble.server.common.exception.ApplicationException; import com.wayble.server.common.response.CommonResponse; import com.wayble.server.user.dto.UserLoginRequestDto; import com.wayble.server.user.dto.UserRegisterRequestDto; import com.wayble.server.user.dto.token.TokenResponseDto; +import com.wayble.server.user.exception.UserErrorCase; import com.wayble.server.user.service.UserService; import com.wayble.server.user.service.auth.AuthService; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -24,6 +28,7 @@ public class UserController { private final UserService userService; private final AuthService authService; + private final JwtTokenProvider jwtProvider; @PostMapping("/signup") @Operation( @@ -57,4 +62,54 @@ public CommonResponse login(@RequestBody @Valid UserLoginReque return CommonResponse.success(tokenDto); } + @PostMapping("/reissue") + @Operation( + summary = "AccessToken 재발급", + description = "클라이언트의 accessToken 만료 시, 유효한 refreshToken으로 새로운 accessToken 및 refreshToken을 재발급합니다." + + + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "토큰 재발급 성공", + content = @Content(schema = @Schema(implementation = com.wayble.server.user.dto.token.TokenResponseDto.class))), + @ApiResponse(responseCode = "400", description = "refreshToken이 유효하지 않음", + content = @Content(schema = @Schema(implementation = com.wayble.server.common.response.CommonResponse.class))) + }) + public CommonResponse reissue( + @Parameter(description = "재발급용 refreshToken", required = true) + @RequestParam String refreshToken + ) { + TokenResponseDto tokens = authService.reissue(refreshToken); + return CommonResponse.success(tokens); + } + + @PostMapping("/logout") + @Operation( + summary = "유저 로그아웃", + description = "로그아웃 처리(서버에 저장된 refreshToken을 삭제합니다)." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "로그아웃 성공"), + @ApiResponse(responseCode = "401", description = "유효하지 않은 accessToken") + }) + public CommonResponse logout( + @Parameter( + description = "사용자 인증용 accessToken (Bearer {token} 형태)", + required = true, + example = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + ) + @RequestHeader("Authorization") String accessToken + ) { + if (accessToken == null || !accessToken.startsWith("Bearer ")) { + throw new ApplicationException(UserErrorCase.INVALID_CREDENTIALS); + } + String token = accessToken.replace("Bearer ", ""); + if (token.isEmpty()) { + throw new ApplicationException(UserErrorCase.INVALID_CREDENTIALS); + } + Long userId = jwtProvider.getUserId(token); + authService.logout(userId); + return CommonResponse.success("로그아웃에 성공하였습니다."); + } + } diff --git a/src/main/java/com/wayble/server/user/dto/token/TokenResponseDto.java b/src/main/java/com/wayble/server/user/dto/token/TokenResponseDto.java index 57c54dbc..f006ecc8 100644 --- a/src/main/java/com/wayble/server/user/dto/token/TokenResponseDto.java +++ b/src/main/java/com/wayble/server/user/dto/token/TokenResponseDto.java @@ -1,3 +1,3 @@ package com.wayble.server.user.dto.token; -public record TokenResponseDto(String accessToken) {} \ No newline at end of file +public record TokenResponseDto(String accessToken, String refreshToken) {} \ No newline at end of file diff --git a/src/main/java/com/wayble/server/user/entity/RefreshToken.java b/src/main/java/com/wayble/server/user/entity/RefreshToken.java new file mode 100644 index 00000000..5fdecb6e --- /dev/null +++ b/src/main/java/com/wayble/server/user/entity/RefreshToken.java @@ -0,0 +1,34 @@ +package com.wayble.server.user.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Table(name = "refresh_token") +public class RefreshToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) // 1인 1토큰 + private Long userId; + + @Column(nullable = false) + private String token; + + @Column(nullable = false) + private Long expiry; // 만료 시간 + + public void setToken(String token) { + this.token = token; + } + + public void setExpiry(Long expiry) { + this.expiry = expiry; + } +} \ No newline at end of file diff --git a/src/main/java/com/wayble/server/user/repository/RefreshTokenRepository.java b/src/main/java/com/wayble/server/user/repository/RefreshTokenRepository.java new file mode 100644 index 00000000..f23e2252 --- /dev/null +++ b/src/main/java/com/wayble/server/user/repository/RefreshTokenRepository.java @@ -0,0 +1,15 @@ +package com.wayble.server.user.repository; + +import com.wayble.server.user.entity.RefreshToken; +import jakarta.transaction.Transactional; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface RefreshTokenRepository extends JpaRepository { + Optional findByUserId(Long userId); + Optional findByToken(String token); + + @Transactional + void deleteByUserId(Long userId); +} \ No newline at end of file diff --git a/src/main/java/com/wayble/server/user/service/auth/AuthService.java b/src/main/java/com/wayble/server/user/service/auth/AuthService.java index 3f9a1fa7..e73f338e 100644 --- a/src/main/java/com/wayble/server/user/service/auth/AuthService.java +++ b/src/main/java/com/wayble/server/user/service/auth/AuthService.java @@ -4,9 +4,12 @@ import com.wayble.server.common.exception.ApplicationException; import com.wayble.server.user.dto.UserLoginRequestDto; import com.wayble.server.user.dto.token.TokenResponseDto; +import com.wayble.server.user.entity.RefreshToken; import com.wayble.server.user.entity.User; import com.wayble.server.user.exception.UserErrorCase; +import com.wayble.server.user.repository.RefreshTokenRepository; import com.wayble.server.user.repository.UserRepository; +import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -16,6 +19,7 @@ public class AuthService { private final UserRepository userRepository; + private final RefreshTokenRepository refreshTokenRepository; private final PasswordEncoder encoder; private final JwtTokenProvider jwtProvider; @@ -25,7 +29,55 @@ public TokenResponseDto login(UserLoginRequestDto req) { if (!encoder.matches(req.password(), user.getPassword())) { throw new ApplicationException(UserErrorCase.INVALID_CREDENTIALS); } - String token = jwtProvider.generateToken(user.getId(), user.getUserType().name()); - return new TokenResponseDto(token); + String accessToken = jwtProvider.generateToken(user.getId(), user.getUserType().name()); + String refreshToken = jwtProvider.generateRefreshToken(user.getId()); + Long expiry = jwtProvider.getTokenExpiry(refreshToken); + + refreshTokenRepository.deleteByUserId(user.getId()); + + RefreshToken entity = RefreshToken.builder() + .userId(user.getId()) + .token(refreshToken) + .expiry(expiry) + .build(); + refreshTokenRepository.save(entity); + + return new TokenResponseDto(accessToken, refreshToken); + } + + public TokenResponseDto reissue(String refreshToken) { + if (!jwtProvider.validateToken(refreshToken)) { + throw new ApplicationException(UserErrorCase.INVALID_CREDENTIALS); + } + Long userId = jwtProvider.getUserId(refreshToken); + RefreshToken saved = refreshTokenRepository.findByUserId(userId) + .orElseThrow(() -> new ApplicationException(UserErrorCase.INVALID_CREDENTIALS)); + + if (!saved.getToken().equals(refreshToken)) { + throw new ApplicationException(UserErrorCase.INVALID_CREDENTIALS); + } + + if (saved.getExpiry() < System.currentTimeMillis()) { + refreshTokenRepository.delete(saved); + throw new ApplicationException(UserErrorCase.INVALID_CREDENTIALS); + } + + User user = userRepository.findById(userId) + .orElseThrow(() -> new ApplicationException(UserErrorCase.USER_NOT_FOUND)); + + String newRefreshToken = jwtProvider.generateRefreshToken(userId); + Long newExpiry = jwtProvider.getTokenExpiry(newRefreshToken); + + saved.setToken(newRefreshToken); + saved.setExpiry(newExpiry); + refreshTokenRepository.save(saved); + + String newAccessToken = jwtProvider.generateToken(userId, user.getUserType().name()); + return new TokenResponseDto(newAccessToken, newRefreshToken); + } + + @Transactional + public void logout(Long userId) { + refreshTokenRepository.deleteByUserId(userId); } }