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
14 changes: 6 additions & 8 deletions docs/pr/PR-141-refactor---auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@

이번 PR은 소셜 로그인 프로세스에 존재했던 **치명적인 보안 취약점**을 해결하고, 불필요한 데이터베이스 호출을 줄이며 도메인 책임을 명확히 했습니다.

| 항목 | Before | After | 결과 |
| :------------------- | :------------------------ | :--------------------- | :------------------------ |
| **보안 위험** | **높음** (소셜 인증 우회) | **완화됨** | **치명적 취약점 해결** |
| **회원가입 DB 쿼리** | 4회 | 1회 | **75% 감소** |
| **로그인 DB 쿼리** | 2회 | 1회 | **50% 감소** |
| **이메일 검증** | 중복 이메일 처리 회원가입에서만 | 로그인도 같이 | **유저 경험 개선** |
| 항목 | Before | After | 결과 |
| :------------------- | :------------------------------ | :------------ | :--------------------- |
| **보안 위험** | **높음** (소셜 인증 우회) | **완화됨** | **치명적 취약점 해결** |
| **회원가입 DB 쿼리** | 4회 | 1회 | **75% 감소** |
| **로그인 DB 쿼리** | 2회 | 1회 | **50% 감소** |
| **이메일 검증** | 중복 이메일 처리 회원가입에서만 | 로그인도 같이 | **유저 경험 개선** |

---

Expand Down Expand Up @@ -127,5 +127,3 @@ final OAuthUser oAuthUser = providerFactory.getOAuthUser(oAuthLoginRequest);
• 인증/인가 실패 상황(providerId 불일치, 로그인되지 않은 사용자, 토큰 유효성 문제 등)에 대해 별도 예외 클래스를 정의
• BaseException으로부터 상속, ErrorCode를 명확히 지정
• 차후 로그 필터링/슬랙 알림/보안 모니터링 등에서 인증 이슈만 별도로 추적 가능


149 changes: 149 additions & 0 deletions docs/pr/PR-142-refactor---auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# 인증 시스템 보안 강화 및 아키텍처 리팩토링 (PR [#142](https://github.com/juulabel/juulabel-back/pull/144))

## TL;DR

본 PR은 소셜 인증 시스템의 종합적인 보안 개선을 구현하여, 여러 중요한 보안 취약점을 해결하고 전체적인 아키텍처를 개선합니다. CSRF 보호, 토큰 분리, 그리고 간소화된 API 계약을 도입합니다.

1. **보안 강화**: CSRF 보호 및 안전한 쿠키 처리 구현
2. **아키텍처 단순화**: API 표면 영역 축소 및 미사용 필드 제거
3. **토큰 보안**: 토큰 시크릿 분리 및 적절한 토큰 생명주기 관리
4. **사용자 경험**: 보안 강화를 유지하면서 원활한 인증 플로우 제공

## 🔧 기술적 변경사항

### 1. 로그인 엔드포인트 리팩토링 (`/v1/api/auth/login/{provider}`)

#### 응답 바디 최적화

**변경 전**: 미사용 필드가 포함된 복잡한 응답
**변경 후**: 필수 데이터 플로우에 집중한 간소화된 응답

```java
public record LoginResponse(
String accessToken, // 기존 사용자의 경우 제공, 신규 사용자의 경우 null
String signUpToken, // 신규 사용자의 경우 제공, 기존 사용자의 경우 null
String email // 식별을 위해 항상 제공
) {}
```

**근거**:

- 미사용 필드를 통한 데이터 누출 방지
- 기존 사용자 로그인과 신규 사용자 회원가입 플로우의 명확한 분리
- 클라이언트 측 상태 관리 단순화

#### CSRF 보호 구현

- **쿠키**: 로그인 시 `CSRF-TOKEN`을 쿠키로 저장
- **사용처**: 후속 토큰 갱신 작업에 필요
- **보안**: 민감한 토큰 작업에 대한 CSRF 공격 방지

### 2. 회원가입 엔드포인트 강화 (`/v1/api/auth/sign-up`)

#### Authorization 헤더 요구사항

```http
Authorization: Bearer {signUpToken}
```

**보안 기능**:

- **토큰 검증**: 회원가입 토큰이 로그인 세션 데이터와 일치하는지 확인
- **시간 검증**: 15분 만료 기간 (초과 시 401 반환)
- **무결성 검사**: 토큰 불일치/변조 시 403 반환

#### 요청 바디 단순화

