diff --git a/src/main/java/com/_data/_data/auth/controller/AuthController.java b/src/main/java/com/_data/_data/auth/controller/AuthController.java index e189fdd..6f93dac 100644 --- a/src/main/java/com/_data/_data/auth/controller/AuthController.java +++ b/src/main/java/com/_data/_data/auth/controller/AuthController.java @@ -2,8 +2,12 @@ import com._data._data.auth.dto.LoginRequest; import com._data._data.auth.dto.LoginResponse; +import com._data._data.auth.dto.RefreshRequest; import com._data._data.auth.exception.EmailNotFoundException; import com._data._data.auth.exception.InvalidPasswordException; +import com._data._data.auth.exception.TokenExpiredException; +import com._data._data.auth.exception.TokenNotFoundException; +import com._data._data.auth.jwt.RefreshTokenService; import com._data._data.auth.service.AuthServiceImpl; import com._data._data.common.dto.ApiResponse; import io.swagger.v3.oas.annotations.Operation; @@ -26,6 +30,7 @@ public class AuthController { private final AuthServiceImpl authServiceImpl; + private final RefreshTokenService refreshTokenService; @Operation( summary = "로그인", @@ -52,4 +57,25 @@ public ResponseEntity login(@Valid @RequestBody LoginRequest request) { .body(new ApiResponse(false, ex.getMessage())); } } + + + @PostMapping("/refresh") + @Operation(summary = "토큰 갱신", description = "Refresh Token으로 새로운 Access Token을 발급받습니다.") + public ResponseEntity refresh(@Valid @RequestBody RefreshRequest request) { + try { + LoginResponse tokens = refreshTokenService.refreshAccessToken(request.refreshToken()); + return ResponseEntity.ok(tokens); + } catch (TokenNotFoundException | TokenExpiredException ex) { + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body(new ApiResponse(false, ex.getMessage())); + } + } + + @PostMapping("/logout") + @Operation(summary = "로그아웃", description = "Refresh Token을 무효화합니다.") + public ResponseEntity logout(@Valid @RequestBody RefreshRequest request) { + refreshTokenService.logout(request.refreshToken()); + return ResponseEntity.ok(new ApiResponse(true, "로그아웃되었습니다.")); + } } diff --git a/src/main/java/com/_data/_data/auth/dto/LoginResponse.java b/src/main/java/com/_data/_data/auth/dto/LoginResponse.java index 086241c..254b0be 100644 --- a/src/main/java/com/_data/_data/auth/dto/LoginResponse.java +++ b/src/main/java/com/_data/_data/auth/dto/LoginResponse.java @@ -2,5 +2,8 @@ public record LoginResponse( String accessToken, - String refreshToken + String refreshToken, + String tokenType, + Long expiresIn + ) {} \ No newline at end of file diff --git a/src/main/java/com/_data/_data/auth/entity/RefreshToken.java b/src/main/java/com/_data/_data/auth/entity/RefreshToken.java new file mode 100644 index 0000000..6e55c53 --- /dev/null +++ b/src/main/java/com/_data/_data/auth/entity/RefreshToken.java @@ -0,0 +1,36 @@ +package com._data._data.auth.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import java.time.LocalDateTime; + +@Entity +@Table(name = "refresh_tokens") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class RefreshToken { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String token; + + @Column(nullable = false) + private Long userId; + + @Column(nullable = false) + private LocalDateTime expiryDate; + + @Column(nullable = false) + private LocalDateTime createdDate; + + public boolean isExpired() { + return LocalDateTime.now().isAfter(this.expiryDate); + } +} \ No newline at end of file diff --git a/src/main/java/com/_data/_data/auth/exception/TokenExpiredException.java b/src/main/java/com/_data/_data/auth/exception/TokenExpiredException.java new file mode 100644 index 0000000..74099d9 --- /dev/null +++ b/src/main/java/com/_data/_data/auth/exception/TokenExpiredException.java @@ -0,0 +1,7 @@ +package com._data._data.auth.exception; + +public class TokenExpiredException extends RuntimeException { + public TokenExpiredException(String message) { + super(message); + } +} diff --git a/src/main/java/com/_data/_data/auth/exception/TokenNotFoundException.java b/src/main/java/com/_data/_data/auth/exception/TokenNotFoundException.java new file mode 100644 index 0000000..722fd86 --- /dev/null +++ b/src/main/java/com/_data/_data/auth/exception/TokenNotFoundException.java @@ -0,0 +1,7 @@ +package com._data._data.auth.exception; + +public class TokenNotFoundException extends RuntimeException { + public TokenNotFoundException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/com/_data/_data/auth/jwt/JwtTokenProvider.java b/src/main/java/com/_data/_data/auth/jwt/JwtTokenProvider.java index ed339d8..7e52574 100644 --- a/src/main/java/com/_data/_data/auth/jwt/JwtTokenProvider.java +++ b/src/main/java/com/_data/_data/auth/jwt/JwtTokenProvider.java @@ -1,6 +1,8 @@ package com._data._data.auth.jwt; import com._data._data.auth.dto.LoginResponse; +import com._data._data.auth.entity.RefreshToken; +import com._data._data.auth.repository.RefreshTokenRepository; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jwts; @@ -11,8 +13,10 @@ import io.jsonwebtoken.security.Keys; import java.security.Key; import java.time.Instant; +import java.time.LocalDateTime; import java.time.ZonedDateTime; import java.util.Date; +import java.util.UUID; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -23,17 +27,19 @@ public class JwtTokenProvider { private final Key key; private final long accessTokenExpMillis; private final long refreshTokenExpMillis; + private final RefreshTokenRepository refreshTokenRepository; public JwtTokenProvider( @Value("${jwt.secret}") String secretKey, @Value("${jwt.expiration_time}") long accessTokenExpMillis, - @Value("${jwt.refresh_token_expiration_time}") long refreshTokenExpMillis - + @Value("${jwt.refresh_token_expiration_time}") long refreshTokenExpMillis, + RefreshTokenRepository refreshTokenRepository ) { byte[] keyBytes = Decoders.BASE64.decode(secretKey); this.key = Keys.hmacShaKeyFor(keyBytes); this.accessTokenExpMillis = accessTokenExpMillis; this.refreshTokenExpMillis = refreshTokenExpMillis; + this.refreshTokenRepository = refreshTokenRepository; } public String createAccessToken(Long userId, String email) { @@ -61,15 +67,44 @@ private String createToken(Long userId, String email, long expireMillis) { } public LoginResponse generateTokenDto(Long userId, String email) { - String accessToken = createAccessToken(userId, email); - String refreshToken = createRefreshToken(userId, email); - return new LoginResponse(accessToken, refreshToken); + String accessToken = createAccessToken(userId, email); + String refreshToken = generateAndSaveRefreshToken(userId); + + return new LoginResponse( + accessToken, + refreshToken, + "Bearer", + accessTokenExpMillis / 1000 + ); + } + + private String generateAndSaveRefreshToken(Long userId) { + // 기존 Refresh Token 삭제 + refreshTokenRepository.deleteByUserId(userId); + + // 새 Refresh Token 생성 + String tokenValue = UUID.randomUUID().toString(); + LocalDateTime expiryDate = LocalDateTime.now() + .plusSeconds(refreshTokenExpMillis / 1000); + + RefreshToken refreshToken = new RefreshToken(); + refreshToken.setToken(tokenValue); + refreshToken.setUserId(userId); + refreshToken.setExpiryDate(expiryDate); + refreshToken.setCreatedDate(LocalDateTime.now()); + + refreshTokenRepository.save(refreshToken); + + return tokenValue; } public Long getUserId(String token) { return parseClaims(token).get("userId", Long.class); } + public String getEmail(String token) { + return parseClaims(token).get("email", String.class); + } public Claims parseClaims(String accessToken) { try { @@ -94,4 +129,10 @@ public boolean validateToken(String token) { } return false; } + + public boolean validateRefreshToken(String refreshToken) { + return refreshTokenRepository.findByToken(refreshToken) + .map(token -> !token.isExpired()) + .orElse(false); + } } diff --git a/src/main/java/com/_data/_data/auth/jwt/RefreshTokenService.java b/src/main/java/com/_data/_data/auth/jwt/RefreshTokenService.java new file mode 100644 index 0000000..aa490c2 --- /dev/null +++ b/src/main/java/com/_data/_data/auth/jwt/RefreshTokenService.java @@ -0,0 +1,43 @@ +package com._data._data.auth.jwt; + +import com._data._data.auth.dto.LoginResponse; +import com._data._data.auth.entity.RefreshToken; +import com._data._data.auth.exception.TokenExpiredException; +import com._data._data.auth.exception.TokenNotFoundException; +import com._data._data.auth.jwt.JwtTokenProvider; +import com._data._data.auth.repository.RefreshTokenRepository; +import com._data._data.user.entity.Users; +import com._data._data.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class RefreshTokenService { + + private final RefreshTokenRepository refreshTokenRepository; + private final UserRepository userRepository; + private final JwtTokenProvider jwtTokenProvider; + + @Transactional + public LoginResponse refreshAccessToken(String refreshTokenValue) { + RefreshToken refreshToken = refreshTokenRepository.findByToken(refreshTokenValue) + .orElseThrow(() -> new TokenNotFoundException("유효하지 않은 Refresh Token입니다.")); + + if (refreshToken.isExpired()) { + refreshTokenRepository.delete(refreshToken); + throw new TokenExpiredException("만료된 Refresh Token입니다."); + } + + Users user = userRepository.findById(refreshToken.getUserId()) + .orElseThrow(() -> new TokenNotFoundException("사용자를 찾을 수 없습니다.")); + + return jwtTokenProvider.generateTokenDto(user.getId(), user.getEmail()); + } + + @Transactional + public void logout(String refreshTokenValue) { + refreshTokenRepository.deleteByToken(refreshTokenValue); + } +} diff --git a/src/main/java/com/_data/_data/auth/repository/RefreshTokenRepository.java b/src/main/java/com/_data/_data/auth/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..3ca914b --- /dev/null +++ b/src/main/java/com/_data/_data/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,15 @@ +package com._data._data.auth.repository; + + +import com._data._data.auth.entity.RefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.util.Optional; + +@Repository +public interface RefreshTokenRepository extends JpaRepository { + Optional findByToken(String token); + Optional findByUserId(Long userId); + void deleteByUserId(Long userId); + void deleteByToken(String token); +} \ No newline at end of file diff --git a/src/main/java/com/_data/_data/user/entity/Nation.java b/src/main/java/com/_data/_data/user/entity/Nation.java index 2d713ab..81b1313 100644 --- a/src/main/java/com/_data/_data/user/entity/Nation.java +++ b/src/main/java/com/_data/_data/user/entity/Nation.java @@ -20,7 +20,7 @@ public class Nation { @Id private Long id; - @Column(unique = true, nullable = false, length = 3) + @Column(unique = true, nullable = false) private String code; @Column(nullable = false, length = 100) diff --git a/src/main/resources/static/images/uploads/post/post_2_3.jpg b/src/main/resources/static/images/uploads/post/post_2_3.jpg new file mode 100644 index 0000000..58f50b2 Binary files /dev/null and b/src/main/resources/static/images/uploads/post/post_2_3.jpg differ diff --git a/src/main/resources/static/images/uploads/profile/profile_2_2.png b/src/main/resources/static/images/uploads/profile/profile_2_2.png new file mode 100644 index 0000000..41377da Binary files /dev/null and b/src/main/resources/static/images/uploads/profile/profile_2_2.png differ