From 800148641abebaf39587798845712dacfff22e4a Mon Sep 17 00:00:00 2001 From: yeoEun Date: Sun, 9 Nov 2025 01:34:15 +0900 Subject: [PATCH] feat/authexchangeex --- .../controller/AuthExchangeController.java | 103 ++++++++++++++---- .../chainee/dto/AuthExchangeRequest.java | 12 ++ .../chainee/dto/AuthExchangeResponse.java | 16 +++ .../chainee/dto/SimpleErrorResponse.java | 10 ++ 4 files changed, 122 insertions(+), 19 deletions(-) create mode 100644 src/main/java/com/chaineeproject/chainee/dto/AuthExchangeRequest.java create mode 100644 src/main/java/com/chaineeproject/chainee/dto/AuthExchangeResponse.java create mode 100644 src/main/java/com/chaineeproject/chainee/dto/SimpleErrorResponse.java diff --git a/src/main/java/com/chaineeproject/chainee/controller/AuthExchangeController.java b/src/main/java/com/chaineeproject/chainee/controller/AuthExchangeController.java index 18924a1..dea8a4a 100644 --- a/src/main/java/com/chaineeproject/chainee/controller/AuthExchangeController.java +++ b/src/main/java/com/chaineeproject/chainee/controller/AuthExchangeController.java @@ -4,15 +4,22 @@ import com.chaineeproject.chainee.auth.AuthService; import com.chaineeproject.chainee.auth.AuthTokens; import com.chaineeproject.chainee.auth.LoginCodeStore; +import com.chaineeproject.chainee.dto.AuthExchangeRequest; +import com.chaineeproject.chainee.dto.AuthExchangeResponse; +import com.chaineeproject.chainee.dto.SimpleErrorResponse; import com.chaineeproject.chainee.jwt.JwtProperties; import com.chaineeproject.chainee.security.oauth.CookieUtil; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.*; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import java.util.Map; @RestController @@ -24,25 +31,86 @@ public class AuthExchangeController { private final AuthService authService; private final JwtProperties jwtProps; - @PostMapping("/exchange") - @Operation(summary = "loginCode 교환", description = "OAuth2 성공 후 fragment로 전달된 1회용 loginCode를 accessToken으로 교환합니다.") - public ResponseEntity exchange(@RequestBody Map body, - HttpServletRequest req, - HttpServletResponse res) { - String code = body.get("loginCode"); - if (code == null || code.isBlank()) { - return ResponseEntity.badRequest().body(Map.of("success", false, "message", "MISSING_CODE")); - } + @PostMapping(value = "/exchange", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + @Operation( + summary = "loginCode 교환", + description = "OAuth2 성공 후 fragment(#)로 전달된 1회용 loginCode를 accessToken으로 교환합니다.", + // ⛔️ 전역 보안 해제: 이 엔드포인트는 인증 없이 호출 가능해야 함 + security = {}, + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = AuthExchangeRequest.class), + examples = { + @ExampleObject( + name = "샘플 요청", + value = """ + { "loginCode": "40253d90a3e047d4a2d47f21be0646d1" } + """ + ) + } + ) + ), + responses = { + @ApiResponse( + responseCode = "200", + description = "교환 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = AuthExchangeResponse.class), + examples = @ExampleObject( + name = "성공 응답", + value = """ + { + "success": true, + "accessToken": "eyJraWQiOiJjaGFpbmVlLWtpZC0xIiwidHlwIjoiSldUIiwiYWxnIjoiUlMyNTYifQ.eyJhdWQiOiJjaGFpbmVlIiwic3ViIjoiNiIsInVpZCI6Niwia3ljIjpmYWxzZSwiaXNzIjoiaHR0cHM6Ly9jaGFpbmVlLnN0b3JlIiwiZXhwIjoxNzYyNjE3Nzk0LCJpYXQiOjE3NjI2MTY4OTQsImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsImRpZCI6ZmFsc2V9.RrRQ56wjtD1m...", + "accessExp": 1762617794, + "refreshExp": 1763826494 + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "400", + description = "코드 누락/만료/무효", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = SimpleErrorResponse.class), + examples = { + @ExampleObject( + name = "코드 누락", + value = """ + { "success": false, "message": "MISSING_CODE" } + """ + ), + @ExampleObject( + name = "무효/만료 코드", + value = """ + { "success": false, "message": "INVALID_OR_EXPIRED_CODE" } + """ + ) + } + ) + ) + } + ) + public ResponseEntity exchange( + @Valid @RequestBody AuthExchangeRequest body, + HttpServletRequest req, + HttpServletResponse res + ) { + String code = body.loginCode(); Long userId = loginCodeStore.consume(code); if (userId == null) { - return ResponseEntity.badRequest().body(Map.of("success", false, "message", "INVALID_OR_EXPIRED_CODE")); + return ResponseEntity.badRequest().body(new SimpleErrorResponse(false, "INVALID_OR_EXPIRED_CODE")); } - // 유저 ID로 토큰 발급 AuthTokens t = authService.issueForUserId(userId); - // (옵션) refresh 쿠키 재세팅 — 도메인이 chainee.store일 때만 Domain 지정 + // (옵션) refresh 토큰을 쿠키로도 내려주고 싶을 경우 int maxAge = (int) jwtProps.refreshTtl().toSeconds(); boolean secure = Boolean.TRUE.equals(jwtProps.cookie().secure()); String sameSite = jwtProps.cookie().sameSite(); @@ -58,11 +126,8 @@ public ResponseEntity exchange(@RequestBody Map body, maxAge ); - return ResponseEntity.ok(Map.of( - "success", true, - "accessToken", t.accessToken(), - "accessExp", t.accessExpEpochSec(), - "refreshExp", t.refreshExpEpochSec() + return ResponseEntity.ok(new AuthExchangeResponse( + true, t.accessToken(), t.accessExpEpochSec(), t.refreshExpEpochSec() )); } diff --git a/src/main/java/com/chaineeproject/chainee/dto/AuthExchangeRequest.java b/src/main/java/com/chaineeproject/chainee/dto/AuthExchangeRequest.java new file mode 100644 index 0000000..997f285 --- /dev/null +++ b/src/main/java/com/chaineeproject/chainee/dto/AuthExchangeRequest.java @@ -0,0 +1,12 @@ +// src/main/java/com/chaineeproject/chainee/dto/AuthExchangeRequest.java +package com.chaineeproject.chainee.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(name = "AuthExchangeRequest", description = "loginCode 교환 요청") +public record AuthExchangeRequest( + @Schema(description = "OAuth2 성공 후 fragment(#)로 전달된 1회용 코드", example = "40253d90a3e047d4a2d47f21be0646d1") + @NotBlank + String loginCode +) {} diff --git a/src/main/java/com/chaineeproject/chainee/dto/AuthExchangeResponse.java b/src/main/java/com/chaineeproject/chainee/dto/AuthExchangeResponse.java new file mode 100644 index 0000000..9fdc865 --- /dev/null +++ b/src/main/java/com/chaineeproject/chainee/dto/AuthExchangeResponse.java @@ -0,0 +1,16 @@ +// src/main/java/com/chaineeproject/chainee/dto/AuthExchangeResponse.java +package com.chaineeproject.chainee.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "AuthExchangeResponse", description = "loginCode 교환 성공 응답") +public record AuthExchangeResponse( + @Schema(example = "true") + boolean success, + @Schema(description = "액세스 토큰(JWT)", example = "eyJraWQiOiJjaGFpbmVlLWtpZC0xIi...") + String accessToken, + @Schema(description = "액세스 토큰 만료(초, epoch)", example = "1762617794") + Long accessExp, + @Schema(description = "리프레시 토큰 만료(초, epoch)", example = "1763826494") + Long refreshExp +) {} diff --git a/src/main/java/com/chaineeproject/chainee/dto/SimpleErrorResponse.java b/src/main/java/com/chaineeproject/chainee/dto/SimpleErrorResponse.java new file mode 100644 index 0000000..de6f56b --- /dev/null +++ b/src/main/java/com/chaineeproject/chainee/dto/SimpleErrorResponse.java @@ -0,0 +1,10 @@ +// src/main/java/com/chaineeproject/chainee/dto/SimpleErrorResponse.java +package com.chaineeproject.chainee.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "SimpleErrorResponse") +public record SimpleErrorResponse( + @Schema(example = "false") boolean success, + @Schema(example = "INVALID_OR_EXPIRED_CODE") String message +) {}