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 clientIds, String issuer, Duration jwksCacheTtl) { + + public Google { + clientIds = clientIds == null ? List.of() : clientIds.stream() + .filter(id -> id != null && !id.isBlank()) + .map(String::trim) + .toList(); + issuer = issuer == null || issuer.isBlank() ? "https://accounts.google.com" : issuer; + jwksCacheTtl = jwksCacheTtl == null ? Duration.ofHours(6) : jwksCacheTtl; + } + } + + public record Jwt(String secret, String issuer, Duration accessTtl, Duration refreshTtl) { + + public Jwt { + issuer = issuer == null || issuer.isBlank() ? "busantour" : issuer; + accessTtl = accessTtl == null ? Duration.ofMinutes(30) : accessTtl; + refreshTtl = refreshTtl == null ? Duration.ofDays(14) : refreshTtl; + } + } +} diff --git a/src/main/java/com/server/auth/config/GoogleJwkConfig.java b/src/main/java/com/server/auth/config/GoogleJwkConfig.java new file mode 100644 index 0000000..45adefc --- /dev/null +++ b/src/main/java/com/server/auth/config/GoogleJwkConfig.java @@ -0,0 +1,38 @@ +package com.server.auth.config; + +import com.auth0.jwk.JwkProvider; +import com.auth0.jwk.JwkProviderBuilder; +import java.net.MalformedURLException; +import java.net.URI; +import java.util.concurrent.TimeUnit; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 구글 공개키 제공자. + * + *

토큰을 검증할 때마다 구글에 키를 물으면 로그인 지연이 그대로 늘고, 구글이 잠깐 + * 응답하지 않으면 로그인 전체가 멈춘다. 캐시하고 호출량을 제한한다. + * + *

키는 주기적으로 교체되므로 영구 캐시는 쓰지 않는다. 캐시가 만료되면 다음 요청에서 + * 다시 가져온다. + */ +@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 parse(String token) { + if (token == null || token.isBlank()) { + return Optional.empty(); + } + try { + DecodedJWT decoded = verifier.verify(token); + return Optional.of(new AuthenticatedUser( + Long.valueOf(decoded.getSubject()), + UserRole.valueOf(decoded.getClaim(ROLE_CLAIM).asString()))); + } catch (Exception exception) { + log.debug("Access token rejected. reason={}", exception.getMessage()); + return Optional.empty(); + } + } + + public Duration accessTtl() { + return accessTtl; + } +} diff --git a/src/main/java/com/server/auth/service/AuthService.java b/src/main/java/com/server/auth/service/AuthService.java new file mode 100644 index 0000000..a2d16c1 --- /dev/null +++ b/src/main/java/com/server/auth/service/AuthService.java @@ -0,0 +1,104 @@ +package com.server.auth.service; + +import com.server.auth.dto.AuthTokenResponse; +import com.server.common.error.BusinessException; +import com.server.common.error.ErrorCode; +import com.server.user.domain.User; +import com.server.user.repository.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AuthService { + + private static final Logger log = LoggerFactory.getLogger(AuthService.class); + + private final GoogleIdTokenVerifier googleIdTokenVerifier; + private final OAuthUserRegistrar oAuthUserRegistrar; + private final AccessTokenProvider accessTokenProvider; + private final RefreshTokenStore refreshTokenStore; + private final UserRepository userRepository; + + public AuthService( + GoogleIdTokenVerifier googleIdTokenVerifier, + OAuthUserRegistrar oAuthUserRegistrar, + AccessTokenProvider accessTokenProvider, + RefreshTokenStore refreshTokenStore, + UserRepository userRepository + ) { + this.googleIdTokenVerifier = googleIdTokenVerifier; + this.oAuthUserRegistrar = oAuthUserRegistrar; + this.accessTokenProvider = accessTokenProvider; + this.refreshTokenStore = refreshTokenStore; + this.userRepository = userRepository; + } + + /** + * 트랜잭션을 걸지 않는다. 토큰 검증은 구글 JWKS 로 나가는 네트워크 호출이라, + * 트랜잭션 안에 두면 구글이 느릴 때 DB 커넥션을 쥔 채 대기한다. 사용자 저장만 + * {@link OAuthUserRegistrar#register} 가 자기 트랜잭션으로 처리한다. + */ + public AuthTokenResponse loginWithGoogle(String idToken) { + GoogleIdentity identity = googleIdTokenVerifier.verify(idToken); + User user = oAuthUserRegistrar.register(identity); + return issueFor(user); + } + + /** + * 리프레시 토큰을 회전시킨다. + * + *

쓴 토큰을 지우고 새 토큰을 준다. 이미 지워진 토큰이 다시 오면 탈취로 보고 그 + * 사용자의 리프레시를 전부 폐기한다. 훔친 쪽과 원래 사용자가 번갈아 갱신하면 반드시 + * 한쪽이 지워진 토큰을 내밀게 되므로 탈취가 드러난다. + */ + @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 keys = new HashSet<>(); + ScanOptions options = ScanOptions.scanOptions() + .match(KEY_PREFIX + userId + ":*") + .count(SCAN_BATCH) + .build(); + try (Cursor cursor = redisTemplate.scan(options)) { + cursor.forEachRemaining(keys::add); + } + if (keys.isEmpty()) { + return 0; + } + Long removed = redisTemplate.delete(keys); + log.info("Revoked all refresh tokens. userId={}, count={}", userId, removed); + return removed == null ? 0 : removed; + } + + /** + * 클라이언트가 보내는 리프레시 값은 {@code {userId}.{token}} 형태다. + * + *

토큰만 받으면 어느 사용자의 키를 지워야 할지 알 수 없고, 전체를 훑으면 사용자 수에 + * 비례해 느려진다. 사용자 ID 를 함께 받되 이는 조회용일 뿐이라 위조해도 남의 토큰을 + * 소비할 수 없다. 해시가 맞지 않으면 키가 없다. + */ + public static Optional parse(String value) { + if (value == null) { + return Optional.empty(); + } + int separator = value.indexOf('.'); + if (separator <= 0 || separator == value.length() - 1) { + return Optional.empty(); + } + try { + Long userId = Long.valueOf(value.substring(0, separator)); + return Optional.of(new RefreshTokenRef(userId, value.substring(separator + 1))); + } catch (NumberFormatException exception) { + return Optional.empty(); + } + } + + public static String format(Long userId, String token) { + return userId + "." + token; + } + + public record RefreshTokenRef(Long userId, String token) { + } + + private String key(Long userId, String token) { + return KEY_PREFIX + userId + ":" + sha256(token); + } + + private String usedKey(Long userId, String token) { + return USED_PREFIX + userId + ":" + sha256(token); + } + + private String sha256(String value) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)); + return java.util.HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is required", exception); + } + } +} diff --git a/src/main/java/com/server/auth/web/AccessTokenAuthenticationFilter.java b/src/main/java/com/server/auth/web/AccessTokenAuthenticationFilter.java new file mode 100644 index 0000000..bc5a23c --- /dev/null +++ b/src/main/java/com/server/auth/web/AccessTokenAuthenticationFilter.java @@ -0,0 +1,71 @@ +package com.server.auth.web; + +import com.server.auth.service.AccessTokenProvider; +import com.server.auth.service.AuthenticatedUser; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * {@code Authorization: Bearer} 로 온 액세스 토큰을 읽어 인증 정보를 채운다. + * + *

