Skip to content

Commit 40231fd

Browse files
authored
Merge pull request #197 from prgrms-web-devcourse-final-project/fix#185
[Feat]: Toss 빌링(카드 등록) 연동 + 쿠키 기반 인증 + 멱등키 발급
2 parents 0953743 + 572e3ce commit 40231fd

File tree

12 files changed

+225
-140
lines changed

12 files changed

+225
-140
lines changed

src/main/java/com/backend/domain/member/controller/ApiV1MemberController.java

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,18 @@
55
import com.backend.global.response.RsData;
66
import io.swagger.v3.oas.annotations.Operation;
77
import io.swagger.v3.oas.annotations.tags.Tag;
8+
import jakarta.servlet.http.HttpServletResponse;
89
import jakarta.validation.Valid;
910
import lombok.RequiredArgsConstructor;
11+
import org.springframework.http.HttpHeaders;
12+
import org.springframework.http.ResponseCookie;
1013
import org.springframework.http.ResponseEntity;
1114
import org.springframework.security.core.Authentication;
1215
import org.springframework.stereotype.Controller;
1316
import org.springframework.web.bind.annotation.*;
1417
import org.springframework.web.multipart.MultipartFile;
1518

19+
import java.time.Duration;
1620
import java.time.LocalDateTime;
1721

