diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 968615f..7540aff 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -89,6 +89,10 @@ jobs: SPRING_DATASOURCE_USERNAME: ${{ secrets.SPRING_DATASOURCE_USERNAME }} SPRING_DATASOURCE_PASSWORD: ${{ secrets.SPRING_DATASOURCE_PASSWORD }} + # 인증 설정 + JWT_SECRET: ${{ secrets.JWT_SECRET }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + # 외부 API 설정 TOUR_API_KEY: ${{ secrets.TOUR_API_KEY }} KAKAO_REST_API_KEY: ${{ secrets.KAKAO_REST_API_KEY }} @@ -171,6 +175,14 @@ jobs: # 그 뒤 상대 컨테이너가 재시작하면 설정을 잃는다. 실제로 8월 11일 # 08:24~09:00 사이에 서로를 덮어썼고 data-ai가 환경변수 없이 기동했다. # 이 레포는 .env.server 만 쓴다. 각자 자기 파일만 쓰면 간섭이 없다. + # 값이 없으면 컨테이너가 기동하다 죽는다. 배포를 시작하기 전에 멈추는 편이 + # 원인을 찾기 쉽다. + if [ -z "$JWT_SECRET" ]; then + echo "ERROR: JWT_SECRET secret is not set" + echo "Register it in GitHub repository secrets (32 bytes or longer)" + exit 1 + fi + echo "[5/8] Create .env.server" # GitHub Secrets를 기반으로 임시 환경변수 파일을 만든다. @@ -187,6 +199,14 @@ jobs: # 일정 생성/조회를 FastAPI(data-ai)에 위임한다. # 이 값이 없으면 기본값 false로 기동해 일정 API 전체가 503을 반환한다. # base-url은 끝에 슬래시를 붙이지 않는다. 붙이면 //api/v1/... 로 요청이 나가 404가 된다. + # 인증. JWT_SECRET 이 없으면 서버가 기동하지 않는다. + # 서명 키 없이 뜨면 누구나 원하는 사용자와 권한으로 토큰을 만들 수 있어, + # 조용히 뚫린 채 도는 것보다 뜨지 않는 편이 낫다고 판단했다. + printf 'JWT_SECRET=%s\n' "$JWT_SECRET" + printf 'GOOGLE_CLIENT_ID=%s\n' "$GOOGLE_CLIENT_ID" + + printf 'REDIS_HOST=redis\n' + printf 'REDIS_PORT=6379\n' printf 'SCHEDULE_FASTAPI_ENABLED=true\n' printf 'SCHEDULE_FASTAPI_BASE_URL=http://data-ai:8010\n' @@ -275,6 +295,20 @@ jobs: docker pull \ "${ECR_REGISTRY}/${ECR_REPOSITORY_NAME}:latest-dev" + echo "Ensure redis container" + + # 리프레시 토큰 저장소. 이미 떠 있으면 건드리지 않는다. + # 재시작해도 로그인 세션이 끊기지 않도록 배포마다 지우지 않는다. + if [ -z "$(docker ps -q -f name=^redis$)" ]; then + docker rm redis || true + docker run -d \ + --name redis \ + --restart unless-stopped \ + --network hackathon-network \ + redis:7-alpine \ + redis-server --save 60 1 --appendonly no + fi + echo "Stop old container" # 기존 컨테이너가 없어도 배포를 계속한다. diff --git a/build.gradle b/build.gradle index 888efb1..6045319 100644 --- a/build.gradle +++ b/build.gradle @@ -22,11 +22,15 @@ dependencies { implementation 'org.springframework.boot:spring-boot-h2console' implementation 'org.springframework.boot:spring-boot-starter-data-jdbc' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-webmvc' implementation 'org.springframework.boot:spring-boot-starter-flyway' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3' + // 자체 JWT 발급·검증과 구글 JWKS 조회 + implementation 'com.auth0:java-jwt:4.4.0' + implementation 'com.auth0:jwks-rsa:0.22.1' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' runtimeOnly 'org.flywaydb:flyway-database-postgresql' diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 073cb75..1e086c1 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -16,5 +16,17 @@ services: timeout: 3s retries: 10 + redis: + image: redis:7-alpine + container_name: tour-redis-local + ports: + - "${LOCAL_REDIS_PORT:-6380}:6379" + command: ["redis-server", "--save", "", "--appendonly", "no"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + volumes: tour_postgres_local_data: diff --git a/docs/ERD.md b/docs/ERD.md index 4bdbca3..8903384 100644 --- a/docs/ERD.md +++ b/docs/ERD.md @@ -139,6 +139,7 @@ TourAPI의 일일 요청 제한을 서버 재시작과 중복 실행 이후에 | 컬럼 | 자료형 | 키·필수 | 의미 | | --- | --- | --- | --- | | `id` | uuid | PK, O | 일정 ID | +| `user_id` | bigint | FK, X | 소유자 `users.id`. 인증 도입 전 일정은 NULL 이며 목록에 나오지 않는다 | | `status` | varchar | O | 일정 상태. 예: `CONFIRMED` | | `start_date` | date | O | 여행 시작일 | | `end_date` | date | O | 여행 종료일 | @@ -303,6 +304,42 @@ TourAPI의 일일 요청 제한을 서버 재시작과 중복 실행 이후에 | `revoked_at` | datetime | X | 폐기시각 | | `created_at` | datetime | O | 생성시각 | +## 14. users + +서비스 사용자다. V6에서 만들고 V9에서 인증·권한 컬럼을 더했다. + +| 컬럼 | 자료형 | 키·필수 | 의미 | +| --- | --- | --- | --- | +| `id` | bigint | PK, O | 사용자 ID | +| `nickname` | varchar(255) | O | 표시 이름. 사용자가 직접 바꾼다 | +| `profile_image_url` | text | X | 프로필 사진 | +| `email` | varchar(255) | X | 제공자가 준 이메일. 식별자로 쓰지 않는다 | +| `provider` | varchar(20) | X | 로그인 제공자. 현재 `GOOGLE` | +| `provider_id` | varchar(255) | X | 제공자 고유 ID. 구글은 `sub` | +| `role` | varchar(20) | O | `USER` 또는 `ADMIN`. 기본 `USER` | +| `status` | varchar(20) | O | `ACTIVE`·`SUSPENDED`·`WITHDRAWN`. 기본 `ACTIVE` | +| `suspended_until` | datetime | X | 정지 만료시각. 지나면 스스로 풀린 것으로 본다 | +| `suspended_reason` | text | X | 정지 사유 | +| `created_at` | datetime | O | 가입시각 | +| `deleted_at` | datetime | X | 탈퇴시각 | + +인덱스는 다음과 같다. + +| 이름 | 대상 | 비고 | +| --- | --- | --- | +| `uk_users_nickname_active` | (`nickname`) | 부분 고유. `deleted_at IS NULL` 인 행만 | +| `uk_users_provider_active` | (`provider`, `provider_id`) | 부분 고유. 같은 계정의 중복 가입을 막는다 | +| `idx_users_role` | (`role`) | 관리자 목록 조회 | + +**`provider`·`provider_id`·`email`은 nullable이다.** V6로 이미 만들어진 행이 있어 +`NOT NULL`을 걸 수 없다. 로그인으로 생기는 행은 애플리케이션이 항상 채운다. + +**고유 인덱스를 탈퇴하지 않은 행에만 적용한다.** 닉네임과 같은 이유다. 탈퇴 후 재가입은 +새 행으로 들어오며, 이전 행은 이력으로 남는다. + +**정지 만료에 배치를 두지 않는다.** `suspended_until`이 지났는지를 읽는 쪽에서 판단한다 +(`User.isWriteBlockedAt`). 상태를 되돌리는 스케줄러가 없어도 만료가 동작한다. + ## 일정 생성 V2 변경 ### V2-1. 기존 테이블 변경 diff --git a/docs/auth-and-admin-spec.md b/docs/auth-and-admin-spec.md index 166a5de..1f845ec 100644 --- a/docs/auth-and-admin-spec.md +++ b/docs/auth-and-admin-spec.md @@ -202,10 +202,20 @@ FastAPI에 `userId`를 넘겨 그쪽에서 걸러야 하며, Spring이 전체를 정지 시 해당 사용자의 리프레시 토큰을 Redis에서 전부 지운다. -**관리자를 누가 만드는가.** 최초 관리자는 UI로 만들 수 없다. 환경변수 -`ADMIN_EMAILS`(쉼표 구분)에 있는 이메일로 로그인하면 `role = ADMIN`으로 승격한다. +**관리자를 누가 만드는가.** DB에서 직접 바꾼다. + +```sql +UPDATE users SET role = 'ADMIN' WHERE email = 'admin@example.com'; +``` + +로그인은 권한을 건드리지 않는다. 새로 만들어지는 사용자는 항상 `USER`이고, 이미 있는 +사용자의 `role`은 로그인 시 손대지 않는다. 로그인이 권한을 덮어쓰면 DB로 준 권한이 +다음 로그인에 사라진다. + +대상 사용자가 **한 번은 로그인했어야 한다.** 행이 있어야 바꿀 수 있다. + 관리자가 다른 관리자를 임명하는 API는 1차 범위에서 제외한다. 권한 상승 경로가 늘수록 -위험하고, 해커톤 규모에서는 환경변수로 충분하다. +위험하고, 운영자가 DB를 만질 수 있는 규모에서는 필요하지 않다. ### 6.3 장소 데이터 관리 @@ -264,7 +274,6 @@ Redis를 새로 띄운다. | `JWT_SECRET` | 액세스 토큰 서명. 최소 256비트 | | `JWT_ACCESS_TTL` | 기본 `30m` | | `JWT_REFRESH_TTL` | 기본 `14d` | -| `ADMIN_EMAILS` | 관리자 승격 대상 이메일 | | `REDIS_HOST`, `REDIS_PORT` | 리프레시 토큰 저장소 | ## 9. 배포 순서 — 인가는 마지막에 diff --git a/src/main/java/com/server/auth/config/AuthProperties.java b/src/main/java/com/server/auth/config/AuthProperties.java new file mode 100644 index 0000000..c27ff2e --- /dev/null +++ b/src/main/java/com/server/auth/config/AuthProperties.java @@ -0,0 +1,42 @@ +package com.server.auth.config; + +import java.time.Duration; +import java.util.List; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 인증 설정. + * + *
실제 값은 {@code .env.server} 에만 둔다. 기본값을 코드에 두는 것은 수명과 발급자처럼
+ * 노출돼도 무해한 항목뿐이다.
+ */
+@ConfigurationProperties(prefix = "app.auth")
+public record AuthProperties(Google google, Jwt jwt) {
+
+ /**
+ * @param clientIds 허용할 구글 OAuth 클라이언트 ID. 쉼표로 여러 개를 넣을 수 있다.
+ * 웹·iOS·안드로이드는 각각 다른 클라이언트 ID 를 쓰므로, 하나만 두면
+ * 다른 플랫폼의 로그인이 전부 aud 검증에서 막힌다.
+ * 프론트가 쓰는 값과 정확히 같아야 한다.
+ */
+ public record Google(List 토큰을 검증할 때마다 구글에 키를 물으면 로그인 지연이 그대로 늘고, 구글이 잠깐
+ * 응답하지 않으면 로그인 전체가 멈춘다. 캐시하고 호출량을 제한한다.
+ *
+ * 키는 주기적으로 교체되므로 영구 캐시는 쓰지 않는다. 캐시가 만료되면 다음 요청에서
+ * 다시 가져온다.
+ */
+@Configuration
+public class GoogleJwkConfig {
+
+ private static final String GOOGLE_JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs";
+
+ /** 동시에 캐시할 키 수. 구글은 보통 2~3개를 노출한다. */
+ private static final int CACHE_SIZE = 10;
+
+ /** 캐시에 없는 kid 가 왔을 때의 조회 상한. 조작된 kid 로 외부 호출을 유발하는 것을 막는다. */
+ private static final long LOOKUPS_PER_MINUTE = 10;
+
+ @Bean
+ JwkProvider googleJwkProvider(AuthProperties properties) throws MalformedURLException {
+ return new JwkProviderBuilder(URI.create(GOOGLE_JWKS_URL).toURL())
+ .cached(CACHE_SIZE, properties.google().jwksCacheTtl().toSeconds(), TimeUnit.SECONDS)
+ .rateLimited(LOOKUPS_PER_MINUTE, 1, TimeUnit.MINUTES)
+ .build();
+ }
+}
diff --git a/src/main/java/com/server/auth/controller/AuthController.java b/src/main/java/com/server/auth/controller/AuthController.java
new file mode 100644
index 0000000..102e36b
--- /dev/null
+++ b/src/main/java/com/server/auth/controller/AuthController.java
@@ -0,0 +1,59 @@
+package com.server.auth.controller;
+
+import com.server.auth.dto.AuthTokenResponse;
+import com.server.auth.dto.GoogleLoginRequest;
+import com.server.auth.dto.RefreshRequest;
+import com.server.auth.service.AuthService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import org.springframework.http.HttpStatus;
+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.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/v1/auth")
+@Tag(name = "인증", description = "구글 로그인과 토큰 갱신")
+public class AuthController {
+
+ private final AuthService authService;
+
+ public AuthController(AuthService authService) {
+ this.authService = authService;
+ }
+
+ @PostMapping("/google")
+ @Operation(
+ summary = "구글 로그인",
+ description = "프론트가 구글에서 받은 ID 토큰을 보내면 서명과 발급 대상을 검증하고 "
+ + "우리 서비스의 액세스·리프레시 토큰을 발급한다. 처음 로그인하는 계정은 이때 만들어진다."
+ )
+ public AuthTokenResponse loginWithGoogle(@Valid @RequestBody GoogleLoginRequest request) {
+ return authService.loginWithGoogle(request.idToken());
+ }
+
+ @PostMapping("/refresh")
+ @Operation(
+ summary = "액세스 토큰 갱신",
+ description = "리프레시 토큰은 쓸 때마다 새 값으로 바뀐다. 응답의 refreshToken 을 보관하고 "
+ + "이전 값은 버린다. 이미 쓴 토큰을 다시 보내면 탈취로 보고 해당 사용자의 "
+ + "모든 기기 로그인을 끊는다."
+ )
+ public AuthTokenResponse refresh(@Valid @RequestBody RefreshRequest request) {
+ return authService.refresh(request.refreshToken());
+ }
+
+ @PostMapping("/logout")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ @Operation(
+ summary = "로그아웃",
+ description = "보낸 리프레시 토큰만 폐기한다. 다른 기기의 로그인은 유지된다. "
+ + "액세스 토큰은 무상태라 남은 수명 동안 유효하므로 클라이언트가 함께 버려야 한다."
+ )
+ public void logout(@Valid @RequestBody RefreshRequest request) {
+ authService.logout(request.refreshToken());
+ }
+}
diff --git a/src/main/java/com/server/auth/dto/AuthTokenResponse.java b/src/main/java/com/server/auth/dto/AuthTokenResponse.java
new file mode 100644
index 0000000..c4925fb
--- /dev/null
+++ b/src/main/java/com/server/auth/dto/AuthTokenResponse.java
@@ -0,0 +1,29 @@
+package com.server.auth.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "발급된 토큰과 로그인한 사용자")
+public record AuthTokenResponse(
+ @Schema(description = "Authorization: Bearer 에 실어 보낸다")
+ String accessToken,
+
+ @Schema(description = "액세스 토큰 만료까지 남은 초", example = "1800")
+ long expiresIn,
+
+ @Schema(description = "갱신용 토큰. 갱신할 때마다 새 값으로 바뀌므로 항상 최신 값을 보관한다",
+ example = "42.Yk9sZ1p6d0hFV3ROanBRTXhLdG5jUQ")
+ String refreshToken,
+
+ @Schema(description = "로그인한 사용자")
+ AuthUser user
+) {
+
+ @Schema(description = "로그인한 사용자 요약")
+ public record AuthUser(
+ @Schema(example = "42") Long id,
+ @Schema(example = "동준") String nickname,
+ @Schema(example = "https://example.com/p.png") String profileImageUrl,
+ @Schema(description = "USER 또는 ADMIN", example = "USER") String role
+ ) {
+ }
+}
diff --git a/src/main/java/com/server/auth/dto/GoogleLoginRequest.java b/src/main/java/com/server/auth/dto/GoogleLoginRequest.java
new file mode 100644
index 0000000..2aa419f
--- /dev/null
+++ b/src/main/java/com/server/auth/dto/GoogleLoginRequest.java
@@ -0,0 +1,11 @@
+package com.server.auth.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+
+@Schema(description = "구글 로그인 요청. 프론트가 구글에서 받은 ID 토큰을 그대로 전달한다.")
+public record GoogleLoginRequest(
+ @Schema(description = "구글 ID 토큰(JWT)", example = "eyJhbGciOiJSUzI1NiIsImtpZCI6...")
+ @NotBlank String idToken
+) {
+}
diff --git a/src/main/java/com/server/auth/dto/RefreshRequest.java b/src/main/java/com/server/auth/dto/RefreshRequest.java
new file mode 100644
index 0000000..62f23c8
--- /dev/null
+++ b/src/main/java/com/server/auth/dto/RefreshRequest.java
@@ -0,0 +1,12 @@
+package com.server.auth.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+
+@Schema(description = "액세스 토큰 갱신 요청")
+public record RefreshRequest(
+ @Schema(description = "로그인 또는 직전 갱신에서 받은 refreshToken",
+ example = "42.Yk9sZ1p6d0hFV3ROanBRTXhLdG5jUQ")
+ @NotBlank String refreshToken
+) {
+}
diff --git a/src/main/java/com/server/auth/service/AccessTokenProvider.java b/src/main/java/com/server/auth/service/AccessTokenProvider.java
new file mode 100644
index 0000000..b49a1a1
--- /dev/null
+++ b/src/main/java/com/server/auth/service/AccessTokenProvider.java
@@ -0,0 +1,104 @@
+package com.server.auth.service;
+
+import com.auth0.jwt.JWT;
+import com.auth0.jwt.JWTVerifier;
+import com.auth0.jwt.algorithms.Algorithm;
+import com.auth0.jwt.interfaces.DecodedJWT;
+import com.server.auth.config.AuthProperties;
+import com.server.user.domain.User;
+import com.server.user.domain.UserRole;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Optional;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+/**
+ * 자체 액세스 토큰을 발급하고 검증한다.
+ *
+ * 무상태다. 저장하지 않으므로 발급 후에는 취소할 수 없고, 수명이 다할 때까지 유효하다.
+ * 그래서 수명을 짧게 두고 긴 수명은 리프레시 토큰이 맡는다.
+ *
+ * {@code role} 을 함께 담아 매 요청마다 사용자를 조회하지 않는다. 대신 권한 변경이
+ * 액세스 토큰 수명만큼 늦게 반영된다. 즉시 끊어야 하면 리프레시를 폐기해 갱신을 막는다.
+ */
+@Component
+public class AccessTokenProvider {
+
+ private static final Logger log = LoggerFactory.getLogger(AccessTokenProvider.class);
+
+ private static final String ROLE_CLAIM = "role";
+
+ /** HMAC-SHA256 의 키 길이. 이보다 짧으면 서명 강도가 알고리즘 가정을 밑돈다. */
+ private static final int MINIMUM_SECRET_BYTES = 32;
+
+ private final Algorithm algorithm;
+ private final JWTVerifier verifier;
+ private final String issuer;
+ private final Duration accessTtl;
+
+ public AccessTokenProvider(AuthProperties properties) {
+ AuthProperties.Jwt jwt = properties.jwt();
+ this.algorithm = Algorithm.HMAC256(requireStrongSecret(jwt.secret()));
+ this.issuer = jwt.issuer();
+ this.accessTtl = jwt.accessTtl();
+ this.verifier = JWT.require(algorithm).withIssuer(issuer).build();
+ }
+
+ /**
+ * 비밀키가 없거나 짧으면 기동을 멈춘다.
+ *
+ * 비어 있으면 서명 없는 토큰이 되고, 짧으면 서명이 추측 가능해진다. 어느 쪽이든
+ * 누구나 원하는 {@code sub} 와 {@code role} 로 토큰을 만들 수 있다. 운영에서 조용히
+ * 뚫리는 것보다 뜨지 않는 편이 낫다.
+ */
+ private static byte[] requireStrongSecret(String secret) {
+ if (secret == null || secret.isBlank()) {
+ throw new IllegalStateException(
+ "JWT_SECRET 이 없다. 서명 키 없이는 토큰을 위조할 수 있다.");
+ }
+ byte[] bytes = secret.getBytes(StandardCharsets.UTF_8);
+ if (bytes.length < MINIMUM_SECRET_BYTES) {
+ throw new IllegalStateException(
+ "JWT_SECRET 이 너무 짧다. 최소 %d바이트가 필요하며 현재 %d바이트다."
+ .formatted(MINIMUM_SECRET_BYTES, bytes.length));
+ }
+ return bytes;
+ }
+
+ public String issue(User user) {
+ Instant now = Instant.now();
+ return JWT.create()
+ .withIssuer(issuer)
+ .withSubject(String.valueOf(user.getId()))
+ .withClaim(ROLE_CLAIM, user.getRole().name())
+ .withIssuedAt(now)
+ .withExpiresAt(now.plus(accessTtl))
+ .sign(algorithm);
+ }
+
+ /**
+ * @return 유효하면 토큰이 가리키는 사용자, 아니면 비어 있음. 잘못된 토큰은 예외가 아니라
+ * 빈 값으로 다룬다. 인증이 필요 없는 경로에도 토큰이 실려 올 수 있어서다.
+ */
+ public Optional 쓴 토큰을 지우고 새 토큰을 준다. 이미 지워진 토큰이 다시 오면 탈취로 보고 그
+ * 사용자의 리프레시를 전부 폐기한다. 훔친 쪽과 원래 사용자가 번갈아 갱신하면 반드시
+ * 한쪽이 지워진 토큰을 내밀게 되므로 탈취가 드러난다.
+ */
+ @Transactional(readOnly = true)
+ public AuthTokenResponse refresh(String refreshToken) {
+ RefreshTokenStore.RefreshTokenRef ref = RefreshTokenStore.parse(refreshToken)
+ .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_TOKEN));
+
+ // 사용자를 먼저 읽는다. 토큰을 소비한 뒤에 DB 를 읽으면, DB 가 잠시 실패했을 때
+ // Redis 삭제만 남는다. 그 상태에서 클라이언트가 재시도하면 소비된 토큰으로 보여
+ // 탈취로 판정되고 모든 기기가 끊긴다. 순간 장애가 전체 로그아웃이 되면 안 된다.
+ User user = userRepository.findByIdAndDeletedAtIsNull(ref.userId())
+ .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_TOKEN));
+
+ if (!refreshTokenStore.consume(ref.userId(), ref.token())) {
+ // 저장소에 없다. 이미 회전으로 소비된 토큰이면 탈취로 본다. 훔친 쪽과 원래
+ // 사용자가 번갈아 갱신하면 반드시 한쪽이 소비된 토큰을 내밀게 된다.
+ if (refreshTokenStore.wasRotated(ref.userId(), ref.token())) {
+ log.warn("Refresh token reuse detected. Revoking all sessions. userId={}",
+ ref.userId());
+ refreshTokenStore.revokeAll(ref.userId());
+ } else {
+ // 만료·로그아웃·조작이다. 이 요청만 거절하면 되고 다른 기기를 끊을 근거는 없다.
+ log.debug("Refresh token not active. userId={}", ref.userId());
+ }
+ throw new BusinessException(ErrorCode.INVALID_TOKEN);
+ }
+
+ return issueFor(user);
+ }
+
+ /** 이 기기의 리프레시만 지운다. 다른 기기의 로그인은 유지한다. */
+ public void logout(String refreshToken) {
+ // 회전 흔적을 남기지 않는다. 로그아웃한 토큰이 다시 오는 것은 클라이언트 재시도일 뿐이라
+ // 탈취로 보고 다른 기기까지 끊으면 안 된다.
+ RefreshTokenStore.parse(refreshToken)
+ .ifPresent(ref -> refreshTokenStore.consume(ref.userId(), ref.token(), false));
+ }
+
+ private AuthTokenResponse issueFor(User user) {
+ String refreshToken = refreshTokenStore.issue(user.getId());
+ return new AuthTokenResponse(
+ accessTokenProvider.issue(user),
+ accessTokenProvider.accessTtl().toSeconds(),
+ RefreshTokenStore.format(user.getId(), refreshToken),
+ new AuthTokenResponse.AuthUser(
+ user.getId(),
+ user.getNickname(),
+ user.getProfileImageUrl(),
+ user.getRole().name()));
+ }
+}
diff --git a/src/main/java/com/server/auth/service/AuthenticatedUser.java b/src/main/java/com/server/auth/service/AuthenticatedUser.java
new file mode 100644
index 0000000..d9d8c8c
--- /dev/null
+++ b/src/main/java/com/server/auth/service/AuthenticatedUser.java
@@ -0,0 +1,11 @@
+package com.server.auth.service;
+
+import com.server.user.domain.UserRole;
+
+/** 액세스 토큰이 가리키는 사용자. DB 조회 없이 토큰만으로 만든다. */
+public record AuthenticatedUser(Long id, UserRole role) {
+
+ public boolean isAdmin() {
+ return role == UserRole.ADMIN;
+ }
+}
diff --git a/src/main/java/com/server/auth/service/GoogleIdTokenVerifier.java b/src/main/java/com/server/auth/service/GoogleIdTokenVerifier.java
new file mode 100644
index 0000000..033b6a1
--- /dev/null
+++ b/src/main/java/com/server/auth/service/GoogleIdTokenVerifier.java
@@ -0,0 +1,110 @@
+package com.server.auth.service;
+
+import com.auth0.jwk.Jwk;
+import com.auth0.jwk.JwkProvider;
+import com.auth0.jwt.JWT;
+import com.auth0.jwt.algorithms.Algorithm;
+import com.auth0.jwt.interfaces.Claim;
+import com.auth0.jwt.interfaces.DecodedJWT;
+import com.server.auth.config.AuthProperties;
+import com.server.common.error.BusinessException;
+import com.server.common.error.ErrorCode;
+import java.security.interfaces.RSAPublicKey;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+/**
+ * 구글 ID 토큰을 검증한다.
+ *
+ * 프론트가 구글 로그인으로 받은 토큰을 그대로 보내오므로, 우리 서버가 직접 확인하지 않으면
+ * 아무나 만든 문자열로 로그인할 수 있다. 다음을 모두 본다.
+ *
+ * 실패 사유는 로그에만 남기고 응답에는 담지 않는다. 어떤 검증에서 걸렸는지 알려 주면
+ * 토큰을 조립해 보는 쪽에 단서가 된다.
+ */
+@Component
+public class GoogleIdTokenVerifier {
+
+ private static final Logger log = LoggerFactory.getLogger(GoogleIdTokenVerifier.class);
+
+ /** 구글은 두 형태를 모두 발급한다. 둘 다 정상이다. */
+ private static final String ISSUER_WITHOUT_SCHEME = "accounts.google.com";
+
+ private final JwkProvider jwkProvider;
+ private final AuthProperties.Google properties;
+
+ public GoogleIdTokenVerifier(JwkProvider jwkProvider, AuthProperties properties) {
+ this.jwkProvider = jwkProvider;
+ this.properties = properties.google();
+
+ if (this.properties.clientIds().isEmpty()) {
+ // 비어 있으면 어떤 토큰도 aud 검증을 통과하지 못해 모든 로그인이 401 이 된다.
+ // 안전한 방향이지만 로그만 봐서는 원인이 드러나지 않아 미리 알린다.
+ log.warn("GOOGLE_CLIENT_ID 가 비어 있다. 구글 로그인이 전부 실패한다. "
+ + "프론트가 쓰는 클라이언트 ID 와 같은 값을 넣어야 한다.");
+ }
+ }
+
+ public GoogleIdentity verify(String idToken) {
+ if (idToken == null || idToken.isBlank()) {
+ throw new BusinessException(ErrorCode.INVALID_GOOGLE_TOKEN);
+ }
+
+ DecodedJWT verified = verifySignatureAndClaims(idToken);
+ requireVerifiedEmail(verified);
+
+ return new GoogleIdentity(
+ verified.getSubject(),
+ text(verified, "email"),
+ text(verified, "name"),
+ text(verified, "picture"));
+ }
+
+ private DecodedJWT verifySignatureAndClaims(String idToken) {
+ try {
+ DecodedJWT decoded = JWT.decode(idToken);
+ Jwk jwk = jwkProvider.get(decoded.getKeyId());
+ Algorithm algorithm = Algorithm.RSA256((RSAPublicKey) jwk.getPublicKey(), null);
+
+ return JWT.require(algorithm)
+ .withIssuer(properties.issuer(), ISSUER_WITHOUT_SCHEME)
+ .withAnyOfAudience(properties.clientIds().toArray(String[]::new))
+ .build()
+ .verify(idToken);
+ } catch (BusinessException exception) {
+ throw exception;
+ } catch (Exception exception) {
+ log.warn("Google ID token verification failed. reason={}", exception.getMessage());
+ throw new BusinessException(ErrorCode.INVALID_GOOGLE_TOKEN);
+ }
+ }
+
+ /**
+ * 구글 계정에 붙어 있기만 하고 소유가 확인되지 않은 이메일은 거절한다. 확인되지 않은
+ * 이메일을 그대로 믿으면 남의 이메일을 주장하는 계정이 관리자 승격 목록에 걸릴 수 있다.
+ */
+ private void requireVerifiedEmail(DecodedJWT verified) {
+ Claim claim = verified.getClaim("email_verified");
+ Boolean emailVerified = claim.isMissing() || claim.isNull() ? null : claim.asBoolean();
+ if (!Boolean.TRUE.equals(emailVerified)) {
+ log.warn("Google ID token rejected. reason=email not verified, subject={}",
+ verified.getSubject());
+ throw new BusinessException(ErrorCode.INVALID_GOOGLE_TOKEN);
+ }
+ }
+
+ private String text(DecodedJWT verified, String name) {
+ Claim claim = verified.getClaim(name);
+ return claim.isMissing() || claim.isNull() ? null : claim.asString();
+ }
+}
diff --git a/src/main/java/com/server/auth/service/GoogleIdentity.java b/src/main/java/com/server/auth/service/GoogleIdentity.java
new file mode 100644
index 0000000..e407d66
--- /dev/null
+++ b/src/main/java/com/server/auth/service/GoogleIdentity.java
@@ -0,0 +1,9 @@
+package com.server.auth.service;
+
+/**
+ * 검증을 통과한 구글 ID 토큰에서 뽑은 사용자 정보.
+ *
+ * @param subject 구글 고유 ID({@code sub}). 이메일과 달리 바뀌지 않으므로 식별자로 쓴다
+ */
+public record GoogleIdentity(String subject, String email, String name, String pictureUrl) {
+}
diff --git a/src/main/java/com/server/auth/service/OAuthUserRegistrar.java b/src/main/java/com/server/auth/service/OAuthUserRegistrar.java
new file mode 100644
index 0000000..4d8b678
--- /dev/null
+++ b/src/main/java/com/server/auth/service/OAuthUserRegistrar.java
@@ -0,0 +1,108 @@
+package com.server.auth.service;
+
+import com.server.common.error.BusinessException;
+import com.server.common.error.ErrorCode;
+import com.server.user.domain.AuthProvider;
+import com.server.user.domain.User;
+import com.server.user.domain.UserRole;
+import com.server.user.repository.UserRepository;
+import java.util.concurrent.ThreadLocalRandom;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * 검증을 통과한 구글 계정을 우리 사용자로 만든다.
+ *
+ * 식별자는 구글이 준 {@code sub} 다. 이메일은 바뀔 수 있고 해지 후 재사용될 수도 있어
+ * 계정을 잇는 기준으로 쓰지 않는다.
+ *
+ * 권한은 여기서 정하지 않는다. 로그인으로 만들어지는 사용자는 항상 일반 사용자이며,
+ * 관리자 지정은 DB 에서 한다({@code UPDATE users SET role='ADMIN' WHERE id=?}).
+ * 로그인이 권한을 덮어쓰면 DB 로 준 권한이 다음 로그인에 사라진다.
+ */
+@Component
+public class OAuthUserRegistrar {
+
+ private static final Logger log = LoggerFactory.getLogger(OAuthUserRegistrar.class);
+
+ private static final int NICKNAME_MAX_LENGTH = 20;
+ private static final String FALLBACK_NICKNAME = "여행자";
+ private static final int NICKNAME_RETRY_LIMIT = 5;
+
+ private final UserRepository userRepository;
+
+ public OAuthUserRegistrar(UserRepository userRepository) {
+ this.userRepository = userRepository;
+ }
+
+ @Transactional
+ public User register(GoogleIdentity identity) {
+ return userRepository
+ .findByProviderAndProviderIdAndDeletedAtIsNull(AuthProvider.GOOGLE, identity.subject())
+ .map(existing -> updateExisting(existing, identity))
+ .orElseGet(() -> createNew(identity));
+ }
+
+ /** 이미 있는 사용자를 로그인 시점 정보로 맞춘다. 권한은 건드리지 않는다. */
+ private User updateExisting(User user, GoogleIdentity identity) {
+ user.syncFromProvider(identity.email(), identity.pictureUrl());
+ return user;
+ }
+
+ /**
+ * 닉네임 확보는 확인 후 저장이라 같은 이름이 동시에 들어오면 한쪽이 고유 인덱스에
+ * 걸린다. 흔한 이름이면 실제로 일어난다. 사용자에게는 구글 로그인이 그냥 실패한
+ * 것으로 보이므로, 충돌하면 다른 이름으로 다시 시도한다.
+ */
+ private User createNew(GoogleIdentity identity) {
+ for (int attempt = 0; attempt < NICKNAME_RETRY_LIMIT; attempt++) {
+ try {
+ return userRepository.saveAndFlush(User.ofOAuth(
+ AuthProvider.GOOGLE,
+ identity.subject(),
+ identity.email(),
+ uniqueNickname(identity.name()),
+ identity.pictureUrl(),
+ UserRole.USER));
+ } catch (DataIntegrityViolationException exception) {
+ log.info("Nickname taken while creating user. retrying. attempt={}", attempt + 1);
+ }
+ }
+ throw new BusinessException(ErrorCode.INTERNAL_ERROR);
+ }
+
+ /**
+ * 닉네임은 살아 있는 사용자 사이에서 고유해야 한다({@code uk_users_nickname_active}).
+ * 구글 표시 이름은 흔히 겹치므로 충돌하면 숫자를 붙인다.
+ *
+ * 여기서 확보하지 않으면 첫 로그인이 고유 제약 위반으로 실패한다. 사용자 입장에서는
+ * 이름이 겹쳤을 뿐인데 가입이 안 되는 것으로 보인다.
+ */
+ private String uniqueNickname(String preferred) {
+ String base = normalizeNickname(preferred);
+ if (!userRepository.existsByNicknameAndDeletedAtIsNull(base)) {
+ return base;
+ }
+ for (int attempt = 0; attempt < 10; attempt++) {
+ String candidate = base + ThreadLocalRandom.current().nextInt(1000, 10000);
+ if (!userRepository.existsByNicknameAndDeletedAtIsNull(candidate)) {
+ return candidate;
+ }
+ }
+ // 열 번 모두 겹치는 것은 사실상 없지만, 가입이 막히는 것보다는 긴 이름이 낫다.
+ return base + System.nanoTime();
+ }
+
+ private String normalizeNickname(String preferred) {
+ if (preferred == null || preferred.isBlank()) {
+ return FALLBACK_NICKNAME;
+ }
+ String trimmed = preferred.trim();
+ return trimmed.length() <= NICKNAME_MAX_LENGTH
+ ? trimmed
+ : trimmed.substring(0, NICKNAME_MAX_LENGTH);
+ }
+}
diff --git a/src/main/java/com/server/auth/service/RefreshTokenStore.java b/src/main/java/com/server/auth/service/RefreshTokenStore.java
new file mode 100644
index 0000000..eb460cd
--- /dev/null
+++ b/src/main/java/com/server/auth/service/RefreshTokenStore.java
@@ -0,0 +1,189 @@
+package com.server.auth.service;
+
+import com.server.auth.config.AuthProperties;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.time.Duration;
+import java.util.Base64;
+import java.util.HashSet;
+import java.util.Optional;
+import java.util.Set;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.data.redis.core.Cursor;
+import org.springframework.data.redis.core.ScanOptions;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Component;
+
+/**
+ * 리프레시 토큰을 Redis 에 둔다.
+ *
+ * 키는 {@code refresh:{userId}:{해시}} 다. 사용자별로 묶여 있어 정지·탈퇴 때 한 번에
+ * 지울 수 있다. 만료는 Redis TTL 이 처리하므로 정리 배치가 필요 없다.
+ *
+ * 원문을 저장하지 않는다. SHA-256 해시만 넣는다. Redis 를 읽을 수 있는 사람이
+ * 그대로 남의 세션을 이어받지 못하게 한다. 공유 링크 토큰과 같은 방식이다.
+ *
+ * 회전한다. 갱신할 때마다 새 토큰을 주고 쓴 토큰은 지운다. 이미 지워진 토큰이
+ * 다시 오면 탈취로 보고 그 사용자의 리프레시를 전부 폐기한다. 훔친 쪽과 원래 사용자가
+ * 번갈아 갱신하면 반드시 한쪽이 지워진 토큰을 내밀게 되므로 탈취가 드러난다.
+ */
+@Component
+public class RefreshTokenStore {
+
+ private static final Logger log = LoggerFactory.getLogger(RefreshTokenStore.class);
+
+ private static final String KEY_PREFIX = "refresh:";
+
+ /**
+ * 회전으로 소비된 토큰의 흔적. 재사용과 단순 무효를 구분하기 위한 것이다.
+ *
+ * 이 표시가 없으면 만료됐거나 로그아웃한 토큰까지 탈취로 보게 된다. 로그아웃 직후
+ * 클라이언트가 한 번만 재시도해도 다른 기기가 모두 끊기는 일이 생긴다.
+ */
+ private static final String USED_PREFIX = "refresh-used:";
+
+ /**
+ * 흔적을 남겨 두는 기간. 탈취된 토큰은 보통 곧바로 쓰이므로 길게 둘 필요가 없고,
+ * 길수록 Redis 에 쌓인다.
+ */
+ private static final Duration USED_MARK_TTL = Duration.ofMinutes(10);
+
+ private static final int TOKEN_BYTES = 32;
+
+ /** SCAN 한 번에 훑을 키 수. 작을수록 다른 요청을 덜 막고, 클수록 왕복이 준다. */
+ private static final int SCAN_BATCH = 100;
+
+ private final StringRedisTemplate redisTemplate;
+ private final Duration refreshTtl;
+ private final SecureRandom random = new SecureRandom();
+
+ public RefreshTokenStore(StringRedisTemplate redisTemplate, AuthProperties properties) {
+ this.redisTemplate = redisTemplate;
+ this.refreshTtl = properties.jwt().refreshTtl();
+ }
+
+ /** @return 클라이언트에게 줄 원문 토큰. 서버에는 해시만 남는다. */
+ public String issue(Long userId) {
+ byte[] bytes = new byte[TOKEN_BYTES];
+ random.nextBytes(bytes);
+ String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+
+ redisTemplate.opsForValue().set(key(userId, token), "1", refreshTtl);
+ return token;
+ }
+
+ /**
+ * 토큰을 소비한다. 유효하면 지우고 참을 준다. 같은 토큰으로 두 번 성공할 수 없다.
+ *
+ * 삭제 결과로 판단하므로 동시에 같은 토큰이 들어와도 하나만 통과한다. 조회 후 삭제로
+ * 나누면 둘 다 통과할 수 있다.
+ */
+ public boolean consume(Long userId, String token) {
+ return consume(userId, token, true);
+ }
+
+ /**
+ * @param markUsed 회전이면 {@code true}. 소비한 토큰의 흔적을 남겨 두었다가 다시 오면
+ * 탈취로 판정한다. 로그아웃이면 {@code false} 다. 로그아웃한 토큰이
+ * 다시 오는 것은 클라이언트 재시도일 뿐이라 다른 기기까지 끊을 이유가 없다.
+ */
+ public boolean consume(Long userId, String token, boolean markUsed) {
+ if (userId == null || token == null || token.isBlank()) {
+ return false;
+ }
+ boolean deleted = Boolean.TRUE.equals(redisTemplate.delete(key(userId, token)));
+ if (deleted && markUsed) {
+ redisTemplate.opsForValue().set(usedKey(userId, token), "1", USED_MARK_TTL);
+ }
+ return deleted;
+ }
+
+ /**
+ * 회전으로 이미 소비된 토큰인지. 참이면 같은 토큰이 두 번 쓰인 것이므로 탈취로 본다.
+ *
+ * 거짓이면 만료·로그아웃·조작 중 하나다. 어느 쪽이든 이 요청만 거절하면 되고
+ * 다른 기기를 끊을 근거는 되지 않는다.
+ */
+ public boolean wasRotated(Long userId, String token) {
+ if (userId == null || token == null || token.isBlank()) {
+ return false;
+ }
+ return Boolean.TRUE.equals(redisTemplate.hasKey(usedKey(userId, token)));
+ }
+
+ /**
+ * 정지·탈퇴·탈취 의심 때 해당 사용자의 모든 기기를 끊는다.
+ *
+ * KEYS 가 아니라 SCAN 으로 훑는다. KEYS 는 전체 키공간을 한 번에 훑으며 그동안
+ * Redis 싱글 스레드를 점유해, 진행되는 내내 다른 모든 명령이 대기한다. 이 Redis 는
+ * 모든 로그인 갱신이 쓰므로 정지 한 번에 갱신 전체가 멈춘다. SCAN 은 조금씩 나눠
+ * 훑어 다른 요청을 막지 않는다.
+ */
+ public long revokeAll(Long userId) {
+ Set 토큰만 받으면 어느 사용자의 키를 지워야 할지 알 수 없고, 전체를 훑으면 사용자 수에
+ * 비례해 느려진다. 사용자 ID 를 함께 받되 이는 조회용일 뿐이라 위조해도 남의 토큰을
+ * 소비할 수 없다. 해시가 맞지 않으면 키가 없다.
+ */
+ public static Optional 토큰을 요구하지 않는다. 없거나 잘못돼도 그냥 통과시킨다. 어디에 인증이 필요한지는
+ * {@code SecurityConfig} 가 정하며, 지금은 대부분 공개다. 인가를 켜는 것은 프론트 배포와
+ * 맞춰야 하는 파괴적 변경이라 마지막에 한다.
+ *
+ * 덕분에 같은 코드로 선택적 인증이 된다. 비로그인도 피드를 보고, 토큰이 있으면 좋아요
+ * 여부가 채워진다. 잘못된 토큰에 401 을 내면 이 동선이 깨진다.
+ */
+@Component
+public class AccessTokenAuthenticationFilter extends OncePerRequestFilter {
+
+ private static final String HEADER = "Authorization";
+ private static final String PREFIX = "Bearer ";
+
+ private final AccessTokenProvider accessTokenProvider;
+
+ public AccessTokenAuthenticationFilter(AccessTokenProvider accessTokenProvider) {
+ this.accessTokenProvider = accessTokenProvider;
+ }
+
+ @Override
+ protected void doFilterInternal(
+ HttpServletRequest request,
+ HttpServletResponse response,
+ FilterChain filterChain
+ ) throws ServletException, IOException {
+ // 이미 인증돼 있으면 덮지 않는다.
+ if (SecurityContextHolder.getContext().getAuthentication() == null) {
+ resolveToken(request)
+ .flatMap(accessTokenProvider::parse)
+ .ifPresent(user -> authenticate(request, user));
+ }
+ filterChain.doFilter(request, response);
+ }
+
+ private void authenticate(HttpServletRequest request, AuthenticatedUser user) {
+ var authentication = new UsernamePasswordAuthenticationToken(
+ user,
+ null,
+ List.of(new SimpleGrantedAuthority(user.role().authority())));
+ authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+ }
+
+ private java.util.Optional 인가를 전면 적용하기 전이라 로그인하지 않은 요청이 대부분이다. 그래서 없을 수 있는
+ * 값으로 다룬다. 컨트롤러 파라미터로 주입하는 방식은 커뮤니티의 {@code X-User-Id} 를
+ * 걷어낼 때 함께 정리한다.
+ */
+public final class CurrentUser {
+
+ private CurrentUser() {
+ }
+
+ public static Optional Spring Security 의 401·403 은 필터 단계에서 만들어져 {@code @ControllerAdvice} 가
+ * 잡지 못한다. 그대로 두면 이 둘만 {@code code}·{@code fieldErrors}·{@code traceId} 가 없는
+ * Spring 기본 응답으로 나가, 클라이언트가 코드로 분기하던 흐름이 401 에서만 깨진다.
+ *
+ * {@code traceId} 는 {@code TraceIdFilter} 가 요청 속성에 넣어 둔 값을 그대로 쓴다.
+ * 응답 헤더 {@code X-Trace-Id} 와 같은 값이어야 로그와 이어진다.
+ */
+@Component
+public class SecurityErrorResponder {
+
+ private static final String TRACE_ID_ATTRIBUTE = "traceId";
+
+ private final ObjectMapper objectMapper;
+
+ public SecurityErrorResponder(ObjectMapper objectMapper) {
+ this.objectMapper = objectMapper;
+ }
+
+ public void write(HttpServletRequest request, HttpServletResponse response, ErrorCode errorCode)
+ throws IOException {
+ if (response.isCommitted()) {
+ return;
+ }
+ response.setStatus(errorCode.getStatus().value());
+ response.setContentType(MediaType.APPLICATION_JSON_VALUE);
+ response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+
+ ErrorResponse body = ErrorResponse.of(errorCode, traceId(request));
+ response.getWriter().write(objectMapper.writeValueAsString(body));
+ }
+
+ private String traceId(HttpServletRequest request) {
+ Object traceId = request.getAttribute(TRACE_ID_ATTRIBUTE);
+ return traceId == null ? "" : traceId.toString();
+ }
+}
diff --git a/src/main/java/com/server/common/config/BearerAuthOpenApiConfig.java b/src/main/java/com/server/common/config/BearerAuthOpenApiConfig.java
new file mode 100644
index 0000000..9bfc5e9
--- /dev/null
+++ b/src/main/java/com/server/common/config/BearerAuthOpenApiConfig.java
@@ -0,0 +1,49 @@
+package com.server.common.config;
+
+import io.swagger.v3.oas.models.Components;
+import io.swagger.v3.oas.models.security.SecurityScheme;
+import org.springdoc.core.customizers.OpenApiCustomizer;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Swagger UI 에서 토큰을 넣을 수 있게 한다.
+ *
+ * 보안 스키마가 없으면 Authorize 버튼이 나타나지 않아, 인증이 필요한 API 를 문서에서
+ * 시험해 볼 방법이 없다.
+ *
+ * 스키마를 등록만 하고 전역 요구사항으로 걸지는 않는다. 비로그인으로도 되는 API 가
+ * 대부분이라, 전역으로 걸면 문서가 실제와 어긋난다. 인증이 필요한 오퍼레이션에만 붙인다.
+ */
+@Configuration
+@ConditionalOnProperty(prefix = "springdoc.api-docs", name = "enabled", havingValue = "true")
+public class BearerAuthOpenApiConfig {
+
+ static final String SCHEME_NAME = "bearerAuth";
+
+ @Bean
+ OpenApiCustomizer bearerAuthCustomizer() {
+ return openApi -> {
+ if (openApi.getComponents() == null) {
+ openApi.setComponents(new Components());
+ }
+ openApi.getComponents().addSecuritySchemes(SCHEME_NAME, new SecurityScheme()
+ .type(SecurityScheme.Type.HTTP)
+ .scheme("bearer")
+ .bearerFormat("JWT")
+ .description("POST /api/v1/auth/google 로 받은 accessToken 을 넣는다. "
+ + "Swagger UI 는 Authorize 에 값만 넣으면 Bearer 접두사를 붙여 보낸다."));
+
+ // 인가가 걸린 경로에만 요구사항을 표시한다.
+ openApi.getPaths().forEach((path, pathItem) -> {
+ if (!path.startsWith("/api/v1/admin")) {
+ return;
+ }
+ pathItem.readOperations().forEach(operation -> operation.addSecurityItem(
+ new io.swagger.v3.oas.models.security.SecurityRequirement()
+ .addList(SCHEME_NAME)));
+ });
+ };
+ }
+}
diff --git a/src/main/java/com/server/common/config/CorsProperties.java b/src/main/java/com/server/common/config/CorsProperties.java
index 8423ad5..553ff82 100644
--- a/src/main/java/com/server/common/config/CorsProperties.java
+++ b/src/main/java/com/server/common/config/CorsProperties.java
@@ -3,13 +3,56 @@
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
+/**
+ * CORS 설정.
+ *
+ * 허용 헤더와 노출 헤더는 기본값을 코드에 둔다. 설정이 비어 있으면 기본값을 쓰므로
+ * 환경변수를 잘못 비워도 브라우저에서 API 전체가 막히지 않는다.
+ */
@ConfigurationProperties(prefix = "app.cors")
-public record CorsProperties(List {@code Authorization}이 없으면 로그인한 요청이 preflight에서 막힌다.
+ * {@code X-User-Id}는 인증 도입 전까지 커뮤니티 API가 작성자를 받는 임시 헤더이며,
+ * 인증이 붙으면 함께 제거한다.
+ */
+ private static final List {@code TraceIdFilter}가 모든 응답에 {@code X-Trace-Id}를 넣고 오류 응답의
+ * {@code traceId}와 같은 값이라고 문서화하지만, 노출 헤더로 지정하지 않으면
+ * 다른 오리진의 스크립트는 이 값을 읽을 수 없다.
+ */
+ private static final List 관리자 경로는 만들어진 시점부터 인가가 걸려 있다. 사용자 API 는 인가를 전면
+ * 적용할 때 여기에 더한다.
+ */
+ private boolean requiresAuthentication(String path) {
+ return path.startsWith("/api/v1/admin");
+ }
+
/** Preview를 만들거나 소비하는 경로. */
private boolean consumesPreview(String path, String httpMethod) {
if (path.startsWith("/api/v1/schedule-previews")) {
diff --git a/src/main/java/com/server/common/config/SecurityConfig.java b/src/main/java/com/server/common/config/SecurityConfig.java
index 7e67429..2e27595 100644
--- a/src/main/java/com/server/common/config/SecurityConfig.java
+++ b/src/main/java/com/server/common/config/SecurityConfig.java
@@ -1,10 +1,15 @@
package com.server.common.config;
+import com.server.auth.web.AccessTokenAuthenticationFilter;
+import com.server.auth.web.ErrorResponseAccessDeniedHandler;
+import com.server.auth.web.ErrorResponseAuthenticationEntryPoint;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@@ -13,9 +18,20 @@
public class SecurityConfig {
private final CorsProperties corsProperties;
+ private final AccessTokenAuthenticationFilter accessTokenAuthenticationFilter;
+ private final ErrorResponseAuthenticationEntryPoint authenticationEntryPoint;
+ private final ErrorResponseAccessDeniedHandler accessDeniedHandler;
- public SecurityConfig(CorsProperties corsProperties) {
+ public SecurityConfig(
+ CorsProperties corsProperties,
+ AccessTokenAuthenticationFilter accessTokenAuthenticationFilter,
+ ErrorResponseAuthenticationEntryPoint authenticationEntryPoint,
+ ErrorResponseAccessDeniedHandler accessDeniedHandler
+ ) {
this.corsProperties = corsProperties;
+ this.accessTokenAuthenticationFilter = accessTokenAuthenticationFilter;
+ this.authenticationEntryPoint = authenticationEntryPoint;
+ this.accessDeniedHandler = accessDeniedHandler;
}
@Bean
@@ -23,7 +39,27 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.cors(Customizer.withDefaults());
http.csrf(csrf -> csrf.disable());
http.headers(headers -> headers.frameOptions(frameOptions -> frameOptions.sameOrigin()));
+ // 토큰으로만 인증한다. 세션을 만들면 CSRF 를 끈 상태에서 쿠키 기반 공격면이 생긴다.
+ http.sessionManagement(session ->
+ session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
+
+ // 토큰이 있으면 읽어 인증 정보를 채운다. 없어도 통과시킨다.
+ http.addFilterBefore(
+ accessTokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
+
+ // 401·403 도 다른 오류와 같은 ErrorResponse 형태로 내보낸다. Security 예외는 필터
+ // 단계에서 나 @ControllerAdvice 가 잡지 못하므로 여기서 직접 연결한다.
+ http.exceptionHandling(handling -> handling
+ .authenticationEntryPoint(authenticationEntryPoint)
+ .accessDeniedHandler(accessDeniedHandler));
+
+ // 사용자 API 의 인가는 아직 걸지 않는다. 비로그인 요청이 401 을 받기 시작하는
+ // 파괴적 변경이라 프론트 배포와 맞춰야 한다.
+ //
+ // 관리자 경로만 예외다. /api/v1/** 를 통째로 permitAll 한 채 관리자 컨트롤러를 두면
+ // 누구나 사용자를 정지시키고 게시물을 지울 수 있다. 만드는 시점부터 막는다.
http.authorizeHttpRequests(authorize -> authorize
+ .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.requestMatchers("/api/v1/**", "/h2-console/**").permitAll()
.anyRequest().permitAll()
);
@@ -35,7 +71,8 @@ CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(corsProperties.allowedOrigins());
configuration.setAllowedMethods(java.util.List.of("GET", "POST", "PATCH", "DELETE", "OPTIONS"));
- configuration.setAllowedHeaders(java.util.List.of("Content-Type", "X-Trace-Id", "Idempotency-Key"));
+ configuration.setAllowedHeaders(corsProperties.allowedHeaders());
+ configuration.setExposedHeaders(corsProperties.exposedHeaders());
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/v1/**", configuration);
diff --git a/src/main/java/com/server/common/error/ErrorCode.java b/src/main/java/com/server/common/error/ErrorCode.java
index edcb7ec..b9a1622 100644
--- a/src/main/java/com/server/common/error/ErrorCode.java
+++ b/src/main/java/com/server/common/error/ErrorCode.java
@@ -27,6 +27,14 @@ public enum ErrorCode {
TRANSIT_ROUTE_NOT_FOUND(422, "장소 사이 대중교통 경로를 찾지 못했습니다."),
FACILITY_TYPE_NOT_SUPPORTED(501, "지원하지 않는 편의시설 유형입니다."),
EXTERNAL_PROVIDER_UNAVAILABLE(503, "외부 서비스가 응답하지 않습니다."),
+ INVALID_GOOGLE_TOKEN(401, "구글 로그인 정보를 확인하지 못했습니다."),
+ INVALID_TOKEN(401, "인증 정보가 올바르지 않습니다."),
+ UNAUTHORIZED(401, "로그인이 필요합니다."),
+ TOKEN_EXPIRED(401, "인증이 만료되었습니다. 다시 로그인해 주세요."),
+ FORBIDDEN(403, "이 작업을 수행할 권한이 없습니다."),
+ SCHEDULE_ACCESS_DENIED(403, "본인의 일정만 조회하거나 수정할 수 있습니다."),
+ USER_SUSPENDED(403, "정지된 계정입니다."),
+ USER_NOT_FOUND(404, "사용자를 찾을 수 없습니다."),
INTERNAL_ERROR(500, "서버에서 요청을 처리하지 못했습니다.");
private final HttpStatusCode status;
diff --git a/src/main/java/com/server/common/error/GlobalExceptionHandler.java b/src/main/java/com/server/common/error/GlobalExceptionHandler.java
index 5c8056b..c402dda 100644
--- a/src/main/java/com/server/common/error/GlobalExceptionHandler.java
+++ b/src/main/java/com/server/common/error/GlobalExceptionHandler.java
@@ -214,6 +214,10 @@ private ErrorCode validationErrorCode(HttpServletRequest request) {
if (uri.startsWith("/api/v1/places") || uri.startsWith("/api/v1/locations")) {
return ErrorCode.INVALID_PLACE_SEARCH_REQUEST;
}
+ // 분기가 없으면 로그인 요청 오류가 "일정 조건이 올바르지 않습니다"로 나간다.
+ if (uri.startsWith("/api/v1/auth")) {
+ return ErrorCode.INVALID_TOKEN;
+ }
return ErrorCode.INVALID_SCHEDULE_CONDITION;
}
diff --git a/src/main/java/com/server/common/web/TraceIdFilter.java b/src/main/java/com/server/common/web/TraceIdFilter.java
index 1c33e6c..2e83060 100644
--- a/src/main/java/com/server/common/web/TraceIdFilter.java
+++ b/src/main/java/com/server/common/web/TraceIdFilter.java
@@ -7,10 +7,22 @@
import java.io.IOException;
import java.util.UUID;
import org.slf4j.MDC;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
+/**
+ * 요청마다 추적 ID를 만들어 응답 헤더·오류 본문·로그 MDC에 함께 남긴다.
+ *
+ * Security 필터 체인보다 먼저 실행해야 한다. 뒤에 두면 인증·인가로 걸러진 요청에는
+ * 이 필터가 아예 돌지 않아 401·403 응답에 {@code traceId}가 비고 {@code X-Trace-Id}
+ * 헤더도 없으며, 서버 로그에서도 그 요청을 찾을 수 없다. 정작 추적이 가장 필요한 실패다.
+ *
+ * Security 체인의 기본 순서는 -100 이다. 가장 앞으로 두어 모든 요청을 덮는다.
+ */
@Component
+@Order(Ordered.HIGHEST_PRECEDENCE)
public class TraceIdFilter extends OncePerRequestFilter {
private static final String TRACE_ID = "traceId";
diff --git a/src/main/java/com/server/external/schedule/FastApiScheduleClient.java b/src/main/java/com/server/external/schedule/FastApiScheduleClient.java
index ea760fb..b45e704 100644
--- a/src/main/java/com/server/external/schedule/FastApiScheduleClient.java
+++ b/src/main/java/com/server/external/schedule/FastApiScheduleClient.java
@@ -30,6 +30,15 @@
@Component
public class FastApiScheduleClient {
+ /**
+ * 일정 소유자를 FastAPI 에 알린다.
+ *
+ * Spring 과 FastAPI 사이의 내부 호출에만 쓴다. 외부에서 들어오는 헤더가 아니라
+ * 검증된 토큰에서 꺼낸 값이므로 FastAPI 가 그대로 신뢰해도 된다. 두 컨테이너는
+ * 같은 도커 네트워크에 있고 FastAPI 는 외부에 공개되지 않는다.
+ */
+ private static final String OWNER_HEADER = "X-Auth-User-Id";
+
private static final Logger log = LoggerFactory.getLogger(FastApiScheduleClient.class);
private final RestClient restClient;
@@ -90,14 +99,15 @@ public SchedulePreviewResponse getPreview(UUID previewId) {
}
}
- public ScheduleResponse createSchedule(ScheduleCreateRequest request) {
+ public ScheduleResponse createSchedule(ScheduleCreateRequest request, Long ownerId) {
try {
return executeWithLogging(
"createSchedule",
- "startDate=%s, endDate=%s".formatted(request.startDate(), request.endDate()),
- () -> restClient.post()
- .uri("/api/v1/schedules")
- .contentType(MediaType.APPLICATION_JSON)
+ "startDate=%s, endDate=%s, ownerId=%s"
+ .formatted(request.startDate(), request.endDate(), ownerId),
+ () -> withOwner(restClient.post()
+ .uri("/api/v1/schedules")
+ .contentType(MediaType.APPLICATION_JSON), ownerId)
.body(writeJson(request))
.retrieve()
.body(ScheduleResponse.class)
@@ -112,16 +122,18 @@ public ScheduleResponse createSchedule(ScheduleCreateRequest request) {
public ScheduleResponse createScheduleFromPreview(
SchedulePreviewScheduleRequest request,
- String idempotencyKey
+ String idempotencyKey,
+ Long ownerId
) {
try {
return executeWithLogging(
"createScheduleFromPreview",
- "previewId=%s, idempotencyKey=%s".formatted(request.previewId(), idempotencyKey),
- () -> restClient.post()
- .uri("/api/v1/schedules")
- .contentType(MediaType.APPLICATION_JSON)
- .header("Idempotency-Key", idempotencyKey)
+ "previewId=%s, idempotencyKey=%s, ownerId=%s"
+ .formatted(request.previewId(), idempotencyKey, ownerId),
+ () -> withOwner(restClient.post()
+ .uri("/api/v1/schedules")
+ .contentType(MediaType.APPLICATION_JSON)
+ .header("Idempotency-Key", idempotencyKey), ownerId)
.body(writeJson(request))
.retrieve()
.body(ScheduleResponse.class)
@@ -276,6 +288,12 @@ private BusinessException mapConflictError(RestClientResponseException exception
return new BusinessException(ErrorCode.IDEMPOTENCY_KEY_REUSED, exception);
}
+ /** 로그인하지 않은 요청이면 헤더를 붙이지 않는다. FastAPI 는 소유자 없는 일정으로 저장한다. */
+ private org.springframework.web.client.RestClient.RequestBodySpec withOwner(
+ org.springframework.web.client.RestClient.RequestBodySpec spec, Long ownerId) {
+ return ownerId == null ? spec : spec.header(OWNER_HEADER, String.valueOf(ownerId));
+ }
+
private String writeJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
diff --git a/src/main/java/com/server/schedule/domain/Schedule.java b/src/main/java/com/server/schedule/domain/Schedule.java
index 173a58e..d862d02 100644
--- a/src/main/java/com/server/schedule/domain/Schedule.java
+++ b/src/main/java/com/server/schedule/domain/Schedule.java
@@ -27,6 +27,14 @@ public class Schedule {
@Column(nullable = false)
private String status;
+ /**
+ * 소유자. FastAPI 가 채운다.
+ *
+ * 인증이 없던 시절에 만들어진 일정은 {@code null} 이다. 소유자를 되짚을 근거가 없다.
+ */
+ @Column(name = "user_id")
+ private Long userId;
+
@Column(name = "start_date", nullable = false)
private LocalDate startDate;
@@ -147,6 +155,15 @@ public void applyPreview(
touch();
}
+ public Long getUserId() {
+ return userId;
+ }
+
+ /** 소유자가 없는 예전 일정은 누구의 것도 아니다. */
+ public boolean isOwnedBy(Long candidateId) {
+ return userId != null && userId.equals(candidateId);
+ }
+
public UUID getId() {
return id;
}
diff --git a/src/main/java/com/server/schedule/service/ScheduleService.java b/src/main/java/com/server/schedule/service/ScheduleService.java
index 7684e06..f0b17ab 100644
--- a/src/main/java/com/server/schedule/service/ScheduleService.java
+++ b/src/main/java/com/server/schedule/service/ScheduleService.java
@@ -1,5 +1,6 @@
package com.server.schedule.service;
+import com.server.auth.web.CurrentUser;
import com.server.common.error.BusinessException;
import com.server.common.error.ErrorCode;
import com.server.external.schedule.FastApiScheduleClient;
@@ -24,7 +25,7 @@ void setFastApiScheduleClient(FastApiScheduleClient fastApiScheduleClient) {
}
public ScheduleResponse create(ScheduleCreateRequest request) {
- return requireFastApiScheduleClient().createSchedule(request);
+ return requireFastApiScheduleClient().createSchedule(request, CurrentUser.idOrNull());
}
/**
diff --git a/src/main/java/com/server/schedule/service/ScheduleV2Service.java b/src/main/java/com/server/schedule/service/ScheduleV2Service.java
index f5cb690..1704cf8 100644
--- a/src/main/java/com/server/schedule/service/ScheduleV2Service.java
+++ b/src/main/java/com/server/schedule/service/ScheduleV2Service.java
@@ -1,5 +1,6 @@
package com.server.schedule.service;
+import com.server.auth.web.CurrentUser;
import com.server.common.error.BusinessException;
import com.server.common.error.ErrorCode;
import com.server.external.schedule.FastApiScheduleClient;
@@ -23,7 +24,7 @@ public ScheduleResponse create(
String idempotencyKey
) {
validateKey(idempotencyKey);
- return requireFastApiScheduleClient().createScheduleFromPreview(request, idempotencyKey);
+ return requireFastApiScheduleClient().createScheduleFromPreview(request, idempotencyKey, CurrentUser.idOrNull());
}
private void validateKey(String key) {
diff --git a/src/main/java/com/server/user/domain/AuthProvider.java b/src/main/java/com/server/user/domain/AuthProvider.java
new file mode 100644
index 0000000..c060d47
--- /dev/null
+++ b/src/main/java/com/server/user/domain/AuthProvider.java
@@ -0,0 +1,6 @@
+package com.server.user.domain;
+
+/** 로그인 제공자. 지금은 구글 하나지만 값으로 남겨 두어야 나중에 늘릴 수 있다. */
+public enum AuthProvider {
+ GOOGLE
+}
diff --git a/src/main/java/com/server/user/domain/User.java b/src/main/java/com/server/user/domain/User.java
index a48f129..b020622 100644
--- a/src/main/java/com/server/user/domain/User.java
+++ b/src/main/java/com/server/user/domain/User.java
@@ -2,6 +2,8 @@
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@@ -32,6 +34,29 @@ public class User {
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
+ private String email;
+
+ @Enumerated(EnumType.STRING)
+ private AuthProvider provider;
+
+ /** 제공자가 준 고유 식별자. 구글은 {@code sub} 다. 이메일은 바뀌므로 쓰지 않는다. */
+ @Column(name = "provider_id")
+ private String providerId;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false)
+ private UserRole role = UserRole.USER;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false)
+ private UserStatus status = UserStatus.ACTIVE;
+
+ @Column(name = "suspended_until")
+ private LocalDateTime suspendedUntil;
+
+ @Column(name = "suspended_reason", columnDefinition = "text")
+ private String suspendedReason;
+
protected User() {
}
@@ -41,6 +66,23 @@ public User(String nickname, String profileImageUrl) {
this.createdAt = LocalDateTime.now();
}
+ /** 소셜 로그인으로 처음 들어온 사용자. */
+ public static User ofOAuth(
+ AuthProvider provider,
+ String providerId,
+ String email,
+ String nickname,
+ String profileImageUrl,
+ UserRole role
+ ) {
+ User user = new User(nickname, profileImageUrl);
+ user.provider = provider;
+ user.providerId = providerId;
+ user.email = email;
+ user.role = role == null ? UserRole.USER : role;
+ return user;
+ }
+
public Long getId() {
return id;
}
@@ -68,5 +110,79 @@ public void updateProfile(String nickname, String profileImageUrl) {
public void delete(LocalDateTime deletedAt) {
this.deletedAt = deletedAt;
+ this.status = UserStatus.WITHDRAWN;
+ }
+
+ public String getEmail() {
+ return email;
+ }
+
+ public AuthProvider getProvider() {
+ return provider;
+ }
+
+ public String getProviderId() {
+ return providerId;
+ }
+
+ public UserRole getRole() {
+ return role;
+ }
+
+ public UserStatus getStatus() {
+ return status;
+ }
+
+ public LocalDateTime getSuspendedUntil() {
+ return suspendedUntil;
+ }
+
+ public String getSuspendedReason() {
+ return suspendedReason;
+ }
+
+ public boolean isAdmin() {
+ return role == UserRole.ADMIN;
+ }
+
+ /**
+ * 로그인할 때마다 제공자가 준 최신 정보로 맞춘다. 닉네임은 사용자가 직접 바꿀 수 있으므로
+ * 건드리지 않는다. 구글에서 이름을 바꿨다고 우리 서비스의 닉네임이 되돌아가면 안 된다.
+ */
+ public void syncFromProvider(String email, String profileImageUrl) {
+ this.email = email;
+ if (this.profileImageUrl == null) {
+ this.profileImageUrl = profileImageUrl;
+ }
+ }
+
+ public void changeRole(UserRole role) {
+ this.role = role;
+ }
+
+ public void suspend(LocalDateTime until, String reason) {
+ this.status = UserStatus.SUSPENDED;
+ this.suspendedUntil = until;
+ this.suspendedReason = reason;
+ }
+
+ public void releaseSuspension() {
+ this.status = UserStatus.ACTIVE;
+ this.suspendedUntil = null;
+ this.suspendedReason = null;
+ }
+
+ /**
+ * 지금 쓰기가 막혀 있는지. 기간이 지난 정지는 스스로 풀린 것으로 본다.
+ * 별도 배치 없이 만료를 처리하기 위함이다.
+ */
+ public boolean isWriteBlockedAt(LocalDateTime now) {
+ if (status == UserStatus.WITHDRAWN) {
+ return true;
+ }
+ if (status != UserStatus.SUSPENDED) {
+ return false;
+ }
+ return suspendedUntil == null || suspendedUntil.isAfter(now);
}
}
diff --git a/src/main/java/com/server/user/domain/UserRole.java b/src/main/java/com/server/user/domain/UserRole.java
new file mode 100644
index 0000000..1eb18cf
--- /dev/null
+++ b/src/main/java/com/server/user/domain/UserRole.java
@@ -0,0 +1,16 @@
+package com.server.user.domain;
+
+/**
+ * 사용자 권한.
+ *
+ * Spring Security 의 {@code hasRole("ADMIN")} 은 권한 문자열에 {@code ROLE_} 접두사를
+ * 요구한다. 접두사는 {@link #authority()} 에서만 붙이고 DB 와 토큰에는 이름만 저장한다.
+ */
+public enum UserRole {
+ USER,
+ ADMIN;
+
+ public String authority() {
+ return "ROLE_" + name();
+ }
+}
diff --git a/src/main/java/com/server/user/domain/UserStatus.java b/src/main/java/com/server/user/domain/UserStatus.java
new file mode 100644
index 0000000..9b803cd
--- /dev/null
+++ b/src/main/java/com/server/user/domain/UserStatus.java
@@ -0,0 +1,8 @@
+package com.server.user.domain;
+
+/** 계정 상태. 정지는 쓰기만 막고 읽기는 허용한다. */
+public enum UserStatus {
+ ACTIVE,
+ SUSPENDED,
+ WITHDRAWN
+}
diff --git a/src/main/java/com/server/user/repository/UserRepository.java b/src/main/java/com/server/user/repository/UserRepository.java
index f4dd45b..246152f 100644
--- a/src/main/java/com/server/user/repository/UserRepository.java
+++ b/src/main/java/com/server/user/repository/UserRepository.java
@@ -1,12 +1,19 @@
package com.server.user.repository;
+import com.server.user.domain.AuthProvider;
import com.server.user.domain.User;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository 여기가 뚫리면 훔친 리프레시 토큰으로 계정을 무기한 점유할 수 있다.
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+@Transactional
+@DisplayName("토큰 갱신")
+class AuthServiceRefreshTest {
+
+ @Autowired
+ private UserRepository userRepository;
+
+ private RefreshTokenStore refreshTokenStore;
+ private AuthService authService;
+ private User user;
+
+ @BeforeEach
+ void setUp() {
+ AuthProperties properties = new AuthProperties(
+ new AuthProperties.Google(List.of("client"), null, null),
+ new AuthProperties.Jwt("test-secret-key-long-enough-for-hmac-0123456789",
+ "busantour", Duration.ofMinutes(30), Duration.ofDays(14)));
+
+ refreshTokenStore = new RefreshTokenStore(new InMemoryStringRedisTemplate(), properties);
+ authService = new AuthService(
+ null,
+ null,
+ new AccessTokenProvider(properties),
+ refreshTokenStore,
+ userRepository);
+
+ user = userRepository.save(User.ofOAuth(
+ AuthProvider.GOOGLE, "sub-1", "a@example.com", "동준", null, UserRole.USER));
+ }
+
+ private String issueRefreshToken() {
+ return RefreshTokenStore.format(user.getId(), refreshTokenStore.issue(user.getId()));
+ }
+
+ @Test
+ @DisplayName("갱신하면 새 액세스 토큰과 새 리프레시 토큰을 준다")
+ void rotatesRefreshToken() {
+ String first = issueRefreshToken();
+
+ AuthTokenResponse response = authService.refresh(first);
+
+ assertThat(response.accessToken()).isNotBlank();
+ assertThat(response.refreshToken()).isNotEqualTo(first);
+ assertThat(response.user().id()).isEqualTo(user.getId());
+ }
+
+ @Test
+ @DisplayName("이미 쓴 리프레시 토큰을 다시 보내면 거절한다")
+ void rejectsReusedRefreshToken() {
+ String first = issueRefreshToken();
+ authService.refresh(first);
+
+ assertThatThrownBy(() -> authService.refresh(first))
+ .isInstanceOf(BusinessException.class)
+ .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_TOKEN);
+ }
+
+ @Test
+ @DisplayName("재사용이 감지되면 그 사용자의 다른 기기 로그인도 끊는다")
+ void revokesEveryTokenWhenReuseDetected() {
+ // 훔친 쪽과 원래 사용자가 번갈아 갱신하면 반드시 한쪽이 이미 쓴 토큰을 내민다.
+ // 그 순간 전부 끊어야 훔친 세션이 살아남지 못한다.
+ String stolen = issueRefreshToken();
+ String otherDevice = issueRefreshToken();
+ authService.refresh(stolen);
+
+ assertThatThrownBy(() -> authService.refresh(stolen))
+ .isInstanceOf(BusinessException.class);
+
+ assertThatThrownBy(() -> authService.refresh(otherDevice))
+ .as("재사용 탐지 시 다른 기기의 토큰도 함께 폐기돼야 한다")
+ .isInstanceOf(BusinessException.class);
+ }
+
+ @Test
+ @DisplayName("로그아웃하면 그 기기의 토큰만 폐기한다")
+ void logoutRevokesOnlyOneDevice() {
+ String phone = issueRefreshToken();
+ String laptop = issueRefreshToken();
+
+ authService.logout(phone);
+
+ assertThatThrownBy(() -> authService.refresh(phone))
+ .isInstanceOf(BusinessException.class);
+ assertThat(authService.refresh(laptop).accessToken()).isNotBlank();
+ }
+
+ @Test
+ @DisplayName("만료·로그아웃된 토큰은 다른 기기를 끊지 않는다")
+ void doesNotRevokeEverythingForMerelyInvalidToken() {
+ // 저장소에 없다는 것만으로 탈취로 보면, 로그아웃 직후 클라이언트가 한 번 재시도하는
+ // 것만으로 다른 기기가 전부 끊긴다. 회전으로 소비된 흔적이 있을 때만 탈취로 본다.
+ String loggedOut = issueRefreshToken();
+ String laptop = issueRefreshToken();
+ authService.logout(loggedOut);
+
+ assertThatThrownBy(() -> authService.refresh(loggedOut))
+ .isInstanceOf(BusinessException.class);
+
+ assertThat(authService.refresh(laptop).accessToken())
+ .as("로그아웃된 토큰 재시도가 다른 기기를 끊으면 안 된다")
+ .isNotBlank();
+ }
+
+ @Test
+ @DisplayName("형식이 어긋난 리프레시 토큰을 거절한다")
+ void rejectsMalformedRefreshToken() {
+ assertThatThrownBy(() -> authService.refresh("garbage"))
+ .isInstanceOf(BusinessException.class)
+ .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_TOKEN);
+ }
+
+ @Test
+ @DisplayName("탈퇴한 사용자의 토큰으로는 갱신할 수 없다")
+ void rejectsRefreshForWithdrawnUser() {
+ String token = issueRefreshToken();
+ user.delete(java.time.LocalDateTime.now());
+ userRepository.saveAndFlush(user);
+
+ assertThatThrownBy(() -> authService.refresh(token))
+ .isInstanceOf(BusinessException.class)
+ .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_TOKEN);
+ }
+
+ @Test
+ @DisplayName("사용자를 읽지 못하면 토큰을 소비하지 않는다")
+ void keepsTokenWhenUserLookupFails() {
+ // 소비를 먼저 하면 DB 가 잠시 실패했을 때 Redis 삭제만 남는다. 그 뒤 클라이언트가
+ // 재시도하면 소비된 토큰으로 보여 탈취로 판정되고 모든 기기가 끊긴다.
+ // 여기서는 사용자를 못 찾는 상황으로 같은 순서를 검증한다.
+ String otherUsersToken = RefreshTokenStore.format(9999L, refreshTokenStore.issue(9999L));
+
+ assertThatThrownBy(() -> authService.refresh(otherUsersToken))
+ .isInstanceOf(BusinessException.class);
+
+ assertThat(refreshTokenStore.consume(9999L, otherUsersToken.split("\\.", 2)[1]))
+ .as("사용자 조회에 실패했으면 토큰이 그대로 남아 있어야 한다")
+ .isTrue();
+ }
+}
diff --git a/src/test/java/com/server/auth/service/GoogleIdTokenVerifierTest.java b/src/test/java/com/server/auth/service/GoogleIdTokenVerifierTest.java
new file mode 100644
index 0000000..9a2ff78
--- /dev/null
+++ b/src/test/java/com/server/auth/service/GoogleIdTokenVerifierTest.java
@@ -0,0 +1,216 @@
+package com.server.auth.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.auth0.jwk.Jwk;
+import com.auth0.jwk.JwkProvider;
+import com.auth0.jwt.JWT;
+import com.auth0.jwt.algorithms.Algorithm;
+import com.server.auth.config.AuthProperties;
+import com.server.common.error.BusinessException;
+import com.server.common.error.ErrorCode;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.interfaces.RSAPrivateKey;
+import java.security.interfaces.RSAPublicKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * 실제 RSA 키로 토큰을 만들어 검증한다. 구글을 호출하지 않는다.
+ *
+ * 검증기는 로그인의 유일한 관문이라, 각 검사가 실제로 걸러 내는지 하나씩 확인한다.
+ */
+@DisplayName("구글 ID 토큰 검증")
+class GoogleIdTokenVerifierTest {
+
+ private static final String CLIENT_ID = "our-app.apps.googleusercontent.com";
+ private static final String KEY_ID = "test-key";
+ private static final String ISSUER = "https://accounts.google.com";
+
+ private static RSAPublicKey publicKey;
+ private static RSAPrivateKey privateKey;
+ private static RSAPublicKey otherPublicKey;
+ private static RSAPrivateKey otherPrivateKey;
+
+ @BeforeAll
+ static void generateKeys() throws Exception {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+ generator.initialize(2048);
+
+ KeyPair pair = generator.generateKeyPair();
+ publicKey = (RSAPublicKey) pair.getPublic();
+ privateKey = (RSAPrivateKey) pair.getPrivate();
+
+ KeyPair other = generator.generateKeyPair();
+ otherPublicKey = (RSAPublicKey) other.getPublic();
+ otherPrivateKey = (RSAPrivateKey) other.getPrivate();
+ }
+
+ private GoogleIdTokenVerifier verifier() {
+ return verifier(publicKey);
+ }
+
+ /** 구글이 노출한다고 가정할 공개키를 지정한다. */
+ private GoogleIdTokenVerifier verifier(RSAPublicKey served) {
+ JwkProvider provider = keyId -> new Jwk(
+ keyId, "RSA", "RS256", null, List.of(), null, null, null,
+ Map.of("n", base64Url(served.getModulus().toByteArray()),
+ "e", base64Url(served.getPublicExponent().toByteArray())));
+
+ AuthProperties properties = new AuthProperties(
+ new AuthProperties.Google(List.of(CLIENT_ID), ISSUER, Duration.ofHours(6)),
+ new AuthProperties.Jwt("secret", "busantour", null, null));
+ return new GoogleIdTokenVerifier(provider, properties);
+ }
+
+ private static String base64Url(byte[] bytes) {
+ int start = bytes.length > 1 && bytes[0] == 0 ? 1 : 0;
+ byte[] trimmed = new byte[bytes.length - start];
+ System.arraycopy(bytes, start, trimmed, 0, trimmed.length);
+ return java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(trimmed);
+ }
+
+ private JWTBuilderFixture token() {
+ return new JWTBuilderFixture();
+ }
+
+ /** 기본값은 유효한 토큰이고, 테스트마다 한 가지만 어긋나게 만든다. */
+ private static class JWTBuilderFixture {
+ private String issuer = ISSUER;
+ private String audience = CLIENT_ID;
+ private Object emailVerified = true;
+ private Instant expiresAt = Instant.now().plusSeconds(600);
+ private RSAPrivateKey signingKey = privateKey;
+
+ JWTBuilderFixture issuer(String value) {
+ this.issuer = value;
+ return this;
+ }
+
+ JWTBuilderFixture audience(String value) {
+ this.audience = value;
+ return this;
+ }
+
+ JWTBuilderFixture emailVerified(Object value) {
+ this.emailVerified = value;
+ return this;
+ }
+
+ JWTBuilderFixture expiresAt(Instant value) {
+ this.expiresAt = value;
+ return this;
+ }
+
+ JWTBuilderFixture signedWith(RSAPrivateKey value) {
+ this.signingKey = value;
+ return this;
+ }
+
+ String build() {
+ var builder = JWT.create()
+ .withKeyId(KEY_ID)
+ .withIssuer(issuer)
+ .withAudience(audience)
+ .withSubject("google-sub-1")
+ .withClaim("email", "traveler@example.com")
+ .withClaim("name", "여행자")
+ .withClaim("picture", "https://example.com/p.png")
+ .withExpiresAt(Date.from(expiresAt));
+ if (emailVerified instanceof Boolean value) {
+ builder.withClaim("email_verified", value);
+ } else if (emailVerified instanceof String value) {
+ builder.withClaim("email_verified", value);
+ }
+ return builder.sign(Algorithm.RSA256(null, signingKey));
+ }
+ }
+
+ @Test
+ @DisplayName("유효한 토큰에서 sub·이메일·이름·사진을 뽑는다")
+ void extractsIdentityFromValidToken() {
+ GoogleIdentity identity = verifier().verify(token().build());
+
+ assertThat(identity.subject()).isEqualTo("google-sub-1");
+ assertThat(identity.email()).isEqualTo("traveler@example.com");
+ assertThat(identity.name()).isEqualTo("여행자");
+ assertThat(identity.pictureUrl()).isEqualTo("https://example.com/p.png");
+ }
+
+ @Test
+ @DisplayName("다른 앱에 발급된 토큰을 거절한다")
+ void rejectsTokenIssuedForAnotherAudience() {
+ // aud 를 확인하지 않으면 다른 서비스용으로 발급된 유효한 구글 토큰으로 로그인된다.
+ assertThatThrownBy(() -> verifier().verify(token().audience("other-app").build()))
+ .isInstanceOf(BusinessException.class)
+ .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_GOOGLE_TOKEN);
+ }
+
+ @Test
+ @DisplayName("구글이 발급하지 않은 토큰을 거절한다")
+ void rejectsTokenFromAnotherIssuer() {
+ assertThatThrownBy(() -> verifier().verify(token().issuer("https://evil.example").build()))
+ .isInstanceOf(BusinessException.class)
+ .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_GOOGLE_TOKEN);
+ }
+
+ @Test
+ @DisplayName("scheme 없는 issuer 도 허용한다")
+ void acceptsIssuerWithoutScheme() {
+ // 구글은 accounts.google.com 형태로도 발급한다. 둘 다 정상이다.
+ assertThat(verifier().verify(token().issuer("accounts.google.com").build()).subject())
+ .isEqualTo("google-sub-1");
+ }
+
+ @Test
+ @DisplayName("서명이 맞지 않는 토큰을 거절한다")
+ void rejectsTokenWithWrongSignature() {
+ // 구글이 노출하는 키는 publicKey 인데 토큰은 다른 키로 서명됐다.
+ assertThatThrownBy(() -> verifier().verify(token().signedWith(otherPrivateKey).build()))
+ .isInstanceOf(BusinessException.class)
+ .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_GOOGLE_TOKEN);
+ assertThat(otherPublicKey).isNotEqualTo(publicKey);
+ }
+
+ @Test
+ @DisplayName("만료된 토큰을 거절한다")
+ void rejectsExpiredToken() {
+ assertThatThrownBy(() -> verifier()
+ .verify(token().expiresAt(Instant.now().minusSeconds(10)).build()))
+ .isInstanceOf(BusinessException.class)
+ .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_GOOGLE_TOKEN);
+ }
+
+ @Test
+ @DisplayName("이메일이 확인되지 않은 계정을 거절한다")
+ void rejectsUnverifiedEmail() {
+ assertThatThrownBy(() -> verifier().verify(token().emailVerified(false).build()))
+ .isInstanceOf(BusinessException.class)
+ .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_GOOGLE_TOKEN);
+ }
+
+ @Test
+ @DisplayName("email_verified 가 아예 없으면 거절한다")
+ void rejectsMissingEmailVerifiedClaim() {
+ // 없는 것을 참으로 보면 확인되지 않은 이메일이 그대로 통과한다.
+ assertThatThrownBy(() -> verifier().verify(token().emailVerified(null).build()))
+ .isInstanceOf(BusinessException.class)
+ .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_GOOGLE_TOKEN);
+ }
+
+ @Test
+ @DisplayName("비어 있는 토큰을 거절한다")
+ void rejectsBlankToken() {
+ assertThatThrownBy(() -> verifier().verify(" "))
+ .isInstanceOf(BusinessException.class)
+ .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_GOOGLE_TOKEN);
+ }
+}
diff --git a/src/test/java/com/server/auth/service/InMemoryStringRedisTemplate.java b/src/test/java/com/server/auth/service/InMemoryStringRedisTemplate.java
new file mode 100644
index 0000000..59bd525
--- /dev/null
+++ b/src/test/java/com/server/auth/service/InMemoryStringRedisTemplate.java
@@ -0,0 +1,117 @@
+package com.server.auth.service;
+
+import java.lang.reflect.Proxy;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import org.springframework.data.redis.core.Cursor;
+import org.springframework.data.redis.core.ScanOptions;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
+
+/**
+ * 테스트용 {@link StringRedisTemplate} 대역.
+ *
+ * {@link RefreshTokenStore} 가 실제로 쓰는 연산만 다룬다. set·delete·keys 다.
+ * {@code ValueOperations} 는 메서드가 많아 전부 구현하지 않고, 쓰는 것만 프록시로 받는다.
+ * 쓰지 않는 메서드가 호출되면 바로 실패해 대역이 조용히 어긋나는 것을 막는다.
+ *
+ * TTL 은 무시한다. 만료는 Redis 의 몫이고, 여기서 검증하려는 것은 회전과 재사용 판정이다.
+ */
+class InMemoryStringRedisTemplate extends StringRedisTemplate {
+
+ private final Map 살아 있는 Redis 를 요구하지 않도록 {@link StringRedisTemplate} 을 메모리 구현으로
+ * 대신한다. 검증하려는 것은 회전과 재사용 판정이지 Redis 자체가 아니다.
+ */
+@DisplayName("리프레시 토큰 저장소")
+class RefreshTokenStoreTest {
+
+ private InMemoryStringRedisTemplate redis;
+ private RefreshTokenStore store;
+
+ @BeforeEach
+ void setUp() {
+ redis = new InMemoryStringRedisTemplate();
+ store = new RefreshTokenStore(redis, new AuthProperties(
+ new AuthProperties.Google(List.of("client"), null, null),
+ new AuthProperties.Jwt("secret-long-enough-0123456789abcdef", null, null,
+ Duration.ofDays(14))));
+ }
+
+ @Test
+ @DisplayName("발급한 토큰을 한 번 소비할 수 있다")
+ void consumesIssuedTokenOnce() {
+ String token = store.issue(42L);
+
+ assertThat(store.consume(42L, token)).isTrue();
+ }
+
+ @Test
+ @DisplayName("같은 토큰을 두 번 소비할 수 없다")
+ void rejectsSecondUseOfSameToken() {
+ // 회전의 핵심이다. 두 번 통과하면 훔친 토큰이 계속 살아 있다.
+ String token = store.issue(42L);
+ store.consume(42L, token);
+
+ assertThat(store.consume(42L, token)).isFalse();
+ }
+
+ @Test
+ @DisplayName("원문을 저장하지 않는다")
+ void storesHashInsteadOfRawToken() {
+ // Redis 를 읽을 수 있는 사람이 그대로 남의 세션을 이어받지 못하게 한다.
+ String token = store.issue(42L);
+
+ assertThat(redis.keys()).noneMatch(key -> key.contains(token));
+ assertThat(redis.keys()).allMatch(key -> key.startsWith("refresh:42:"));
+ }
+
+ @Test
+ @DisplayName("남의 사용자 ID 로는 토큰을 소비할 수 없다")
+ void rejectsTokenUnderAnotherUserId() {
+ String token = store.issue(42L);
+
+ assertThat(store.consume(99L, token)).isFalse();
+ assertThat(store.consume(42L, token)).isTrue();
+ }
+
+ @Test
+ @DisplayName("한 사용자의 모든 토큰을 한 번에 폐기한다")
+ void revokesEveryTokenOfUser() {
+ // 정지·탈퇴·탈취 의심 때 모든 기기를 끊어야 한다.
+ String first = store.issue(42L);
+ String second = store.issue(42L);
+ String other = store.issue(99L);
+
+ assertThat(store.revokeAll(42L)).isEqualTo(2);
+ assertThat(store.consume(42L, first)).isFalse();
+ assertThat(store.consume(42L, second)).isFalse();
+ assertThat(store.consume(99L, other)).isTrue();
+ }
+
+ @Test
+ @DisplayName("클라이언트가 보내는 형식을 해석한다")
+ void parsesClientFormat() {
+ assertThat(RefreshTokenStore.parse("42.abc").orElseThrow().userId()).isEqualTo(42L);
+ assertThat(RefreshTokenStore.parse("42.abc").orElseThrow().token()).isEqualTo("abc");
+ }
+
+ @Test
+ @DisplayName("형식이 어긋난 값은 해석하지 않는다")
+ void rejectsMalformedFormat() {
+ assertThat(RefreshTokenStore.parse(null)).isEmpty();
+ assertThat(RefreshTokenStore.parse("no-separator")).isEmpty();
+ assertThat(RefreshTokenStore.parse(".abc")).isEmpty();
+ assertThat(RefreshTokenStore.parse("42.")).isEmpty();
+ assertThat(RefreshTokenStore.parse("abc.def")).isEmpty();
+ }
+
+ @Test
+ @DisplayName("발급할 때마다 다른 토큰을 준다")
+ void issuesDistinctTokens() {
+ assertThat(store.issue(42L)).isNotEqualTo(store.issue(42L));
+ }
+}
diff --git a/src/test/java/com/server/auth/web/SecurityAuthorizationTest.java b/src/test/java/com/server/auth/web/SecurityAuthorizationTest.java
new file mode 100644
index 0000000..e00071f
--- /dev/null
+++ b/src/test/java/com/server/auth/web/SecurityAuthorizationTest.java
@@ -0,0 +1,125 @@
+package com.server.auth.web;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.server.auth.config.AuthProperties;
+import com.server.auth.service.AccessTokenProvider;
+import com.server.user.domain.AuthProvider;
+import com.server.user.domain.User;
+import com.server.user.domain.UserRole;
+import java.time.Duration;
+import java.util.List;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.context.annotation.Import;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 관리자 경로 인가와 인증 오류 응답 형태.
+ *
+ * 관리자 컨트롤러가 아직 없으므로 테스트 전용 컨트롤러로 경로만 만들어 확인한다.
+ * 실제 관리자 기능을 붙이기 전에 인가가 걸려 있어야 한다.
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@Import(SecurityAuthorizationTest.AdminPingController.class)
+@ActiveProfiles("test")
+@DisplayName("인가와 인증 오류 응답")
+class SecurityAuthorizationTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private AccessTokenProvider accessTokenProvider;
+
+ private String tokenFor(UserRole role) {
+ User user = User.ofOAuth(AuthProvider.GOOGLE, "sub", "a@example.com", "동준", null, role);
+ ReflectionTestUtils.setField(user, "id", 1L);
+ return accessTokenProvider.issue(user);
+ }
+
+ @Test
+ @DisplayName("토큰 없이 관리자 경로에 접근하면 401 이다")
+ void rejectsAnonymousAdminAccess() throws Exception {
+ // SecurityConfig 가 /api/v1/** 를 통째로 permitAll 하던 시절에는 200 이 나갔다.
+ // 관리자 기능을 붙이기 전에 이 경로가 막혀 있어야 한다.
+ mockMvc.perform(get("/api/v1/admin/ping"))
+ .andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ @DisplayName("일반 사용자 토큰으로 관리자 경로에 접근하면 403 이다")
+ void rejectsNonAdminAccess() throws Exception {
+ mockMvc.perform(get("/api/v1/admin/ping")
+ .header("Authorization", "Bearer " + tokenFor(UserRole.USER)))
+ .andExpect(status().isForbidden());
+ }
+
+ @Test
+ @DisplayName("관리자 토큰이면 통과한다")
+ void allowsAdminAccess() throws Exception {
+ mockMvc.perform(get("/api/v1/admin/ping")
+ .header("Authorization", "Bearer " + tokenFor(UserRole.ADMIN)))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ @DisplayName("401 응답도 code·fieldErrors·traceId 를 갖춘다")
+ void unauthorizedUsesCommonErrorShape() throws Exception {
+ // Security 예외는 필터에서 나 @ControllerAdvice 가 잡지 못한다. 그대로 두면
+ // 401·403 만 Spring 기본 응답으로 나가 클라이언트의 code 분기가 깨진다.
+ mockMvc.perform(get("/api/v1/admin/ping"))
+ .andExpect(status().isUnauthorized())
+ .andExpect(jsonPath("$.code").value("UNAUTHORIZED"))
+ .andExpect(jsonPath("$.message").isNotEmpty())
+ .andExpect(jsonPath("$.fieldErrors").isArray())
+ .andExpect(jsonPath("$.traceId").isNotEmpty());
+ }
+
+ @Test
+ @DisplayName("403 응답도 같은 형태이며 traceId 가 응답 헤더와 일치한다")
+ void forbiddenUsesCommonErrorShape() throws Exception {
+ var result = mockMvc.perform(get("/api/v1/admin/ping")
+ .header("Authorization", "Bearer " + tokenFor(UserRole.USER)))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value("FORBIDDEN"))
+ .andExpect(jsonPath("$.traceId").isNotEmpty())
+ .andExpect(header().exists("X-Trace-Id"))
+ .andReturn();
+
+ String header = result.getResponse().getHeader("X-Trace-Id");
+ org.assertj.core.api.Assertions.assertThat(result.getResponse().getContentAsString())
+ .as("본문의 traceId 가 헤더와 달라지면 로그와 이어 볼 수 없다")
+ .contains("\"traceId\":\"" + header + "\"");
+ }
+
+ @Test
+ @DisplayName("잘못된 토큰을 보내도 공개 경로는 열려 있다")
+ void keepsPublicPathsOpenWithBadToken() throws Exception {
+ // 인증 필터는 토큰을 요구하지 않는다. 잘못된 토큰에 401 을 내면 비로그인도 볼 수
+ // 있어야 하는 화면이 깨진다.
+ mockMvc.perform(get("/api/v1/trip-questions")
+ .header("Authorization", "Bearer not-a-real-token"))
+ .andExpect(status().isOk());
+ }
+
+ @RestController
+ static class AdminPingController {
+
+ @GetMapping("/api/v1/admin/ping")
+ String ping() {
+ return "pong";
+ }
+ }
+}
diff --git a/src/test/java/com/server/common/config/CorsPropertiesBindingTest.java b/src/test/java/com/server/common/config/CorsPropertiesBindingTest.java
index b9ca882..30cc7b5 100644
--- a/src/test/java/com/server/common/config/CorsPropertiesBindingTest.java
+++ b/src/test/java/com/server/common/config/CorsPropertiesBindingTest.java
@@ -23,6 +23,27 @@ void bindsAllowedOriginsFromEnvironment() {
.isEqualTo(List.of("https://frontend.example", "https://preview.example")));
}
+ @Test
+ void bindsAllowedHeadersFromEnvironment() {
+ contextRunner
+ .withSystemProperties("CORS_ALLOWED_HEADERS=Content-Type, Authorization")
+ .run(context -> assertThat(context.getBean(CorsProperties.class).allowedHeaders())
+ .isEqualTo(List.of("Content-Type", "Authorization")));
+ }
+
+ @Test
+ void fallsBackToDefaultHeadersWhenConfigurationIsBlank() {
+ // 환경변수를 빈 값으로 두면 허용 헤더가 사라져 브라우저에서 API 전체가 막힌다.
+ contextRunner
+ .withSystemProperties("CORS_ALLOWED_HEADERS=", "CORS_EXPOSED_HEADERS=")
+ .run(context -> {
+ CorsProperties properties = context.getBean(CorsProperties.class);
+ assertThat(properties.allowedHeaders())
+ .contains("Content-Type", "Authorization", "X-User-Id");
+ assertThat(properties.exposedHeaders()).contains("X-Trace-Id");
+ });
+ }
+
@EnableConfigurationProperties(CorsProperties.class)
static class TestConfiguration {
}
diff --git a/src/test/java/com/server/common/config/RedisConnectionConfigTest.java b/src/test/java/com/server/common/config/RedisConnectionConfigTest.java
new file mode 100644
index 0000000..a7c1dd7
--- /dev/null
+++ b/src/test/java/com/server/common/config/RedisConnectionConfigTest.java
@@ -0,0 +1,39 @@
+package com.server.common.config;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
+import org.springframework.test.context.ActiveProfiles;
+
+/**
+ * Redis 접속 설정이 실제로 바인딩되는지 확인한다.
+ *
+ * 리프레시 토큰 저장소로 쓰므로 호스트·포트를 잘못 잡으면 로그인 갱신이 전부 실패한다.
+ * 연결은 지연 생성이라 서버가 뜰 때는 드러나지 않고 첫 갱신 요청에서야 터진다.
+ *
+ * 여기서는 살아 있는 Redis 를 요구하지 않는다. 설정값이 커넥션 팩토리까지 전달되는지만
+ * 본다. 테스트가 Redis 기동에 의존하면 CI 가 불안정해진다.
+ */
+@SpringBootTest(properties = {"REDIS_HOST=redis.example", "REDIS_PORT=6380"})
+@ActiveProfiles("test")
+@DisplayName("Redis 접속 설정")
+class RedisConnectionConfigTest {
+
+ @Autowired
+ private RedisConnectionFactory connectionFactory;
+
+ @Test
+ @DisplayName("환경변수의 호스트와 포트를 커넥션 팩토리에 적용한다")
+ void bindsHostAndPortFromEnvironment() {
+ assertThat(connectionFactory).isInstanceOf(LettuceConnectionFactory.class);
+
+ LettuceConnectionFactory lettuce = (LettuceConnectionFactory) connectionFactory;
+ assertThat(lettuce.getHostName()).isEqualTo("redis.example");
+ assertThat(lettuce.getPort()).isEqualTo(6380);
+ }
+}
diff --git a/src/test/java/com/server/common/config/SecurityConfigTest.java b/src/test/java/com/server/common/config/SecurityConfigTest.java
index d4af480..1ed74d7 100644
--- a/src/test/java/com/server/common/config/SecurityConfigTest.java
+++ b/src/test/java/com/server/common/config/SecurityConfigTest.java
@@ -79,6 +79,47 @@ void deployedFrontendAllowsScheduleV2CorsPreflight() throws Exception {
));
}
+ @Test
+ @DisplayName("로그인 요청의 Authorization 헤더를 preflight에서 허용한다")
+ void allowsAuthorizationHeaderInPreflight() throws Exception {
+ // 허용 헤더에 Authorization 이 없으면 브라우저에서 로그인한 요청이 전부 막힌다.
+ mockMvc.perform(options("/api/v1/schedules")
+ .header("Origin", "https://www.busantour.site")
+ .header("Access-Control-Request-Method", "GET")
+ .header("Access-Control-Request-Headers", "Authorization"))
+ .andExpect(status().isOk())
+ .andExpect(header().string(
+ "Access-Control-Allow-Headers",
+ org.hamcrest.Matchers.containsString("Authorization")));
+ }
+
+ @Test
+ @DisplayName("커뮤니티가 쓰는 X-User-Id 헤더를 preflight에서 허용한다")
+ void allowsUserIdHeaderInPreflight() throws Exception {
+ mockMvc.perform(options("/api/v1/posts")
+ .header("Origin", "https://www.busantour.site")
+ .header("Access-Control-Request-Method", "POST")
+ .header("Access-Control-Request-Headers", "Content-Type, X-User-Id"))
+ .andExpect(status().isOk())
+ .andExpect(header().string(
+ "Access-Control-Allow-Headers",
+ org.hamcrest.Matchers.containsString("X-User-Id")));
+ }
+
+ @Test
+ @DisplayName("브라우저가 응답에서 X-Trace-Id를 읽을 수 있도록 노출한다")
+ void exposesTraceIdHeader() throws Exception {
+ // TraceIdFilter 가 모든 응답에 넣고 오류 응답의 traceId 와 같은 값이라고 문서화하지만,
+ // 노출 헤더로 지정하지 않으면 다른 오리진의 스크립트는 읽을 수 없다.
+ mockMvc.perform(get("/api/v1/locations/search")
+ .param("keyword", "부산역")
+ .header("Origin", "https://www.busantour.site"))
+ .andExpect(status().isOk())
+ .andExpect(header().string(
+ "Access-Control-Expose-Headers",
+ org.hamcrest.Matchers.containsString("X-Trace-Id")));
+ }
+
@TestConfiguration
static class TestConfig {
diff --git a/src/test/java/com/server/common/database/PostgresMigrationIntegrationTest.java b/src/test/java/com/server/common/database/PostgresMigrationIntegrationTest.java
index f1dbbb4..0e67bb5 100644
--- a/src/test/java/com/server/common/database/PostgresMigrationIntegrationTest.java
+++ b/src/test/java/com/server/common/database/PostgresMigrationIntegrationTest.java
@@ -61,6 +61,25 @@ void migrationsMatchJpaSchema() throws IOException {
+ "and column_name in ('arrive_at', 'depart_at')",
Integer.class
);
+ Integer userAuthColumnCount = jdbcTemplate.queryForObject(
+ "select count(*) from information_schema.columns "
+ + "where table_schema = 'public' and table_name = 'users' "
+ + "and column_name in ('email', 'provider', 'provider_id', 'role', "
+ + "'status', 'suspended_until', 'suspended_reason')",
+ Integer.class
+ );
+ Integer providerIndexCount = jdbcTemplate.queryForObject(
+ "select count(*) from pg_indexes "
+ + "where schemaname = 'public' and tablename = 'users' "
+ + "and indexname = 'uk_users_provider_active'",
+ Integer.class
+ );
+ Integer scheduleOwnerColumnCount = jdbcTemplate.queryForObject(
+ "select count(*) from information_schema.columns "
+ + "where table_schema = 'public' and table_name = 'schedules' "
+ + "and column_name = 'user_id'",
+ Integer.class
+ );
Integer questionUiStepColumnCount = jdbcTemplate.queryForObject(
"select count(*) from information_schema.columns "
+ "where table_schema = 'public' and table_name = 'questions' "
@@ -77,6 +96,10 @@ void migrationsMatchJpaSchema() throws IOException {
assertThat(creationRequestTableCount).isEqualTo(1);
assertThat(questionUiStepColumnCount).isEqualTo(1);
assertThat(stopTimeColumnCount).isEqualTo(2);
+ assertThat(userAuthColumnCount).isEqualTo(7);
+ // 같은 구글 계정으로 두 번 가입되지 않게 막는 부분 고유 인덱스.
+ assertThat(providerIndexCount).isEqualTo(1);
+ assertThat(scheduleOwnerColumnCount).isEqualTo(1);
}
/** classpath의 db/migration 아래 있는 실제 스크립트 수. */
diff --git a/src/test/java/com/server/external/schedule/FastApiScheduleClientTest.java b/src/test/java/com/server/external/schedule/FastApiScheduleClientTest.java
index cf65d15..21756a9 100644
--- a/src/test/java/com/server/external/schedule/FastApiScheduleClientTest.java
+++ b/src/test/java/com/server/external/schedule/FastApiScheduleClientTest.java
@@ -62,7 +62,7 @@ void createFromPreviewForwardsIdempotencyKey() {
""".formatted(ZERO), MediaType.APPLICATION_JSON));
var response = fixture.client()
- .createScheduleFromPreview(new SchedulePreviewScheduleRequest(ZERO), "key-1");
+ .createScheduleFromPreview(new SchedulePreviewScheduleRequest(ZERO), "key-1", null);
assertThat(response.status()).isEqualTo("CONFIRMED");
fixture.server().verify();
@@ -117,7 +117,7 @@ private void assertConflictMaps(String detail, ErrorCode expected) {
.contentType(MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> fixture.client()
- .createScheduleFromPreview(new SchedulePreviewScheduleRequest(ZERO), "key-1"))
+ .createScheduleFromPreview(new SchedulePreviewScheduleRequest(ZERO), "key-1", null))
.isInstanceOf(BusinessException.class)
.extracting(this::errorCodeOf)
.isEqualTo(expected);
diff --git a/src/test/java/com/server/external/schedule/ScheduleOwnerHeaderTest.java b/src/test/java/com/server/external/schedule/ScheduleOwnerHeaderTest.java
new file mode 100644
index 0000000..0540162
--- /dev/null
+++ b/src/test/java/com/server/external/schedule/ScheduleOwnerHeaderTest.java
@@ -0,0 +1,119 @@
+package com.server.external.schedule;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.server.auth.service.AuthenticatedUser;
+import com.server.schedule.dto.SchedulePreviewScheduleRequest;
+import com.server.user.domain.UserRole;
+import com.sun.net.httpserver.HttpServer;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+/**
+ * 일정 소유자가 FastAPI 까지 전달되는지 확인한다.
+ *
+ * 스텁 서버가 받은 헤더를 직접 본다. 여기서 빠지면 일정에 주인이 없는 채로 저장되고,
+ * 내 일정 목록·수정 권한·공유가 전부 성립하지 않는다.
+ */
+@DisplayName("일정 소유자 전달")
+class ScheduleOwnerHeaderTest {
+
+ private static final String OWNER_HEADER = "X-Auth-User-Id";
+
+ private HttpServer server;
+ private final AtomicReference
+ *
+ *
+ *