Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file modified gradlew
100644 → 100755
Empty file.
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package until.the.eternity.das.auth.dto.response;

import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;

import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;

@Builder
public record SignUpResponse(

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,7 @@
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
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;
import org.springframework.web.bind.annotation.*;
import until.the.eternity.das.auth.application.AuthService;
import until.the.eternity.das.auth.dto.request.LoginRequest;
import until.the.eternity.das.auth.dto.request.SignUpRequest;
Expand All @@ -26,9 +20,13 @@
import until.the.eternity.das.auth.dto.response.LoginResultResponse;
import until.the.eternity.das.auth.dto.response.SignUpResponse;
import until.the.eternity.das.common.constant.JwtConstant;
import until.the.eternity.das.common.exception.CustomException;
import until.the.eternity.das.common.exception.GlobalExceptionCode;
import until.the.eternity.das.common.response.CommonResponse;
import until.the.eternity.das.common.util.CookieUtil;
import until.the.eternity.das.common.util.JwtUtil;
import until.the.eternity.das.oauth.service.SocialAuthService;
import until.the.eternity.das.token.application.TokenService;

import static org.springframework.http.HttpStatus.CREATED;

Expand All @@ -38,8 +36,10 @@
public class AuthController {

private final AuthService authService;
private final TokenService tokenService;
private final SocialAuthService socialAuthService;
private final CookieUtil cookieUtil;
private final JwtUtil jwtUtil;
private final JwtConstant jwtConstant;

/**
Expand Down Expand Up @@ -209,4 +209,38 @@ public ResponseEntity<CommonResponse<LoginResponse>> login(

return ResponseEntity.ok(CommonResponse.success(loginResponse));
}

/**
* 토큰 갱신 API
*
* @param request 쿠키에서 refresh token을 추출하기 위한 HttpRequest
* @param response 새 토큰을 쿠키에 담기 위한 HttpResponse
* @return 갱신된 사용자 정보
*/
@PostMapping("/refresh")
@Operation(summary = "토큰 갱신 API", description = """
- Description : 리프레시 토큰을 사용하여 새로운 액세스 토큰과 리프레시 토큰을 발급합니다.
- 기존 리프레시 토큰은 revoke되고 새로운 토큰이 발급됩니다 (토큰 로테이션).
""")
@ApiResponse(
responseCode = "200",
content = @Content(schema = @Schema(implementation = LoginResponse.class)))
public ResponseEntity<CommonResponse<LoginResponse>> refresh(
HttpServletRequest request,
HttpServletResponse response
) {
String refreshToken = jwtUtil.extractRefreshToken(request);
if (refreshToken == null) {
throw new CustomException(GlobalExceptionCode.INVALID_REFRESH_TOKEN);
}

LoginResultResponse loginResultResponse = tokenService.refresh(refreshToken);

cookieUtil.createAccessTokenCookie(response, loginResultResponse.accessToken());
cookieUtil.createRefreshTokenCookie(response, loginResultResponse.refreshToken());

Comment on lines +238 to +241
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

refresh() is extracting the refresh token via jwtUtil.extractRefreshToken(request), but JwtUtil.extractRefreshToken is hard-coded to the cookie name "refresh_token" while the rest of the app uses jwtConstant.getRefreshTokenCookieName() (e.g., logout and cookie creation). If the configured cookie name differs, refresh will always fail. Consider extracting by jwtConstant.getRefreshTokenCookieName() (or updating the util) so refresh is consistent with configured cookie names.

Copilot uses AI. Check for mistakes.
LoginResponse loginResponse = LoginResponse.from(loginResultResponse.user());

return ResponseEntity.ok(CommonResponse.success(loginResponse));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public enum GlobalExceptionCode implements ExceptionCode {
// JWT TOKEN
INVALID_TOKEN(UNAUTHORIZED, "유효하지 않는 토큰입니다."),
EXPIRED_TOKEN(UNAUTHORIZED, "만료된 토큰입니다."),
INVALID_REFRESH_TOKEN(UNAUTHORIZED, "유효하지 않은 리프레시 토큰입니다."),

// AUTH
EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 가입되어 있는 이메일입니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
package until.the.eternity.das.common.util;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.http.Cookie;
Expand Down
15 changes: 2 additions & 13 deletions src/main/java/until/the/eternity/das/login/entity/AccountLock.java
Original file line number Diff line number Diff line change
@@ -1,19 +1,8 @@
package until.the.eternity.das.login.entity;


import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.MapsId;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.Comment;
import until.the.eternity.das.user.entity.User;

Expand Down
15 changes: 2 additions & 13 deletions src/main/java/until/the/eternity/das/role/entity/Role.java
Original file line number Diff line number Diff line change
@@ -1,19 +1,8 @@
package until.the.eternity.das.role.entity;


import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import jakarta.persistence.*;
import lombok.*;
import until.the.eternity.das.role.entity.enums.Name;

@Entity
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package until.the.eternity.das.role.entity;

import java.util.Optional;
import until.the.eternity.das.role.entity.enums.Name;

import java.util.Optional;

public interface RoleRepository {

Optional<Role> findByName(Name name);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
package until.the.eternity.das.role.infrastructure;

import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import until.the.eternity.das.role.entity.Role;
import until.the.eternity.das.role.entity.enums.Name;

import java.util.Optional;

public interface JpaRoleRepository extends JpaRepository<Role, Long> {

Optional<Role> findByName(Name name);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
package until.the.eternity.das.role.infrastructure;

import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import until.the.eternity.das.role.entity.Role;
import until.the.eternity.das.role.entity.RoleRepository;
import until.the.eternity.das.role.entity.enums.Name;

import java.util.Optional;

@Repository
@RequiredArgsConstructor
public class RoleRepositoryImpl implements RoleRepository {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import until.the.eternity.das.auth.dto.response.LoginResultResponse;
import until.the.eternity.das.common.exception.CustomException;
import until.the.eternity.das.common.exception.GlobalExceptionCode;
import until.the.eternity.das.common.util.JwtUtil;
import until.the.eternity.das.token.entity.RefreshToken;
import until.the.eternity.das.token.entity.RefreshTokenRepository;
import until.the.eternity.das.user.entity.User;
import until.the.eternity.das.user.entity.UserRepository;
import until.the.eternity.das.user.entity.enums.Status;

import java.time.LocalDateTime;
import java.util.List;
Expand Down Expand Up @@ -46,6 +48,48 @@ public void saveNewRefreshToken(Long userId, String token) {
refreshTokenRepository.save(newRefreshToken);
}

@Transactional
public LoginResultResponse refresh(String refreshTokenStr) {
// 1. JWT 서명/만료 검증
jwtUtil.validateToken(refreshTokenStr);

// 2. Claims에서 type == "REFRESH" 확인
var claims = jwtUtil.extractAllClaims(refreshTokenStr);
if (!"REFRESH".equals(claims.get("type", String.class))) {
throw new CustomException(GlobalExceptionCode.INVALID_REFRESH_TOKEN);
}

// 3. DB에서 유효한(revoked=false) 토큰 조회
RefreshToken storedToken = refreshTokenRepository.findByTokenAndRevokedFalse(refreshTokenStr)
.orElseThrow(() -> new CustomException(GlobalExceptionCode.INVALID_REFRESH_TOKEN));

// 4. 사용자 존재 + ACTIVE 상태 확인
Long userId = claims.get("userId", Long.class);
User user = userRepository.findById(userId)
.orElseThrow(() -> new CustomException(GlobalExceptionCode.USER_NOT_EXISTS));

if (user.getStatus() != Status.ACTIVE) {
throw new CustomException(GlobalExceptionCode.USER_DISABLED);
}

// 5. 기존 refresh token revoke
storedToken.revoke();

// 6. 새 access token + 새 refresh token 발급 (토큰 로테이션)
Comment on lines +63 to +78
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TokenService.refresh() has a race condition: two concurrent refresh requests with the same (still valid) refresh token can both pass findByTokenAndRevokedFalse(...) before either transaction commits the revoke, resulting in multiple new refresh tokens issued from one old token. Make the revoke step atomic (e.g., UPDATE ... SET revoked=true WHERE token=? AND revoked=false and check affected rows) or lock the row (@Lock(PESSIMISTIC_WRITE) / optimistic @Version) so only one refresh succeeds.

Copilot uses AI. Check for mistakes.
String newAccessToken = jwtUtil.generateAccessToken(user);
String newRefreshToken = jwtUtil.generateRefreshToken(user);

// 7. 새 refresh token DB 저장
saveNewRefreshToken(userId, newRefreshToken);

// 8. LoginResultResponse 반환
Comment on lines +64 to +85
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

refresh() loads the user by ID from claims, and then saveNewRefreshToken() loads the same user again. Since storedToken already references the user, you can avoid extra DB lookups by using storedToken.getUser() (and/or adding a save method that accepts User) and reusing it for status checks + saving the rotated token.

Copilot uses AI. Check for mistakes.
return LoginResultResponse.builder()
.user(user)
.accessToken(newAccessToken)
.refreshToken(newRefreshToken)
.build();
Comment on lines +53 to +90
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

refresh() parses the JWT twice (validateToken() calls extractAllClaims(), then extractAllClaims() is called again). This is unnecessary work on the hot path and makes it harder to keep token error handling consistent. Consider extracting claims once and handling parse/expiry exceptions in one place.

Suggested change
// 1. JWT 서명/만료 검증
jwtUtil.validateToken(refreshTokenStr);
// 2. Claims에서 type == "REFRESH" 확인
var claims = jwtUtil.extractAllClaims(refreshTokenStr);
if (!"REFRESH".equals(claims.get("type", String.class))) {
throw new CustomException(GlobalExceptionCode.INVALID_REFRESH_TOKEN);
}
// 3. DB에서 유효한(revoked=false) 토큰 조회
RefreshToken storedToken = refreshTokenRepository.findByTokenAndRevokedFalse(refreshTokenStr)
.orElseThrow(() -> new CustomException(GlobalExceptionCode.INVALID_REFRESH_TOKEN));
// 4. 사용자 존재 + ACTIVE 상태 확인
Long userId = claims.get("userId", Long.class);
User user = userRepository.findById(userId)
.orElseThrow(() -> new CustomException(GlobalExceptionCode.USER_NOT_EXISTS));
if (user.getStatus() != Status.ACTIVE) {
throw new CustomException(GlobalExceptionCode.USER_DISABLED);
}
// 5. 기존 refresh token revoke
storedToken.revoke();
// 6. 새 access token + 새 refresh token 발급 (토큰 로테이션)
String newAccessToken = jwtUtil.generateAccessToken(user);
String newRefreshToken = jwtUtil.generateRefreshToken(user);
// 7. 새 refresh token DB 저장
saveNewRefreshToken(userId, newRefreshToken);
// 8. LoginResultResponse 반환
return LoginResultResponse.builder()
.user(user)
.accessToken(newAccessToken)
.refreshToken(newRefreshToken)
.build();
try {
// 1. Claims에서 type == "REFRESH" 확인 (파싱 및 검증을 한 번에 수행)
var claims = jwtUtil.extractAllClaims(refreshTokenStr);
if (!"REFRESH".equals(claims.get("type", String.class))) {
throw new CustomException(GlobalExceptionCode.INVALID_REFRESH_TOKEN);
}
// 2. DB에서 유효한(revoked=false) 토큰 조회
RefreshToken storedToken = refreshTokenRepository.findByTokenAndRevokedFalse(refreshTokenStr)
.orElseThrow(() -> new CustomException(GlobalExceptionCode.INVALID_REFRESH_TOKEN));
// 3. 사용자 존재 + ACTIVE 상태 확인
Long userId = claims.get("userId", Long.class);
User user = userRepository.findById(userId)
.orElseThrow(() -> new CustomException(GlobalExceptionCode.USER_NOT_EXISTS));
if (user.getStatus() != Status.ACTIVE) {
throw new CustomException(GlobalExceptionCode.USER_DISABLED);
}
// 4. 기존 refresh token revoke
storedToken.revoke();
// 5. 새 access token + 새 refresh token 발급 (토큰 로테이션)
String newAccessToken = jwtUtil.generateAccessToken(user);
String newRefreshToken = jwtUtil.generateRefreshToken(user);
// 6. 새 refresh token DB 저장
saveNewRefreshToken(userId, newRefreshToken);
// 7. LoginResultResponse 반환
return LoginResultResponse.builder()
.user(user)
.accessToken(newAccessToken)
.refreshToken(newRefreshToken)
.build();
} catch (CustomException e) {
// 기존 도메인 예외는 그대로 전파
throw e;
} catch (Exception e) {
// JWT 파싱/검증 관련 기타 예외는 INVALID_REFRESH_TOKEN으로 매핑
throw new CustomException(GlobalExceptionCode.INVALID_REFRESH_TOKEN);
}

Copilot uses AI. Check for mistakes.
}

@Transactional
public void revokeAllUserTokens(Long userId) {
User user = userRepository.findById(userId)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,8 @@
package until.the.eternity.das.token.entity;


import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.Comment;
import until.the.eternity.das.user.entity.User;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@ public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long
* @return List<RefreshToken>
*/
List<RefreshToken> findAllByUserAndRevokedFalse(User user);

Optional<RefreshToken> findByTokenAndRevokedFalse(String token);
}
18 changes: 2 additions & 16 deletions src/main/java/until/the/eternity/das/user/entity/User.java
Original file line number Diff line number Diff line change
@@ -1,21 +1,7 @@
package until.the.eternity.das.user.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.Comment;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package until.the.eternity.das.user.entity.enums;

import lombok.*;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,7 @@
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
import until.the.eternity.das.common.response.CommonResponse;
import until.the.eternity.das.user.application.UserService;
import until.the.eternity.das.user.dto.request.UserInfoUpdateRequest;
Expand Down
Loading