**제거된 필드** (현재 signUpToken 페이로드에 포함):

- `email` - 토큰 페이로드에서 추출
- `provider` - 토큰 페이로드에서 추출
- `providerId` - 토큰 페이로드에서 추출

**이점**:

- 요청과 토큰 간의 데이터 불일치 방지
- 데이터 조작에 대한 공격 표면 축소
- 단일 진실 소스 원칙 적용

#### 응답 최적화

```java
public record SignUpMemberResponse(
Long memberId,
String accessToken
) {}
```

### 3. 토큰 갱신 보안 (`/v1/api/auth/refresh`)

#### CSRF 헤더 검증

```http
X-CSRF-TOKEN: {csrfToken}
```

**구현 세부사항**:

- 쿠키 값과 헤더 값이 일치해야 함
- 각 요청마다 토큰 자동 갱신
- 토큰 누락/무효 시 403 오류 반환

#### 보안 설정

```java
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
.requireCsrfProtectionMatcher(request ->
request.getServletPath().equals("/v1/api/auth/refresh")))
```

## 🔐 보안 강화사항

### 토큰 시크릿 분리

- **Access Token**: 사용자 세션 전용 시크릿
- **Refresh Token**: 토큰 갱신 전용 시크릿
- **Signup Token**: 등록 플로우 전용 시크릿

**이점**: 시크릿 침해 시 피해 범위 제한

## 🔄 마이그레이션 전략

### Breaking Changes

1. **API 계약 변경**: 클라이언트 애플리케이션이 새 응답 형식으로 업데이트 필요
2. **헤더 요구사항**: 회원가입 요청에 Authorization 헤더 필요
3. **CSRF 헤더**: 갱신 요청에 X-CSRF-TOKEN 헤더 필요

### 호환성 고려사항

- 전환 기간 동안 기존 액세스 토큰 유효 유지
- 프로덕션 배포 시 점진적 롤아웃 권장
- 헤더 관리를 위한 클라이언트 측 변경 필요

## 🚀 향후 고려사항

### OAuth 2.0 PKCE 구현

- **상태**: 다음 반복에서 계획됨
- **접근방식**: 프론트엔드 팀과 협력하여 클라이언트 측 구현
- **이점**: 공개 OAuth 클라이언트에 대한 보안 강화

### 쿠키 보안 강화

**현재 구현**: 도메인 마이그레이션 전략

- **기존**: `juulabel.com/app`
- **변경**: `m.juulabel.com`
- **API 도메인**: `api.juulabel.com`
- **SameSite**: CSRF 보호 강화를 위한 Strict 정책

---

**리뷰 체크리스트**:

- [ ] CSRF 보호 검증, 엔드투엔드 인증 플로우
- [ ] 도메인 마이그레이션 전략 검토
- [ ] 모든 인증 플로우에 대한 QA 검증
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package com.juu.juulabel.admin;


import com.juu.juulabel.common.provider.JwtTokenProvider;
import com.juu.juulabel.member.domain.Member;
import com.juu.juulabel.member.service.MemberService;
import io.swagger.v3.oas.annotations.Operation;
Expand All @@ -10,26 +8,21 @@
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.juu.juulabel.common.provider.jwt.AccessTokenProvider;

@Tag(
name = "테스트 API",
description = "테스트 API"
)
@Tag(name = "테스트 API", description = "테스트 API")
@RestController
@RequiredArgsConstructor
public class TestAccessTokenController {

private final JwtTokenProvider jwtTokenProvider;
private final AccessTokenProvider accessTokenProvider;
private final MemberService memberService;

@Operation(
summary = "JWT 테스트용 토큰 발급 API",
description = "기본 [email protected] 이메일로 JWT 발급"
)
@Operation(summary = "JWT 테스트용 토큰 발급 API", description = "기본 [email protected] 이메일로 JWT 발급")
@GetMapping("/token")
public String testAccessToken(@RequestParam(defaultValue = "[email protected]") String email) {
Member member = memberService.getMemberByEmail(email);
return jwtTokenProvider.createAccessToken(member);
return accessTokenProvider.createToken(member);
}

}
22 changes: 12 additions & 10 deletions src/main/java/com/juu/juulabel/auth/controller/AuthApiDocs.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@

import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;

import com.juu.juulabel.auth.domain.SignUpToken;
import com.juu.juulabel.common.constants.AuthConstants;
import com.juu.juulabel.common.dto.request.OAuthLoginRequest;
import com.juu.juulabel.common.dto.request.SignUpMemberRequest;
Expand All @@ -26,36 +28,39 @@
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;