토큰을 요구하지 않는다. 없거나 잘못돼도 그냥 통과시킨다. 어디에 인증이 필요한지는 + * {@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 resolveToken(HttpServletRequest request) { + String header = request.getHeader(HEADER); + if (header == null || !header.startsWith(PREFIX)) { + return java.util.Optional.empty(); + } + return java.util.Optional.of(header.substring(PREFIX.length()).trim()); + } +} diff --git a/src/main/java/com/server/auth/web/CurrentUser.java b/src/main/java/com/server/auth/web/CurrentUser.java new file mode 100644 index 0000000..f3c61e1 --- /dev/null +++ b/src/main/java/com/server/auth/web/CurrentUser.java @@ -0,0 +1,32 @@ +package com.server.auth.web; + +import com.server.auth.service.AuthenticatedUser; +import java.util.Optional; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * 지금 요청을 보낸 사용자. + * + *

인가를 전면 적용하기 전이라 로그인하지 않은 요청이 대부분이다. 그래서 없을 수 있는 + * 값으로 다룬다. 컨트롤러 파라미터로 주입하는 방식은 커뮤니티의 {@code X-User-Id} 를 + * 걷어낼 때 함께 정리한다. + */ +public final class CurrentUser { + + private CurrentUser() { + } + + public static Optional get() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !(authentication.getPrincipal() instanceof AuthenticatedUser user)) { + return Optional.empty(); + } + return Optional.of(user); + } + + /** @return 로그인했으면 사용자 ID, 아니면 {@code null}. */ + public static Long idOrNull() { + return get().map(AuthenticatedUser::id).orElse(null); + } +} diff --git a/src/main/java/com/server/auth/web/ErrorResponseAccessDeniedHandler.java b/src/main/java/com/server/auth/web/ErrorResponseAccessDeniedHandler.java new file mode 100644 index 0000000..a550184 --- /dev/null +++ b/src/main/java/com/server/auth/web/ErrorResponseAccessDeniedHandler.java @@ -0,0 +1,29 @@ +package com.server.auth.web; + +import com.server.common.error.ErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +/** 인증은 됐지만 권한이 모자랄 때. 관리자 경로에 일반 사용자가 접근한 경우가 대표적이다. */ +@Component +public class ErrorResponseAccessDeniedHandler implements AccessDeniedHandler { + + private final SecurityErrorResponder responder; + + public ErrorResponseAccessDeniedHandler(SecurityErrorResponder responder) { + this.responder = responder; + } + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException { + responder.write(request, response, ErrorCode.FORBIDDEN); + } +} diff --git a/src/main/java/com/server/auth/web/ErrorResponseAuthenticationEntryPoint.java b/src/main/java/com/server/auth/web/ErrorResponseAuthenticationEntryPoint.java new file mode 100644 index 0000000..994286c --- /dev/null +++ b/src/main/java/com/server/auth/web/ErrorResponseAuthenticationEntryPoint.java @@ -0,0 +1,29 @@ +package com.server.auth.web; + +import com.server.common.error.ErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +/** 인증이 필요한데 토큰이 없거나 유효하지 않을 때. */ +@Component +public class ErrorResponseAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final SecurityErrorResponder responder; + + public ErrorResponseAuthenticationEntryPoint(SecurityErrorResponder responder) { + this.responder = responder; + } + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + responder.write(request, response, ErrorCode.UNAUTHORIZED); + } +} diff --git a/src/main/java/com/server/auth/web/SecurityErrorResponder.java b/src/main/java/com/server/auth/web/SecurityErrorResponder.java new file mode 100644 index 0000000..dce2699 --- /dev/null +++ b/src/main/java/com/server/auth/web/SecurityErrorResponder.java @@ -0,0 +1,51 @@ +package com.server.auth.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.server.common.error.ErrorCode; +import com.server.common.error.ErrorResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; + +/** + * 인증·인가 실패를 다른 오류와 같은 형태로 내보낸다. + * + *

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 allowedOrigins) { +public record CorsProperties( + List allowedOrigins, + List allowedHeaders, + List exposedHeaders +) { + + /** + * 브라우저가 보낼 수 있는 요청 헤더. + * + *

{@code Authorization}이 없으면 로그인한 요청이 preflight에서 막힌다. + * {@code X-User-Id}는 인증 도입 전까지 커뮤니티 API가 작성자를 받는 임시 헤더이며, + * 인증이 붙으면 함께 제거한다. + */ + private static final List DEFAULT_ALLOWED_HEADERS = List.of( + "Content-Type", + "Authorization", + "X-Trace-Id", + "Idempotency-Key", + "X-User-Id"); + + /** + * 브라우저가 응답에서 읽을 수 있는 헤더. + * + *

{@code TraceIdFilter}가 모든 응답에 {@code X-Trace-Id}를 넣고 오류 응답의 + * {@code traceId}와 같은 값이라고 문서화하지만, 노출 헤더로 지정하지 않으면 + * 다른 오리진의 스크립트는 이 값을 읽을 수 없다. + */ + private static final List DEFAULT_EXPOSED_HEADERS = List.of("X-Trace-Id"); public CorsProperties { - allowedOrigins = allowedOrigins == null ? List.of() : allowedOrigins.stream() - .filter(origin -> origin != null && !origin.isBlank()) + allowedOrigins = clean(allowedOrigins, List.of()); + allowedHeaders = clean(allowedHeaders, DEFAULT_ALLOWED_HEADERS); + exposedHeaders = clean(exposedHeaders, DEFAULT_EXPOSED_HEADERS); + } + + private static List clean(List values, List fallback) { + if (values == null) { + return fallback; + } + List cleaned = values.stream() + .filter(value -> value != null && !value.isBlank()) .map(String::trim) .toList(); + return cleaned.isEmpty() ? fallback : cleaned; } } diff --git a/src/main/java/com/server/common/config/ErrorResponseOpenApiConfig.java b/src/main/java/com/server/common/config/ErrorResponseOpenApiConfig.java index 9ddd5a4..facef16 100644 --- a/src/main/java/com/server/common/config/ErrorResponseOpenApiConfig.java +++ b/src/main/java/com/server/common/config/ErrorResponseOpenApiConfig.java @@ -70,6 +70,11 @@ private void applyErrorResponses(String path, String httpMethod, Operation opera + "또는 같은 요청의 생성이 진행 중이다.", codesOf(409)); putIfAbsent(responses, "410", "Preview가 만료됐다.", codesOf(410)); } + // 인증이 필요한 경로. 지금은 관리자만이며, 인가를 전면 적용하면 대상이 늘어난다. + if (requiresAuthentication(path)) { + putIfAbsent(responses, "401", "인증이 필요하거나 토큰이 유효하지 않다.", codesOf(401)); + putIfAbsent(responses, "403", "권한이 없다.", codesOf(403)); + } if (delegatesToFastApi(path, httpMethod)) { putIfAbsent(responses, "503", "일정 생성·조회를 담당하는 FastAPI가 응답하지 않는다.", codesOf(503)); @@ -79,6 +84,16 @@ private void applyErrorResponses(String path, String httpMethod, Operation opera + "원인은 같은 traceId로 서버 로그에 남는다.", codesOf(500)); } + /** + * 인증이 필요한 경로. + * + *

