Skip to content
Merged
12 changes: 8 additions & 4 deletions .claude/agents/security-reviewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,12 @@ tools: Read, Bash, Grep
## Coming 인증 정책 (기준)

- Access Token: 30분, `Authorization: Bearer {token}` 헤더로만 전달
- Refresh Token: 7일, HttpOnly Cookie로만 전달 (JS에서 접근 불가해야 함)
- Refresh Token: 7일
- 웹: HttpOnly Cookie로만 전달 (JS에서 접근 불가해야 함)
- 앱(네이티브): 앱 전용 엔드포인트(`/api/auth/login/{provider}/token`, `/api/auth/app/**`)에서만 요청·응답 바디로 주고받는다. 앱은 Keychain/Keystore에 보관한다. 웹 엔드포인트는 바디로 내려보내지 않는다
- 로그아웃 시 Access Token을 Redis 블랙리스트에 등록
- OAuth2 Provider: Google, Kakao만 지원
- OAuth2 Provider: 웹은 Google, Kakao. 앱은 Google, Kakao, Apple(앱 전용)
- 앱 소셜 로그인: 제공자 토큰을 서버가 검증한다(Google·Apple은 JWKS 서명, iss, aud, 서버 발급 1회용 nonce. Kakao는 토큰 정보 API의 app_id)

## 심각도 기준

Expand All @@ -43,8 +46,9 @@ tools: Read, Bash, Grep
### 🔴 critical
- 신규/변경 엔드포인트에 `@PreAuthorize` 또는 SecurityConfig 경로 설정 누락 (인증 필요 API가 허용 목록에 포함)
- `ROLE_ADMIN` 필요 엔드포인트에 권한 체크 누락 (일반 사용자가 403 없이 접근 가능)
- Refresh Token을 응답 바디·헤더·로그에 노출 (HttpOnly Cookie 외 경로로 전달)
- Access Token을 쿠키에 저장하거나 Refresh Token을 로컬 스토리지·바디로 내려보내는 등 Bearer/Cookie 역할 뒤바뀜
- Refresh Token을 로그에 노출하거나, 앱 전용 엔드포인트가 아닌 웹 엔드포인트에서 응답 바디·헤더로 내려보냄 (웹은 HttpOnly Cookie 외 경로 금지)
- Access Token을 쿠키에 저장하거나 웹에서 Refresh Token을 로컬 스토리지·바디로 내려보내는 등 Bearer/Cookie 역할 뒤바뀜
- 앱 소셜 로그인에서 제공자 토큰 서명·aud·nonce(Google·Apple) 또는 app_id(Kakao) 검증 누락
- 로그아웃/토큰 폐기 로직에서 Redis 블랙리스트 등록 누락
- JWT 서명 검증 없이 payload를 신뢰 (예: 파싱만 하고 `verify` 생략)
- 사용자 입력(닉네임, 문의 내용 등)을 파라미터 바인딩 없이 쿼리 문자열에 직접 결합 (SQL Injection) — JPA 파라미터 바인딩·엔티티 필드 대입은 해당 없음
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ com.Coming.Backend

**인증 정책**
- Access Token: 30분, `Authorization: Bearer {token}` 헤더
- Refresh Token: 7일, HttpOnly Cookie
- Refresh Token: 7일. 웹은 HttpOnly Cookie, 앱(네이티브)은 앱 전용 엔드포인트(`/api/auth/login/{provider}/token`, `/api/auth/app/**`)에서 요청·응답 바디로 주고받는다
- 소셜 로그인: 웹은 Google·Kakao(OAuth2 리다이렉트). 앱은 Google·Kakao·Apple SDK 토큰을 서버가 검증한다(Google·Apple은 서버 발급 nonce 필수)
- 로그아웃: Redis 블랙리스트 등록

**에러 응답 형식**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.Coming.Backend.auth.controller;

import com.Coming.Backend.auth.dto.AppLogoutRequest;
import com.Coming.Backend.auth.dto.AppRefreshRequest;
import com.Coming.Backend.auth.dto.MarketingUpdateRequest;
import com.Coming.Backend.auth.dto.MeResponse;
import com.Coming.Backend.auth.dto.NicknameCheckResponse;
Expand Down Expand Up @@ -64,6 +66,24 @@ public ResponseEntity<Void> login(@PathVariable String provider) {
.build();
}

