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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<String, String> 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();
Expand All @@ -58,11 +126,8 @@ public ResponseEntity<?> exchange(@RequestBody Map<String, String> 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()
));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
@@ -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
) {}