관리자 경로는 만들어진 시점부터 인가가 걸려 있다. 사용자 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 { + Optional findByIdAndDeletedAtIsNull(Long id); + Optional findByNicknameAndDeletedAtIsNull(String nickname); boolean existsByNicknameAndDeletedAtIsNull(String nickname); + + /** 소셜 로그인 식별. 이메일이 아니라 제공자가 준 고유 ID 로 찾는다. */ + Optional findByProviderAndProviderIdAndDeletedAtIsNull( + AuthProvider provider, String providerId); } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 9231fed..c10eae0 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -26,6 +26,10 @@ spring: enabled: true baseline-on-migrate: true baseline-version: 0 + # 두 갈래 작업이 동시에 migration 을 추가한다. 커뮤니티(V8)와 인증(V9)처럼 서로 + # 독립적인 변경이 다른 순서로 머지되면, 낮은 버전이 나중에 도착해 기동이 깨진다. + # 두 순서 모두 안전하도록 허용한다. 같은 테이블을 건드리는 migration 을 나눠 만들지 않는 것이 전제다. + out-of-order: true app: seed: diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 13f009d..78d580e 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -9,6 +9,11 @@ spring: username: ${LOCAL_POSTGRES_USER:tour} password: ${LOCAL_POSTGRES_PASSWORD:tour} + data: + redis: + host: ${LOCAL_REDIS_HOST:localhost} + port: ${LOCAL_REDIS_PORT:6380} + h2: console: enabled: false @@ -26,11 +31,21 @@ spring: enabled: true baseline-on-migrate: true baseline-version: 0 + # 두 갈래 작업이 동시에 migration 을 추가한다. 커뮤니티(V8)와 인증(V9)처럼 서로 + # 독립적인 변경이 다른 순서로 머지되면, 낮은 버전이 나중에 도착해 기동이 깨진다. + # 두 순서 모두 안전하도록 허용한다. 같은 테이블을 건드리는 migration 을 나눠 만들지 않는 것이 전제다. + out-of-order: true app: seed: dummy-place: enabled: ${DUMMY_PLACE_SEED_ENABLED:true} + auth: + jwt: + # 로컬 개발 전용 기본값. dev·prod 는 반드시 환경변수로 넣는다. + secret: ${JWT_SECRET:local-development-only-secret-key-0123456789abcdef} + google: + client-ids: ${GOOGLE_CLIENT_ID:} springdoc: api-docs: diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index c63b5e7..2567c7d 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,6 +1,12 @@ spring: application: name: server + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + timeout: ${REDIS_TIMEOUT:2s} flyway: enabled: false jpa: @@ -35,6 +41,19 @@ external: app: cors: allowed-origins: "${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://127.0.0.1:3000,http://localhost:8080,http://127.0.0.1:8080,https://busantour.site,https://www.busantour.site}" + # 비워 두면 CorsProperties 의 기본값을 쓴다. Authorization 이 빠지면 로그인 요청이 preflight 에서 막힌다. + allowed-headers: "${CORS_ALLOWED_HEADERS:Content-Type,Authorization,X-Trace-Id,Idempotency-Key,X-User-Id}" + exposed-headers: "${CORS_EXPOSED_HEADERS:X-Trace-Id}" + auth: + google: + # 프론트가 쓰는 값과 같아야 한다. 앱이 붙으면 쉼표로 여러 개를 넣는다. + client-ids: ${GOOGLE_CLIENT_ID:} + jwks-cache-ttl: ${GOOGLE_JWKS_CACHE_TTL:6h} + jwt: + # 최소 256비트. 값은 .env.server 에만 둔다. + secret: ${JWT_SECRET:} + access-ttl: ${JWT_ACCESS_TTL:30m} + refresh-ttl: ${JWT_REFRESH_TTL:14d} schedule-fastapi: enabled: ${SCHEDULE_FASTAPI_ENABLED:false} base-url: ${SCHEDULE_FASTAPI_BASE_URL:http://127.0.0.1:8010} diff --git a/src/main/resources/db/migration/V10__add_schedule_owner.sql b/src/main/resources/db/migration/V10__add_schedule_owner.sql new file mode 100644 index 0000000..7abba80 --- /dev/null +++ b/src/main/resources/db/migration/V10__add_schedule_owner.sql @@ -0,0 +1,12 @@ +-- 일정에 소유자를 붙인다. +-- +-- nullable 이다. 이미 저장된 일정은 소유자를 알 수 없다. 인증이 없던 시절에 만들어졌고 +-- 만든 사람을 되짚을 근거가 남아 있지 않다. NULL 인 일정은 목록에서 아무에게도 보이지 +-- 않으며, 단건 조회는 공유 링크로만 닿는다. +-- +-- 이 테이블은 FastAPI 도 쓴다. 값을 채우는 쪽은 FastAPI 이며, 컬럼이 먼저 있어야 +-- 그쪽 배포가 가능하다. +ALTER TABLE schedules ADD COLUMN IF NOT EXISTS user_id bigint REFERENCES users(id); + +-- 내 일정 목록 조회. 사용자당 일정 수는 적고 전체는 계속 늘어난다. +CREATE INDEX IF NOT EXISTS idx_schedules_user_id ON schedules (user_id); diff --git a/src/main/resources/db/migration/V9__add_user_auth_columns.sql b/src/main/resources/db/migration/V9__add_user_auth_columns.sql new file mode 100644 index 0000000..50d40d0 --- /dev/null +++ b/src/main/resources/db/migration/V9__add_user_auth_columns.sql @@ -0,0 +1,21 @@ +-- 구글 로그인과 권한을 위해 users 를 확장한다. +-- +-- provider, provider_id, email 은 nullable 이다. V6 로 이미 만들어진 행이 있으면 +-- NOT NULL 은 실패한다. 로그인으로 생기는 행은 애플리케이션이 항상 채운다. +ALTER TABLE users ADD COLUMN IF NOT EXISTS email varchar(255); +ALTER TABLE users ADD COLUMN IF NOT EXISTS provider varchar(20); +ALTER TABLE users ADD COLUMN IF NOT EXISTS provider_id varchar(255); +ALTER TABLE users ADD COLUMN IF NOT EXISTS role varchar(20) NOT NULL DEFAULT 'USER'; +ALTER TABLE users ADD COLUMN IF NOT EXISTS status varchar(20) NOT NULL DEFAULT 'ACTIVE'; +ALTER TABLE users ADD COLUMN IF NOT EXISTS suspended_until timestamp; +-- 정지 사유. 관리자가 남기고 필요하면 사용자에게 안내한다. +ALTER TABLE users ADD COLUMN IF NOT EXISTS suspended_reason text; + +-- 같은 구글 계정으로 두 번 가입되지 않게 한다. 닉네임 고유 인덱스와 같은 이유로 +-- 탈퇴하지 않은 행에만 적용한다. 탈퇴 후 재가입은 새 행으로 들어온다. +CREATE UNIQUE INDEX IF NOT EXISTS uk_users_provider_active + ON users (provider, provider_id) + WHERE deleted_at IS NULL; + +-- 관리자 목록 조회용. ADMIN 은 극소수라 선택도가 높다. +CREATE INDEX IF NOT EXISTS idx_users_role ON users (role); diff --git a/src/test/java/com/server/auth/service/AccessTokenProviderTest.java b/src/test/java/com/server/auth/service/AccessTokenProviderTest.java new file mode 100644 index 0000000..23b5c90 --- /dev/null +++ b/src/test/java/com/server/auth/service/AccessTokenProviderTest.java @@ -0,0 +1,115 @@ +package com.server.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.server.auth.config.AuthProperties; +import com.server.user.domain.AuthProvider; +import com.server.user.domain.User; +import com.server.user.domain.UserRole; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +@DisplayName("액세스 토큰") +class AccessTokenProviderTest { + + private static final String SECRET = "test-secret-key-long-enough-for-hmac-256-0123456789"; + + private AccessTokenProvider provider() { + return provider(SECRET, Duration.ofMinutes(30)); + } + + private AccessTokenProvider provider(String secret, Duration accessTtl) { + return new AccessTokenProvider(new AuthProperties(new AuthProperties.Google(List.of("client"), null, null), new AuthProperties.Jwt(secret, "busantour", accessTtl, null))); + } + + private User user(Long id, UserRole role) { + User user = User.ofOAuth(AuthProvider.GOOGLE, "sub", "a@example.com", "동준", null, role); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + @Test + @DisplayName("발급한 토큰에서 사용자 ID와 권한을 되읽는다") + void issuesAndParsesToken() { + AccessTokenProvider provider = provider(); + + AuthenticatedUser parsed = provider.parse(provider.issue(user(42L, UserRole.ADMIN))) + .orElseThrow(); + + assertThat(parsed.id()).isEqualTo(42L); + assertThat(parsed.role()).isEqualTo(UserRole.ADMIN); + assertThat(parsed.isAdmin()).isTrue(); + } + + @Test + @DisplayName("다른 키로 서명된 토큰을 받아들이지 않는다") + void rejectsTokenSignedWithAnotherSecret() { + // 검증이 없으면 아무나 role=ADMIN 토큰을 만들어 관리자 API 를 부를 수 있다. + String forged = JWT.create() + .withIssuer("busantour") + .withSubject("42") + .withClaim("role", "ADMIN") + .withExpiresAt(Date.from(Instant.now().plusSeconds(600))) + .sign(Algorithm.HMAC256("attacker-secret-key-long-enough-0123456789")); + + assertThat(provider().parse(forged)).isEmpty(); + } + + @Test + @DisplayName("만료된 토큰을 받아들이지 않는다") + void rejectsExpiredToken() { + AccessTokenProvider expired = provider(SECRET, Duration.ofSeconds(-1)); + + assertThat(provider().parse(expired.issue(user(42L, UserRole.USER)))).isEmpty(); + } + + @Test + @DisplayName("발급자가 다른 토큰을 받아들이지 않는다") + void rejectsTokenFromAnotherIssuer() { + String other = JWT.create() + .withIssuer("someone-else") + .withSubject("42") + .withClaim("role", "USER") + .withExpiresAt(Date.from(Instant.now().plusSeconds(600))) + .sign(Algorithm.HMAC256(SECRET)); + + assertThat(provider().parse(other)).isEmpty(); + } + + @Test + @DisplayName("잘못된 토큰은 예외가 아니라 빈 값으로 다룬다") + void returnsEmptyForGarbage() { + // 인증이 필요 없는 경로에도 토큰이 실려 올 수 있다. 여기서 예외를 던지면 + // 비로그인도 볼 수 있어야 하는 화면이 깨진다. + assertThat(provider().parse("not-a-token")).isEmpty(); + assertThat(provider().parse(null)).isEmpty(); + assertThat(provider().parse(" ")).isEmpty(); + } + + @Test + @DisplayName("비밀키가 없으면 기동을 멈춘다") + void refusesToStartWithoutSecret() { + // 서명 키 없이 뜨면 누구나 원하는 사용자와 권한으로 토큰을 만들 수 있다. + assertThatThrownBy(() -> provider(null, Duration.ofMinutes(30))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("JWT_SECRET"); + assertThatThrownBy(() -> provider(" ", Duration.ofMinutes(30))) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("비밀키가 짧으면 기동을 멈춘다") + void refusesToStartWithShortSecret() { + assertThatThrownBy(() -> provider("too-short", Duration.ofMinutes(30))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("짧다"); + } +} diff --git a/src/test/java/com/server/auth/service/AuthServiceRefreshTest.java b/src/test/java/com/server/auth/service/AuthServiceRefreshTest.java new file mode 100644 index 0000000..176454f --- /dev/null +++ b/src/test/java/com/server/auth/service/AuthServiceRefreshTest.java @@ -0,0 +1,170 @@ +package com.server.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.server.auth.config.AuthProperties; +import com.server.auth.dto.AuthTokenResponse; +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.time.Duration; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +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.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +/** + * 리프레시 회전과 재사용 탐지. + * + *