@Operation(summary = "앱 Access Token 재발급")
@ApiResponse(responseCode = "401", description = "Refresh Token 만료 또는 유효하지 않음")
@PostMapping("/app/refresh")
public ResponseEntity<TokenPair> refreshForApp(@Valid @RequestBody AppRefreshRequest request) {
return ResponseEntity.ok(authService.refreshToken(request.refreshToken()));
}

@Operation(summary = "앱 로그아웃")
@PostMapping("/app/logout")
public ResponseEntity<Void> logoutForApp(
@RequestHeader("Authorization") String authorizationHeader,
@AuthenticationPrincipal Long userId,
@RequestBody(required = false) AppLogoutRequest request) {
String refreshToken = request == null ? null : request.refreshToken();
authService.logout(authorizationHeader.substring(BEARER_PREFIX.length()), userId, refreshToken);
return ResponseEntity.ok().build();
}

@Operation(summary = "Access Token 재발급")
@ApiResponse(responseCode = "401", description = "Refresh Token 만료 또는 유효하지 않음")
@PostMapping("/refresh")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.Coming.Backend.auth.controller;

import com.Coming.Backend.auth.dto.LoginNonceResponse;
import com.Coming.Backend.auth.dto.SocialLoginResponse;
import com.Coming.Backend.auth.dto.SocialTokenLoginRequest;
import com.Coming.Backend.auth.service.SocialLoginService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "Auth")
@RestController
@RequestMapping("/api/auth/login")
@RequiredArgsConstructor
public class SocialLoginController {

private final SocialLoginService socialLoginService;

@Operation(summary = "앱 소셜 로그인용 nonce 발급")
@PostMapping("/nonce")
public ResponseEntity<LoginNonceResponse> issueLoginNonce() {
return ResponseEntity.ok(socialLoginService.issueNonce());
}

@Operation(summary = "앱 소셜 토큰 로그인")
@ApiResponse(responseCode = "400", description = "지원하지 않는 provider 또는 apple인데 authorizationCode 누락")
@ApiResponse(responseCode = "401", description = "유효하지 않은 소셜 로그인 토큰, nonce 불일치·재사용")
@ApiResponse(responseCode = "403", description = "정지된 계정")
@ApiResponse(responseCode = "502", description = "소셜 로그인 제공자 장애")
@PostMapping("/{provider}/token")
public ResponseEntity<SocialLoginResponse> loginWithSocialToken(
@PathVariable String provider,
@Valid @RequestBody SocialTokenLoginRequest request) {
return ResponseEntity.ok(socialLoginService.login(provider, request));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public Map<String, String> devLogin(
) {
DevTokenResult result = devAuthService.login(request.nickname(), request.role());
addRefreshTokenCookie(response, result.refreshToken());
return Map.of("accessToken", result.accessToken());
return Map.of("accessToken", result.accessToken(), "refreshToken", result.refreshToken());
}

private void addRefreshTokenCookie(HttpServletResponse response, String refreshToken) {
Expand Down
18 changes: 5 additions & 13 deletions src/main/java/com/Coming/Backend/auth/dev/DevAuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@
import com.Coming.Backend.auth.entity.User;
import com.Coming.Backend.auth.entity.UserRole;
import com.Coming.Backend.auth.entity.UserStatus;
import com.Coming.Backend.auth.jwt.JwtProvider;
import com.Coming.Backend.auth.repository.TokenRepository;
import com.Coming.Backend.auth.dto.TokenPair;
import com.Coming.Backend.auth.repository.UserRepository;
import com.Coming.Backend.auth.service.AuthService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -22,11 +21,7 @@ public class DevAuthService {
private static final String DEV_PROVIDER = "local";

private final UserRepository userRepository;
private final JwtProvider jwtProvider;
private final TokenRepository tokenRepository;

@Value("${jwt.refresh-token-expiry}")
private long refreshTokenExpiry;
private final AuthService authService;

/**
* nickname 기준으로 로컬 유저를 upsert하고 토큰을 발급한다.
Expand All @@ -46,13 +41,10 @@ public DevTokenResult login(String nickname, UserRole role) {
return userRepository.save(created);
});

String accessToken = jwtProvider.generateAccessToken(user.getId(), user.getRole().name());
String refreshToken = jwtProvider.generateRefreshToken(user.getId());
tokenRepository.saveSession(user.getId(), jwtProvider.parseRefreshToken(refreshToken).sessionId(),
refreshToken, refreshTokenExpiry);
TokenPair tokens = authService.issueTokens(user);

log.info("Dev 로그인 — userId: {}, nickname: {}", user.getId(), user.getNickname());
return new DevTokenResult(accessToken, refreshToken);
return new DevTokenResult(tokens.accessToken(), tokens.refreshToken());
}

record DevTokenResult(String accessToken, String refreshToken) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.Coming.Backend.auth.dto;

/**
* 앱 로그아웃 요청. refreshToken이 없으면 세션 삭제는 건너뛴다(세션은 TTL 만료로 정리).
*/
public record AppLogoutRequest(String refreshToken) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.Coming.Backend.auth.dto;

import jakarta.validation.constraints.NotBlank;

public record AppRefreshRequest(
@NotBlank
String refreshToken
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.Coming.Backend.auth.dto;

public record LoginNonceResponse(String nonce) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.Coming.Backend.auth.dto;

public record SocialLoginResponse(String accessToken, String refreshToken, boolean isNewUser) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.Coming.Backend.auth.dto;

import jakarta.validation.constraints.NotBlank;

/**
* 앱 소셜 토큰 로그인 요청. token은 Google ID Token·Kakao Access Token·Apple identityToken이고,
* authorizationCode는 apple일 때만 필수다.
*/
public record SocialTokenLoginRequest(
@NotBlank
String token,

String authorizationCode
) {
}
7 changes: 7 additions & 0 deletions src/main/java/com/Coming/Backend/auth/entity/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ public class User extends BaseTimeEntity {
@Column(name = "agreed_marketing")
private Boolean agreedMarketing;

@Column(name = "apple_refresh_token", length = 512)
private String appleRefreshToken;

public void completeRegistration(String nickname, int birthYear, boolean agreedMarketing) {
this.nickname = nickname;
this.birthYear = birthYear;
Expand All @@ -77,6 +80,10 @@ public void updateEmail(String email) {
this.email = email;
}

public void updateAppleRefreshToken(String appleRefreshToken) {
this.appleRefreshToken = appleRefreshToken;
}

public boolean hasNoEmail() {
return email == null || email.isBlank();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.Coming.Backend.auth.event;

public record AppleTokenRevokeEvent(Long userId, String appleRefreshToken) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.Coming.Backend.auth.event;

import com.Coming.Backend.auth.service.SocialUserService;
import com.Coming.Backend.auth.social.AppleAuthClient;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

/**
* 탈퇴 트랜잭션이 커밋된 뒤 Apple 토큰을 revoke한다. 성공했을 때만 저장된 토큰을 지운다.
* 실패하면 토큰을 남겨 두지만 자동 재시도는 하지 않는다(WARN 로그로 확인 후 수동 처리).
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AppleTokenRevokeListener {

private final AppleAuthClient appleAuthClient;
private final SocialUserService socialUserService;

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onAppleTokenRevoke(AppleTokenRevokeEvent event) {
try {
appleAuthClient.revoke(event.appleRefreshToken());
} catch (RuntimeException e) {
log.warn("Apple 토큰 revoke 실패 — 토큰 유지(수동 재시도 필요), userId: {}, error: {}", event.userId(), e.getMessage());
return;
}
socialUserService.clearAppleRefreshToken(event.userId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.Coming.Backend.auth.exception;

import com.Coming.Backend.common.exception.BusinessException;
import com.Coming.Backend.common.exception.ErrorCode;

public class SocialProviderUnavailableException extends BusinessException {

public SocialProviderUnavailableException() {
super(ErrorCode.SOCIAL_PROVIDER_UNAVAILABLE);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.Coming.Backend.auth.exception;

import com.Coming.Backend.common.exception.BusinessException;
import com.Coming.Backend.common.exception.ErrorCode;

public class SocialTokenInvalidException extends BusinessException {

public SocialTokenInvalidException() {
super(ErrorCode.SOCIAL_TOKEN_INVALID);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.Coming.Backend.auth.exception;

import com.Coming.Backend.common.exception.BusinessException;
import com.Coming.Backend.common.exception.ErrorCode;

public class UserSuspendedException extends BusinessException {

public UserSuspendedException() {
super(ErrorCode.USER_SUSPENDED);
}
}
Loading
Loading