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
18 changes: 15 additions & 3 deletions src/main/java/com/back/domain/user/controller/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@
import com.back.domain.user.dto.UserResponse;
import com.back.domain.user.service.UserService;
import com.back.global.common.dto.RsData;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
Expand All @@ -19,6 +16,8 @@
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
Expand Down Expand Up @@ -66,4 +65,17 @@ public ResponseEntity<RsData<Void>> logout(
null
));
}

// 토큰 재발급
@PostMapping("/refresh")
public ResponseEntity<RsData<Map<String, String>>> refreshToken(
HttpServletRequest request,
HttpServletResponse response
) {
String newAccessToken = userService.refreshToken(request, response);
return ResponseEntity.ok(RsData.success(
"토큰이 재발급되었습니다.",
Map.of("accessToken", newAccessToken)
));
}
}
142 changes: 114 additions & 28 deletions src/main/java/com/back/domain/user/controller/AuthControllerDocs.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestBody;

import java.util.Map;

@Tag(name = "Auth API", description = "인증/인가 관련 API")
public interface AuthControllerDocs {

Expand Down Expand Up @@ -243,13 +245,13 @@ ResponseEntity<RsData<UserResponse>> login(
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(value = """
{
"success": true,
"code": "SUCCESS_200",
"message": "로그아웃 되었습니다.",
"data": null
}
""")
{
"success": true,
"code": "SUCCESS_200",
"message": "로그아웃 되었습니다.",
"data": null
}
""")
)
),
@ApiResponse(
Expand All @@ -258,13 +260,13 @@ ResponseEntity<RsData<UserResponse>> login(
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(value = """
{
"success": false,
"code": "AUTH_401",
"message": "이미 만료되었거나 유효하지 않은 토큰입니다.",
"data": null
}
""")
{
"success": false,
"code": "AUTH_401",
"message": "이미 만료되었거나 유효하지 않은 토큰입니다.",
"data": null
}
""")
)
),
@ApiResponse(
Expand All @@ -273,13 +275,13 @@ ResponseEntity<RsData<UserResponse>> login(
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(value = """
{
"success": false,
"code": "COMMON_400",
"message": "잘못된 요청입니다.",
"data": null
}
""")
{
"success": false,
"code": "COMMON_400",
"message": "잘못된 요청입니다.",
"data": null
}
""")
)
),
@ApiResponse(
Expand All @@ -288,18 +290,102 @@ ResponseEntity<RsData<UserResponse>> login(
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(value = """
{
"success": false,
"code": "COMMON_500",
"message": "서버 오류가 발생했습니다.",
"data": null
}
""")
{
"success": false,
"code": "COMMON_500",
"message": "서버 오류가 발생했습니다.",
"data": null
}
""")
)
)
})
ResponseEntity<RsData<Void>> logout(
HttpServletRequest request,
HttpServletResponse response
);

@Operation(
summary = "토큰 재발급",
description = "만료된 Access Token 대신 Refresh Token을 이용해 새로운 Access Token을 발급받습니다. " +
"Refresh Token은 HttpOnly 쿠키에서 추출하며, 재발급 성공 시 응답 헤더와 본문에 새로운 Access Token을 담습니다."
)
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "토큰 재발급 성공",
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(value = """
{
"success": true,
"code": "SUCCESS_200",
"message": "토큰이 재발급되었습니다.",
"data": {
"accessToken": "{newAccessToken}"
}
}
""")
)
),
@ApiResponse(
responseCode = "400",
description = "Refresh Token 없음 / 잘못된 요청",
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(value = """
{
"success": false,
"code": "COMMON_400",
"message": "잘못된 요청입니다.",
"data": null
}
""")
)
),
@ApiResponse(
responseCode = "401",
description = "Refresh Token 만료 또는 위조/무효",
content = @Content(
mediaType = "application/json",
examples = {
@ExampleObject(name = "Refresh Token 만료", value = """
{
"success": false,
"code": "AUTH_401",
"message": "만료된 리프레시 토큰입니다.",
"data": null
}
"""),
@ExampleObject(name = "Refresh Token 위조/무효", value = """
{
"success": false,
"code": "AUTH_401",
"message": "유효하지 않은 Refresh Token입니다.",
"data": null
}
""")
}
)
),
@ApiResponse(
responseCode = "500",
description = "서버 내부 오류",
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(value = """
{
"success": false,
"code": "COMMON_500",
"message": "서버 오류가 발생했습니다.",
"data": null
}
""")
)
)
})
ResponseEntity<RsData<Map<String, String>>> refreshToken(
HttpServletRequest request,
HttpServletResponse response
);
}
42 changes: 41 additions & 1 deletion src/main/java/com/back/domain/user/service/UserService.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
import com.back.domain.user.repository.UserTokenRepository;
import com.back.global.exception.CustomException;
import com.back.global.exception.ErrorCode;
import com.back.global.security.CurrentUser;
import com.back.global.security.JwtTokenProvider;
import com.back.global.util.CookieUtil;
import jakarta.servlet.http.Cookie;
Expand Down Expand Up @@ -161,6 +160,47 @@ public void logout(HttpServletRequest request, HttpServletResponse response) {
CookieUtil.clearCookie(response, "refreshToken", "/api/auth");
}

/**
* 토큰 재발급 서비스
* 1. 쿠키에서 Refresh Token 추출
* 2. Refresh Token 검증 (만료/위조 확인)
* 3. DB에 저장된 Refresh Token 여부 확인
* 4. 새 Access Token 발급
*/
public String refreshToken(HttpServletRequest request, HttpServletResponse response) {
// Refresh Token 검증
String refreshToken = resolveRefreshToken(request);

// Refresh Token 존재 여부 확인
if (refreshToken == null) {
throw new CustomException(ErrorCode.BAD_REQUEST);
}

// Refresh Token 검증
if (!jwtTokenProvider.validateToken(refreshToken)) {
throw new CustomException(ErrorCode.INVALID_TOKEN);
}

// DB에서 Refresh Token 조회
UserToken userToken = userTokenRepository.findByRefreshToken(refreshToken)
.orElseThrow(() -> new CustomException(ErrorCode.INVALID_TOKEN));

// 사용자 정보 조회
User user = userToken.getUser();

// 새로운 Access Token 발급
String newAccessToken = jwtTokenProvider.createAccessToken(
user.getId(),
user.getUsername(),
user.getRole().name()
);

// 새로운 Access Token을 응답 헤더에 설정
response.setHeader("Authorization", "Bearer " + newAccessToken);

return newAccessToken;
}

/**
* 회원가입 시 중복 검증
* - username, email, nickname
Expand Down
6 changes: 4 additions & 2 deletions src/main/java/com/back/global/security/JwtTokenProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,10 @@ public boolean validateToken(String token) {
.build()
.parseSignedClaims(token);
return true;
} catch (JwtException e) {
return false;
} catch (ExpiredJwtException e) {
throw new CustomException(ErrorCode.EXPIRED_REFRESH_TOKEN);
} catch (JwtException | IllegalArgumentException e) {
throw new CustomException(ErrorCode.INVALID_TOKEN);
}
}

Expand Down
Loading
Loading