여기가 뚫리면 훔친 리프레시 토큰으로 계정을 무기한 점유할 수 있다. + */ +@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 store = new LinkedHashMap<>(); + + Set keys() { + return Set.copyOf(store.keySet()); + } + + @Override + public ValueOperations opsForValue() { + return (ValueOperations) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{ValueOperations.class}, + (proxy, method, args) -> { + if ("set".equals(method.getName()) && args != null && args.length >= 2) { + store.put((String) args[0], (String) args[1]); + return null; + } + throw new UnsupportedOperationException( + "테스트 대역이 지원하지 않는 연산이다: " + method.getName()); + }); + } + + @Override + public Boolean hasKey(String key) { + return store.containsKey(key); + } + + @Override + public Boolean delete(String key) { + return store.remove(key) != null; + } + + @Override + public Long delete(Collection keys) { + return keys.stream().filter(key -> store.remove(key) != null).count(); + } + + /** + * {@code revokeAll} 이 KEYS 대신 SCAN 을 쓰므로 대역도 지원해야 한다. + * 커서 의미는 흉내내지 않고 일치하는 키를 한 번에 돌려준다. 검증하려는 것은 + * 폐기 대상 선정이지 Redis 의 커서 동작이 아니다. + */ + @Override + public Cursor scan(ScanOptions options) { + String pattern = options.getPattern(); + Iterator matched = (pattern == null ? keys() : keys(pattern)).iterator(); + return new Cursor<>() { + @Override + public boolean hasNext() { + return matched.hasNext(); + } + + @Override + public String next() { + return matched.next(); + } + + @Override + public Cursor.CursorId getId() { + return Cursor.CursorId.initial(); + } + + @Override + public long getCursorId() { + return 0; + } + + @Override + public boolean isClosed() { + return false; + } + + @Override + public long getPosition() { + return 0; + } + + @Override + public void close() { + } + }; + } + + @Override + public Set keys(String pattern) { + // refresh:{userId}:* 만 쓰므로 * 만 처리하면 충분하다. + Pattern regex = Pattern.compile(Pattern.quote(pattern).replace("*", "\\E.*\\Q")); + return store.keySet().stream() + .filter(key -> regex.matcher(key).matches()) + .collect(Collectors.toSet()); + } +} diff --git a/src/test/java/com/server/auth/service/OAuthUserRegistrarTest.java b/src/test/java/com/server/auth/service/OAuthUserRegistrarTest.java new file mode 100644 index 0000000..bd51f09 --- /dev/null +++ b/src/test/java/com/server/auth/service/OAuthUserRegistrarTest.java @@ -0,0 +1,124 @@ +package com.server.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; + +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.time.LocalDateTime; +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.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@DisplayName("구글 계정 등록") +class OAuthUserRegistrarTest { + + @Autowired + private UserRepository userRepository; + + private OAuthUserRegistrar registrar() { + return new OAuthUserRegistrar(userRepository); + } + + private GoogleIdentity identity(String subject, String email, String name) { + return new GoogleIdentity(subject, email, name, "https://example.com/p.png"); + } + + @Test + @DisplayName("처음 로그인하면 사용자를 만든다") + void createsUserOnFirstLogin() { + User user = registrar().register(identity("sub-1", "a@example.com", "동준")); + + assertThat(user.getId()).isNotNull(); + assertThat(user.getProvider()).isEqualTo(AuthProvider.GOOGLE); + assertThat(user.getProviderId()).isEqualTo("sub-1"); + assertThat(user.getEmail()).isEqualTo("a@example.com"); + assertThat(user.getNickname()).isEqualTo("동준"); + assertThat(user.getRole()).isEqualTo(UserRole.USER); + } + + @Test + @DisplayName("같은 sub 로 다시 로그인하면 사용자를 새로 만들지 않는다") + void reusesUserOnSecondLogin() { + User first = registrar().register(identity("sub-1", "a@example.com", "동준")); + User second = registrar().register(identity("sub-1", "a@example.com", "동준")); + + assertThat(second.getId()).isEqualTo(first.getId()); + } + + @Test + @DisplayName("이메일이 바뀌어도 sub 가 같으면 같은 사용자다") + void tracksUserBySubjectNotEmail() { + // 이메일은 바뀔 수 있어 계정을 잇는 기준으로 쓰지 않는다. + User first = registrar().register(identity("sub-1", "old@example.com", "동준")); + User second = registrar().register(identity("sub-1", "new@example.com", "동준")); + + assertThat(second.getId()).isEqualTo(first.getId()); + assertThat(second.getEmail()).isEqualTo("new@example.com"); + } + + @Test + @DisplayName("사용자가 바꾼 닉네임을 구글 이름으로 되돌리지 않는다") + void keepsUserChosenNickname() { + User user = registrar().register(identity("sub-1", "a@example.com", "구글이름")); + user.updateProfile("내가정한닉", user.getProfileImageUrl()); + + User again = registrar().register(identity("sub-1", "a@example.com", "구글이름")); + + assertThat(again.getNickname()).isEqualTo("내가정한닉"); + } + + @Test + @DisplayName("닉네임이 겹치면 다른 값을 만들어 가입시킨다") + void avoidsNicknameCollision() { + // 확보하지 않으면 첫 로그인이 uk_users_nickname_active 위반으로 실패한다. + // 사용자에게는 이름이 겹쳤을 뿐인데 가입이 안 되는 것으로 보인다. + registrar().register(identity("sub-1", "a@example.com", "동준")); + User second = registrar().register(identity("sub-2", "b@example.com", "동준")); + + assertThat(second.getNickname()).isNotEqualTo("동준").startsWith("동준"); + assertThat(second.getId()).isNotNull(); + } + + @Test + @DisplayName("이름이 없으면 기본 닉네임을 쓴다") + void fallsBackWhenNameIsMissing() { + User user = registrar().register(identity("sub-1", "a@example.com", null)); + + assertThat(user.getNickname()).isNotBlank(); + } + + @Test + @DisplayName("로그인은 DB 로 준 관리자 권한을 건드리지 않는다") + void loginDoesNotOverwriteRole() { + // 관리자 지정은 DB 에서 한다. 로그인이 권한을 다시 계산하면 UPDATE 로 준 권한이 + // 다음 로그인에 사라져, DB 로 관리자를 만드는 방법 자체가 성립하지 않는다. + User user = registrar().register(identity("sub-1", "a@example.com", "동준")); + user.changeRole(UserRole.ADMIN); + userRepository.saveAndFlush(user); + + User after = registrar().register(identity("sub-1", "a@example.com", "동준")); + + assertThat(after.getRole()).isEqualTo(UserRole.ADMIN); + } + + @Test + @DisplayName("탈퇴한 계정으로 다시 로그인하면 새 사용자로 만든다") + void createsNewUserAfterWithdrawal() { + User first = registrar().register(identity("sub-1", "a@example.com", "동준")); + first.delete(LocalDateTime.now()); + userRepository.saveAndFlush(first); + + User second = registrar().register(identity("sub-1", "a@example.com", "동준")); + + assertThat(second.getId()).isNotEqualTo(first.getId()); + } +} diff --git a/src/test/java/com/server/auth/service/RefreshTokenStoreTest.java b/src/test/java/com/server/auth/service/RefreshTokenStoreTest.java new file mode 100644 index 0000000..744b1b9 --- /dev/null +++ b/src/test/java/com/server/auth/service/RefreshTokenStoreTest.java @@ -0,0 +1,107 @@ +package com.server.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.server.auth.config.AuthProperties; +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.core.StringRedisTemplate; + +/** + * 리프레시 토큰 저장소. + * + *

살아 있는 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 receivedOwner = new AtomicReference<>(); + + @BeforeEach + void startStub() throws IOException { + receivedOwner.set(null); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + receivedOwner.set(exchange.getRequestHeaders().getFirst(OWNER_HEADER)); + exchange.getRequestBody().readAllBytes(); + byte[] body = scheduleJson(); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(201, body.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + }); + server.start(); + } + + @AfterEach + void tearDown() { + server.stop(0); + SecurityContextHolder.clearContext(); + } + + private static byte[] scheduleJson() { + return ("{\"id\":\"" + UUID.randomUUID() + "\",\"status\":\"READY\",\"days\":[]}") + .getBytes(StandardCharsets.UTF_8); + } + + private FastApiScheduleClient client() { + FastApiScheduleProperties properties = new FastApiScheduleProperties( + true, + "http://127.0.0.1:" + server.getAddress().getPort(), + Duration.ofSeconds(3), + Duration.ofSeconds(15)); + return new FastApiScheduleClient( + new FastApiScheduleConfig().fastApiScheduleRestClient(properties), + properties, + new ObjectMapper()); + } + + private void loginAs(Long userId) { + AuthenticatedUser user = new AuthenticatedUser(userId, UserRole.USER); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + user, null, List.of(new SimpleGrantedAuthority("ROLE_USER")))); + } + + @Test + @DisplayName("로그인한 사용자의 ID를 헤더로 보낸다") + void sendsOwnerHeaderWhenLoggedIn() { + client().createScheduleFromPreview( + new SchedulePreviewScheduleRequest(UUID.randomUUID()), "key-1", 42L); + + assertThat(receivedOwner.get()).isEqualTo("42"); + } + + @Test + @DisplayName("로그인하지 않았으면 헤더를 붙이지 않는다") + void omitsOwnerHeaderWhenAnonymous() { + // 인가를 켜기 전이라 비로그인 생성이 가능하다. 이때는 소유자 없는 일정으로 저장된다. + client().createScheduleFromPreview( + new SchedulePreviewScheduleRequest(UUID.randomUUID()), "key-1", null); + + assertThat(receivedOwner.get()).isNull(); + } + + @Test + @DisplayName("SecurityContext 의 사용자를 그대로 읽는다") + void readsUserFromSecurityContext() { + loginAs(7L); + + assertThat(com.server.auth.web.CurrentUser.idOrNull()).isEqualTo(7L); + } + + @Test + @DisplayName("인증 정보가 없으면 사용자 ID는 null 이다") + void returnsNullWithoutAuthentication() { + assertThat(com.server.auth.web.CurrentUser.idOrNull()).isNull(); + } +} diff --git a/src/test/java/com/server/question/controller/QuestionControllerTest.java b/src/test/java/com/server/question/controller/QuestionControllerTest.java index 3e3a4ab..c2646b6 100644 --- a/src/test/java/com/server/question/controller/QuestionControllerTest.java +++ b/src/test/java/com/server/question/controller/QuestionControllerTest.java @@ -2,11 +2,13 @@ import com.server.question.dto.QuestionResponse; import com.server.question.dto.TripQuestionsResponse; +import com.server.auth.service.AccessTokenProvider; import com.server.question.service.QuestionService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import com.server.answer.dto.AnswerResponse; @@ -20,11 +22,19 @@ @WebMvcTest(QuestionController.class) @AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") class QuestionControllerTest { @MockitoBean private QuestionService questionService; + /** + * 슬라이스 테스트는 서비스 계층을 싣지 않는다. SecurityConfig 가 인증 필터를 거치면서 + * 필터가 의존하는 토큰 제공자가 필요해진다. addFilters = false 여도 빈은 만들어진다. + */ + @MockitoBean + private AccessTokenProvider accessTokenProvider; + @Autowired private MockMvc mockMvc; diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index ed1ef31..ab22c71 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -25,3 +25,9 @@ app: enabled: false scheduler: enabled: false + auth: + jwt: + # 테스트 전용 고정값. 운영 비밀값과 무관하다. + secret: test-only-secret-key-not-used-anywhere-else-0123456789 + google: + client-ids: test-client-id