import io.swagger.v3.oas.annotations.headers.Header;
import io.swagger.v3.oas.annotations.media.Schema;

@Tag(name = "인증 API", description = "로그인, 회원가입, 회원탈퇴, 토큰 관리 등 인증 관련 API")
@RequestMapping("/v1/api/auth")
public interface AuthApiDocs {

@Operation(summary = "OAuth 소셜 로그인", description = "지원되는 OAuth 제공자(Google, Kakao)를 통한 로그인")
@Operation(summary = "OAuth 소셜 로그인 콜백", description = "지원되는 OAuth 제공자(Google, Kakao)를 통한 로그인 콜백")
@ApiResponse(responseCode = "200", description = "로그인 성공", headers = {
@Header(name = "Set-Cookie", description = "계정이 존재할시만 리프레시 토큰 발급", schema = @Schema(type = "string"))
})
@ApiResponse(responseCode = "400", description = "잘못된 요청 데이터")
@ApiResponse(responseCode = "401", description = "인증 실패")
@PostMapping("/login/{provider}")
public ResponseEntity<CommonResponse<LoginResponse>> oauthLogin(
public ResponseEntity<CommonResponse<LoginResponse>> login(
@Parameter(description = "OAuth 제공자 (GOOGLE, KAKAO)", required = true) @PathVariable Provider provider,
@Valid @RequestBody OAuthLoginRequest requestBody);
CsrfToken csrfToken,
@Valid @RequestBody OAuthLoginRequest request);

@Operation(summary = "회원가입", description = "새로운 회원 등록 및 초기 토큰 발급")
@ApiResponse(responseCode = "200", description = "회원가입 성공", headers = {
@Header(name = "Set-Cookie", description = "리프레시 토큰 발급", schema = @Schema(type = "string"))
@Header(name = "Set-Cookie", description = "리프레시 토큰 발급", schema = @Schema(type = "string")),
})
@ApiResponse(responseCode = "400", description = "유효성 검사 실패, 중복된 이메일 또는 닉네임")
@PostMapping("/sign-up")
public ResponseEntity<CommonResponse<SignUpMemberResponse>> signUp(
@AuthenticationPrincipal SignUpToken signUpToken,
@Valid @RequestBody SignUpMemberRequest request);

@Operation(summary = "액세스 토큰 갱신", description = "리프레시 토큰을 사용하여 새로운 액세스 토큰 발급")
@Operation(summary = "토큰 갱신", description = "리프레시 토큰을 사용하여 새로운 토큰 로테이션")
@ApiResponse(responseCode = "200", description = "토큰 갱신 성공", headers = {
@Header(name = "Set-Cookie", description = "리프레시 토큰 갱신", schema = @Schema(type = "string"))
@Header(name = "Set-Cookie", description = "리프레시 토큰 갱신", schema = @Schema(type = "string")),
})
@ApiResponse(responseCode = "401", description = "유효하지 않은 리프레시 토큰", headers = {
@Header(name = "Set-Cookie", description = "리프레시 토큰 즉시 삭제", schema = @Schema(type = "string"))
Expand All @@ -65,8 +70,7 @@ public ResponseEntity<CommonResponse<SignUpMemberResponse>> signUp(
})
@PostMapping("/refresh")
public ResponseEntity<CommonResponse<RefreshResponse>> refresh(
@Parameter(description = "리프레시 토큰 (쿠키)", required = true) @CookieValue(value = AuthConstants.REFRESH_TOKEN_HEADER_NAME, required = true) String refreshToken,
@AuthenticationPrincipal Member member);
@Parameter(description = "리프레시 토큰 (쿠키)", required = true) @CookieValue(value = AuthConstants.REFRESH_TOKEN_NAME, required = true) String refreshToken);

@Operation(summary = "로그아웃", description = "현재 디바이스의 리프레시 토큰 무효화")
@ApiResponse(responseCode = "200", description = "로그아웃 성공", headers = {
Expand All @@ -75,7 +79,6 @@ public ResponseEntity<CommonResponse<RefreshResponse>> refresh(
@ApiResponse(responseCode = "401", description = "인증되지 않은 요청")
@PostMapping("/logout")
public ResponseEntity<CommonResponse<Void>> logout(
@Parameter(description = "리프레시 토큰 (쿠키)", required = true) @CookieValue(value = AuthConstants.REFRESH_TOKEN_HEADER_NAME, required = true) String refreshToken,
@AuthenticationPrincipal Member member);

@Operation(summary = "회원 탈퇴", description = "회원 계정 삭제 및 모든 토큰 무효화")
Expand All @@ -86,7 +89,6 @@ public ResponseEntity<CommonResponse<Void>> logout(
@ApiResponse(responseCode = "401", description = "인증되지 않은 요청")
@DeleteMapping("/me")
public ResponseEntity<CommonResponse<Void>> deleteAccount(
@Parameter(description = "리프레시 토큰 (쿠키)", required = true) @CookieValue(value = AuthConstants.REFRESH_TOKEN_HEADER_NAME, required = true) String refreshToken,
@AuthenticationPrincipal Member member,
@Valid @RequestBody WithdrawalRequest request);

Expand Down
35 changes: 15 additions & 20 deletions src/main/java/com/juu/juulabel/auth/controller/AuthController.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.juu.juulabel.auth.controller;

import com.juu.juulabel.auth.domain.SignUpToken;
import com.juu.juulabel.auth.service.AuthService;
import com.juu.juulabel.common.constants.AuthConstants;
import com.juu.juulabel.common.dto.request.OAuthLoginRequest;
Expand All @@ -13,12 +14,12 @@
import com.juu.juulabel.member.domain.Member;
import com.juu.juulabel.member.domain.Provider;

import io.swagger.v3.oas.annotations.Parameter;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.*;

@RestController
Expand All @@ -28,51 +29,45 @@ public class AuthController implements AuthApiDocs {
private final AuthService authService;

@Override
public ResponseEntity<CommonResponse<LoginResponse>> oauthLogin(
@Parameter(description = "OAuth 제공자 (GOOGLE, KAKAO)", required = true) @PathVariable Provider provider,
@Valid @RequestBody OAuthLoginRequest requestBody) {

LoginResponse loginResponse = authService.login(requestBody);
public ResponseEntity<CommonResponse<LoginResponse>> login(
@PathVariable Provider provider,
CsrfToken csrfToken,
@Valid @RequestBody OAuthLoginRequest request) {
csrfToken.getToken();

return CommonResponse.success(SuccessCode.SUCCESS, loginResponse);
return CommonResponse.success(SuccessCode.SUCCESS, authService.login(request));
}
Comment on lines +32 to 39
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

로그인 메서드 시그니처 변경사항을 확인해야 해, fool!

메서드명이 oauthLogin에서 login으로 변경되고 CSRF 토큰 매개변수가 추가됐어. 하지만 36번 라인에서 csrfToken.getToken()을 호출하고 반환값을 사용하지 않는 것이 의심스러워.

CSRF 토큰 검증이 제대로 작동하는지 확인이 필요해. I pity the fool who calls getToken() without proper validation!

다음 스크립트로 AuthApiDocs 인터페이스와 시그니처가 일치하는지 확인해:


🏁 Script executed:

#!/bin/bash
# AuthApiDocs 인터페이스에서 login 메서드 시그니처 확인
ast-grep --pattern $'interface AuthApiDocs {
  $$$
  login($$$) {
    $$$
  }
  $$$
}'

Length of output: 87


다음 스크립트로 AuthApiDocs 인터페이스와 AuthController의 login 시그니처를 직접 비교해보자, fool!


🏁 Script executed:

#!/bin/bash
# 난 getToken()만 호출하는 바보를 허용하지 않는다! AuthApiDocs 시그니처와 비교 시작.
file=$(rg --files-with-matches "interface AuthApiDocs" -l)
echo "[AuthApiDocs 파일 경로] $file"
echo "===== AuthApiDocs.login 시그니처 ====="
rg -n "login(" "$file"
echo "===== AuthController.login 시그니처 ====="
rg -n "public.*login" src/main/java/com/juu/juulabel/auth/controller/AuthController.java

Length of output: 767


다시 검증하자, fool! 괄호를 제대로 이스케이프하고 AuthApiDocs의 login 시그니처를 확인해야 해—I pity the fool who skips this!


🏁 Script executed:

#!/bin/bash
# AuthApiDocs와 AuthController의 login 시그니처 비교
file="src/main/java/com/juu/juulabel/auth/controller/AuthApiDocs.java"
echo "[AuthApiDocs 파일 경로] $file"
echo "===== AuthApiDocs.login 시그니처 ====="
rg -n -F "login(" "$file"
echo
echo "===== AuthController.login 시그니처 ====="
rg -n "public.*login" src/main/java/com/juu/juulabel/auth/controller/AuthController.java

Length of output: 732


다음으로 AuthApiDocs의 login 시그니처 전체를 확인해보자, fool! I pity the fool who skips this!


🏁 Script executed:

#!/bin/bash
file="src/main/java/com/juu/juulabel/auth/controller/AuthApiDocs.java"
echo "[AuthApiDocs 파일 경로] $file"
echo "===== AuthApiDocs.login 시그니처 (전후 2줄 포함) ====="
rg -n -C2 "public.*login" "$file"

echo
echo "===== AuthController.login 시그니처 (전후 2줄 포함) ====="
rg -n -C2 "public.*login" src/main/java/com/juu/juulabel/auth/controller/AuthController.java

Length of output: 1186


CSRF 토큰 호출 로직 검증 필요, fool!
AuthController.java:36에서 csrfToken.getToken()을 호출만 하고 반환값을 사용하지 않음. 이 상태로는 CSRF 유효성 검증이나 토큰 전송이 처리되지 않습니다. 아래 조치 중 하나를 선택하세요, I pity the fool who ignores this!

  • CSRF 검증이 이미 CsrfFilter에서 처리된다면 메서드 시그니처와 getToken() 호출 제거
  • 클라이언트에 토큰을 전달해야 한다면 응답 헤더·쿠키에 csrfToken.getToken() 값을 설정하는 로직 추가

참고: AuthApiDocs 인터페이스(4648행)와 AuthController(3234행)의 login 시그니처는 일치합니다.

🤖 Prompt for AI Agents
In src/main/java/com/juu/juulabel/auth/controller/AuthController.java lines 32
to 39, the call to csrfToken.getToken() on line 36 does not use its return
value, so CSRF token validation or transmission is not properly handled. To fix
this, either remove the CsrfToken parameter and the getToken() call if CSRF
validation is handled elsewhere (e.g., CsrfFilter), or if the token needs to be
sent to the client, add logic to include the token value in the response headers
or cookies accordingly.


@Override
public ResponseEntity<CommonResponse<SignUpMemberResponse>> signUp(
@AuthenticationPrincipal SignUpToken signUpToken,
@Valid @RequestBody SignUpMemberRequest request) {

SignUpMemberResponse signUpMemberResponse = authService.signUp(request);

return CommonResponse.success(SuccessCode.SUCCESS, signUpMemberResponse);
return CommonResponse.success(SuccessCode.SUCCESS, authService.signUp(signUpToken, request));
}

@Override
public ResponseEntity<CommonResponse<RefreshResponse>> refresh(
@Parameter(description = "리프레시 토큰 (쿠키)", required = true) @CookieValue(value = AuthConstants.REFRESH_TOKEN_HEADER_NAME, required = true) String refreshToken,
@AuthenticationPrincipal Member member) {

RefreshResponse refreshResponse = authService.refresh(refreshToken);
@CookieValue(value = AuthConstants.REFRESH_TOKEN_NAME, required = true) String refreshToken) {

return CommonResponse.success(SuccessCode.SUCCESS, refreshResponse);
return CommonResponse.success(SuccessCode.SUCCESS, authService.refresh(refreshToken));
}

@Override
public ResponseEntity<CommonResponse<Void>> logout(
@Parameter(description = "리프레시 토큰 (쿠키)", required = true) @CookieValue(value = AuthConstants.REFRESH_TOKEN_HEADER_NAME, required = true) String refreshToken,
@AuthenticationPrincipal Member member) {

authService.logout(refreshToken);
authService.logout(member.getId());

return CommonResponse.success(SuccessCode.SUCCESS);
}

@Override
public ResponseEntity<CommonResponse<Void>> deleteAccount(
@Parameter(description = "리프레시 토큰 (쿠키)", required = true) @CookieValue(value = AuthConstants.REFRESH_TOKEN_HEADER_NAME, required = true) String refreshToken,
@AuthenticationPrincipal Member member,
@Valid @RequestBody WithdrawalRequest request) {

authService.deleteAccount(member, request, refreshToken);
authService.deleteAccount(member, request);

return CommonResponse.success(SuccessCode.SUCCESS_DELETE);
}
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/com/juu/juulabel/auth/domain/SignUpToken.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.juu.juulabel.auth.domain;

import com.juu.juulabel.member.domain.Provider;

public record SignUpToken(
String token,
String email,
Provider provider,
String providerId,
String nonce) {
}
Loading
Loading