1822
@Controller
@@ -32,9 +36,10 @@ public ResponseEntity<RsData<MemberSignUpResponseDto>> memberSignUp(@Valid @Requ
3236

3337
@Operation(summary = "로그인 API", description = "이메일과 비밀번호를 받아 로그인 처리 후 토큰 발급")
3438
@PostMapping("/auth/login")
35-
public ResponseEntity<RsData<LoginResponseDto>> login(@Valid @RequestBody LoginRequestDto loginRequestDto) {
39+
public ResponseEntity<RsData<LoginResponseDto>> login(@Valid @RequestBody LoginRequestDto loginRequestDto,
40+
HttpServletResponse response) {
3641
RsData<LoginResponseDto> loginResponse = memberService.login(loginRequestDto);
37-
42+
writeAuthCookies(response, loginResponse.data());
3843
return ResponseEntity.status(loginResponse.statusCode()).body(loginResponse);
3944
}
4045

@@ -105,4 +110,22 @@ public ResponseEntity<RsData<Void>> memberWithdraw(Authentication authentication
105110
RsData<Void> withdrawResult = memberService.withdraw(authentication.getName());
106111
return ResponseEntity.status(withdrawResult.statusCode()).body(withdrawResult);
107112
}
113+
114+
// 로그인 성공 후 토큰을 안전한 쿠키로 내려줌..
115+
private void writeAuthCookies(HttpServletResponse res, LoginResponseDto dto) {
116+
// access 60분, refresh 7일
117+
ResponseCookie access = ResponseCookie.from("ACCESS_TOKEN", dto.accessToken())
118+
.httpOnly(true).secure(false)
119+
.sameSite("Lax").path("/")
120+
.maxAge(Duration.ofMinutes(60))
121+
.build();
122+
ResponseCookie refresh = ResponseCookie.from("REFRESH_TOKEN", dto.refreshToken())
123+
.httpOnly(true).secure(false)
124+
.sameSite("Lax").path("/")
125+
.maxAge(Duration.ofDays(7))
126+
.build();
127+
128+
res.addHeader(HttpHeaders.SET_COOKIE, access.toString());
129+
res.addHeader(HttpHeaders.SET_COOKIE, refresh.toString());
130+
}
108131
}

src/main/java/com/backend/domain/payment/controller/ApiV1PaymentController.java

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,7 @@
44
import com.backend.domain.member.service.MemberService;
55
import com.backend.domain.payment.dto.request.PaymentRequest;
66
import com.backend.domain.payment.dto.request.TossIssueBillingKeyRequest;
7-
import com.backend.domain.payment.dto.response.MyPaymentResponse;
8-
import com.backend.domain.payment.dto.response.MyPaymentsResponse;
9-
import com.backend.domain.payment.dto.response.PaymentResponse;
10-
import com.backend.domain.payment.dto.response.TossIssueBillingKeyResponse;
7+
import com.backend.domain.payment.dto.response.*;
118
import com.backend.domain.payment.service.PaymentService;
129
import com.backend.domain.payment.service.TossBillingClientService;
1310
import com.backend.global.response.RsData;
@@ -18,21 +15,30 @@
1815
import io.swagger.v3.oas.annotations.responses.ApiResponse;
1916
import io.swagger.v3.oas.annotations.responses.ApiResponses;
2017
import io.swagger.v3.oas.annotations.tags.Tag;
18+
import jakarta.servlet.http.HttpServletRequest;
2119
import jakarta.validation.Valid;
2220
import lombok.RequiredArgsConstructor;
21+
import org.springframework.beans.factory.annotation.Value;
2322
import org.springframework.http.HttpStatus;
2423
import org.springframework.security.core.annotation.AuthenticationPrincipal;
2524
import org.springframework.security.core.userdetails.User;
2625
import org.springframework.transaction.annotation.Transactional;
2726
import org.springframework.web.bind.annotation.*;
2827
import org.springframework.web.server.ResponseStatusException;
28+
import org.springframework.web.util.UriComponentsBuilder;
29+
30+
import java.util.Map;
31+
import java.util.UUID;
2932

3033
@RestController
3134
@RequiredArgsConstructor
3235
@RequestMapping("/api/v1/payments")
3336
@Tag(name = "Payments", description = "지갑 충전 API")
3437
public class ApiV1PaymentController {
3538

39+
@Value("${pg.toss.clientKey}")
40+
private String tossClientKey;
41+
3642
private final MemberService memberService;
3743
private final PaymentService paymentService;
3844
private final TossBillingClientService tossBillingClientService;
@@ -44,7 +50,7 @@ private Member getActor(User user) {
4450
}
4551

4652
@PostMapping
47-
@Operation(summary="지갑 충전 요청", description="idempotencyKey로 중복 충전 방지, 일단은 idempotencyKey 아무키로 등록해주세요!")
53+
@Operation(summary="지갑 충전 요청", description="idempotencyKey로 중복 충전 방지")
4854
@ApiResponses({
4955
@ApiResponse(responseCode = "201", description = "충전 완료",
5056
content = @Content(schema = @Schema(implementation = RsData.class))),
@@ -86,6 +92,41 @@ public RsData<TossIssueBillingKeyResponse> issueBillingKey(
8692

8793
return RsData.ok("빌링키가 발급되었습니다.", data);
8894
}
95+
@GetMapping("/toss/billing-auth-params")
96+
@Operation(
97+
summary = "토스 카드등록(빌링) 팝업 파라미터 조회",
98+
description = """
99+
토스 카드 등록 창을 띄우기 위해 FE가 먼저 호출하는 엔드포인트입니다.
100+
- 로그인(인증) 필요: 서버가 로그인 사용자의 customerKey(`user-{id}`)를 만들어 줍니다.
101+
- 응답 값:
102+
* clientKey : Toss Payments 공개 키 (FE에서 Toss SDK 초기화에 사용)
103+
* customerKey : 사용자 고유키 (카드 등록/결제 시 동일 값 사용)
104+
* successUrl : 카드 등록 성공 후 리다이렉트될 페이지
105+
* failUrl : 카드 등록 실패 시 리다이렉트될 페이지
106+
"""
107+
)
108+
@ApiResponses({
109+
@ApiResponse(responseCode = "200", description = "조회 성공",
110+
content = @Content(schema = @Schema(implementation = RsData.class)))
111+
})
112+
public RsData<TossBillingAuthParamsResponse> getBillingAuthParams(
113+
@AuthenticationPrincipal User user, HttpServletRequest req) {
114+
115+
Member me = memberService.findMemberByEmail(user.getUsername());
116+
String customerKey = "user-" + me.getId();
117+
118+
// origin 계산(예: http://localhost:8080)
119+
String origin = req.getScheme() + "://" + req.getServerName()
120+
+ ((req.getServerPort()==80 || req.getServerPort()==443) ? "" : ":" + req.getServerPort());
121+
122+
TossBillingAuthParamsResponse data = new TossBillingAuthParamsResponse(
123+
tossClientKey,
124+
customerKey,
125+
origin + "/payments/toss/billing-success.html",
126+
origin + "/payments/toss/billing-fail.html"
127+
);
128+
return RsData.ok("ok", data);
129+
}
89130

90131
@GetMapping("/me")
91132
@Operation(summary="내 결제 내역")
@@ -132,4 +173,18 @@ public RsData<MyPaymentResponse> getMyPaymentDetail(
132173
return RsData.ok("결제 상세가 조회되었습니다.", data);
133174
}
134175

176+
@GetMapping("/idempotency-key")
177+
@Operation(
178+
summary = "멱등키(결제 재시도 식별 키) 발급",
179+
description = "결제 요청 전에 1회 호출해서 받은 키를 재시도 시에도 동일하게 사용하세요."
180+
)
181+
@ApiResponses({
182+
@ApiResponse(responseCode = "200", description = "발급 성공",
183+
content = @Content(schema = @Schema(implementation = RsData.class)))
184+
})
185+
public RsData<IdempotencyKeyResponse> newIdempotencyKey() {
186+
String key = UUID.randomUUID().toString();
187+
return RsData.ok("멱등키가 발급되었습니다.", new IdempotencyKeyResponse(key));
188+
}
189+
135190
}

src/main/java/com/backend/domain/payment/dto/request/PaymentRequest.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.backend.domain.payment.dto.request;
22

3+
import io.swagger.v3.oas.annotations.media.Schema;
34
import jakarta.validation.constraints.Min;
45
import jakarta.validation.constraints.NotBlank;
56
import jakarta.validation.constraints.NotNull;
@@ -18,6 +19,10 @@ public class PaymentRequest {
1819
@Min(100)
1920
private Long amount; // 충전 금액(원)..
2021

21-
@NotBlank
22+
@Schema(
23+
description = "멱등키(재시도 시 동일하게 사용)",
24+
example = "d6a6f3ad-5d9a-4a3a-b0a5-7e0a2b77c2b1",
25+
requiredMode = Schema.RequiredMode.REQUIRED
26+
)
2227
private String idempotencyKey; // 멱등키(재시도 동일키)..
2328
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
package com.backend.domain.payment.dto.request;
22

3+
import io.swagger.v3.oas.annotations.media.Schema;
34
import lombok.Getter;
45
import lombok.Setter;
56

67
@Getter
78
@Setter
89
public class TossIssueBillingKeyRequest {
9-
private String customerKey;
10+
@Schema(description = "Toss가 successUrl로 전달한 키", example = "bln_xxx")
1011
private String authKey;
1112
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package com.backend.domain.payment.dto.response;
2+
3+
public record IdempotencyKeyResponse(
4+
String idempotencyKey
5+
) {}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.backend.domain.payment.dto.response;
2+
3+
public record TossBillingAuthParamsResponse(
4+
String clientKey,
5+
String customerKey,
6+
String successUrl,
7+
String failUrl
8+
) {}
Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,26 @@
11
package com.backend.domain.payment.dto.response;
22

3+
import io.swagger.v3.oas.annotations.media.Schema;
34
import lombok.Builder;
45
import lombok.Getter;
56

67
@Getter
78
@Builder
89
public class TossIssueBillingKeyResponse {
10+
@Schema(description = "Toss 토큰(우리 쪽 저장용)", example = "PAhFKE3...")
911
private String billingKey;
12+
1013
private String provider; // "toss"
14+
15+
@Schema(description = "카드 브랜드/발급사", example = "SHINHAN")
1116
private String cardBrand; // 선택: 스냅샷에 쓰려면
12-
private String cardNumber; // 선택
17+
18+
@Schema(description = "카드 끝 4자리", example = "1234")
19+
private String last4; // 선택
20+
21+
@Schema(description = "만료월", example = "12")
1322
private Integer expMonth; // 선택
23+
24+
@Schema(description = "만료년", example = "2028")
1425
private Integer expYear; // 선택
1526
}

src/main/java/com/backend/domain/payment/service/PaymentService.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import org.springframework.web.server.ResponseStatusException;
2626

2727
import java.time.*;
28+
import java.util.UUID;
2829

2930
@Slf4j
3031
@Service
@@ -50,8 +51,10 @@ public PaymentResponse charge(Member actor, PaymentRequest req) {
5051
if (req.getAmount() > MAX_AMOUNT_PER_TX)
5152
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"1회 최대 충전 한도를 초과했습니다.");
5253

53-
if (req.getIdempotencyKey() == null || req.getIdempotencyKey().isBlank())
54-
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "idempotencyKey가 필요합니다.");
54+
if (req.getIdempotencyKey() == null || req.getIdempotencyKey().isBlank()){
55+
req.setIdempotencyKey(UUID.randomUUID().toString());
56+
log.info("[IDEMP] generated new idempotencyKey={}", req.getIdempotencyKey());
57+
}
5558

5659
// 멱등 선조회(같은 회원+키면 기존 결과 그대로 반환)..
5760
var existingOpt = paymentRepository.findByMemberAndIdempotencyKey(actor, req.getIdempotencyKey());

src/main/java/com/backend/domain/payment/service/TossBillingClientService.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ public TossIssueBillingKeyResponse issueBillingKey(String customerKey, String au
9090
return TossIssueBillingKeyResponse.builder()
9191
.billingKey(billingKey)
9292
.cardBrand(cardBrand)
93-
.cardNumber(cardNumber)
93+
.last4(cardNumber)
9494
.expMonth(expMonth)
9595
.expYear(expYear)
9696
.build();

src/main/java/com/backend/global/security/JwtAuthenticationFilter.java

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import com.backend.global.redis.RedisUtil;
66
import jakarta.servlet.FilterChain;
77
import jakarta.servlet.ServletException;
8+
import jakarta.servlet.http.Cookie;
89
import jakarta.servlet.http.HttpServletRequest;
910
import jakarta.servlet.http.HttpServletResponse;
1011
import lombok.RequiredArgsConstructor;
@@ -25,27 +26,37 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
2526
private final MemberRepository memberRepository;
2627
private final RedisUtil redisUtil;
2728

29+
2830
@Override
29-
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
30-
31-
String header = request.getHeader("Authorization");
31+
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
32+
throws ServletException, IOException {
3233

33-
if (header != null && header.startsWith("Bearer ")) {
34-
String token = header.substring(7);
34+
String token = resolveToken(request); // 헤더/쿠키 둘 다 지원(아래 메소드)
3535

36-
if (redisUtil.getData(token) == null && jwtUtil.validateToken(token)) {
37-
String email = jwtUtil.getEmailFromToken(token);
38-
Member member = memberRepository.findByEmail(email).orElseThrow(); // 토큰이 유효하면 유저는 반드시 존재
36+
if (token != null && redisUtil.getData(token) == null && jwtUtil.validateToken(token)) {
37+
String email = jwtUtil.getEmailFromToken(token);
3938

39+
memberRepository.findByEmail(email).ifPresent(member -> {
4040
User user = new User(member.getEmail(), member.getPassword(), List.of());
41-
4241
UsernamePasswordAuthenticationToken authentication =
4342
new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
44-
4543
SecurityContextHolder.getContext().setAuthentication(authentication);
46-
}
44+
});
45+
4746
}
4847

49-
filterChain.doFilter(request, response);
48+
chain.doFilter(request, response);
5049
}
50+
51+
private String resolveToken(HttpServletRequest request) {
52+
String h = request.getHeader("Authorization");
53+
if (h != null && h.startsWith("Bearer ")) return h.substring(7);
54+
if (request.getCookies() != null) {
55+
for (Cookie c : request.getCookies()) {
56+
if ("ACCESS_TOKEN".equals(c.getName())) return c.getValue();
57+
}
58+
}
59+
return null;
60+
}
61+
5162
}

0 commit comments

Comments
 (0)