diff --git a/.github/workflows/cd-dev.yml b/.github/workflows/cd-dev.yml index e5358f4..18fb516 100644 --- a/.github/workflows/cd-dev.yml +++ b/.github/workflows/cd-dev.yml @@ -62,6 +62,14 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Resolve application version + run: | + chmod +x gradlew + APP_VERSION=$(./gradlew -q printVersion) + SHORT_SHA=${GITHUB_SHA::12} + echo "APP_VERSION=$APP_VERSION" >> "$GITHUB_ENV" + echo "APP_IMAGE_TAG=${APP_VERSION}-dev-${SHORT_SHA}" >> "$GITHUB_ENV" + - name: Build and test direct push if: ${{ steps.push-source.outputs.direct_push == 'true' }} run: | @@ -83,13 +91,19 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build & push image (arm64, :dev) + - name: Build & push versioned image (arm64) uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . platforms: linux/arm64 push: true - tags: ghcr.io/nalssilog/api:dev + tags: | + ghcr.io/nalssilog/api:dev + ghcr.io/nalssilog/api:${{ env.APP_IMAGE_TAG }} + ghcr.io/nalssilog/api:sha-${{ github.sha }} + build-args: | + APP_VERSION=${{ env.APP_VERSION }} + VCS_REF=${{ github.sha }} - name: Open SSH (add runner IP to SG) env: @@ -124,6 +138,8 @@ jobs: - name: Write .env.dev & deploy on EC2 uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5 env: + APP_VERSION: ${{ env.APP_VERSION }} + APP_IMAGE_TAG: ${{ env.APP_IMAGE_TAG }} GHCR_USERNAME: ${{ github.actor }} GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} DB_HOST: ${{ secrets.DEV_DB_HOST }} @@ -135,6 +151,10 @@ jobs: OAUTH_GOOGLE_CLIENT_SECRET: ${{ secrets.OAUTH_GOOGLE_CLIENT_SECRET }} OAUTH_KAKAO_CLIENT_ID: ${{ secrets.OAUTH_KAKAO_CLIENT_ID }} OAUTH_KAKAO_CLIENT_SECRET: ${{ secrets.OAUTH_KAKAO_CLIENT_SECRET }} + APPLE_CLIENT_ID: ${{ secrets.DEV_OAUTH_APPLE_CLIENT_ID }} + APPLE_TEAM_ID: ${{ secrets.DEV_OAUTH_APPLE_TEAM_ID }} + APPLE_KEY_ID: ${{ secrets.DEV_OAUTH_APPLE_KEY_ID }} + APPLE_PRIVATE_KEY_BASE64: ${{ secrets.DEV_OAUTH_APPLE_PRIVATE_KEY_BASE64 }} DEV_R2_PUBLIC_BASE_URL: ${{ secrets.DEV_R2_PUBLIC_BASE_URL }} DEV_R2_ENDPOINT: ${{ secrets.DEV_R2_ENDPOINT }} DEV_R2_BUCKET: ${{ secrets.DEV_R2_BUCKET }} @@ -144,18 +164,36 @@ jobs: host: ${{ secrets.EC2_HOST_DEV }} username: ${{ secrets.EC2_USER_DEV }} key: ${{ secrets.EC2_SSH_KEY_DEV }} - envs: GHCR_USERNAME,GHCR_TOKEN,DB_HOST,DB_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,OAUTH_GOOGLE_CLIENT_ID,OAUTH_GOOGLE_CLIENT_SECRET,OAUTH_KAKAO_CLIENT_ID,OAUTH_KAKAO_CLIENT_SECRET,DEV_R2_PUBLIC_BASE_URL,DEV_R2_ENDPOINT,DEV_R2_BUCKET,DEV_R2_ACCESS_KEY,DEV_R2_SECRET_KEY + envs: APP_VERSION,APP_IMAGE_TAG,GHCR_USERNAME,GHCR_TOKEN,DB_HOST,DB_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,OAUTH_GOOGLE_CLIENT_ID,OAUTH_GOOGLE_CLIENT_SECRET,OAUTH_KAKAO_CLIENT_ID,OAUTH_KAKAO_CLIENT_SECRET,APPLE_CLIENT_ID,APPLE_TEAM_ID,APPLE_KEY_ID,APPLE_PRIVATE_KEY_BASE64,DEV_R2_PUBLIC_BASE_URL,DEV_R2_ENDPOINT,DEV_R2_BUCKET,DEV_R2_ACCESS_KEY,DEV_R2_SECRET_KEY script: | set -eu cd /home/ubuntu/nalssilog-dev-api - for name in GHCR_USERNAME GHCR_TOKEN DB_HOST DB_PORT DB_NAME DB_USERNAME DB_PASSWORD DEV_R2_PUBLIC_BASE_URL DEV_R2_ENDPOINT DEV_R2_BUCKET DEV_R2_ACCESS_KEY DEV_R2_SECRET_KEY; do + for name in APP_VERSION APP_IMAGE_TAG GHCR_USERNAME GHCR_TOKEN DB_HOST DB_PORT DB_NAME DB_USERNAME DB_PASSWORD DEV_R2_PUBLIC_BASE_URL DEV_R2_ENDPOINT DEV_R2_BUCKET DEV_R2_ACCESS_KEY DEV_R2_SECRET_KEY; do if [ -z "$(printenv "$name")" ]; then echo "Missing required deployment value: $name" exit 1 fi done + APPLE_VALUE_COUNT=0 + for name in APPLE_CLIENT_ID APPLE_TEAM_ID APPLE_KEY_ID APPLE_PRIVATE_KEY_BASE64; do + if [ -n "$(printenv "$name" 2>/dev/null || true)" ]; then + APPLE_VALUE_COUNT=$((APPLE_VALUE_COUNT + 1)) + fi + done + if [ "$APPLE_VALUE_COUNT" -ne 0 ] && [ "$APPLE_VALUE_COUNT" -ne 4 ]; then + echo "Apple OAuth secrets must be configured all together." + exit 1 + fi + ACTIVE_PROFILES=dev + if [ "$APPLE_VALUE_COUNT" -eq 4 ]; then + ACTIVE_PROFILES=dev,apple + fi + export SPRING_PROFILES_ACTIVE=$ACTIVE_PROFILES umask 077 cat > .env.dev </dev/null 2>&1 || true' EXIT - docker compose -f compose-dev.yml pull - docker compose -f compose-dev.yml up -d + docker compose --env-file .env.dev -f compose-dev.yml pull + docker compose --env-file .env.dev -f compose-dev.yml up -d healthy=false for attempt in $(seq 1 30); do if curl --fail --silent --max-time 3 http://127.0.0.1:8080/api/health >/dev/null; then diff --git a/.github/workflows/cd-prod.yml b/.github/workflows/cd-prod.yml index 867224c..50d8d30 100644 --- a/.github/workflows/cd-prod.yml +++ b/.github/workflows/cd-prod.yml @@ -1,6 +1,6 @@ name: CD (prod) -# main PR CI 통과 후 merge → prod 이미지(ghcr :prod) → prod EC2 배포. +# main PR CI 통과 후 merge → 버전·커밋 태그 이미지 생성 → 해당 불변 태그로 prod EC2 배포. on: push: branches: [main] @@ -53,6 +53,14 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Resolve application version + run: | + chmod +x gradlew + APP_VERSION=$(./gradlew -q printVersion) + SHORT_SHA=${GITHUB_SHA::12} + echo "APP_VERSION=$APP_VERSION" >> "$GITHUB_ENV" + echo "APP_IMAGE_TAG=${APP_VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV" + - name: Build bootJar run: | chmod +x gradlew @@ -68,13 +76,20 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build & push image (arm64, :prod) + - name: Build & push versioned image (arm64) uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . platforms: linux/arm64 push: true - tags: ghcr.io/nalssilog/api:prod + tags: | + ghcr.io/nalssilog/api:prod + ghcr.io/nalssilog/api:${{ env.APP_VERSION }} + ghcr.io/nalssilog/api:${{ env.APP_IMAGE_TAG }} + ghcr.io/nalssilog/api:sha-${{ github.sha }} + build-args: | + APP_VERSION=${{ env.APP_VERSION }} + VCS_REF=${{ github.sha }} # GitHub 러너 IP 는 매번 바뀌므로 SSH 전에 SG 22번에 추가, 끝나면 제거(항상). (aws CLI 는 러너에 기본 설치) - name: Open SSH (add runner IP to SG) @@ -110,6 +125,8 @@ jobs: - name: Write .env.prod & deploy on EC2 uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5 env: + APP_VERSION: ${{ env.APP_VERSION }} + APP_IMAGE_TAG: ${{ env.APP_IMAGE_TAG }} GHCR_USERNAME: ${{ github.actor }} GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} DB_HOST: ${{ secrets.PROD_DB_HOST }} @@ -124,6 +141,10 @@ jobs: OAUTH_GOOGLE_CLIENT_SECRET: ${{ secrets.OAUTH_GOOGLE_CLIENT_SECRET }} OAUTH_KAKAO_CLIENT_ID: ${{ secrets.OAUTH_KAKAO_CLIENT_ID }} OAUTH_KAKAO_CLIENT_SECRET: ${{ secrets.OAUTH_KAKAO_CLIENT_SECRET }} + APPLE_CLIENT_ID: ${{ secrets.PROD_OAUTH_APPLE_CLIENT_ID }} + APPLE_TEAM_ID: ${{ secrets.PROD_OAUTH_APPLE_TEAM_ID }} + APPLE_KEY_ID: ${{ secrets.PROD_OAUTH_APPLE_KEY_ID }} + APPLE_PRIVATE_KEY_BASE64: ${{ secrets.PROD_OAUTH_APPLE_PRIVATE_KEY_BASE64 }} PROD_R2_PUBLIC_BASE_URL: ${{ secrets.PROD_R2_PUBLIC_BASE_URL }} PROD_R2_ENDPOINT: ${{ secrets.PROD_R2_ENDPOINT }} PROD_R2_BUCKET: ${{ secrets.PROD_R2_BUCKET }} @@ -133,18 +154,36 @@ jobs: host: ${{ secrets.EC2_HOST_PROD }} username: ${{ secrets.EC2_USER_PROD }} key: ${{ secrets.EC2_SSH_KEY_PROD }} - envs: GHCR_USERNAME,GHCR_TOKEN,DB_HOST,DB_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,REDIS_HOST,REDIS_PORT,REDIS_PASSWORD,OAUTH_GOOGLE_CLIENT_ID,OAUTH_GOOGLE_CLIENT_SECRET,OAUTH_KAKAO_CLIENT_ID,OAUTH_KAKAO_CLIENT_SECRET,PROD_R2_PUBLIC_BASE_URL,PROD_R2_ENDPOINT,PROD_R2_BUCKET,PROD_R2_ACCESS_KEY,PROD_R2_SECRET_KEY + envs: APP_VERSION,APP_IMAGE_TAG,GHCR_USERNAME,GHCR_TOKEN,DB_HOST,DB_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,REDIS_HOST,REDIS_PORT,REDIS_PASSWORD,OAUTH_GOOGLE_CLIENT_ID,OAUTH_GOOGLE_CLIENT_SECRET,OAUTH_KAKAO_CLIENT_ID,OAUTH_KAKAO_CLIENT_SECRET,APPLE_CLIENT_ID,APPLE_TEAM_ID,APPLE_KEY_ID,APPLE_PRIVATE_KEY_BASE64,PROD_R2_PUBLIC_BASE_URL,PROD_R2_ENDPOINT,PROD_R2_BUCKET,PROD_R2_ACCESS_KEY,PROD_R2_SECRET_KEY script: | set -eu cd /home/ubuntu/nalssilog-prod-api - for name in GHCR_USERNAME GHCR_TOKEN DB_HOST DB_PORT DB_NAME DB_USERNAME DB_PASSWORD REDIS_HOST REDIS_PORT REDIS_PASSWORD PROD_R2_PUBLIC_BASE_URL PROD_R2_ENDPOINT PROD_R2_BUCKET PROD_R2_ACCESS_KEY PROD_R2_SECRET_KEY; do + for name in APP_VERSION APP_IMAGE_TAG GHCR_USERNAME GHCR_TOKEN DB_HOST DB_PORT DB_NAME DB_USERNAME DB_PASSWORD REDIS_HOST REDIS_PORT REDIS_PASSWORD PROD_R2_PUBLIC_BASE_URL PROD_R2_ENDPOINT PROD_R2_BUCKET PROD_R2_ACCESS_KEY PROD_R2_SECRET_KEY; do if [ -z "$(printenv "$name")" ]; then echo "Missing required deployment value: $name" exit 1 fi done + APPLE_VALUE_COUNT=0 + for name in APPLE_CLIENT_ID APPLE_TEAM_ID APPLE_KEY_ID APPLE_PRIVATE_KEY_BASE64; do + if [ -n "$(printenv "$name" 2>/dev/null || true)" ]; then + APPLE_VALUE_COUNT=$((APPLE_VALUE_COUNT + 1)) + fi + done + if [ "$APPLE_VALUE_COUNT" -ne 0 ] && [ "$APPLE_VALUE_COUNT" -ne 4 ]; then + echo "Apple OAuth secrets must be configured all together." + exit 1 + fi + ACTIVE_PROFILES=prod + if [ "$APPLE_VALUE_COUNT" -eq 4 ]; then + ACTIVE_PROFILES=prod,apple + fi + export SPRING_PROFILES_ACTIVE=$ACTIVE_PROFILES umask 077 cat > .env.prod </dev/null 2>&1 || true' EXIT - docker compose -f compose-prod.yml pull - docker compose -f compose-prod.yml up -d + docker compose --env-file .env.prod -f compose-prod.yml pull + docker compose --env-file .env.prod -f compose-prod.yml up -d healthy=false for attempt in $(seq 1 30); do if curl --fail --silent --max-time 3 http://127.0.0.1:8081/api/health >/dev/null; then diff --git a/Dockerfile b/Dockerfile index 058937c..b234c15 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,14 @@ # JAR 은 아키텍처 무관(JVM 바이트코드)이고 base 는 multi-arch 라, buildx --platform linux/arm64 로 t4g(ARM) 이미지 생성 가능. FROM eclipse-temurin:25-jre +ARG APP_VERSION=development +ARG VCS_REF=unknown + +LABEL org.opencontainers.image.title="nalssilog-api" \ + org.opencontainers.image.version="${APP_VERSION}" \ + org.opencontainers.image.revision="${VCS_REF}" \ + org.opencontainers.image.source="https://github.com/nalssilog/api" + WORKDIR /app # 애플리케이션은 특권이 필요 없으므로 고정 UID/GID의 비-root 사용자로 실행한다. diff --git a/app/build.gradle b/app/build.gradle index 7d78bbd..c0d98f6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,6 +2,12 @@ plugins { id 'org.springframework.boot' } +springBoot { + buildInfo { + excludes = ['time'] + } +} + dependencies { implementation project(':common') implementation project(':auth') diff --git a/app/src/main/java/com/nalssilog/app/api/GlobalExceptionHandler.java b/app/src/main/java/com/nalssilog/app/api/GlobalExceptionHandler.java index ce3a02f..4eac030 100644 --- a/app/src/main/java/com/nalssilog/app/api/GlobalExceptionHandler.java +++ b/app/src/main/java/com/nalssilog/app/api/GlobalExceptionHandler.java @@ -1,19 +1,23 @@ package com.nalssilog.app.api; -import com.nalssilog.auth.config.AuthCookieManager; -import com.nalssilog.auth.domain.RefreshRejectedException; +import com.nalssilog.auth.core.RefreshRejectedException; +import com.nalssilog.auth.web.AuthCookieManager; import com.nalssilog.common.exception.ErrorCode; import com.nalssilog.common.exception.ErrorResponse; import com.nalssilog.common.exception.NalssiLogException; import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.ConstraintViolationException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.servlet.resource.NoResourceFoundException; @Slf4j @@ -33,6 +37,7 @@ public ErrorResponse handleNalssiLogException( } ErrorCode errorCode = exception.getErrorCode(); + response.setStatus(errorCode.getStatus().value()); log.warn("NalssiLogException [{}] {} (status={})", errorCode.getCode(), exception.getMessage(), errorCode.getStatus().value()); @@ -51,6 +56,19 @@ public ErrorResponse handleValidationException(MethodArgumentNotValidException e return new ErrorResponse("VALIDATION_ERROR", message); } + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler({ + ConstraintViolationException.class, + HandlerMethodValidationException.class, + MissingServletRequestParameterException.class, + MethodArgumentTypeMismatchException.class + }) + public ErrorResponse handleRequestParameterValidation(Exception exception) { + return new ErrorResponse( + "VALIDATION_ERROR", + "요청 파라미터가 올바르지 않습니다."); + } + @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(HttpMessageNotReadableException.class) public ErrorResponse handleNotReadable(HttpMessageNotReadableException exception) { diff --git a/app/src/main/java/com/nalssilog/app/api/HealthController.java b/app/src/main/java/com/nalssilog/app/api/HealthController.java index cc2a9bd..f5344ee 100644 --- a/app/src/main/java/com/nalssilog/app/api/HealthController.java +++ b/app/src/main/java/com/nalssilog/app/api/HealthController.java @@ -1,5 +1,7 @@ package com.nalssilog.app.api; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.info.BuildProperties; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @@ -7,13 +9,16 @@ * 로드밸런서·모니터링용 라이브니스 체크. 의존 상태 점검이 필요하면 Actuator 도입을 고려한다. */ @RestController +@RequiredArgsConstructor public class HealthController { + private final BuildProperties buildProperties; + @GetMapping("/api/health") public HealthResponse health() { - return new HealthResponse("UP"); + return new HealthResponse("UP", buildProperties.getVersion()); } - public record HealthResponse(String status) { + public record HealthResponse(String status, String version) { } } diff --git a/app/src/main/java/com/nalssilog/app/config/SchedulingConfig.java b/app/src/main/java/com/nalssilog/app/config/SchedulingConfig.java new file mode 100644 index 0000000..7ff3b38 --- /dev/null +++ b/app/src/main/java/com/nalssilog/app/config/SchedulingConfig.java @@ -0,0 +1,9 @@ +package com.nalssilog.app.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class SchedulingConfig { +} diff --git a/app/src/main/resources/db/migration/V10__add_popular_location_snapshots.sql b/app/src/main/resources/db/migration/V10__add_popular_location_snapshots.sql new file mode 100644 index 0000000..29ae429 --- /dev/null +++ b/app/src/main/resources/db/migration/V10__add_popular_location_snapshots.sql @@ -0,0 +1,55 @@ +CREATE TABLE popular_location_snapshot ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + calculated_at timestamp(6) with time zone NOT NULL, + window_started_at timestamp(6) with time zone NOT NULL, + window_ended_at timestamp(6) with time zone NOT NULL, + ranking_limit integer NOT NULL, + algorithm_version character varying(40) NOT NULL, + CONSTRAINT chk_popular_location_snapshot_window + CHECK (window_started_at < window_ended_at), + CONSTRAINT chk_popular_location_snapshot_limit + CHECK (ranking_limit BETWEEN 1 AND 20) +); + +CREATE INDEX idx_popular_location_snapshot_calculated + ON popular_location_snapshot (calculated_at); + +CREATE TABLE popular_location_rank ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + snapshot_id bigint NOT NULL, + location_id bigint NOT NULL, + ranking_position integer NOT NULL, + previous_ranking_position integer, + rank_change integer, + movement character varying(10) NOT NULL, + unique_reporter_count bigint NOT NULL, + report_count bigint NOT NULL, + latest_report_at timestamp(6) with time zone NOT NULL, + CONSTRAINT fk_popular_location_rank_snapshot + FOREIGN KEY (snapshot_id) + REFERENCES popular_location_snapshot (id) + ON DELETE CASCADE, + CONSTRAINT fk_popular_location_rank_location + FOREIGN KEY (location_id) + REFERENCES location (id), + CONSTRAINT uk_popular_location_rank_snapshot_position + UNIQUE (snapshot_id, ranking_position), + CONSTRAINT uk_popular_location_rank_snapshot_location + UNIQUE (snapshot_id, location_id), + CONSTRAINT chk_popular_location_rank_position + CHECK (ranking_position BETWEEN 1 AND 20), + CONSTRAINT chk_popular_location_previous_rank + CHECK (previous_ranking_position IS NULL + OR previous_ranking_position BETWEEN 1 AND 20), + CONSTRAINT chk_popular_location_rank_movement + CHECK (movement IN ('NEW', 'UP', 'DOWN', 'SAME')), + CONSTRAINT chk_popular_location_rank_counts + CHECK (unique_reporter_count >= 0 AND report_count >= 1) +); + +CREATE INDEX idx_popular_location_rank_snapshot + ON popular_location_rank (snapshot_id, ranking_position); + +CREATE INDEX idx_weather_report_popular_window + ON weather_report (created_at DESC, location_id) + INCLUDE (id, author_member_id, author_anonymous_key); diff --git a/app/src/main/resources/db/migration/V11__harden_concurrent_writes.sql b/app/src/main/resources/db/migration/V11__harden_concurrent_writes.sql new file mode 100644 index 0000000..fbc8b7f --- /dev/null +++ b/app/src/main/resources/db/migration/V11__harden_concurrent_writes.sql @@ -0,0 +1,19 @@ +ALTER TABLE social_account + ADD CONSTRAINT uk_social_account_member_provider + UNIQUE (member_id, provider); + +ALTER TABLE popular_location_snapshot + ADD CONSTRAINT uk_popular_location_snapshot_calculation + UNIQUE ( + calculated_at, + window_started_at, + ranking_limit, + algorithm_version + ); + +CREATE TABLE popular_location_snapshot_lock ( + id bigint PRIMARY KEY +); + +INSERT INTO popular_location_snapshot_lock (id) +VALUES (1); diff --git a/app/src/main/resources/db/migration/V7__add_mobile_guest_credentials.sql b/app/src/main/resources/db/migration/V7__add_mobile_guest_credentials.sql new file mode 100644 index 0000000..352a792 --- /dev/null +++ b/app/src/main/resources/db/migration/V7__add_mobile_guest_credentials.sql @@ -0,0 +1,18 @@ +CREATE TABLE mobile_guest_credential ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + created_at timestamp(6) with time zone NOT NULL, + updated_at timestamp(6) with time zone NOT NULL, + token_hash character varying(64) NOT NULL, + anonymous_key character varying(36) NOT NULL, + expires_at timestamp(6) with time zone NOT NULL, + revoked_at timestamp(6) with time zone +); + +ALTER TABLE mobile_guest_credential + ADD CONSTRAINT uk_mobile_guest_token_hash UNIQUE (token_hash); + +ALTER TABLE mobile_guest_credential + ADD CONSTRAINT uk_mobile_guest_anonymous_key UNIQUE (anonymous_key); + +CREATE INDEX idx_mobile_guest_expires_at + ON mobile_guest_credential (expires_at); diff --git a/app/src/main/resources/db/migration/V8__allow_apple_social_provider.sql b/app/src/main/resources/db/migration/V8__allow_apple_social_provider.sql new file mode 100644 index 0000000..efa69f6 --- /dev/null +++ b/app/src/main/resources/db/migration/V8__allow_apple_social_provider.sql @@ -0,0 +1,6 @@ +ALTER TABLE social_account + DROP CONSTRAINT social_account_provider_check; + +ALTER TABLE social_account + ADD CONSTRAINT social_account_provider_check + CHECK (provider IN ('GOOGLE', 'KAKAO', 'NAVER', 'APPLE')); diff --git a/app/src/main/resources/db/migration/V9__optimize_location_search.sql b/app/src/main/resources/db/migration/V9__optimize_location_search.sql new file mode 100644 index 0000000..ed3366a --- /dev/null +++ b/app/src/main/resources/db/migration/V9__optimize_location_search.sql @@ -0,0 +1,17 @@ +-- 한 글자 검색은 prefix 인덱스로, 두 글자 이상의 부분 검색은 trigram 인덱스로 처리한다. +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE INDEX idx_location_sido_prefix + ON location (sido text_pattern_ops); + +CREATE INDEX idx_location_sigungu_prefix + ON location (sigungu text_pattern_ops); + +CREATE INDEX idx_location_dong_prefix + ON location (dong text_pattern_ops); + +CREATE INDEX idx_location_label_prefix + ON location ((sido || ' ' || sigungu || ' ' || dong) text_pattern_ops); + +CREATE INDEX idx_location_label_trgm + ON location USING gin ((sido || ' ' || sigungu || ' ' || dong) gin_trgm_ops); diff --git a/app/src/test/java/com/nalssilog/app/api/GlobalExceptionHandlerTest.java b/app/src/test/java/com/nalssilog/app/api/GlobalExceptionHandlerTest.java index d24656d..893c55a 100644 --- a/app/src/test/java/com/nalssilog/app/api/GlobalExceptionHandlerTest.java +++ b/app/src/test/java/com/nalssilog/app/api/GlobalExceptionHandlerTest.java @@ -2,10 +2,10 @@ import static org.assertj.core.api.Assertions.assertThat; -import com.nalssilog.auth.config.AuthCookieManager; import com.nalssilog.auth.config.AuthProperties; -import com.nalssilog.auth.domain.AuthErrorCode; -import com.nalssilog.auth.domain.RefreshRejectedException; +import com.nalssilog.auth.core.AuthErrorCode; +import com.nalssilog.auth.core.RefreshRejectedException; +import com.nalssilog.auth.web.AuthCookieManager; import com.nalssilog.common.exception.ErrorResponse; import com.nalssilog.common.exception.NalssiLogException; import java.time.Duration; diff --git a/app/src/test/java/com/nalssilog/app/api/HealthControllerTest.java b/app/src/test/java/com/nalssilog/app/api/HealthControllerTest.java new file mode 100644 index 0000000..790b35e --- /dev/null +++ b/app/src/test/java/com/nalssilog/app/api/HealthControllerTest.java @@ -0,0 +1,26 @@ +package com.nalssilog.app.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Properties; +import org.junit.jupiter.api.Test; +import org.springframework.boot.info.BuildProperties; + +@SuppressWarnings("java:S5960") +class HealthControllerTest { + + @Test + void exposesBuildVersionWithHealthStatus() { + String expectedVersion = "test-version"; + Properties properties = new Properties(); + + properties.setProperty("version", expectedVersion); + HealthController controller = + new HealthController(new BuildProperties(properties)); + + HealthController.HealthResponse response = controller.health(); + + assertThat(response.status()).isEqualTo("UP"); + assertThat(response.version()).isEqualTo(expectedVersion); + } +} diff --git a/auth/build.gradle b/auth/build.gradle index 6561fe6..b08238a 100644 --- a/auth/build.gradle +++ b/auth/build.gradle @@ -5,8 +5,6 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-data-redis' - implementation 'com.fasterxml.jackson.core:jackson-databind' - implementation 'io.jsonwebtoken:jjwt-api:0.13.0' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0' diff --git a/auth/src/main/java/com/nalssilog/auth/api/AuthController.java b/auth/src/main/java/com/nalssilog/auth/api/AuthController.java deleted file mode 100644 index d4e00f0..0000000 --- a/auth/src/main/java/com/nalssilog/auth/api/AuthController.java +++ /dev/null @@ -1,182 +0,0 @@ -package com.nalssilog.auth.api; - -import java.io.IOException; -import java.util.List; - -import org.springframework.http.HttpStatus; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.ResponseStatus; -import org.springframework.web.bind.annotation.RestController; - -import com.nalssilog.auth.api.dto.LinkConsentResponse; -import com.nalssilog.auth.api.dto.MeResponse; -import com.nalssilog.auth.api.dto.SessionResponse; -import com.nalssilog.auth.api.dto.SignupRequest; -import com.nalssilog.auth.application.AuthService; -import com.nalssilog.auth.application.AuthService.SignupResult; -import com.nalssilog.auth.application.AuthService.SocialLinkStart; -import com.nalssilog.auth.application.TokenPair; -import com.nalssilog.auth.config.AuthCookieManager; -import com.nalssilog.auth.config.DeviceInfoResolver; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; - -@RestController -@RequestMapping("/api/auth") -@RequiredArgsConstructor -public class AuthController { - - private final AuthService authService; - private final AuthCookieManager cookieManager; - private final DeviceInfoResolver deviceInfoResolver; - - /** 소셜 로그인 진입. 내부 Spring OAuth 경로를 은닉하고 302 시킨다. */ - @GetMapping("/login/{provider}") - public void login( - @PathVariable String provider, - HttpServletResponse response - ) throws IOException { - response.sendRedirect(authService.oauthAuthorizationUrl(provider)); - } - - /** 인증 상태 조회(stateless — AT/티켓 쿠키로 판단). */ - @GetMapping("/me") - public MeResponse me( - @AuthenticationPrincipal Long memberId, - HttpServletRequest request - ) { - boolean hasAuthenticationCookie = cookieManager.readAccessToken(request).isPresent() - || cookieManager.readRefreshToken(request).isPresent(); - - return MeResponse.from(authService.me( - memberId, - cookieManager.readSignupTicket(request), - cookieManager.readLinkTicket(request), - hasAuthenticationCookie)); - } - - /** 회원가입 확정. OAuth 정보는 signup 티켓에서 읽고, Member 는 여기서 처음 생성된다. */ - @PostMapping("/signup") - public MeResponse signup( - @Valid @RequestBody SignupRequest request, - HttpServletRequest httpRequest, - HttpServletResponse response - ) { - SignupResult result = authService.signup(cookieManager.readSignupTicket(httpRequest).orElse(null), - request.agreedTerms(), deviceInfoResolver.resolve(httpRequest)); - TokenPair tokens = result.tokens(); - - cookieManager.addAuthCookies(response, tokens.accessToken(), tokens.refreshToken(), - tokens.refreshTokenMaxAge()); - cookieManager.clearSignupTicketCookie(response); - - return MeResponse.authenticated(result.member()); - } - - @PostMapping("/refresh") - public void refresh( - HttpServletRequest request, - HttpServletResponse response - ) { - TokenPair tokens = authService.refresh( - cookieManager.readRefreshToken(request).orElse(null), - deviceInfoResolver.resolve(request)); - - cookieManager.addAuthCookies( - response, tokens.accessToken(), tokens.refreshToken(), tokens.refreshTokenMaxAge()); - } - - @PostMapping("/logout") - public void logout( - HttpServletRequest request, - HttpServletResponse response - ) { - authService.logout(cookieManager.readRefreshToken(request)); - cookieManager.clearAuthCookies(response); - } - - /** 회원 탈퇴: 익명화(member) + 제보 익명화(event) + 전 기기 세션 만료 + 쿠키 정리. 세션·쿠키 때문에 auth 소유. */ - @DeleteMapping("/withdraw") - @ResponseStatus(HttpStatus.NO_CONTENT) - public void withdraw( - @AuthenticationPrincipal Long memberId, - HttpServletResponse response - ) { - authService.withdraw(memberId); - cookieManager.clearAuthCookies(response); - } - - /** 로그인된 기기 목록(current=이 기기). */ - @GetMapping("/sessions") - public List sessions( - @AuthenticationPrincipal Long memberId, - HttpServletRequest request - ) { - return authService.sessions(memberId, cookieManager.readRefreshToken(request)).stream() - .map(SessionResponse::from) - .toList(); - } - - /** 특정 기기 로그아웃. 대상이 현재 기기면 인증 쿠키까지 정리한다. */ - @DeleteMapping("/sessions/{sessionId}") - @ResponseStatus(HttpStatus.NO_CONTENT) - public void revokeSession( - @AuthenticationPrincipal Long memberId, - @PathVariable String sessionId, - HttpServletRequest request, - HttpServletResponse response - ) { - boolean revokedCurrent = authService.revokeSession( - memberId, sessionId, cookieManager.readRefreshToken(request)); - - if (revokedCurrent) { - cookieManager.clearAuthCookies(response); - } - } - - /** 설정에서 소셜 추가 연동 시작. intent 세팅 후 OAuth 진입 URL 반환(실제 연동은 성공 핸들러). */ - @PostMapping("/link/social/{provider}") - public LinkConsentResponse startSocialLink( - @AuthenticationPrincipal Long memberId, - @PathVariable String provider, - HttpServletResponse response - ) { - SocialLinkStart link = authService.startSocialLink(memberId, provider); - cookieManager.addLinkIntentCookie(response, link.intentId()); - - return new LinkConsentResponse(link.authorizationUrl()); - } - - /** 로그인-시점 연동 동의 + 기존 수단 재인증 URL 반환. */ - @PostMapping("/link/consent") - public LinkConsentResponse consentLink(HttpServletRequest request) { - return new LinkConsentResponse(authService.consentLink( - cookieManager.readLinkTicket(request).orElse(null))); - } - - @GetMapping("/link/reauth/{provider}") - public void linkReauth( - @PathVariable String provider, - HttpServletResponse response - ) throws IOException { - response.sendRedirect(authService.oauthAuthorizationUrl(provider)); - } - - @PostMapping("/link/cancel") - public void cancelLink( - HttpServletRequest request, - HttpServletResponse response - ) { - authService.cancelLink(cookieManager.readLinkTicket(request)); - cookieManager.clearLinkTicketCookie(response); - } -} diff --git a/auth/src/main/java/com/nalssilog/auth/application/JwtTokenProvider.java b/auth/src/main/java/com/nalssilog/auth/application/JwtTokenProvider.java deleted file mode 100644 index fc747d5..0000000 --- a/auth/src/main/java/com/nalssilog/auth/application/JwtTokenProvider.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.nalssilog.auth.application; - -import com.nalssilog.auth.config.AuthProperties; -import com.nalssilog.member.domain.MemberStatus; -import com.nalssilog.member.domain.Provider; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.JwtException; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.security.Keys; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.time.Instant; -import java.util.Date; -import java.util.Optional; -import javax.crypto.SecretKey; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -@Slf4j -@Component -// S2143: jjwt 빌더가 Instant 오버로드를 제공하지 않아 토큰 발급 경계에서만 java.util.Date 로 변환(내부 계산은 java.time). -@SuppressWarnings("java:S2143") -public class JwtTokenProvider { - - private final SecretKey key; - private final Duration accessTokenTtl; - - public JwtTokenProvider(AuthProperties properties) { - String secret = properties.jwt().secret(); - if (secret == null || secret.isBlank()) { - log.warn("JWT secret 이 설정되지 않아 임시 키를 생성합니다. 재시작하면 모든 액세스 토큰이 무효화됩니다."); - this.key = Jwts.SIG.HS256.key().build(); - } else { - this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); - } - this.accessTokenTtl = properties.jwt().accessTokenTtl(); - } - - public String createAccessToken(Long memberId, MemberStatus status, Provider provider) { - Instant now = Instant.now(); - - return Jwts.builder() - .subject(String.valueOf(memberId)) - .claim("status", status.name()) - .claim("provider", provider.name()) - .issuedAt(Date.from(now)) - .expiration(Date.from(now.plus(accessTokenTtl))) - .signWith(key) - .compact(); - } - - public Optional parse(String token) { - try { - Claims claims = Jwts.parser() - .verifyWith(key) - .build() - .parseSignedClaims(token) - .getPayload(); - String status = claims.get("status", String.class); - String provider = claims.get("provider", String.class); - - if (status == null || provider == null) { - return Optional.empty(); - } - - return Optional.of(new AccessTokenPayload( - Long.parseLong(claims.getSubject()), - MemberStatus.valueOf(status), - Provider.valueOf(provider) - )); - } catch (JwtException | IllegalArgumentException _) { - return Optional.empty(); - } - } - - public record AccessTokenPayload(Long memberId, MemberStatus status, Provider provider) { - } -} diff --git a/auth/src/main/java/com/nalssilog/auth/config/AuthProperties.java b/auth/src/main/java/com/nalssilog/auth/config/AuthProperties.java index 3a60eaa..464a664 100644 --- a/auth/src/main/java/com/nalssilog/auth/config/AuthProperties.java +++ b/auth/src/main/java/com/nalssilog/auth/config/AuthProperties.java @@ -1,15 +1,49 @@ package com.nalssilog.auth.config; import java.time.Duration; +import java.util.List; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; @ConfigurationProperties(prefix = "nalssilog.auth") -public record AuthProperties(Jwt jwt, Cookie cookie, Ticket ticket, Csrf csrf, Refresh refresh) { +public record AuthProperties( + Jwt jwt, + Cookie cookie, + Ticket ticket, + Csrf csrf, + Refresh refresh, + Mobile mobile, + Guest guest +) { + @ConstructorBinding public AuthProperties { if (refresh == null) { refresh = new Refresh(Duration.ofSeconds(5)); } + + String ipHmacFallback = jwt != null + && jwt.secret() != null + && !jwt.secret().isBlank() + ? jwt.secret() + : "local-mobile-auth-ip-key"; + + if (mobile == null) { + mobile = Mobile.defaults(ipHmacFallback); + } + + if (mobile.ipHmacSecret() == null + || mobile.ipHmacSecret().isBlank()) { + mobile = mobile.withIpHmacSecret(ipHmacFallback); + } + + if (guest == null) { + guest = Guest.defaults(); + } + } + + public AuthProperties(Jwt jwt, Cookie cookie, Ticket ticket, Csrf csrf, Refresh refresh) { + this(jwt, cookie, ticket, csrf, refresh, null, Guest.defaults()); } public record Jwt(String secret, Duration accessTokenTtl, Duration refreshTokenTtl) { @@ -35,4 +69,106 @@ public record Refresh(Duration retryGrace) { } } } + + public record Mobile( + List redirectUris, + Duration transactionTtl, + Duration codeTtl, + String ipHmacSecret, + List trustedProxies + ) { + + public Mobile { + redirectUris = redirectUris == null || redirectUris.isEmpty() + ? List.of() + : List.copyOf(redirectUris); + + if (transactionTtl == null || transactionTtl.isNegative() || transactionTtl.isZero()) { + transactionTtl = Duration.ofMinutes(10); + } + + if (codeTtl == null || codeTtl.isNegative() || codeTtl.isZero()) { + codeTtl = Duration.ofSeconds(90); + } + + trustedProxies = trustedProxies == null + ? List.of() + : List.copyOf(trustedProxies); + } + + private Mobile withIpHmacSecret(String secret) { + return new Mobile( + redirectUris, + transactionTtl, + codeTtl, + secret, + trustedProxies); + } + + private static Mobile defaults(String ipHmacSecret) { + return new Mobile(null, null, null, ipHmacSecret, null); + } + } + + public record Guest( + Duration ttl, + int maxIssuances, + Duration rateLimitWindow, + int globalMaxIssuances, + Duration globalRateLimitWindow, + Duration expiredRetention, + Duration cleanupInterval, + Duration cleanupInitialDelay + ) { + + public Guest { + if (ttl == null || ttl.isNegative() || ttl.isZero()) { + ttl = Duration.ofDays(365); + } + + if (maxIssuances <= 0) { + maxIssuances = 300; + } + + if (rateLimitWindow == null || rateLimitWindow.isNegative() || rateLimitWindow.isZero()) { + rateLimitWindow = Duration.ofMinutes(10); + } + + if (globalMaxIssuances <= 0) { + globalMaxIssuances = 3_000; + } + + if (globalRateLimitWindow == null + || globalRateLimitWindow.isNegative() + || globalRateLimitWindow.isZero()) { + globalRateLimitWindow = Duration.ofMinutes(1); + } + + if (expiredRetention == null || expiredRetention.isNegative()) { + expiredRetention = Duration.ofDays(7); + } + + if (cleanupInterval == null || cleanupInterval.isNegative() || cleanupInterval.isZero()) { + cleanupInterval = Duration.ofHours(6); + } + + if (cleanupInitialDelay == null + || cleanupInitialDelay.isNegative() + || cleanupInitialDelay.isZero()) { + cleanupInitialDelay = Duration.ofMinutes(1); + } + } + + public Guest( + Duration ttl, + int maxIssuances, + Duration rateLimitWindow + ) { + this(ttl, maxIssuances, rateLimitWindow, 0, null, null, null, null); + } + + private static Guest defaults() { + return new Guest(null, 0, null, 0, null, null, null, null); + } + } } diff --git a/auth/src/main/java/com/nalssilog/auth/config/JwtAuthenticationFilter.java b/auth/src/main/java/com/nalssilog/auth/config/JwtAuthenticationFilter.java deleted file mode 100644 index 7910d91..0000000 --- a/auth/src/main/java/com/nalssilog/auth/config/JwtAuthenticationFilter.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.nalssilog.auth.config; - -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.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -import com.nalssilog.auth.application.JwtTokenProvider; -import com.nalssilog.auth.application.SocialAuthPrincipal; -import com.nalssilog.common.filter.RequestLoggingFilter; -import com.nalssilog.member.domain.MemberStatus; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; - -@Component -@RequiredArgsConstructor -public class JwtAuthenticationFilter extends OncePerRequestFilter { - - private final AuthCookieManager cookieManager; - private final JwtTokenProvider jwtTokenProvider; - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { - cookieManager.readAccessToken(request) - .flatMap(jwtTokenProvider::parse) - .filter(payload -> payload.status() != MemberStatus.WITHDRAWN) - .ifPresent(payload -> { - var authentication = UsernamePasswordAuthenticationToken.authenticated( - payload.memberId(), - null, - List.of(new SimpleGrantedAuthority(SocialAuthPrincipal.roleOf(payload.status())))); - authentication.setDetails(payload.provider()); - SecurityContextHolder.getContext().setAuthentication(authentication); - // STATELESS 라 체인 종료 후 SecurityContext 가 비므로, 액세스 로그용으로 memberId 를 남겨둔다. - request.setAttribute(RequestLoggingFilter.ACTOR_MEMBER_ID, payload.memberId()); - }); - filterChain.doFilter(request, response); - } -} diff --git a/auth/src/main/java/com/nalssilog/auth/config/OAuth2LoginSuccessHandler.java b/auth/src/main/java/com/nalssilog/auth/config/OAuth2LoginSuccessHandler.java deleted file mode 100644 index 705a55b..0000000 --- a/auth/src/main/java/com/nalssilog/auth/config/OAuth2LoginSuccessHandler.java +++ /dev/null @@ -1,176 +0,0 @@ -package com.nalssilog.auth.config; - -import com.nalssilog.auth.application.AuthTokenService; -import com.nalssilog.auth.application.SocialAuthPrincipal; -import com.nalssilog.auth.application.TokenPair; -import com.nalssilog.auth.client.MemberClient; -import com.nalssilog.auth.client.OAuthUserInfo; -import com.nalssilog.auth.domain.LinkTicket; -import com.nalssilog.auth.domain.SignupTicket; -import com.nalssilog.auth.repository.AuthTicketStore; -import com.nalssilog.common.exception.NalssiLogException; -import com.nalssilog.member.application.dto.MemberInfo; -import com.nalssilog.member.domain.MemberStatus; -import com.nalssilog.member.domain.Provider; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.util.Objects; -import java.util.Optional; -import java.util.UUID; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.core.Authentication; -import org.springframework.security.web.authentication.AuthenticationSuccessHandler; -import org.springframework.stereotype.Component; - -/** 소셜 인증 성공 후 항상 `{front}/auth/callback?result=...` 로 리다이렉트. 토큰·민감정보는 URL 에 안 넣고 HttpOnly 쿠키만. */ -@Component -@RequiredArgsConstructor -public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { - - private static final String LINK_FAILED = "LINK_FAILED"; - - private final AuthTokenService authTokenService; - private final AuthCookieManager cookieManager; - private final AuthTicketStore ticketStore; - private final MemberClient memberClient; - private final AuthProperties properties; - private final DeviceInfoResolver deviceInfoResolver; - - @Value("${nalssilog.frontend.base-url}") - private String frontendBaseUrl; - - @Override - public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, - Authentication authentication) throws IOException { - SocialAuthPrincipal principal = - Objects.requireNonNull((SocialAuthPrincipal) authentication.getPrincipal(), "principal must not be null"); - - // 설정 화면에서 시작한 '소셜 추가 연동'이면 로그인 분기보다 먼저 처리한다(현재 회원에 새 소셜 연동). - Optional linkIntent = cookieManager.readLinkIntent(request); - if (linkIntent.isPresent()) { - handleSettingsLink(response, linkIntent.get(), principal); - return; - } - - switch (principal.result().outcome()) { - case EXISTING -> handleExisting(request, response, principal); - case NEW -> handleNew(response, principal); - case LINK_REQUIRED -> handleLinkRequired(response, principal); - } - } - - // 설정에서 로그인 상태로 새 소셜 추가 연동. intent 의 memberId 에 붙이고 기존 세션 유지(토큰 재발급 없음). - private void handleSettingsLink(HttpServletResponse response, String intentId, SocialAuthPrincipal principal) - throws IOException { - Optional memberId = ticketStore.findLinkIntent(intentId); - - cookieManager.clearLinkIntentCookie(response); - ticketStore.deleteLinkIntent(intentId); - - if (memberId.isEmpty()) { - response.sendRedirect(callbackUrl(LINK_FAILED)); - return; - } - - try { - memberClient.linkSocial(memberId.get(), principal.userInfo()); - response.sendRedirect(callbackUrl("LINK_SUCCESS")); - } catch (NalssiLogException e) { - response.sendRedirect(callbackUrl(LINK_FAILED) + "&code=" + e.getErrorCode().getCode()); - } - } - - private void handleExisting(HttpServletRequest request, HttpServletResponse response, - SocialAuthPrincipal principal) throws IOException { - Long memberId = principal.result().memberId(); - MemberStatus status = principal.result().status(); - - if (status == MemberStatus.WITHDRAWN) { - response.sendRedirect(callbackUrl("FAILED")); - return; - } - - Optional pendingLink = cookieManager.readLinkTicket(request); - - if (pendingLink.isPresent()) { - completeLink( - request, response, pendingLink.get(), memberId, status, principal.userInfo().provider()); - return; - } - - loginAndRedirect( - request, response, memberId, status, principal.userInfo().provider(), "SUCCESS"); - } - - // 연동 완료 시도. 명시 동의 + 대상 일치일 때만 실제 연동(방치/미동의 티켓 자동연동 방지), 아니면 일반 로그인. - private void completeLink(HttpServletRequest request, HttpServletResponse response, String ticketId, - Long authenticatedMemberId, MemberStatus status, Provider authenticatedProvider) - throws IOException { - LinkTicket ticket = ticketStore.findLink(ticketId).orElse(null); - boolean consented = ticketStore.isLinkConsented(ticketId); - - cookieManager.clearLinkTicketCookie(response); - ticketStore.deleteLink(ticketId); - ticketStore.deleteLinkConsent(ticketId); - - if (ticket == null || !consented) { - loginAndRedirect( - request, response, authenticatedMemberId, status, authenticatedProvider, "SUCCESS"); - return; - } - - if (!ticket.targetMemberId().equals(authenticatedMemberId)) { - response.sendRedirect(callbackUrl(LINK_FAILED)); - return; - } - - MemberInfo member = memberClient.linkSocial( - ticket.targetMemberId(), - new OAuthUserInfo(ticket.provider(), ticket.providerUserId(), ticket.email(), null)); - - loginAndRedirect( - request, response, member.id(), member.status(), authenticatedProvider, "LINK_SUCCESS"); - } - - private void handleNew(HttpServletResponse response, SocialAuthPrincipal principal) throws IOException { - OAuthUserInfo userInfo = principal.userInfo(); - String ticketId = UUID.randomUUID().toString(); - - ticketStore.saveSignup(ticketId, - new SignupTicket(userInfo.provider(), userInfo.providerUserId(), userInfo.email(), userInfo.socialName()), - properties.ticket().ttl()); - cookieManager.addSignupTicketCookie(response, ticketId); - - response.sendRedirect(callbackUrl("SIGNUP_REQUIRED")); - } - - private void handleLinkRequired(HttpServletResponse response, SocialAuthPrincipal principal) throws IOException { - OAuthUserInfo userInfo = principal.userInfo(); - Long targetMemberId = principal.result().memberId(); - String ticketId = UUID.randomUUID().toString(); - - ticketStore.saveLink(ticketId, - new LinkTicket(userInfo.provider(), userInfo.providerUserId(), userInfo.email(), targetMemberId, - principal.result().existingProviders()), - properties.ticket().ttl()); - cookieManager.addLinkTicketCookie(response, ticketId); - - response.sendRedirect(callbackUrl("LINK_REQUIRED")); - } - - private void loginAndRedirect(HttpServletRequest request, HttpServletResponse response, Long memberId, - MemberStatus status, Provider provider, String result) throws IOException { - TokenPair tokens = authTokenService.issue( - memberId, status, provider, deviceInfoResolver.resolve(request)); - cookieManager.addAuthCookies( - response, tokens.accessToken(), tokens.refreshToken(), tokens.refreshTokenMaxAge()); - - response.sendRedirect(callbackUrl(result)); - } - - private String callbackUrl(String result) { - return frontendBaseUrl + "/auth/callback?result=" + result; - } -} diff --git a/auth/src/main/java/com/nalssilog/auth/core/AuthErrorCode.java b/auth/src/main/java/com/nalssilog/auth/core/AuthErrorCode.java new file mode 100644 index 0000000..653aa3f --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/core/AuthErrorCode.java @@ -0,0 +1,50 @@ +package com.nalssilog.auth.core; + +import com.nalssilog.common.exception.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum AuthErrorCode implements ErrorCode { + + AUTH_ACCESS_TOKEN_EXPIRED("AUTH_ACCESS_TOKEN_EXPIRED", "액세스 토큰이 만료되었습니다.", HttpStatus.UNAUTHORIZED), + AUTH_ACCESS_TOKEN_INVALID("AUTH_ACCESS_TOKEN_INVALID", "유효하지 않은 액세스 토큰입니다.", HttpStatus.UNAUTHORIZED), + AUTH_SESSION_EXPIRED("AUTH_SESSION_EXPIRED", "인증 세션이 만료되었습니다. 다시 로그인해 주세요.", + HttpStatus.UNAUTHORIZED), + AUTH_REFRESH_REUSED("AUTH_REFRESH_REUSED", "재사용된 인증 세션이 감지되어 세션을 폐기했습니다.", + HttpStatus.UNAUTHORIZED), + TICKET_NOT_FOUND("AUTH_SESSION_EXPIRED", "인증 세션이 만료되었습니다. 다시 로그인해 주세요.", + HttpStatus.BAD_REQUEST), + UNSUPPORTED_PROVIDER("OAUTH_FAILED", "지원하지 않는 소셜 로그인입니다.", HttpStatus.BAD_REQUEST), + OAUTH_FAILED("OAUTH_FAILED", "소셜 로그인에 실패했습니다.", HttpStatus.UNAUTHORIZED), + OAUTH_CANCELLED("OAUTH_CANCELLED", "소셜 로그인이 취소되었습니다.", HttpStatus.UNAUTHORIZED), + OAUTH_EMAIL_REQUIRED("OAUTH_EMAIL_REQUIRED", "소셜 계정 이메일 제공 동의가 필요합니다.", + HttpStatus.BAD_REQUEST), + AUTH_MOBILE_CODE_INVALID("AUTH_MOBILE_CODE_INVALID", "모바일 인증 코드가 만료되었거나 유효하지 않습니다.", + HttpStatus.BAD_REQUEST), + AUTH_PKCE_VERIFICATION_FAILED("AUTH_PKCE_VERIFICATION_FAILED", "PKCE 검증에 실패했습니다.", + HttpStatus.BAD_REQUEST), + AUTH_REDIRECT_URI_INVALID("AUTH_REDIRECT_URI_INVALID", "허용되지 않은 모바일 리다이렉트 URI입니다.", + HttpStatus.BAD_REQUEST), + AUTH_MOBILE_TRANSACTION_EXPIRED("AUTH_MOBILE_TRANSACTION_EXPIRED", "모바일 로그인 요청이 만료되었습니다.", + HttpStatus.BAD_REQUEST), + AUTH_FLOW_IN_PROGRESS("AUTH_FLOW_IN_PROGRESS", "인증 요청을 처리 중입니다.", HttpStatus.CONFLICT), + AUTH_TICKET_CHANNEL_MISMATCH("AUTH_TICKET_CHANNEL_MISMATCH", "이 인증 티켓은 현재 요청에서 사용할 수 없습니다.", + HttpStatus.BAD_REQUEST), + GUEST_CREDENTIAL_INVALID("GUEST_CREDENTIAL_INVALID", "유효하지 않은 게스트 인증 정보입니다.", + HttpStatus.UNAUTHORIZED), + GUEST_CREDENTIAL_EXPIRED("GUEST_CREDENTIAL_EXPIRED", "게스트 인증 정보가 만료되었습니다.", + HttpStatus.UNAUTHORIZED), + GUEST_ISSUANCE_RATE_LIMITED("GUEST_ISSUANCE_RATE_LIMITED", "게스트 인증 정보 발급 요청이 너무 많습니다.", + HttpStatus.TOO_MANY_REQUESTS), + GUEST_ISSUANCE_UNAVAILABLE("GUEST_ISSUANCE_UNAVAILABLE", "게스트 인증 정보를 발급할 수 없습니다.", + HttpStatus.SERVICE_UNAVAILABLE), + SESSION_NOT_FOUND("SESSION_NOT_FOUND", "세션을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + ALREADY_LINKED_PROVIDER("ACCOUNT_ALREADY_LINKED", "이미 연결된 소셜 로그인입니다.", HttpStatus.CONFLICT); + + private final String code; + private final String message; + private final HttpStatus status; +} diff --git a/auth/src/main/java/com/nalssilog/auth/application/AuthService.java b/auth/src/main/java/com/nalssilog/auth/core/AuthService.java similarity index 57% rename from auth/src/main/java/com/nalssilog/auth/application/AuthService.java rename to auth/src/main/java/com/nalssilog/auth/core/AuthService.java index 6ed0ea3..f1fa1a5 100644 --- a/auth/src/main/java/com/nalssilog/auth/application/AuthService.java +++ b/auth/src/main/java/com/nalssilog/auth/core/AuthService.java @@ -1,15 +1,20 @@ -package com.nalssilog.auth.application; +package com.nalssilog.auth.core; -import com.nalssilog.auth.application.dto.DeviceInfo; -import com.nalssilog.auth.application.dto.SessionView; -import com.nalssilog.auth.client.MemberClient; -import com.nalssilog.auth.client.OAuthUserInfo; import com.nalssilog.auth.config.AuthProperties; -import com.nalssilog.auth.domain.AuthErrorCode; -import com.nalssilog.auth.domain.LinkTicket; -import com.nalssilog.auth.domain.RefreshRejectedException; -import com.nalssilog.auth.domain.SignupTicket; -import com.nalssilog.auth.repository.AuthTicketStore; +import com.nalssilog.auth.device.DeviceInfo; +import com.nalssilog.auth.member.MemberClient; +import com.nalssilog.auth.oauth.OAuthUserInfo; +import com.nalssilog.auth.ticket.AuthChannel; +import com.nalssilog.auth.ticket.AuthTicketStore.SignupClaim; +import com.nalssilog.auth.ticket.AuthTicketStore.SignupClaimStatus; +import com.nalssilog.auth.ticket.AuthTicketStore.SignupCompletion; +import com.nalssilog.auth.ticket.AuthTicketStore; +import com.nalssilog.auth.ticket.LinkTicket; +import com.nalssilog.auth.ticket.SignupTicket; +import com.nalssilog.auth.token.AuthSessionService; +import com.nalssilog.auth.token.AuthTokenService; +import com.nalssilog.auth.token.SessionView; +import com.nalssilog.auth.token.TokenPair; import com.nalssilog.common.exception.NalssiLogException; import com.nalssilog.member.application.dto.MemberInfo; import com.nalssilog.member.application.dto.TermsAgreement; @@ -19,6 +24,7 @@ import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; /** @@ -46,14 +52,18 @@ public MeState me( } Optional signup = signupTicketId.flatMap(ticketStore::findSignup); + if (signup.isPresent()) { SignupTicket ticket = signup.get(); + return MeState.signupRequired(ticket.provider(), ticket.email()); } Optional link = linkTicketId.flatMap(ticketStore::findLink); + if (link.isPresent()) { LinkTicket ticket = link.get(); + return MeState.linkRequired(ticket.provider(), ticket.email(), ticket.existingProviders()); } @@ -65,19 +75,89 @@ public MeState me( } public SignupResult signup(String ticketId, List agreedTerms, DeviceInfo device) { - SignupTicket ticket = findSignupTicket(ticketId); - MemberInfo member = memberClient.registerMember( - new OAuthUserInfo(ticket.provider(), ticket.providerUserId(), ticket.email(), ticket.socialName()), - agreedTerms); - TokenPair tokens = authTokenService.issue( - member.id(), member.status(), ticket.provider(), device); + return signup(ticketId, agreedTerms, device, AuthChannel.WEB); + } - ticketStore.deleteSignup(ticketId); + public SignupResult signupMobile( + String ticketId, + List agreedTerms, + DeviceInfo device + ) { + return signup(ticketId, agreedTerms, device, AuthChannel.MOBILE); + } - return new SignupResult(member, tokens); + private SignupResult signup( + String ticketId, + List agreedTerms, + DeviceInfo device, + AuthChannel channel + ) { + if (ticketId == null || ticketId.isBlank()) { + throw new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND); + } + + String claimId = UUID.randomUUID().toString(); + SignupClaim claim = ticketStore.claimSignup( + ticketId, + claimId, + properties.ticket().ttl()); + + if (claim.status() == SignupClaimStatus.COMPLETED) { + return replayCompletedSignup(claim.completion()); + } + + if (claim.status() == SignupClaimStatus.IN_PROGRESS) { + throw new NalssiLogException(AuthErrorCode.AUTH_FLOW_IN_PROGRESS); + } + + if (claim.status() == SignupClaimStatus.MISSING) { + throw new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND); + } + + SignupTicket ticket = claim.ticket(); + + if (ticket.effectiveChannel() != channel) { + ticketStore.releaseSignupClaim(ticketId, claimId); + throw new NalssiLogException(AuthErrorCode.AUTH_TICKET_CHANNEL_MISMATCH); + } + + try { + MemberInfo member = registerOrRecover(ticket, agreedTerms); + TokenPair tokens = authTokenService.issue( + member.id(), member.status(), ticket.provider(), device); + + ticketStore.completeSignup( + ticketId, + claimId, + new SignupCompletion( + member.id(), + ticket.provider(), + tokens.accessToken(), + tokens.refreshToken(), + tokens.refreshTokenMaxAge().toMillis(), + java.time.Instant.now().toEpochMilli()), + properties.refresh().retryGrace()); + + return new SignupResult(member, tokens); + } catch (RuntimeException exception) { + ticketStore.releaseSignupClaim(ticketId, claimId); + throw exception; + } } public TokenPair refresh(String refreshToken, DeviceInfo device) { + return refresh(refreshToken, device, true); + } + + public TokenPair refreshMobile(String refreshToken, DeviceInfo device) { + return refresh(refreshToken, device, false); + } + + private TokenPair refresh( + String refreshToken, + DeviceInfo device, + boolean clearWebCookiesOnFailure + ) { try { if (refreshToken == null || refreshToken.isBlank()) { throw new NalssiLogException(AuthErrorCode.AUTH_SESSION_EXPIRED); @@ -85,7 +165,8 @@ public TokenPair refresh(String refreshToken, DeviceInfo device) { return authTokenService.refresh(refreshToken, device); } catch (NalssiLogException exception) { - if (isTerminalRefreshError(exception)) { + if (clearWebCookiesOnFailure + && isTerminalRefreshError(exception)) { throw new RefreshRejectedException(exception); } @@ -102,16 +183,16 @@ public void withdraw(Long memberId) { authTokenService.revokeAllSessions(memberId); } - public List sessions(Long memberId, Optional refreshToken) { - return authSessionService.listSessions(memberId, currentTokenHash(refreshToken)); + public List sessions(Long memberId, String currentSessionId) { + return authSessionService.listSessions(memberId, currentSessionId); } public boolean revokeSession( Long memberId, String sessionId, - Optional refreshToken + String currentSessionId ) { - return authSessionService.revokeSession(memberId, sessionId, currentTokenHash(refreshToken)); + return authSessionService.revokeSession(memberId, sessionId, currentSessionId); } public SocialLinkStart startSocialLink(Long memberId, String provider) { @@ -123,6 +204,7 @@ public SocialLinkStart startSocialLink(Long memberId, String provider) { } String intentId = UUID.randomUUID().toString(); + ticketStore.saveLinkIntent(intentId, memberId, properties.ticket().ttl()); return new SocialLinkStart(intentId, loginUrl(target)); @@ -130,6 +212,7 @@ public SocialLinkStart startSocialLink(Long memberId, String provider) { public String consentLink(String ticketId) { LinkTicket ticket = findLinkTicket(ticketId); + ticketStore.markLinkConsented(ticketId, properties.ticket().ttl()); MemberInfo target = memberClient.getMemberInfo(ticket.targetMemberId()); @@ -160,6 +243,47 @@ private SignupTicket findSignupTicket(String ticketId) { .orElseThrow(() -> new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND)); } + private MemberInfo registerOrRecover( + SignupTicket ticket, + List agreedTerms + ) { + try { + return memberClient.registerMember( + new OAuthUserInfo( + ticket.provider(), + ticket.providerUserId(), + ticket.email(), + ticket.socialName()), + agreedTerms); + } catch (DataIntegrityViolationException exception) { + return memberClient.findMemberInfo( + ticket.provider(), + ticket.providerUserId()) + .orElseThrow(() -> exception); + } + } + + private SignupResult replayCompletedSignup(SignupCompletion completion) { + MemberInfo member = memberClient.getMemberInfo(completion.memberId()); + long elapsedMillis = Math.max( + 0, + java.time.Instant.now().toEpochMilli() + - completion.completedAtEpochMillis()); + long remainingMillis = + completion.refreshTokenMaxAgeMillis() - elapsedMillis; + + if (remainingMillis < 1_000) { + throw new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND); + } + + TokenPair tokens = new TokenPair( + completion.accessToken(), + completion.refreshToken(), + java.time.Duration.ofMillis(remainingMillis)); + + return new SignupResult(member, tokens); + } + private LinkTicket findLinkTicket(String ticketId) { if (ticketId == null || ticketId.isBlank()) { throw new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND); @@ -169,10 +293,6 @@ private LinkTicket findLinkTicket(String ticketId) { .orElseThrow(() -> new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND)); } - private String currentTokenHash(Optional refreshToken) { - return refreshToken.map(authTokenService::tokenHash).orElse(null); - } - private boolean isTerminalRefreshError(NalssiLogException exception) { return exception.getErrorCode() == AuthErrorCode.AUTH_SESSION_EXPIRED || exception.getErrorCode() == AuthErrorCode.AUTH_REFRESH_REUSED; diff --git a/auth/src/main/java/com/nalssilog/auth/domain/RefreshRejectedException.java b/auth/src/main/java/com/nalssilog/auth/core/RefreshRejectedException.java similarity index 92% rename from auth/src/main/java/com/nalssilog/auth/domain/RefreshRejectedException.java rename to auth/src/main/java/com/nalssilog/auth/core/RefreshRejectedException.java index c31ef7a..36d4941 100644 --- a/auth/src/main/java/com/nalssilog/auth/domain/RefreshRejectedException.java +++ b/auth/src/main/java/com/nalssilog/auth/core/RefreshRejectedException.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.domain; +package com.nalssilog.auth.core; import com.nalssilog.common.exception.NalssiLogException; diff --git a/auth/src/main/java/com/nalssilog/auth/application/dto/DeviceInfo.java b/auth/src/main/java/com/nalssilog/auth/device/DeviceInfo.java similarity index 86% rename from auth/src/main/java/com/nalssilog/auth/application/dto/DeviceInfo.java rename to auth/src/main/java/com/nalssilog/auth/device/DeviceInfo.java index 7cbab65..f5e765e 100644 --- a/auth/src/main/java/com/nalssilog/auth/application/dto/DeviceInfo.java +++ b/auth/src/main/java/com/nalssilog/auth/device/DeviceInfo.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.application.dto; +package com.nalssilog.auth.device; /** * 세션이 생성된 기기 정보. User-Agent 에서 파생한 표시용 이름 + 클라이언트 IP. diff --git a/auth/src/main/java/com/nalssilog/auth/config/DeviceInfoResolver.java b/auth/src/main/java/com/nalssilog/auth/device/DeviceInfoResolver.java similarity index 80% rename from auth/src/main/java/com/nalssilog/auth/config/DeviceInfoResolver.java rename to auth/src/main/java/com/nalssilog/auth/device/DeviceInfoResolver.java index 07894d6..b2fec85 100644 --- a/auth/src/main/java/com/nalssilog/auth/config/DeviceInfoResolver.java +++ b/auth/src/main/java/com/nalssilog/auth/device/DeviceInfoResolver.java @@ -1,6 +1,7 @@ -package com.nalssilog.auth.config; +package com.nalssilog.auth.device; -import com.nalssilog.auth.application.dto.DeviceInfo; +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.common.web.TrustedProxyChain; import jakarta.servlet.http.HttpServletRequest; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -15,10 +16,26 @@ public class DeviceInfoResolver { private static final int MAX_IP_LENGTH = 45; // IPv6 최대 길이 private static final int MAX_NAME_LENGTH = 60; + private final TrustedProxyChain trustedProxyChain; + + public DeviceInfoResolver(AuthProperties properties) { + this.trustedProxyChain = new TrustedProxyChain(properties.mobile().trustedProxies()); + } + public DeviceInfo resolve(HttpServletRequest request) { String userAgent = request.getHeader("User-Agent"); - return new DeviceInfo(deviceName(userAgent), clientIp(request)); + return new DeviceInfo(deviceName(userAgent), resolveIp(request)); + } + + public String resolveIp(HttpServletRequest request) { + String ip = trustedProxyChain.resolve(request); + + if (!StringUtils.hasText(ip)) { + return ""; + } + + return ip.length() > MAX_IP_LENGTH ? ip.substring(0, MAX_IP_LENGTH) : ip; } private String deviceName(String userAgent) { @@ -37,18 +54,23 @@ private String browser(String ua) { if (ua.contains("Edg")) { return "Edge"; } + if (ua.contains("SamsungBrowser")) { return "Samsung Internet"; } + if (ua.contains("OPR") || ua.contains("Opera")) { return "Opera"; } + if (ua.contains("Firefox")) { return "Firefox"; } + if (ua.contains("Chrome")) { return "Chrome"; } + if (ua.contains("Safari")) { return "Safari"; } @@ -60,18 +82,23 @@ private String os(String ua) { if (ua.contains("iPhone")) { return "iPhone"; } + if (ua.contains("iPad")) { return "iPad"; } + if (ua.contains("Android")) { return "Android"; } + if (ua.contains("Windows")) { return "Windows"; } + if (ua.contains("Mac OS X") || ua.contains("Macintosh")) { return "Mac"; } + if (ua.contains("Linux")) { return "Linux"; } @@ -79,16 +106,4 @@ private String os(String ua) { return "기타"; } - private String clientIp(HttpServletRequest request) { - String forwarded = request.getHeader("X-Forwarded-For"); - String ip = StringUtils.hasText(forwarded) - ? forwarded.split(",")[0].strip() - : request.getRemoteAddr(); - - if (!StringUtils.hasText(ip)) { - return ""; - } - - return ip.length() > MAX_IP_LENGTH ? ip.substring(0, MAX_IP_LENGTH) : ip; - } } diff --git a/auth/src/main/java/com/nalssilog/auth/domain/AuthErrorCode.java b/auth/src/main/java/com/nalssilog/auth/domain/AuthErrorCode.java deleted file mode 100644 index ce43197..0000000 --- a/auth/src/main/java/com/nalssilog/auth/domain/AuthErrorCode.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.nalssilog.auth.domain; - -import com.nalssilog.common.exception.ErrorCode; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; - -@Getter -@RequiredArgsConstructor -public enum AuthErrorCode implements ErrorCode { - - AUTH_ACCESS_TOKEN_EXPIRED("AUTH_ACCESS_TOKEN_EXPIRED", "액세스 토큰이 만료되었습니다.", HttpStatus.UNAUTHORIZED), - AUTH_SESSION_EXPIRED("AUTH_SESSION_EXPIRED", "인증 세션이 만료되었습니다. 다시 로그인해 주세요.", HttpStatus.UNAUTHORIZED), - AUTH_REFRESH_REUSED("AUTH_REFRESH_REUSED", "재사용된 인증 세션을 폐기했습니다. 다시 로그인해 주세요.", - HttpStatus.UNAUTHORIZED), - TICKET_NOT_FOUND("AUTH_SESSION_EXPIRED", "인증 세션이 만료되었습니다. 다시 로그인해 주세요.", HttpStatus.BAD_REQUEST), - UNSUPPORTED_PROVIDER("OAUTH_FAILED", "지원하지 않는 소셜 로그인입니다.", HttpStatus.BAD_REQUEST), - OAUTH_FAILED("OAUTH_FAILED", "소셜 로그인에 실패했습니다.", HttpStatus.UNAUTHORIZED), - OAUTH_CANCELLED("OAUTH_CANCELLED", "소셜 로그인이 취소되었습니다.", HttpStatus.UNAUTHORIZED), - SESSION_NOT_FOUND("SESSION_NOT_FOUND", "세션을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), - ALREADY_LINKED_PROVIDER("ACCOUNT_ALREADY_LINKED", "이미 연동된 소셜 로그인입니다.", HttpStatus.CONFLICT); - - private final String code; - private final String message; - private final HttpStatus status; -} diff --git a/auth/src/main/java/com/nalssilog/auth/domain/SignupTicket.java b/auth/src/main/java/com/nalssilog/auth/domain/SignupTicket.java deleted file mode 100644 index 146bec9..0000000 --- a/auth/src/main/java/com/nalssilog/auth/domain/SignupTicket.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.nalssilog.auth.domain; - -import com.fasterxml.jackson.annotation.JsonAlias; -import com.nalssilog.member.domain.Provider; - -/** - * 소셜 인증은 끝났지만 아직 가입을 확정하지 않은 신규 사용자의 임시 상태. (Redis 단기 보관) - */ -public record SignupTicket( - Provider provider, - String providerUserId, - String email, - @JsonAlias("nickname") String socialName -) { -} diff --git a/auth/src/main/java/com/nalssilog/auth/client/MemberClient.java b/auth/src/main/java/com/nalssilog/auth/member/MemberClient.java similarity index 80% rename from auth/src/main/java/com/nalssilog/auth/client/MemberClient.java rename to auth/src/main/java/com/nalssilog/auth/member/MemberClient.java index 6c6b40f..d884f21 100644 --- a/auth/src/main/java/com/nalssilog/auth/client/MemberClient.java +++ b/auth/src/main/java/com/nalssilog/auth/member/MemberClient.java @@ -1,10 +1,12 @@ -package com.nalssilog.auth.client; +package com.nalssilog.auth.member; +import com.nalssilog.auth.oauth.OAuthUserInfo; import com.nalssilog.member.application.MemberAccountService; import com.nalssilog.member.application.MemberRegistrationService; import com.nalssilog.member.application.dto.MemberInfo; import com.nalssilog.member.application.dto.SocialLoginResult; import com.nalssilog.member.application.dto.TermsAgreement; +import com.nalssilog.member.domain.Provider; import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -39,10 +41,21 @@ public MemberInfo getMemberInfo(Long memberId) { return memberAccountService.getMemberInfo(memberId); } + public void recordLogin(Long memberId, Provider provider) { + memberAccountService.recordLogin(memberId, provider); + } + public Optional findMemberInfo(Long memberId) { return memberAccountService.findMemberInfo(memberId); } + public Optional findMemberInfo( + Provider provider, + String providerUserId + ) { + return memberAccountService.findMemberInfo(provider, providerUserId); + } + public void withdraw(Long memberId) { memberAccountService.withdraw(memberId); } diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileAuthController.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileAuthController.java new file mode 100644 index 0000000..1734e54 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileAuthController.java @@ -0,0 +1,127 @@ +package com.nalssilog.auth.mobile; + +import com.nalssilog.auth.config.AuthProperties; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/auth/mobile") +@RequiredArgsConstructor +public class MobileAuthController { + + private final MobileAuthService mobileAuthService; + private final MobileDeviceInfoResolver deviceInfoResolver; + private final AuthProperties properties; + + @GetMapping("/login/{provider}") + public void login( + @PathVariable String provider, + @RequestParam(name = "redirect_uri") @NotBlank @Size(max = 500) String redirectUri, + @RequestParam(name = "code_challenge") @NotBlank @Size(min = 43, max = 43) String codeChallenge, + @RequestParam(name = "code_challenge_method") @NotBlank String codeChallengeMethod, + @RequestParam @NotBlank @Size(min = 16, max = 256) String state, + HttpServletResponse response + ) throws IOException { + response.sendRedirect(mobileAuthService.startLogin( + provider, + redirectUri, + codeChallenge, + codeChallengeMethod, + state)); + } + + @PostMapping("/token") + public MobileTokenResponse token( + @Valid @RequestBody MobileTokenRequest request, + HttpServletRequest httpRequest + ) { + return MobileTokenResponse.from( + mobileAuthService.exchange( + request.code(), + request.codeVerifier(), + request.redirectUri(), + deviceInfoResolver.resolve(request.device(), httpRequest)), + properties); + } + + @PostMapping("/refresh") + public MobileRefreshResponse refresh( + @Valid @RequestBody MobileRefreshRequest request, + HttpServletRequest httpRequest + ) { + return MobileRefreshResponse.from( + mobileAuthService.refresh( + request.refreshToken(), + deviceInfoResolver.resolve(request.device(), httpRequest)), + properties); + } + + @PostMapping("/logout") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void logout(@Valid @RequestBody MobileLogoutRequest request) { + mobileAuthService.logout(request.refreshToken()); + } + + @PostMapping("/signup") + public MobileTokenResponse signup( + @Valid @RequestBody MobileSignupRequest request, + HttpServletRequest httpRequest + ) { + return MobileTokenResponse.signup( + mobileAuthService.signup( + request.signupTicket(), + request.agreedTerms(), + deviceInfoResolver.resolve(request.device(), httpRequest)), + properties); + } + + @PostMapping("/link/consent") + public MobileAuthorizationResponse consentLink( + @Valid @RequestBody MobileLinkConsentRequest request + ) { + return new MobileAuthorizationResponse(mobileAuthService.consentLink( + request.linkTicket(), + request.redirectUri(), + request.codeChallenge(), + request.codeChallengeMethod(), + request.state())); + } + + @PostMapping("/link/cancel") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void cancelLink(@Valid @RequestBody MobileLinkCancelRequest request) { + mobileAuthService.cancelLink(request.linkTicket()); + } + + @PostMapping("/link/social/{provider}") + public MobileAuthorizationResponse startSettingsLink( + @AuthenticationPrincipal Long memberId, + @PathVariable String provider, + @Valid @RequestBody MobileLinkStartRequest request + ) { + return new MobileAuthorizationResponse(mobileAuthService.startSettingsLink( + memberId, + provider, + request.redirectUri(), + request.codeChallenge(), + request.codeChallengeMethod(), + request.state())); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileAuthService.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileAuthService.java new file mode 100644 index 0000000..854159c --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileAuthService.java @@ -0,0 +1,99 @@ +package com.nalssilog.auth.mobile; + +import com.nalssilog.auth.core.AuthService.SignupResult; +import com.nalssilog.auth.core.AuthService; +import com.nalssilog.auth.device.DeviceInfo; +import com.nalssilog.auth.mobile.oauth.MobileOAuthService.ExchangeResult; +import com.nalssilog.auth.mobile.oauth.MobileOAuthService; +import com.nalssilog.auth.token.TokenPair; +import com.nalssilog.member.application.dto.TermsAgreement; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MobileAuthService { + + private final MobileOAuthService mobileOAuthService; + private final AuthService authService; + + public String startLogin( + String provider, + String redirectUri, + String codeChallenge, + String codeChallengeMethod, + String state + ) { + return mobileOAuthService.startLogin( + provider, + redirectUri, + codeChallenge, + codeChallengeMethod, + state); + } + + public ExchangeResult exchange( + String code, + String verifier, + String redirectUri, + DeviceInfo device + ) { + return mobileOAuthService.exchange(code, verifier, redirectUri, device); + } + + public TokenPair refresh(String refreshToken, DeviceInfo device) { + return authService.refreshMobile(refreshToken, device); + } + + public void logout(String refreshToken) { + authService.logout(Optional.ofNullable(refreshToken)); + } + + public SignupResult signup( + String ticketId, + List agreedTerms, + DeviceInfo device + ) { + mobileOAuthService.mobileSignupTicket(ticketId); + + return authService.signupMobile(ticketId, agreedTerms, device); + } + + public String consentLink( + String linkTicket, + String redirectUri, + String codeChallenge, + String codeChallengeMethod, + String state + ) { + return mobileOAuthService.startLinkReauthentication( + linkTicket, + redirectUri, + codeChallenge, + codeChallengeMethod, + state); + } + + public void cancelLink(String linkTicket) { + mobileOAuthService.cancelLink(linkTicket); + } + + public String startSettingsLink( + Long memberId, + String provider, + String redirectUri, + String codeChallenge, + String codeChallengeMethod, + String state + ) { + return mobileOAuthService.startSettingsLink( + memberId, + provider, + redirectUri, + codeChallenge, + codeChallengeMethod, + state); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileAuthorizationResponse.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileAuthorizationResponse.java new file mode 100644 index 0000000..4e1408d --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileAuthorizationResponse.java @@ -0,0 +1,4 @@ +package com.nalssilog.auth.mobile; + +public record MobileAuthorizationResponse(String authorizationUrl) { +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileDeviceInfoResolver.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileDeviceInfoResolver.java new file mode 100644 index 0000000..7d6eb03 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileDeviceInfoResolver.java @@ -0,0 +1,31 @@ +package com.nalssilog.auth.mobile; + +import com.nalssilog.auth.device.DeviceInfo; +import com.nalssilog.auth.device.DeviceInfoResolver; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MobileDeviceInfoResolver { + + private static final int MAX_NAME_LENGTH = 60; + + private final DeviceInfoResolver deviceInfoResolver; + + public DeviceInfo resolve( + MobileDeviceRequest device, + HttpServletRequest request + ) { + String label = device.platform().name() + + " · " + device.deviceName().strip() + + " · " + device.appVersion().strip(); + + if (label.length() > MAX_NAME_LENGTH) { + label = label.substring(0, MAX_NAME_LENGTH); + } + + return new DeviceInfo(label, deviceInfoResolver.resolveIp(request)); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileDeviceRequest.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileDeviceRequest.java new file mode 100644 index 0000000..5452867 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileDeviceRequest.java @@ -0,0 +1,19 @@ +package com.nalssilog.auth.mobile; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record MobileDeviceRequest( + @NotNull MobilePlatform platform, + @NotBlank + @Size(max = 60) + @Pattern(regexp = "^[^\\p{Cntrl}]+$") + String deviceName, + @NotBlank + @Size(max = 30) + @Pattern(regexp = "^[0-9A-Za-z][0-9A-Za-z._+() -]*$") + String appVersion +) { +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileLinkCancelRequest.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileLinkCancelRequest.java new file mode 100644 index 0000000..3c158d4 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileLinkCancelRequest.java @@ -0,0 +1,9 @@ +package com.nalssilog.auth.mobile; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record MobileLinkCancelRequest( + @NotBlank @Size(max = 200) String linkTicket +) { +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileLinkConsentRequest.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileLinkConsentRequest.java new file mode 100644 index 0000000..c1d24f6 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileLinkConsentRequest.java @@ -0,0 +1,13 @@ +package com.nalssilog.auth.mobile; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record MobileLinkConsentRequest( + @NotBlank @Size(max = 200) String linkTicket, + @NotBlank @Size(max = 500) String redirectUri, + @NotBlank @Size(min = 43, max = 43) String codeChallenge, + @NotBlank String codeChallengeMethod, + @NotBlank @Size(min = 16, max = 256) String state +) { +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileLinkStartRequest.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileLinkStartRequest.java new file mode 100644 index 0000000..09cb08a --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileLinkStartRequest.java @@ -0,0 +1,12 @@ +package com.nalssilog.auth.mobile; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record MobileLinkStartRequest( + @NotBlank @Size(max = 500) String redirectUri, + @NotBlank @Size(min = 43, max = 43) String codeChallenge, + @NotBlank String codeChallengeMethod, + @NotBlank @Size(min = 16, max = 256) String state +) { +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileLogoutRequest.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileLogoutRequest.java new file mode 100644 index 0000000..a041adc --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileLogoutRequest.java @@ -0,0 +1,9 @@ +package com.nalssilog.auth.mobile; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record MobileLogoutRequest( + @NotBlank @Size(max = 500) String refreshToken +) { +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobilePlatform.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobilePlatform.java new file mode 100644 index 0000000..afa6d71 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobilePlatform.java @@ -0,0 +1,6 @@ +package com.nalssilog.auth.mobile; + +public enum MobilePlatform { + ANDROID, + IOS +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileRefreshRequest.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileRefreshRequest.java new file mode 100644 index 0000000..e805ddc --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileRefreshRequest.java @@ -0,0 +1,12 @@ +package com.nalssilog.auth.mobile; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record MobileRefreshRequest( + @NotBlank @Size(max = 500) String refreshToken, + @Valid @NotNull MobileDeviceRequest device +) { +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileRefreshResponse.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileRefreshResponse.java new file mode 100644 index 0000000..9b3eb56 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileRefreshResponse.java @@ -0,0 +1,25 @@ +package com.nalssilog.auth.mobile; + +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.auth.token.TokenPair; + +public record MobileRefreshResponse( + String tokenType, + String accessToken, + long accessTokenExpiresIn, + String refreshToken, + long refreshTokenExpiresIn +) { + + public static MobileRefreshResponse from( + TokenPair tokens, + AuthProperties properties + ) { + return new MobileRefreshResponse( + "Bearer", + tokens.accessToken(), + properties.jwt().accessTokenTtl().toSeconds(), + tokens.refreshToken(), + tokens.refreshTokenMaxAge().toSeconds()); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileSignupRequest.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileSignupRequest.java new file mode 100644 index 0000000..60b6c2e --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileSignupRequest.java @@ -0,0 +1,15 @@ +package com.nalssilog.auth.mobile; + +import com.nalssilog.member.application.dto.TermsAgreement; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; + +public record MobileSignupRequest( + @NotBlank @Size(max = 200) String signupTicket, + @NotNull List<@Valid @NotNull TermsAgreement> agreedTerms, + @Valid @NotNull MobileDeviceRequest device +) { +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileTokenRequest.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileTokenRequest.java new file mode 100644 index 0000000..e4fc9e5 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileTokenRequest.java @@ -0,0 +1,14 @@ +package com.nalssilog.auth.mobile; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record MobileTokenRequest( + @NotBlank @Size(max = 200) String code, + @NotBlank @Size(min = 43, max = 128) String codeVerifier, + @NotBlank @Size(max = 500) String redirectUri, + @Valid @NotNull MobileDeviceRequest device +) { +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileTokenResponse.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileTokenResponse.java new file mode 100644 index 0000000..e050021 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileTokenResponse.java @@ -0,0 +1,81 @@ +package com.nalssilog.auth.mobile; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.auth.core.AuthService.SignupResult; +import com.nalssilog.auth.mobile.oauth.MobileAuthResult; +import com.nalssilog.auth.mobile.oauth.MobileOAuthService.ExchangeResult; +import com.nalssilog.auth.token.TokenPair; +import com.nalssilog.auth.web.MeResponse.Avatar; +import com.nalssilog.auth.web.MeResponse.PendingAuth; +import com.nalssilog.auth.web.MeResponse.User; +import com.nalssilog.member.application.dto.MemberInfo; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record MobileTokenResponse( + MobileAuthResult result, + String tokenType, + String accessToken, + Long accessTokenExpiresIn, + String refreshToken, + Long refreshTokenExpiresIn, + User user, + String signupTicket, + String linkTicket, + PendingAuth pendingAuth +) { + + public static MobileTokenResponse from( + ExchangeResult result, + AuthProperties properties + ) { + TokenPair tokens = result.tokens(); + + return new MobileTokenResponse( + result.result(), + tokens == null ? null : "Bearer", + tokens == null ? null : tokens.accessToken(), + tokens == null ? null : properties.jwt().accessTokenTtl().toSeconds(), + tokens == null ? null : tokens.refreshToken(), + tokens == null ? null : tokens.refreshTokenMaxAge().toSeconds(), + user(result.member()), + result.signupTicket(), + result.linkTicket(), + result.pendingProvider() == null + ? null + : new PendingAuth( + result.pendingProvider(), + result.pendingEmail(), + result.existingProviders())); + } + + public static MobileTokenResponse signup( + SignupResult result, + AuthProperties properties + ) { + TokenPair tokens = result.tokens(); + + return new MobileTokenResponse( + MobileAuthResult.SUCCESS, + "Bearer", + tokens.accessToken(), + properties.jwt().accessTokenTtl().toSeconds(), + tokens.refreshToken(), + tokens.refreshTokenMaxAge().toSeconds(), + user(result.member()), + null, + null, + null); + } + + private static User user(MemberInfo member) { + if (member == null) { + return null; + } + + return new User( + String.valueOf(member.id()), + member.nickname(), + new Avatar(member.avatarType(), member.avatarValue())); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/guest/GuestCredentialResponse.java b/auth/src/main/java/com/nalssilog/auth/mobile/guest/GuestCredentialResponse.java new file mode 100644 index 0000000..ede69c4 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/guest/GuestCredentialResponse.java @@ -0,0 +1,12 @@ +package com.nalssilog.auth.mobile.guest; + +import com.nalssilog.auth.mobile.guest.MobileGuestCredentialService.IssuedGuestCredential; + +public record GuestCredentialResponse(String guestToken, long expiresIn) { + + public static GuestCredentialResponse from(IssuedGuestCredential credential) { + return new GuestCredentialResponse( + credential.token(), + credential.expiresIn().toSeconds()); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestController.java b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestController.java new file mode 100644 index 0000000..84cc9b3 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestController.java @@ -0,0 +1,26 @@ +package com.nalssilog.auth.mobile.guest; + +import com.nalssilog.auth.device.DeviceInfoResolver; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/mobile/guests") +@RequiredArgsConstructor +public class MobileGuestController { + + private final MobileGuestCredentialService credentialService; + private final DeviceInfoResolver deviceInfoResolver; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public GuestCredentialResponse issue(HttpServletRequest request) { + return GuestCredentialResponse.from( + credentialService.issue(deviceInfoResolver.resolveIp(request))); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredential.java b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredential.java new file mode 100644 index 0000000..e410b1f --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredential.java @@ -0,0 +1,68 @@ +package com.nalssilog.auth.mobile.guest; + +import com.nalssilog.common.domain.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.Instant; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "mobile_guest_credential", uniqueConstraints = { + @UniqueConstraint(name = "uk_mobile_guest_token_hash", columnNames = "token_hash"), + @UniqueConstraint(name = "uk_mobile_guest_anonymous_key", columnNames = "anonymous_key") +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MobileGuestCredential extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "token_hash", nullable = false, length = 64, updatable = false) + private String tokenHash; + + @Column(name = "anonymous_key", nullable = false, length = 36, updatable = false) + private String anonymousKey; + + @Column(name = "expires_at", nullable = false, updatable = false) + private Instant expiresAt; + + @Column(name = "revoked_at") + private Instant revokedAt; + + public static MobileGuestCredential issue( + String tokenHash, + String anonymousKey, + Instant expiresAt + ) { + MobileGuestCredential credential = new MobileGuestCredential(); + + credential.tokenHash = tokenHash; + credential.anonymousKey = anonymousKey; + credential.expiresAt = expiresAt; + + return credential; + } + + public boolean isExpired(Instant now) { + return !expiresAt.isAfter(now); + } + + public boolean isRevoked() { + return revokedAt != null; + } + + public void revoke() { + if (revokedAt == null) { + revokedAt = Instant.now(); + } + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialCleanupService.java b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialCleanupService.java new file mode 100644 index 0000000..026c444 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialCleanupService.java @@ -0,0 +1,37 @@ +package com.nalssilog.auth.mobile.guest; + +import com.nalssilog.auth.config.AuthProperties; +import java.time.Instant; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MobileGuestCredentialCleanupService { + + private final MobileGuestCredentialRepository repository; + private final AuthProperties properties; + + @Scheduled( + fixedDelayString = "${nalssilog.auth.guest.cleanup-interval:6h}", + initialDelayString = "${nalssilog.auth.guest.cleanup-initial-delay:1m}") + @Transactional + public void cleanupExpiredCredentials() { + cleanupExpiredCredentialsAt(Instant.now()); + } + + int cleanupExpiredCredentialsAt(Instant now) { + Instant cutoff = now.minus(properties.guest().expiredRetention()); + int deleted = repository.deleteByExpiresAtLessThanEqual(cutoff); + + if (deleted > 0) { + log.info("auth.guest.expired_credentials_deleted count={}", deleted); + } + + return deleted; + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialFilter.java b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialFilter.java new file mode 100644 index 0000000..ca917f2 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialFilter.java @@ -0,0 +1,74 @@ +package com.nalssilog.auth.mobile.guest; + +import com.nalssilog.auth.core.AuthErrorCode; +import com.nalssilog.auth.security.ApiAuthenticationEntryPoint; +import com.nalssilog.auth.security.CredentialAuthenticationException; +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.common.security.VerifiedRequestCredentials; +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.Collections; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@RequiredArgsConstructor +public class MobileGuestCredentialFilter extends OncePerRequestFilter { + + private final MobileGuestCredentialService credentialService; + private final ApiAuthenticationEntryPoint authenticationEntryPoint; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + List values = Collections.list( + request.getHeaders(MobileGuestCredentialService.HEADER)); + + if (values.isEmpty()) { + filterChain.doFilter(request, response); + + return; + } + + if (values.size() != 1 || values.getFirst() == null + || values.getFirst().isBlank() || values.getFirst().contains(",")) { + reject(request, response, AuthErrorCode.GUEST_CREDENTIAL_INVALID); + + return; + } + + try { + String anonymousKey = credentialService.authenticate(values.getFirst()); + + VerifiedRequestCredentials.markGuest(request, anonymousKey); + filterChain.doFilter(request, response); + } catch (NalssiLogException exception) { + AuthErrorCode errorCode = exception.getErrorCode() instanceof AuthErrorCode authErrorCode + ? authErrorCode + : AuthErrorCode.GUEST_CREDENTIAL_INVALID; + + reject(request, response, errorCode); + } + } + + private void reject( + HttpServletRequest request, + HttpServletResponse response, + AuthErrorCode errorCode + ) throws IOException { + SecurityContextHolder.clearContext(); + authenticationEntryPoint.commence( + request, + response, + new CredentialAuthenticationException(errorCode, false)); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialRepository.java b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialRepository.java new file mode 100644 index 0000000..fbebf8a --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialRepository.java @@ -0,0 +1,12 @@ +package com.nalssilog.auth.mobile.guest; + +import java.time.Instant; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MobileGuestCredentialRepository extends JpaRepository { + + Optional findByTokenHash(String tokenHash); + + int deleteByExpiresAtLessThanEqual(Instant cutoff); +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialService.java b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialService.java new file mode 100644 index 0000000..0b5d31a --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialService.java @@ -0,0 +1,75 @@ +package com.nalssilog.auth.mobile.guest; + +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.auth.core.AuthErrorCode; +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.common.security.SecretFingerprint; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MobileGuestCredentialService { + + public static final String HEADER = "X-Nalssilog-Guest-Token"; + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final int TOKEN_BYTES = 32; + + private final MobileGuestCredentialRepository repository; + private final MobileGuestIssuanceRateLimiter rateLimiter; + private final AuthProperties properties; + + @Transactional + public IssuedGuestCredential issue(String clientIp) { + rateLimiter.check(clientIp); + + String rawToken = randomToken(); + Duration ttl = properties.guest().ttl(); + MobileGuestCredential credential = MobileGuestCredential.issue( + SecretFingerprint.sha256(rawToken), + UUID.randomUUID().toString(), + Instant.now().plus(ttl)); + + repository.save(credential); + + return new IssuedGuestCredential(rawToken, ttl); + } + + public String authenticate(String rawToken) { + if (rawToken == null || rawToken.isBlank() || rawToken.length() > 200) { + throw new NalssiLogException(AuthErrorCode.GUEST_CREDENTIAL_INVALID); + } + + MobileGuestCredential credential = repository.findByTokenHash(SecretFingerprint.sha256(rawToken)) + .orElseThrow(() -> new NalssiLogException(AuthErrorCode.GUEST_CREDENTIAL_INVALID)); + + if (credential.isRevoked()) { + throw new NalssiLogException(AuthErrorCode.GUEST_CREDENTIAL_INVALID); + } + + if (credential.isExpired(Instant.now())) { + throw new NalssiLogException(AuthErrorCode.GUEST_CREDENTIAL_EXPIRED); + } + + return credential.getAnonymousKey(); + } + + private String randomToken() { + byte[] bytes = new byte[TOKEN_BYTES]; + + SECURE_RANDOM.nextBytes(bytes); + + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + public record IssuedGuestCredential(String token, Duration expiresIn) { + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestIssuanceRateLimiter.java b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestIssuanceRateLimiter.java new file mode 100644 index 0000000..d5a935f --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestIssuanceRateLimiter.java @@ -0,0 +1,80 @@ +package com.nalssilog.auth.mobile.guest; + +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.auth.core.AuthErrorCode; +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.common.security.SecretFingerprint; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class MobileGuestIssuanceRateLimiter { + + private static final String KEY_PREFIX = "auth:guest:issue:"; + private static final String GLOBAL_KEY = KEY_PREFIX + "global"; + private static final DefaultRedisScript INCREMENT_SCRIPT = new DefaultRedisScript<>(""" + local ipCount = redis.call('INCR', KEYS[1]) + if ipCount == 1 then + redis.call('PEXPIRE', KEYS[1], ARGV[1]) + end + local globalCount = redis.call('INCR', KEYS[2]) + if globalCount == 1 then + redis.call('PEXPIRE', KEYS[2], ARGV[2]) + end + if globalCount > tonumber(ARGV[4]) then + return -globalCount + end + if ipCount > tonumber(ARGV[3]) then + return ipCount + end + return 0 + """, Long.class); + + private final StringRedisTemplate redisTemplate; + private final AuthProperties properties; + + public void check(String clientIp) { + String fingerprint = SecretFingerprint.hmacSha256( + properties.mobile().ipHmacSecret(), + clientIp == null || clientIp.isBlank() ? "unknown" : clientIp.strip()); + Long result; + + try { + result = redisTemplate.execute( + INCREMENT_SCRIPT, + List.of(KEY_PREFIX + "ip:" + fingerprint, GLOBAL_KEY), + String.valueOf(properties.guest().rateLimitWindow().toMillis()), + String.valueOf(properties.guest().globalRateLimitWindow().toMillis()), + String.valueOf(properties.guest().maxIssuances()), + String.valueOf(properties.guest().globalMaxIssuances())); + } catch (DataAccessException exception) { + log.warn("auth.guest.issue_rate_limit_unavailable ip={}", + fingerprint.substring(0, 12)); + throw new NalssiLogException(AuthErrorCode.GUEST_ISSUANCE_UNAVAILABLE); + } + + if (result == null) { + log.warn("auth.guest.issue_rate_limit_unavailable ip={}", + fingerprint.substring(0, 12)); + throw new NalssiLogException(AuthErrorCode.GUEST_ISSUANCE_UNAVAILABLE); + } + + if (result > 0) { + log.warn("auth.guest.issue_rate_limited scope=ip ip={} count={}", + fingerprint.substring(0, 12), result); + throw new NalssiLogException(AuthErrorCode.GUEST_ISSUANCE_RATE_LIMITED); + } + + if (result < 0) { + log.warn("auth.guest.issue_rate_limited scope=global count={}", -result); + throw new NalssiLogException(AuthErrorCode.GUEST_ISSUANCE_RATE_LIMITED); + } + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileAuthResult.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileAuthResult.java new file mode 100644 index 0000000..1591fb0 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileAuthResult.java @@ -0,0 +1,9 @@ +package com.nalssilog.auth.mobile.oauth; + +public enum MobileAuthResult { + SUCCESS, + SIGNUP_REQUIRED, + LINK_REQUIRED, + LINK_SUCCESS, + FAILED +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestRepository.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestRepository.java new file mode 100644 index 0000000..2a8fce2 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestRepository.java @@ -0,0 +1,50 @@ +package com.nalssilog.auth.mobile.oauth; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; +import org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.stereotype.Component; + +@Component +public class MobileOAuthAuthorizationRequestRepository + implements AuthorizationRequestRepository { + + private final HttpSessionOAuth2AuthorizationRequestRepository delegate = + new HttpSessionOAuth2AuthorizationRequestRepository(); + + @Override + public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) { + return delegate.loadAuthorizationRequest(request); + } + + @Override + public void saveAuthorizationRequest( + OAuth2AuthorizationRequest authorizationRequest, + HttpServletRequest request, + HttpServletResponse response + ) { + delegate.saveAuthorizationRequest(authorizationRequest, request, response); + } + + @Override + public OAuth2AuthorizationRequest removeAuthorizationRequest( + HttpServletRequest request, + HttpServletResponse response + ) { + OAuth2AuthorizationRequest authorizationRequest = + delegate.removeAuthorizationRequest(request, response); + + if (authorizationRequest != null) { + String transactionId = authorizationRequest.getAttribute( + MobileOAuthRequestAttributes.AUTHORIZATION_ATTRIBUTE); + + if (transactionId != null && !transactionId.isBlank()) { + MobileOAuthRequestAttributes.expose(request, transactionId); + } + } + + return authorizationRequest; + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestResolver.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestResolver.java new file mode 100644 index 0000000..22ee758 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestResolver.java @@ -0,0 +1,108 @@ +package com.nalssilog.auth.mobile.oauth; + +import com.nalssilog.auth.oauth.apple.AppleOAuthProperties; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.stereotype.Component; + +@Component +public class MobileOAuthAuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver { + + private final DefaultOAuth2AuthorizationRequestResolver delegate; + private final MobileOAuthTransactionStore transactionStore; + + public MobileOAuthAuthorizationRequestResolver( + ClientRegistrationRepository clientRegistrationRepository, + MobileOAuthTransactionStore transactionStore + ) { + this.delegate = new DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository); + this.transactionStore = transactionStore; + } + + @Override + public OAuth2AuthorizationRequest resolve(HttpServletRequest request) { + return attachMobileTransaction( + request, + customizeProvider(delegate.resolve(request))); + } + + @Override + public OAuth2AuthorizationRequest resolve( + HttpServletRequest request, + String clientRegistrationId + ) { + return attachMobileTransaction( + request, + customizeProvider( + delegate.resolve(request, clientRegistrationId))); + } + + private OAuth2AuthorizationRequest customizeProvider( + OAuth2AuthorizationRequest authorizationRequest + ) { + if (authorizationRequest == null) { + return null; + } + + String registrationId = authorizationRequest.getAttribute( + OAuth2ParameterNames.REGISTRATION_ID); + + if (!AppleOAuthProperties.REGISTRATION_ID.equals(registrationId)) { + return authorizationRequest; + } + + return OAuth2AuthorizationRequest.from(authorizationRequest) + .additionalParameters(parameters -> + parameters.put("response_mode", "form_post")) + .build(); + } + + private OAuth2AuthorizationRequest attachMobileTransaction( + HttpServletRequest request, + OAuth2AuthorizationRequest authorizationRequest + ) { + if (authorizationRequest == null) { + return null; + } + + String[] values = request.getParameterValues( + MobileOAuthRequestAttributes.TRANSACTION_PARAMETER); + + if (values == null || values.length == 0) { + return authorizationRequest; + } + + if (values.length != 1 || values[0] == null || values[0].isBlank()) { + throw invalidTransaction(); + } + + String transactionId = values[0]; + MobileOAuthTransaction transaction = transactionStore.find(transactionId) + .orElseThrow(this::invalidTransaction); + String registrationId = authorizationRequest.getAttribute( + OAuth2ParameterNames.REGISTRATION_ID); + + if (registrationId == null + || !transaction.provider().name().equalsIgnoreCase(registrationId)) { + throw invalidTransaction(); + } + + return OAuth2AuthorizationRequest.from(authorizationRequest) + .attributes(attributes -> attributes.put( + MobileOAuthRequestAttributes.AUTHORIZATION_ATTRIBUTE, + transactionId)) + .build(); + } + + private OAuth2AuthenticationException invalidTransaction() { + return new OAuth2AuthenticationException( + new OAuth2Error("invalid_mobile_transaction"), + "Invalid mobile OAuth transaction"); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthCodeStore.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthCodeStore.java new file mode 100644 index 0000000..f8cfdf6 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthCodeStore.java @@ -0,0 +1,143 @@ +package com.nalssilog.auth.mobile.oauth; + +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.auth.core.AuthErrorCode; +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.common.security.SecretFingerprint; +import com.nalssilog.member.domain.Provider; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class MobileOAuthCodeStore { + + private static final String KEY_PREFIX = "auth:mobile:code:"; + private static final String FIELD_RESULT = "result"; + private static final String FIELD_PROVIDER = "provider"; + private static final String FIELD_MEMBER_ID = "memberId"; + private static final String FIELD_TICKET_ID = "ticketId"; + private static final String FIELD_ERROR_CODE = "errorCode"; + private static final String FIELD_ISSUE_TOKENS = "issueTokens"; + private static final String FIELD_REDIRECT_URI = "redirectUri"; + private static final String FIELD_CODE_CHALLENGE = "codeChallenge"; + + @SuppressWarnings("rawtypes") + private static final DefaultRedisScript CONSUME_SCRIPT = new DefaultRedisScript<>(""" + if redis.call('EXISTS', KEYS[1]) == 0 then + return {'MISSING'} + end + if redis.call('HGET', KEYS[1], 'redirectUri') ~= ARGV[1] then + return {'REDIRECT_MISMATCH'} + end + if redis.call('HGET', KEYS[1], 'codeChallenge') ~= ARGV[2] then + return {'PKCE_MISMATCH'} + end + + local result = redis.call('HGET', KEYS[1], 'result') or '' + local provider = redis.call('HGET', KEYS[1], 'provider') or '' + local memberId = redis.call('HGET', KEYS[1], 'memberId') or '' + local ticketId = redis.call('HGET', KEYS[1], 'ticketId') or '' + local errorCode = redis.call('HGET', KEYS[1], 'errorCode') or '' + local issueTokens = redis.call('HGET', KEYS[1], 'issueTokens') or 'false' + redis.call('DEL', KEYS[1]) + return {'OK', result, provider, memberId, ticketId, errorCode, issueTokens} + """, List.class); + + private final StringRedisTemplate redisTemplate; + private final AuthProperties properties; + + public void save( + String rawCode, + MobileOAuthGrant grant, + String redirectUri, + String codeChallenge + ) { + Map fields = new HashMap<>(); + + fields.put(FIELD_RESULT, grant.result().name()); + fields.put(FIELD_PROVIDER, grant.provider() == null ? "" : grant.provider().name()); + fields.put(FIELD_MEMBER_ID, grant.memberId() == null ? "" : String.valueOf(grant.memberId())); + fields.put(FIELD_TICKET_ID, blank(grant.ticketId())); + fields.put(FIELD_ERROR_CODE, blank(grant.errorCode())); + fields.put(FIELD_ISSUE_TOKENS, String.valueOf(grant.issueTokens())); + fields.put(FIELD_REDIRECT_URI, redirectUri); + fields.put(FIELD_CODE_CHALLENGE, codeChallenge); + + String key = key(rawCode); + + redisTemplate.opsForHash().putAll(key, fields); + redisTemplate.expire(key, properties.mobile().codeTtl()); + } + + public MobileOAuthGrant consume( + String rawCode, + String redirectUri, + String calculatedChallenge + ) { + if (rawCode == null || rawCode.isBlank() || rawCode.length() > 200) { + throw new NalssiLogException(AuthErrorCode.AUTH_MOBILE_CODE_INVALID); + } + + @SuppressWarnings("unchecked") + List values = redisTemplate.execute( + CONSUME_SCRIPT, + List.of(key(rawCode)), + redirectUri, + calculatedChallenge); + String status = value(values, 0); + + if ("REDIRECT_MISMATCH".equals(status)) { + throw new NalssiLogException(AuthErrorCode.AUTH_REDIRECT_URI_INVALID); + } + + if ("PKCE_MISMATCH".equals(status)) { + throw new NalssiLogException(AuthErrorCode.AUTH_PKCE_VERIFICATION_FAILED); + } + + if (!"OK".equals(status)) { + throw new NalssiLogException(AuthErrorCode.AUTH_MOBILE_CODE_INVALID); + } + + return new MobileOAuthGrant( + MobileAuthResult.valueOf(value(values, 1)), + enumValue(Provider.class, value(values, 2)), + longValue(value(values, 3)), + nullIfBlank(value(values, 4)), + nullIfBlank(value(values, 5)), + Boolean.parseBoolean(value(values, 6))); + } + + private String key(String rawCode) { + return KEY_PREFIX + SecretFingerprint.sha256(rawCode); + } + + private String blank(String value) { + return value == null ? "" : value; + } + + private String value(List values, int index) { + if (values == null || index >= values.size() || values.get(index) == null) { + return ""; + } + + return String.valueOf(values.get(index)); + } + + private Long longValue(String value) { + return value == null || value.isBlank() ? null : Long.valueOf(value); + } + + private String nullIfBlank(String value) { + return value == null || value.isBlank() ? null : value; + } + + private > T enumValue(Class type, String value) { + return value == null || value.isBlank() ? null : Enum.valueOf(type, value); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthGrant.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthGrant.java new file mode 100644 index 0000000..c7e5c9e --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthGrant.java @@ -0,0 +1,42 @@ +package com.nalssilog.auth.mobile.oauth; + +import com.nalssilog.member.domain.Provider; + +public record MobileOAuthGrant( + MobileAuthResult result, + Provider provider, + Long memberId, + String ticketId, + String errorCode, + boolean issueTokens +) { + + public static MobileOAuthGrant success(Long memberId, Provider provider) { + return new MobileOAuthGrant( + MobileAuthResult.SUCCESS, provider, memberId, null, null, true); + } + + public static MobileOAuthGrant signupRequired(Provider provider, String ticketId) { + return new MobileOAuthGrant( + MobileAuthResult.SIGNUP_REQUIRED, provider, null, ticketId, null, false); + } + + public static MobileOAuthGrant linkRequired(Provider provider, String ticketId) { + return new MobileOAuthGrant( + MobileAuthResult.LINK_REQUIRED, provider, null, ticketId, null, false); + } + + public static MobileOAuthGrant linkSuccess( + Long memberId, + Provider provider, + boolean issueTokens + ) { + return new MobileOAuthGrant( + MobileAuthResult.LINK_SUCCESS, provider, memberId, null, null, issueTokens); + } + + public static MobileOAuthGrant failed(Provider provider, String errorCode) { + return new MobileOAuthGrant( + MobileAuthResult.FAILED, provider, null, null, errorCode, false); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthPurpose.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthPurpose.java new file mode 100644 index 0000000..f97a559 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthPurpose.java @@ -0,0 +1,7 @@ +package com.nalssilog.auth.mobile.oauth; + +public enum MobileOAuthPurpose { + LOGIN, + LOGIN_LINK_REAUTH, + SETTINGS_LINK +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthRequestAttributes.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthRequestAttributes.java new file mode 100644 index 0000000..62ce9a2 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthRequestAttributes.java @@ -0,0 +1,28 @@ +package com.nalssilog.auth.mobile.oauth; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; + +public final class MobileOAuthRequestAttributes { + + public static final String TRANSACTION_PARAMETER = "mobile_transaction"; + public static final String AUTHORIZATION_ATTRIBUTE = + MobileOAuthRequestAttributes.class.getName() + ".authorizationTransaction"; + private static final String REQUEST_ATTRIBUTE = + MobileOAuthRequestAttributes.class.getName() + ".requestTransaction"; + + private MobileOAuthRequestAttributes() { + } + + public static void expose(HttpServletRequest request, String transactionId) { + request.setAttribute(REQUEST_ATTRIBUTE, transactionId); + } + + public static Optional transactionId(HttpServletRequest request) { + Object value = request.getAttribute(REQUEST_ATTRIBUTE); + + return value instanceof String text && !text.isBlank() + ? Optional.of(text) + : Optional.empty(); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthService.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthService.java new file mode 100644 index 0000000..3b1cc11 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthService.java @@ -0,0 +1,521 @@ +package com.nalssilog.auth.mobile.oauth; + +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.auth.core.AuthErrorCode; +import com.nalssilog.auth.member.MemberClient; +import com.nalssilog.auth.oauth.OAuthUserInfo; +import com.nalssilog.auth.oauth.SocialPrincipal; +import com.nalssilog.auth.ticket.AuthChannel; +import com.nalssilog.auth.ticket.AuthTicketStore; +import com.nalssilog.auth.ticket.LinkTicket; +import com.nalssilog.auth.ticket.SignupTicket; +import com.nalssilog.auth.token.AuthTokenService; +import com.nalssilog.auth.token.TokenPair; +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.member.application.dto.MemberInfo; +import com.nalssilog.member.domain.MemberStatus; +import com.nalssilog.member.domain.Provider; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.UUID; +import java.util.regex.Pattern; +import lombok.RequiredArgsConstructor; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.stereotype.Service; +import org.springframework.web.util.UriComponentsBuilder; + +@Service +@RequiredArgsConstructor +public class MobileOAuthService { + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final int OPAQUE_BYTES = 32; + private static final Pattern CODE_CHALLENGE_PATTERN = + Pattern.compile("^[A-Za-z0-9_-]{43}$"); + private static final Pattern CODE_VERIFIER_PATTERN = + Pattern.compile("^[A-Za-z0-9\\-._~]{43,128}$"); + private static final Pattern STATE_PATTERN = + Pattern.compile("^[A-Za-z0-9_-]{16,256}$"); + + private final MobileOAuthTransactionStore transactionStore; + private final MobileOAuthCodeStore codeStore; + private final AuthTicketStore ticketStore; + private final MemberClient memberClient; + private final AuthTokenService authTokenService; + private final AuthProperties properties; + private final ClientRegistrationRepository clientRegistrationRepository; + + public String startLogin( + String providerText, + String redirectUri, + String codeChallenge, + String codeChallengeMethod, + String appState + ) { + Provider provider = provider(providerText); + + validateStart(provider, redirectUri, codeChallenge, codeChallengeMethod, appState); + + MobileOAuthTransaction transaction = new MobileOAuthTransaction( + MobileOAuthPurpose.LOGIN, + provider, + redirectUri, + codeChallenge, + appState, + null, + null); + + return saveAndAuthorizationUrl(transaction); + } + + public String startLinkReauthentication( + String linkTicketId, + String redirectUri, + String codeChallenge, + String codeChallengeMethod, + String appState + ) { + LinkTicket ticket = mobileLinkTicket(linkTicketId); + + ticketStore.markLinkConsented(linkTicketId, properties.ticket().ttl()); + + MemberInfo target = memberClient.getMemberInfo(ticket.targetMemberId()); + Provider reauthenticationProvider = target.lastLoginProvider() != null + ? target.lastLoginProvider() + : target.connectedProviders().getFirst(); + + validateStart( + reauthenticationProvider, + redirectUri, + codeChallenge, + codeChallengeMethod, + appState); + + MobileOAuthTransaction transaction = new MobileOAuthTransaction( + MobileOAuthPurpose.LOGIN_LINK_REAUTH, + reauthenticationProvider, + redirectUri, + codeChallenge, + appState, + linkTicketId, + ticket.targetMemberId()); + + return saveAndAuthorizationUrl(transaction); + } + + public String startSettingsLink( + Long memberId, + String providerText, + String redirectUri, + String codeChallenge, + String codeChallengeMethod, + String appState + ) { + Provider provider = provider(providerText); + MemberInfo member = memberClient.getMemberInfo(memberId); + + if (member.connectedProviders().contains(provider)) { + throw new NalssiLogException(AuthErrorCode.ALREADY_LINKED_PROVIDER); + } + + validateStart(provider, redirectUri, codeChallenge, codeChallengeMethod, appState); + + MobileOAuthTransaction transaction = new MobileOAuthTransaction( + MobileOAuthPurpose.SETTINGS_LINK, + provider, + redirectUri, + codeChallenge, + appState, + null, + memberId); + + return saveAndAuthorizationUrl(transaction); + } + + public void cancelLink(String linkTicketId) { + LinkTicket ticket = mobileLinkTicket(linkTicketId); + + ticketStore.deleteLink(linkTicketId); + ticketStore.deleteLinkConsent(linkTicketId); + } + + public String complete(String transactionId, SocialPrincipal principal) { + MobileOAuthTransaction transaction = transactionStore.take(transactionId) + .orElseThrow(() -> new NalssiLogException( + AuthErrorCode.AUTH_MOBILE_TRANSACTION_EXPIRED)); + MobileOAuthGrant grant = resolveGrant(transaction, principal); + + return issueCallback(transaction, grant); + } + + private MobileOAuthGrant resolveGrant( + MobileOAuthTransaction transaction, + SocialPrincipal principal + ) { + if (transaction.provider() != principal.userInfo().provider()) { + return MobileOAuthGrant.failed( + transaction.provider(), + AuthErrorCode.OAUTH_FAILED.getCode()); + } + + return switch (transaction.purpose()) { + case LOGIN -> completeLogin(principal); + case LOGIN_LINK_REAUTH -> completeLoginLink(transaction, principal); + case SETTINGS_LINK -> completeSettingsLink(transaction, principal); + }; + } + + public Optional completeFailure(String transactionId, String errorCode) { + return transactionStore.take(transactionId) + .map(transaction -> issueCallback( + transaction, + MobileOAuthGrant.failed(transaction.provider(), errorCode))); + } + + public ExchangeResult exchange( + String rawCode, + String codeVerifier, + String redirectUri, + com.nalssilog.auth.device.DeviceInfo device + ) { + validateRedirectUri(redirectUri); + + if (codeVerifier == null || !CODE_VERIFIER_PATTERN.matcher(codeVerifier).matches()) { + throw new NalssiLogException(AuthErrorCode.AUTH_PKCE_VERIFICATION_FAILED); + } + + MobileOAuthGrant grant = codeStore.consume( + rawCode, + redirectUri, + codeChallenge(codeVerifier)); + + return switch (grant.result()) { + case SUCCESS -> authenticated(grant, MobileAuthResult.SUCCESS, device); + case LINK_SUCCESS -> grant.issueTokens() + ? authenticated(grant, MobileAuthResult.LINK_SUCCESS, device) + : linkedWithoutTokens(grant); + case SIGNUP_REQUIRED -> signupRequired(grant); + case LINK_REQUIRED -> linkRequired(grant); + case FAILED -> throw failure(grant.errorCode()); + }; + } + + public LinkTicket mobileLinkTicket(String ticketId) { + if (ticketId == null || ticketId.isBlank()) { + throw new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND); + } + + LinkTicket ticket = ticketStore.findLink(ticketId) + .orElseThrow(() -> new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND)); + + requireMobileChannel(ticket.effectiveChannel()); + + return ticket; + } + + public SignupTicket mobileSignupTicket(String ticketId) { + if (ticketId == null || ticketId.isBlank()) { + throw new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND); + } + + SignupTicket ticket = ticketStore.findSignup(ticketId) + .orElseThrow(() -> new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND)); + + requireMobileChannel(ticket.effectiveChannel()); + + return ticket; + } + + private MobileOAuthGrant completeLogin(SocialPrincipal principal) { + OAuthUserInfo userInfo = principal.userInfo(); + + return switch (principal.result().outcome()) { + case EXISTING -> principal.result().status() == MemberStatus.WITHDRAWN + ? MobileOAuthGrant.failed(userInfo.provider(), AuthErrorCode.OAUTH_FAILED.getCode()) + : MobileOAuthGrant.success(principal.result().memberId(), userInfo.provider()); + case NEW -> { + String ticketId = UUID.randomUUID().toString(); + + ticketStore.saveSignup( + ticketId, + new SignupTicket( + userInfo.provider(), + userInfo.providerUserId(), + userInfo.email(), + userInfo.socialName(), + AuthChannel.MOBILE), + properties.ticket().ttl()); + yield MobileOAuthGrant.signupRequired(userInfo.provider(), ticketId); + } + case LINK_REQUIRED -> { + String ticketId = UUID.randomUUID().toString(); + + ticketStore.saveLink( + ticketId, + new LinkTicket( + userInfo.provider(), + userInfo.providerUserId(), + userInfo.email(), + principal.result().memberId(), + principal.result().existingProviders(), + AuthChannel.MOBILE), + properties.ticket().ttl()); + yield MobileOAuthGrant.linkRequired(userInfo.provider(), ticketId); + } + }; + } + + private MobileOAuthGrant completeLoginLink( + MobileOAuthTransaction transaction, + SocialPrincipal principal + ) { + String ticketId = transaction.referenceId(); + + try { + LinkTicket ticket = mobileLinkTicket(ticketId); + boolean validOwner = principal.result().outcome() + == com.nalssilog.member.application.dto.SocialLoginResult.Outcome.EXISTING + && ticket.targetMemberId().equals(principal.result().memberId()) + && ticket.targetMemberId().equals(transaction.targetMemberId()) + && ticketStore.isLinkConsented(ticketId); + + if (!validOwner) { + return MobileOAuthGrant.failed( + transaction.provider(), AuthErrorCode.OAUTH_FAILED.getCode()); + } + + MemberInfo member = memberClient.linkSocial( + ticket.targetMemberId(), + new OAuthUserInfo( + ticket.provider(), + ticket.providerUserId(), + ticket.email(), + null)); + + return MobileOAuthGrant.linkSuccess( + member.id(), + ticket.provider(), + true); + } catch (NalssiLogException _) { + return MobileOAuthGrant.failed( + transaction.provider(), AuthErrorCode.OAUTH_FAILED.getCode()); + } finally { + if (ticketId != null) { + ticketStore.deleteLink(ticketId); + ticketStore.deleteLinkConsent(ticketId); + } + } + } + + private MobileOAuthGrant completeSettingsLink( + MobileOAuthTransaction transaction, + SocialPrincipal principal + ) { + try { + MemberInfo member = memberClient.linkSocial( + transaction.targetMemberId(), + principal.userInfo()); + + return MobileOAuthGrant.linkSuccess( + member.id(), + principal.userInfo().provider(), + false); + } catch (NalssiLogException _) { + return MobileOAuthGrant.failed( + transaction.provider(), AuthErrorCode.OAUTH_FAILED.getCode()); + } + } + + private ExchangeResult authenticated( + MobileOAuthGrant grant, + MobileAuthResult result, + com.nalssilog.auth.device.DeviceInfo device + ) { + MemberInfo member = memberClient.getMemberInfo(grant.memberId()); + + if (member.status() == MemberStatus.WITHDRAWN) { + throw new NalssiLogException(AuthErrorCode.OAUTH_FAILED); + } + + TokenPair tokens = authTokenService.issue( + member.id(), + member.status(), + grant.provider(), + device); + + memberClient.recordLogin(member.id(), grant.provider()); + + return new ExchangeResult( + result, tokens, member, null, null, null, null, List.of()); + } + + private ExchangeResult linkedWithoutTokens(MobileOAuthGrant grant) { + MemberInfo member = memberClient.getMemberInfo(grant.memberId()); + + return new ExchangeResult( + MobileAuthResult.LINK_SUCCESS, + null, + member, + null, + null, + null, + null, + List.of()); + } + + private ExchangeResult signupRequired(MobileOAuthGrant grant) { + SignupTicket ticket = mobileSignupTicket(grant.ticketId()); + + return new ExchangeResult( + MobileAuthResult.SIGNUP_REQUIRED, + null, + null, + grant.ticketId(), + null, + ticket.provider(), + ticket.email(), + List.of()); + } + + private ExchangeResult linkRequired(MobileOAuthGrant grant) { + LinkTicket ticket = mobileLinkTicket(grant.ticketId()); + + return new ExchangeResult( + MobileAuthResult.LINK_REQUIRED, + null, + null, + null, + grant.ticketId(), + ticket.provider(), + ticket.email(), + ticket.existingProviders()); + } + + private NalssiLogException failure(String errorCode) { + if (AuthErrorCode.OAUTH_CANCELLED.getCode().equals(errorCode)) { + return new NalssiLogException(AuthErrorCode.OAUTH_CANCELLED); + } + + if (AuthErrorCode.OAUTH_EMAIL_REQUIRED.getCode().equals(errorCode)) { + return new NalssiLogException(AuthErrorCode.OAUTH_EMAIL_REQUIRED); + } + + return new NalssiLogException(AuthErrorCode.OAUTH_FAILED); + } + + private String saveAndAuthorizationUrl(MobileOAuthTransaction transaction) { + String transactionId = opaqueValue(); + + transactionStore.save(transactionId, transaction); + + return UriComponentsBuilder + .fromPath("/oauth2/authorization/" + + transaction.provider().name().toLowerCase(Locale.ROOT)) + .queryParam(MobileOAuthRequestAttributes.TRANSACTION_PARAMETER, transactionId) + .build() + .encode() + .toUriString(); + } + + private String issueCallback( + MobileOAuthTransaction transaction, + MobileOAuthGrant grant + ) { + String code = opaqueValue(); + + codeStore.save(code, grant, transaction.redirectUri(), transaction.codeChallenge()); + + return UriComponentsBuilder + .fromUriString(transaction.redirectUri()) + .queryParam("code", code) + .queryParam("state", transaction.appState()) + .build() + .encode() + .toUriString(); + } + + private void validateStart( + Provider provider, + String redirectUri, + String codeChallenge, + String codeChallengeMethod, + String appState + ) { + validateRedirectUri(redirectUri); + + if (!"S256".equals(codeChallengeMethod) + || codeChallenge == null + || !CODE_CHALLENGE_PATTERN.matcher(codeChallenge).matches()) { + throw new NalssiLogException(AuthErrorCode.AUTH_PKCE_VERIFICATION_FAILED); + } + + if (appState == null + || !STATE_PATTERN.matcher(appState).matches()) { + throw new NalssiLogException(AuthErrorCode.OAUTH_FAILED); + } + + String registrationId = provider.name().toLowerCase(Locale.ROOT); + + if (clientRegistrationRepository.findByRegistrationId(registrationId) == null) { + throw new NalssiLogException(AuthErrorCode.UNSUPPORTED_PROVIDER); + } + } + + private void validateRedirectUri(String redirectUri) { + if (redirectUri == null + || !properties.mobile().redirectUris().contains(redirectUri)) { + throw new NalssiLogException(AuthErrorCode.AUTH_REDIRECT_URI_INVALID); + } + } + + private Provider provider(String text) { + try { + return Provider.from(text); + } catch (IllegalArgumentException exception) { + throw new NalssiLogException(AuthErrorCode.UNSUPPORTED_PROVIDER); + } + } + + private void requireMobileChannel(AuthChannel channel) { + if (channel != AuthChannel.MOBILE) { + throw new NalssiLogException(AuthErrorCode.AUTH_TICKET_CHANNEL_MISMATCH); + } + } + + private String codeChallenge(String verifier) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(verifier.getBytes(StandardCharsets.US_ASCII)); + + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 not available", exception); + } + } + + private String opaqueValue() { + byte[] bytes = new byte[OPAQUE_BYTES]; + + SECURE_RANDOM.nextBytes(bytes); + + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + public record ExchangeResult( + MobileAuthResult result, + TokenPair tokens, + MemberInfo member, + String signupTicket, + String linkTicket, + Provider pendingProvider, + String pendingEmail, + List existingProviders + ) { + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthTransaction.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthTransaction.java new file mode 100644 index 0000000..9b6b318 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthTransaction.java @@ -0,0 +1,14 @@ +package com.nalssilog.auth.mobile.oauth; + +import com.nalssilog.member.domain.Provider; + +public record MobileOAuthTransaction( + MobileOAuthPurpose purpose, + Provider provider, + String redirectUri, + String codeChallenge, + String appState, + String referenceId, + Long targetMemberId +) { +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthTransactionStore.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthTransactionStore.java new file mode 100644 index 0000000..b600473 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthTransactionStore.java @@ -0,0 +1,76 @@ +package com.nalssilog.auth.mobile.oauth; + +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.common.security.SecretFingerprint; +import java.util.Optional; +import java.util.regex.Pattern; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +@Repository +@RequiredArgsConstructor +public class MobileOAuthTransactionStore { + + private static final String KEY_PREFIX = "auth:mobile:transaction:"; + private static final Pattern TRANSACTION_ID_PATTERN = + Pattern.compile("^[A-Za-z0-9_-]{43}$"); + + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + private final AuthProperties properties; + + public void save(String transactionId, MobileOAuthTransaction transaction) { + redisTemplate.opsForValue().set( + key(transactionId), + serialize(transaction), + properties.mobile().transactionTtl()); + } + + public Optional find(String transactionId) { + if (!validTransactionId(transactionId)) { + return Optional.empty(); + } + + return deserialize(redisTemplate.opsForValue().get(key(transactionId))); + } + + public Optional take(String transactionId) { + if (!validTransactionId(transactionId)) { + return Optional.empty(); + } + + return deserialize(redisTemplate.opsForValue().getAndDelete(key(transactionId))); + } + + private String key(String transactionId) { + return KEY_PREFIX + SecretFingerprint.sha256(transactionId); + } + + private boolean validTransactionId(String transactionId) { + return transactionId != null + && TRANSACTION_ID_PATTERN.matcher(transactionId).matches(); + } + + private String serialize(MobileOAuthTransaction transaction) { + try { + return objectMapper.writeValueAsString(transaction); + } catch (JacksonException exception) { + throw new IllegalStateException("mobile OAuth transaction serialization failed", exception); + } + } + + private Optional deserialize(String value) { + if (value == null) { + return Optional.empty(); + } + + try { + return Optional.of(objectMapper.readValue(value, MobileOAuthTransaction.class)); + } catch (JacksonException exception) { + throw new IllegalStateException("mobile OAuth transaction deserialization failed", exception); + } + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/application/CustomOAuth2UserService.java b/auth/src/main/java/com/nalssilog/auth/oauth/CustomOAuth2UserService.java similarity index 93% rename from auth/src/main/java/com/nalssilog/auth/application/CustomOAuth2UserService.java rename to auth/src/main/java/com/nalssilog/auth/oauth/CustomOAuth2UserService.java index 5abf7cb..afc136e 100644 --- a/auth/src/main/java/com/nalssilog/auth/application/CustomOAuth2UserService.java +++ b/auth/src/main/java/com/nalssilog/auth/oauth/CustomOAuth2UserService.java @@ -1,7 +1,6 @@ -package com.nalssilog.auth.application; +package com.nalssilog.auth.oauth; -import com.nalssilog.auth.client.MemberClient; -import com.nalssilog.auth.client.OAuthUserInfo; +import com.nalssilog.auth.member.MemberClient; import com.nalssilog.member.application.dto.SocialLoginResult; import java.util.Objects; import lombok.RequiredArgsConstructor; @@ -27,6 +26,7 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic String registrationId = userRequest.getClientRegistration().getRegistrationId(); OAuthUserInfo userInfo = OAuthUserInfo.of(registrationId, oAuth2User.getAttributes()); SocialLoginResult result = memberClient.resolveSocialLogin(userInfo); + requireEmailForOnboarding(result, userInfo.email()); return new SocialAuthPrincipal(result, userInfo, oAuth2User.getAttributes()); diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/CustomOidcUserService.java b/auth/src/main/java/com/nalssilog/auth/oauth/CustomOidcUserService.java new file mode 100644 index 0000000..0d83b91 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/oauth/CustomOidcUserService.java @@ -0,0 +1,46 @@ +package com.nalssilog.auth.oauth; + +import com.nalssilog.auth.member.MemberClient; +import com.nalssilog.auth.oauth.apple.AppleAuthorizationUserContext; +import com.nalssilog.member.application.dto.SocialLoginResult; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.stereotype.Service; + +/** + * Loads OpenID Connect providers, including Sign in with Apple, through the same member resolution + * policy used by classic OAuth2 providers. + */ +@Service +@RequiredArgsConstructor +public class CustomOidcUserService extends OidcUserService { + + private final MemberClient memberClient; + private final AppleAuthorizationUserContext appleAuthorizationUserContext; + + @Override + public OidcUser loadUser(OidcUserRequest userRequest) + throws OAuth2AuthenticationException { + OidcUser oidcUser = Objects.requireNonNull( + super.loadUser(userRequest), + "OidcUser must not be null"); + String registrationId = + userRequest.getClientRegistration().getRegistrationId(); + OAuthUserInfo userInfo = OAuthUserInfo.of( + registrationId, + oidcUser.getClaims(), + appleAuthorizationUserContext.currentSocialName().orElse(null)); + SocialLoginResult result = + memberClient.resolveSocialLogin(userInfo); + + CustomOAuth2UserService.requireEmailForOnboarding( + result, + userInfo.email()); + + return new SocialOidcPrincipal(result, userInfo, oidcUser); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/config/OAuth2LoginFailureHandler.java b/auth/src/main/java/com/nalssilog/auth/oauth/OAuth2LoginFailureHandler.java similarity index 54% rename from auth/src/main/java/com/nalssilog/auth/config/OAuth2LoginFailureHandler.java rename to auth/src/main/java/com/nalssilog/auth/oauth/OAuth2LoginFailureHandler.java index cb601e4..8084d44 100644 --- a/auth/src/main/java/com/nalssilog/auth/config/OAuth2LoginFailureHandler.java +++ b/auth/src/main/java/com/nalssilog/auth/oauth/OAuth2LoginFailureHandler.java @@ -1,10 +1,13 @@ -package com.nalssilog.auth.config; +package com.nalssilog.auth.oauth; -import static com.nalssilog.auth.application.CustomOAuth2UserService.EMAIL_REQUIRED_ERROR; +import static com.nalssilog.auth.oauth.CustomOAuth2UserService.EMAIL_REQUIRED_ERROR; +import com.nalssilog.auth.mobile.oauth.MobileOAuthRequestAttributes; +import com.nalssilog.auth.mobile.oauth.MobileOAuthService; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.AuthenticationException; @@ -12,21 +15,40 @@ import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.stereotype.Component; -/** - * 소셜 인증 실패 시에도 성공과 동일한 단일 콜백으로 보낸다. 사용자가 provider 에서 취소하면 OAUTH_CANCELLED. - */ @Slf4j @Component +@RequiredArgsConstructor public class OAuth2LoginFailureHandler implements AuthenticationFailureHandler { + private final MobileOAuthService mobileOAuthService; + @Value("${nalssilog.frontend.base-url}") private String frontendBaseUrl; @Override - public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, - AuthenticationException exception) throws IOException { + public void onAuthenticationFailure( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception + ) throws IOException { String code = resolveCode(exception); - log.warn("OAuth2 login failed ({}): {}", code, exception.getMessage()); + + log.warn("OAuth2 login failed code={} type={}", + code, exception.getClass().getSimpleName()); + + var mobileTransaction = MobileOAuthRequestAttributes.transactionId(request); + + if (mobileTransaction.isPresent()) { + var callback = mobileOAuthService.completeFailure( + mobileTransaction.get(), + code); + + if (callback.isPresent()) { + response.sendRedirect(callback.get()); + + return; + } + } response.sendRedirect(frontendBaseUrl + "/auth/callback?result=FAILED&code=" + code); } diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/OAuth2LoginSuccessHandler.java b/auth/src/main/java/com/nalssilog/auth/oauth/OAuth2LoginSuccessHandler.java new file mode 100644 index 0000000..639c773 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/oauth/OAuth2LoginSuccessHandler.java @@ -0,0 +1,110 @@ +package com.nalssilog.auth.oauth; + +import com.nalssilog.auth.device.DeviceInfoResolver; +import com.nalssilog.auth.mobile.oauth.MobileOAuthRequestAttributes; +import com.nalssilog.auth.mobile.oauth.MobileOAuthService; +import com.nalssilog.auth.oauth.WebOAuthService.Completion; +import com.nalssilog.auth.web.AuthCookieManager; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Objects; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +@Component +@RequiredArgsConstructor +public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { + + private final AuthCookieManager cookieManager; + private final DeviceInfoResolver deviceInfoResolver; + private final MobileOAuthService mobileOAuthService; + private final WebOAuthService webOAuthService; + + @Value("${nalssilog.frontend.base-url}") + private String frontendBaseUrl; + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication + ) throws IOException { + SocialPrincipal principal = Objects.requireNonNull( + (SocialPrincipal) authentication.getPrincipal(), + "principal must not be null"); + + Optional mobileTransaction = + MobileOAuthRequestAttributes.transactionId(request); + + if (mobileTransaction.isPresent()) { + response.sendRedirect(mobileOAuthService.complete( + mobileTransaction.get(), + principal)); + + return; + } + + Completion completion = webOAuthService.complete( + principal, + cookieManager.readLinkIntent(request), + cookieManager.readLinkTicket(request), + deviceInfoResolver.resolve(request)); + + applyCookies(response, completion); + response.sendRedirect(callbackUrl( + completion.result(), + completion.errorCode())); + } + + private void applyCookies( + HttpServletResponse response, + Completion completion + ) { + if (completion.clearLinkIntent()) { + cookieManager.clearLinkIntentCookie(response); + } + + if (completion.clearLinkTicket()) { + cookieManager.clearLinkTicketCookie(response); + } + + if (completion.signupTicket() != null) { + cookieManager.addSignupTicketCookie( + response, + completion.signupTicket()); + } + + if (completion.linkTicket() != null) { + cookieManager.addLinkTicketCookie( + response, + completion.linkTicket()); + } + + if (completion.tokens() != null) { + cookieManager.addAuthCookies( + response, + completion.tokens().accessToken(), + completion.tokens().refreshToken(), + completion.tokens().refreshTokenMaxAge()); + } + } + + private String callbackUrl(String result, String errorCode) { + UriComponentsBuilder builder = UriComponentsBuilder + .fromUriString(frontendBaseUrl) + .path("/auth/callback") + .queryParam("result", result); + + if (errorCode != null && !errorCode.isBlank()) { + builder.queryParam("code", errorCode); + } + + return builder.build().encode().toUriString(); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/client/OAuthUserInfo.java b/auth/src/main/java/com/nalssilog/auth/oauth/OAuthUserInfo.java similarity index 74% rename from auth/src/main/java/com/nalssilog/auth/client/OAuthUserInfo.java rename to auth/src/main/java/com/nalssilog/auth/oauth/OAuthUserInfo.java index 3a9892e..1ed3463 100644 --- a/auth/src/main/java/com/nalssilog/auth/client/OAuthUserInfo.java +++ b/auth/src/main/java/com/nalssilog/auth/oauth/OAuthUserInfo.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.client; +package com.nalssilog.auth.oauth; import com.nalssilog.member.domain.Provider; import java.util.Map; @@ -17,6 +17,15 @@ public record OAuthUserInfo( @SuppressWarnings("unchecked") public static OAuthUserInfo of(String registrationId, Map attributes) { + return of(registrationId, attributes, null); + } + + @SuppressWarnings("unchecked") + public static OAuthUserInfo of( + String registrationId, + Map attributes, + String appleSocialName + ) { Provider provider = Provider.from(registrationId); return switch (provider) { @@ -48,9 +57,19 @@ yield new OAuthUserInfo( name == null || name.isBlank() ? text(response.get("nickname")) : name ); } + case APPLE -> new OAuthUserInfo( + provider, + text(attributes.get("sub")), + text(attributes.get(EMAIL_KEY)), + firstNonBlank(appleSocialName, text(attributes.get("name"))) + ); }; } + private static String firstNonBlank(String first, String second) { + return first == null || first.isBlank() ? second : first; + } + private static String text(Object value) { return value == null ? null : String.valueOf(value); } diff --git a/auth/src/main/java/com/nalssilog/auth/application/SocialAuthPrincipal.java b/auth/src/main/java/com/nalssilog/auth/oauth/SocialAuthPrincipal.java similarity index 92% rename from auth/src/main/java/com/nalssilog/auth/application/SocialAuthPrincipal.java rename to auth/src/main/java/com/nalssilog/auth/oauth/SocialAuthPrincipal.java index 94a9bd6..7cdde0b 100644 --- a/auth/src/main/java/com/nalssilog/auth/application/SocialAuthPrincipal.java +++ b/auth/src/main/java/com/nalssilog/auth/oauth/SocialAuthPrincipal.java @@ -1,6 +1,5 @@ -package com.nalssilog.auth.application; +package com.nalssilog.auth.oauth; -import com.nalssilog.auth.client.OAuthUserInfo; import com.nalssilog.member.application.dto.SocialLoginResult; import com.nalssilog.member.domain.MemberStatus; import java.util.Collection; @@ -19,7 +18,7 @@ public record SocialAuthPrincipal( SocialLoginResult result, OAuthUserInfo userInfo, Map attributes -) implements OAuth2User { +) implements SocialPrincipal { @Override public Map getAttributes() { diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/SocialOidcPrincipal.java b/auth/src/main/java/com/nalssilog/auth/oauth/SocialOidcPrincipal.java new file mode 100644 index 0000000..395c6d2 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/oauth/SocialOidcPrincipal.java @@ -0,0 +1,56 @@ +package com.nalssilog.auth.oauth; + +import com.nalssilog.member.application.dto.SocialLoginResult; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; + +/** + * OIDC counterpart of {@link SocialAuthPrincipal}. The signed ID token is retained so Spring's + * OIDC authentication contract remains intact while the application receives normalized social + * identity data. + */ +public record SocialOidcPrincipal( + SocialLoginResult result, + OAuthUserInfo userInfo, + OidcUser delegate +) implements SocialPrincipal, OidcUser { + + @Override + public Map getClaims() { + return delegate.getClaims(); + } + + @Override + public OidcUserInfo getUserInfo() { + return delegate.getUserInfo(); + } + + @Override + public OidcIdToken getIdToken() { + return delegate.getIdToken(); + } + + @Override + public Map getAttributes() { + return delegate.getAttributes(); + } + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority( + SocialAuthPrincipal.roleOf(result.status()))); + } + + @Override + public String getName() { + return result.memberId() == null + ? delegate.getName() + : String.valueOf(result.memberId()); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/SocialPrincipal.java b/auth/src/main/java/com/nalssilog/auth/oauth/SocialPrincipal.java new file mode 100644 index 0000000..0005ea1 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/oauth/SocialPrincipal.java @@ -0,0 +1,14 @@ +package com.nalssilog.auth.oauth; + +import com.nalssilog.member.application.dto.SocialLoginResult; +import org.springframework.security.oauth2.core.user.OAuth2User; + +/** + * Provider-neutral principal passed from OAuth2/OIDC user loading to the completion service. + */ +public interface SocialPrincipal extends OAuth2User { + + SocialLoginResult result(); + + OAuthUserInfo userInfo(); +} diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/WebOAuthService.java b/auth/src/main/java/com/nalssilog/auth/oauth/WebOAuthService.java new file mode 100644 index 0000000..47a7f38 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/oauth/WebOAuthService.java @@ -0,0 +1,244 @@ +package com.nalssilog.auth.oauth; + +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.auth.device.DeviceInfo; +import com.nalssilog.auth.member.MemberClient; +import com.nalssilog.auth.ticket.AuthChannel; +import com.nalssilog.auth.ticket.AuthTicketStore; +import com.nalssilog.auth.ticket.LinkTicket; +import com.nalssilog.auth.ticket.SignupTicket; +import com.nalssilog.auth.token.AuthTokenService; +import com.nalssilog.auth.token.TokenPair; +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.member.application.dto.MemberInfo; +import com.nalssilog.member.domain.MemberStatus; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * Completes browser OAuth flows without depending on servlet APIs. + * + *

The success handler is an HTTP adapter: it reads and writes cookies and performs the final + * redirect. Ticket decisions, account linking, and token issuance belong here. + */ +@Service +@RequiredArgsConstructor +public class WebOAuthService { + + private static final String SUCCESS = "SUCCESS"; + private static final String FAILED = "FAILED"; + private static final String SIGNUP_REQUIRED = "SIGNUP_REQUIRED"; + private static final String LINK_REQUIRED = "LINK_REQUIRED"; + private static final String LINK_SUCCESS = "LINK_SUCCESS"; + private static final String LINK_FAILED = "LINK_FAILED"; + + private final AuthTokenService authTokenService; + private final AuthTicketStore ticketStore; + private final MemberClient memberClient; + private final AuthProperties properties; + + public Completion complete( + SocialPrincipal principal, + Optional linkIntentId, + Optional pendingLinkTicketId, + DeviceInfo device + ) { + if (linkIntentId.isPresent()) { + return completeSettingsLink(linkIntentId.get(), principal); + } + + return switch (principal.result().outcome()) { + case EXISTING -> completeExisting(principal, pendingLinkTicketId, device); + case NEW -> requireSignup(principal.userInfo()); + case LINK_REQUIRED -> requireLink(principal); + }; + } + + private Completion completeSettingsLink( + String intentId, + SocialPrincipal principal + ) { + Optional memberId = ticketStore.findLinkIntent(intentId); + + ticketStore.deleteLinkIntent(intentId); + + if (memberId.isEmpty()) { + return Completion.clearIntent(LINK_FAILED, null); + } + + try { + memberClient.linkSocial(memberId.get(), principal.userInfo()); + + return Completion.clearIntent(LINK_SUCCESS, null); + } catch (NalssiLogException exception) { + return Completion.clearIntent( + LINK_FAILED, + exception.getErrorCode().getCode()); + } + } + + private Completion completeExisting( + SocialPrincipal principal, + Optional pendingLinkTicketId, + DeviceInfo device + ) { + if (principal.result().status() == MemberStatus.WITHDRAWN) { + return Completion.redirect(FAILED); + } + + if (pendingLinkTicketId.isPresent()) { + return completeLoginLink( + pendingLinkTicketId.get(), + principal, + device); + } + + return authenticated( + principal.result().memberId(), + principal.result().status(), + principal.userInfo().provider(), + SUCCESS, + device, + false); + } + + private Completion completeLoginLink( + String ticketId, + SocialPrincipal principal, + DeviceInfo device + ) { + LinkTicket ticket = ticketStore.findLink(ticketId).orElse(null); + boolean consented = ticketStore.isLinkConsented(ticketId); + + ticketStore.deleteLink(ticketId); + ticketStore.deleteLinkConsent(ticketId); + + if (ticket == null || !consented) { + return authenticated( + principal.result().memberId(), + principal.result().status(), + principal.userInfo().provider(), + SUCCESS, + device, + true); + } + + if (!ticket.targetMemberId().equals(principal.result().memberId())) { + return Completion.clearLinkTicket(LINK_FAILED); + } + + MemberInfo member = memberClient.linkSocial( + ticket.targetMemberId(), + new OAuthUserInfo( + ticket.provider(), + ticket.providerUserId(), + ticket.email(), + null)); + + return authenticated( + member.id(), + member.status(), + ticket.provider(), + LINK_SUCCESS, + device, + true); + } + + private Completion requireSignup(OAuthUserInfo userInfo) { + String ticketId = UUID.randomUUID().toString(); + + ticketStore.saveSignup( + ticketId, + new SignupTicket( + userInfo.provider(), + userInfo.providerUserId(), + userInfo.email(), + userInfo.socialName(), + AuthChannel.WEB), + properties.ticket().ttl()); + + return Completion.signup(ticketId); + } + + private Completion requireLink(SocialPrincipal principal) { + OAuthUserInfo userInfo = principal.userInfo(); + String ticketId = UUID.randomUUID().toString(); + + ticketStore.saveLink( + ticketId, + new LinkTicket( + userInfo.provider(), + userInfo.providerUserId(), + userInfo.email(), + principal.result().memberId(), + principal.result().existingProviders(), + AuthChannel.WEB), + properties.ticket().ttl()); + + return Completion.link(ticketId); + } + + private Completion authenticated( + Long memberId, + MemberStatus status, + com.nalssilog.member.domain.Provider provider, + String result, + DeviceInfo device, + boolean clearLinkTicket + ) { + TokenPair tokens = authTokenService.issue( + memberId, + status, + provider, + device); + + memberClient.recordLogin(memberId, provider); + + return new Completion( + result, + null, + tokens, + null, + null, + false, + clearLinkTicket); + } + + public record Completion( + String result, + String errorCode, + TokenPair tokens, + String signupTicket, + String linkTicket, + boolean clearLinkIntent, + boolean clearLinkTicket + ) { + + private static Completion redirect(String result) { + return new Completion( + result, null, null, null, null, false, false); + } + + private static Completion clearIntent(String result, String errorCode) { + return new Completion( + result, errorCode, null, null, null, true, false); + } + + private static Completion clearLinkTicket(String result) { + return new Completion( + result, null, null, null, null, false, true); + } + + private static Completion signup(String ticketId) { + return new Completion( + SIGNUP_REQUIRED, null, null, ticketId, null, false, false); + } + + private static Completion link(String ticketId) { + return new Completion( + LINK_REQUIRED, null, null, null, ticketId, false, false); + } + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleAuthorizationResponseFilter.java b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleAuthorizationResponseFilter.java new file mode 100644 index 0000000..458866b --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleAuthorizationResponseFilter.java @@ -0,0 +1,104 @@ +package com.nalssilog.auth.oauth.apple; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AppleAuthorizationResponseFilter extends OncePerRequestFilter { + + public static final String CALLBACK_PATH = "/login/oauth2/code/apple"; + private static final int MAX_USER_JSON_LENGTH = 4_096; + private static final int MAX_SOCIAL_NAME_LENGTH = 100; + + private final ObjectMapper objectMapper; + private final AppleAuthorizationUserContext userContext; + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return !HttpMethod.POST.matches(request.getMethod()) + || !CALLBACK_PATH.equals(request.getRequestURI()); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + try { + userContext.set(readSocialName(request.getParameter("user"))); + filterChain.doFilter(request, response); + } finally { + userContext.clear(); + } + } + + private String readSocialName(String payload) { + if (payload == null || payload.isBlank()) { + return null; + } + + if (payload.length() > MAX_USER_JSON_LENGTH) { + log.warn("auth.apple.user_payload_rejected reason=too_large"); + + return null; + } + + try { + AppleUser user = objectMapper.readValue(payload, AppleUser.class); + + if (user == null || user.name() == null) { + return null; + } + + String firstName = sanitize(user.name().firstName()); + String lastName = sanitize(user.name().lastName()); + String combined = (firstName + " " + lastName).strip(); + + if (combined.isBlank()) { + return null; + } + + return combined.length() <= MAX_SOCIAL_NAME_LENGTH + ? combined + : combined.substring(0, MAX_SOCIAL_NAME_LENGTH); + } catch (JacksonException exception) { + log.warn("auth.apple.user_payload_rejected reason=invalid_json"); + + return null; + } + } + + private String sanitize(String value) { + if (value == null) { + return ""; + } + + return value.codePoints() + .filter(codePoint -> !Character.isISOControl(codePoint)) + .collect( + StringBuilder::new, + StringBuilder::appendCodePoint, + StringBuilder::append) + .toString() + .strip(); + } + + private record AppleUser(AppleName name) { + } + + private record AppleName(String firstName, String lastName) { + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleAuthorizationUserContext.java b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleAuthorizationUserContext.java new file mode 100644 index 0000000..cb0c82f --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleAuthorizationUserContext.java @@ -0,0 +1,26 @@ +package com.nalssilog.auth.oauth.apple; + +import java.util.Optional; +import org.springframework.stereotype.Component; + +@Component +public class AppleAuthorizationUserContext { + + private final ThreadLocal socialName = new ThreadLocal<>(); + + public void set(String value) { + socialName.remove(); + + if (value != null && !value.isBlank()) { + socialName.set(value); + } + } + + public Optional currentSocialName() { + return Optional.ofNullable(socialName.get()); + } + + public void clear() { + socialName.remove(); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleClientSecretGenerator.java b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleClientSecretGenerator.java new file mode 100644 index 0000000..38fcc0b --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleClientSecretGenerator.java @@ -0,0 +1,128 @@ +package com.nalssilog.auth.oauth.apple; + +import io.jsonwebtoken.Jwts; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.interfaces.ECPrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; +import org.springframework.stereotype.Component; + +@Component +public class AppleClientSecretGenerator { + + private static final String APPLE_AUDIENCE = "https://appleid.apple.com"; + + private final AppleOAuthProperties properties; + private volatile CachedSecret cachedSecret; + + public AppleClientSecretGenerator(AppleOAuthProperties properties) { + this.properties = properties; + } + + public String generate(String clientId) { + properties.requireConfigured(); + + if (clientId == null || clientId.isBlank()) { + throw new IllegalStateException("Apple OAuth client-id is missing"); + } + + Instant now = Instant.now(); + CachedSecret current = cachedSecret; + + if (isReusable(current, clientId, now)) { + return current.value(); + } + + synchronized (this) { + current = cachedSecret; + + if (isReusable(current, clientId, now)) { + return current.value(); + } + + Instant expiresAt = now.plus(properties.clientSecretTtl()); + String value = Jwts.builder() + .header() + .keyId(properties.keyId()) + .and() + .issuer(properties.teamId()) + .subject(clientId) + .audience() + .add(APPLE_AUDIENCE) + .and() + .issuedAt(Date.from(now)) + .expiration(Date.from(expiresAt)) + .signWith(privateKey(), Jwts.SIG.ES256) + .compact(); + + cachedSecret = new CachedSecret(clientId, value, expiresAt); + + return value; + } + } + + private boolean isReusable( + CachedSecret secret, + String clientId, + Instant now + ) { + return secret != null + && secret.clientId().equals(clientId) + && secret.expiresAt() + .minus(properties.refreshBeforeExpiry()) + .isAfter(now); + } + + private PrivateKey privateKey() { + try { + byte[] encoded = encodedPrivateKey(); + PrivateKey key = KeyFactory.getInstance("EC") + .generatePrivate(new PKCS8EncodedKeySpec(encoded)); + + if (!(key instanceof ECPrivateKey ecPrivateKey) + || ecPrivateKey.getParams().getCurve().getField().getFieldSize() != 256) { + throw new IllegalArgumentException("Apple key must use the P-256 curve"); + } + + return key; + } catch (RuntimeException | java.security.GeneralSecurityException exception) { + throw new IllegalStateException("Apple OAuth private key is invalid", exception); + } + } + + private byte[] encodedPrivateKey() { + if (properties.privateKey() != null + && !properties.privateKey().isBlank()) { + return decodePemOrBase64(properties.privateKey()); + } + + byte[] decoded = Base64.getDecoder().decode( + properties.privateKeyBase64().replaceAll("\\s", "")); + String possiblePem = new String(decoded, StandardCharsets.UTF_8); + + return possiblePem.contains("-----BEGIN PRIVATE KEY-----") + ? decodePemOrBase64(possiblePem) + : decoded; + } + + private byte[] decodePemOrBase64(String value) { + String normalized = value + .replace("\\n", "\n") + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + + return Base64.getDecoder().decode(normalized); + } + + private record CachedSecret( + String clientId, + String value, + Instant expiresAt + ) { + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleOAuthConfig.java b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleOAuthConfig.java new file mode 100644 index 0000000..038f404 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleOAuthConfig.java @@ -0,0 +1,43 @@ +package com.nalssilog.auth.oauth.apple; + +import java.util.Objects; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.client.endpoint.DefaultOAuth2TokenRequestParametersConverter; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.endpoint.RestClientAuthorizationCodeTokenResponseClient; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.util.MultiValueMap; + +@Configuration +public class AppleOAuthConfig { + + @Bean + public OAuth2AccessTokenResponseClient + authorizationCodeTokenResponseClient( + AppleClientSecretGenerator clientSecretGenerator + ) { + RestClientAuthorizationCodeTokenResponseClient client = + new RestClientAuthorizationCodeTokenResponseClient(); + DefaultOAuth2TokenRequestParametersConverter + defaults = new DefaultOAuth2TokenRequestParametersConverter<>(); + + client.setParametersConverter(request -> { + MultiValueMap parameters = + Objects.requireNonNull(defaults.convert(request)); + + if (AppleOAuthProperties.REGISTRATION_ID.equals( + request.getClientRegistration().getRegistrationId())) { + parameters.set( + OAuth2ParameterNames.CLIENT_SECRET, + clientSecretGenerator.generate( + request.getClientRegistration().getClientId())); + } + + return parameters; + }); + + return client; + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleOAuthProfileValidator.java b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleOAuthProfileValidator.java new file mode 100644 index 0000000..6cebed9 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleOAuthProfileValidator.java @@ -0,0 +1,19 @@ +package com.nalssilog.auth.oauth.apple; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +@Configuration +@Profile("apple") +@RequiredArgsConstructor +public class AppleOAuthProfileValidator { + + private final AppleOAuthProperties properties; + + @PostConstruct + void validate() { + properties.requireConfigured(); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleOAuthProperties.java b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleOAuthProperties.java new file mode 100644 index 0000000..3c15f57 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleOAuthProperties.java @@ -0,0 +1,72 @@ +package com.nalssilog.auth.oauth.apple; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "nalssilog.auth.apple") +public record AppleOAuthProperties( + String teamId, + String keyId, + String privateKey, + String privateKeyBase64, + Duration clientSecretTtl, + Duration refreshBeforeExpiry +) { + + public static final String REGISTRATION_ID = "apple"; + private static final Duration MAX_CLIENT_SECRET_TTL = Duration.ofDays(180); + + public AppleOAuthProperties { + if (clientSecretTtl == null) { + clientSecretTtl = Duration.ofDays(30); + } + + if (clientSecretTtl.isNegative() + || clientSecretTtl.isZero() + || clientSecretTtl.compareTo(MAX_CLIENT_SECRET_TTL) > 0) { + throw new IllegalArgumentException( + "Apple OAuth client-secret-ttl must be between 1ms and 180 days"); + } + + if (refreshBeforeExpiry == null) { + refreshBeforeExpiry = Duration.ofDays(1); + } + + if (refreshBeforeExpiry.isNegative() + || refreshBeforeExpiry.compareTo(clientSecretTtl) >= 0) { + throw new IllegalArgumentException( + "Apple OAuth refresh-before-expiry must be shorter than client-secret-ttl"); + } + } + + public void requireConfigured() { + requireText(teamId, "team-id"); + requireText(keyId, "key-id"); + + if (!hasText(privateKey) && !hasText(privateKeyBase64)) { + throw new IllegalStateException( + "Apple OAuth is active but neither private-key nor " + + "private-key-base64 is configured"); + } + } + + private void requireText(String value, String property) { + if (!hasText(value)) { + throw new IllegalStateException( + "Apple OAuth is active but nalssilog.auth.apple." + + property + " is missing"); + } + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + @Override + public String toString() { + return "AppleOAuthProperties[teamId=, keyId=, " + + "privateKey=, privateKeyBase64=, clientSecretTtl=" + + clientSecretTtl + + ", refreshBeforeExpiry=" + refreshBeforeExpiry + "]"; + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/repository/AuthTicketStore.java b/auth/src/main/java/com/nalssilog/auth/repository/AuthTicketStore.java deleted file mode 100644 index 5b5b5ad..0000000 --- a/auth/src/main/java/com/nalssilog/auth/repository/AuthTicketStore.java +++ /dev/null @@ -1,99 +0,0 @@ -package com.nalssilog.auth.repository; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.nalssilog.auth.domain.LinkTicket; -import com.nalssilog.auth.domain.SignupTicket; -import java.time.Duration; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Repository; - -/** 확정 전 상태(가입 대기/연동 대기)를 잠깐 들고 있는 티켓 저장소 (Redis, 단기 TTL, JSON 직렬화). */ -@Repository -@RequiredArgsConstructor -public class AuthTicketStore { - - private static final String SIGNUP_PREFIX = "auth:ticket:signup:"; - private static final String LINK_PREFIX = "auth:ticket:link:"; - private static final String LINK_CONSENT_PREFIX = "auth:ticket:link:consent:"; - private static final String LINK_INTENT_PREFIX = "auth:ticket:link-intent:"; - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - - private final StringRedisTemplate redisTemplate; - - public void saveSignup(String ticketId, SignupTicket ticket, Duration ttl) { - redisTemplate.opsForValue().set(SIGNUP_PREFIX + ticketId, serialize(ticket), ttl); - } - - public Optional findSignup(String ticketId) { - return read(SIGNUP_PREFIX + ticketId, SignupTicket.class); - } - - public void deleteSignup(String ticketId) { - redisTemplate.delete(SIGNUP_PREFIX + ticketId); - } - - public void saveLink(String ticketId, LinkTicket ticket, Duration ttl) { - redisTemplate.opsForValue().set(LINK_PREFIX + ticketId, serialize(ticket), ttl); - } - - public Optional findLink(String ticketId) { - return read(LINK_PREFIX + ticketId, LinkTicket.class); - } - - public void deleteLink(String ticketId) { - redisTemplate.delete(LINK_PREFIX + ticketId); - } - - /** '연동' 명시 동의 플래그. 이게 있어야만 재인증 후 실제 연동됨(방치 티켓 자동연동 방지). */ - public void markLinkConsented(String ticketId, Duration ttl) { - redisTemplate.opsForValue().set(LINK_CONSENT_PREFIX + ticketId, "1", ttl); - } - - public boolean isLinkConsented(String ticketId) { - return Boolean.TRUE.equals(redisTemplate.hasKey(LINK_CONSENT_PREFIX + ticketId)); - } - - public void deleteLinkConsent(String ticketId) { - redisTemplate.delete(LINK_CONSENT_PREFIX + ticketId); - } - - /** 설정에서 시작한 '소셜 추가 연동' 의도(memberId 를 잠깐 보관, 성공 핸들러가 연동에 사용). */ - public void saveLinkIntent(String intentId, Long memberId, Duration ttl) { - redisTemplate.opsForValue().set(LINK_INTENT_PREFIX + intentId, String.valueOf(memberId), ttl); - } - - public Optional findLinkIntent(String intentId) { - String value = redisTemplate.opsForValue().get(LINK_INTENT_PREFIX + intentId); - - return Optional.ofNullable(value).map(Long::valueOf); - } - - public void deleteLinkIntent(String intentId) { - redisTemplate.delete(LINK_INTENT_PREFIX + intentId); - } - - private Optional read(String key, Class type) { - String value = redisTemplate.opsForValue().get(key); - - if (value == null) { - return Optional.empty(); - } - - try { - return Optional.of(OBJECT_MAPPER.readValue(value, type)); - } catch (JsonProcessingException e) { - throw new IllegalStateException("인증 티켓 역직렬화 실패", e); - } - } - - private String serialize(Object ticket) { - try { - return OBJECT_MAPPER.writeValueAsString(ticket); - } catch (JsonProcessingException e) { - throw new IllegalStateException("인증 티켓 직렬화 실패", e); - } - } -} diff --git a/auth/src/main/java/com/nalssilog/auth/security/ApiAuthenticationEntryPoint.java b/auth/src/main/java/com/nalssilog/auth/security/ApiAuthenticationEntryPoint.java new file mode 100644 index 0000000..84c7aec --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/security/ApiAuthenticationEntryPoint.java @@ -0,0 +1,47 @@ +package com.nalssilog.auth.security; + +import com.nalssilog.auth.core.AuthErrorCode; +import com.nalssilog.common.exception.ErrorResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +@Component +@RequiredArgsConstructor +public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception + ) throws IOException { + AuthErrorCode errorCode = exception instanceof CredentialAuthenticationException credentialException + ? credentialException.getErrorCode() + : AuthErrorCode.AUTH_SESSION_EXPIRED; + + response.setStatus(errorCode.getStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.setHeader(HttpHeaders.CACHE_CONTROL, "no-store"); + response.setHeader(HttpHeaders.PRAGMA, "no-cache"); + + if (exception instanceof CredentialAuthenticationException credentialException + && credentialException.isBearer()) { + response.setHeader(HttpHeaders.WWW_AUTHENTICATE, "Bearer"); + } + + objectMapper.writeValue( + response.getWriter(), + new ErrorResponse(errorCode.getCode(), errorCode.getMessage())); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/security/AuthNoStoreFilter.java b/auth/src/main/java/com/nalssilog/auth/security/AuthNoStoreFilter.java new file mode 100644 index 0000000..c21631c --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/security/AuthNoStoreFilter.java @@ -0,0 +1,38 @@ +package com.nalssilog.auth.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 2) +public class AuthNoStoreFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + response.setHeader(HttpHeaders.CACHE_CONTROL, "no-store"); + response.setHeader(HttpHeaders.PRAGMA, "no-cache"); + response.setHeader("Referrer-Policy", "no-referrer"); + filterChain.doFilter(request, response); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + + return !path.startsWith("/api/auth/") + && !path.startsWith("/login/oauth2/code/") + && !path.equals("/api/mobile/guests"); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/config/AuthSecurityConfig.java b/auth/src/main/java/com/nalssilog/auth/security/AuthSecurityConfig.java similarity index 51% rename from auth/src/main/java/com/nalssilog/auth/config/AuthSecurityConfig.java rename to auth/src/main/java/com/nalssilog/auth/security/AuthSecurityConfig.java index e2c22f8..87e5522 100644 --- a/auth/src/main/java/com/nalssilog/auth/config/AuthSecurityConfig.java +++ b/auth/src/main/java/com/nalssilog/auth/security/AuthSecurityConfig.java @@ -1,22 +1,39 @@ -package com.nalssilog.auth.config; +package com.nalssilog.auth.security; -import com.nalssilog.auth.application.CustomOAuth2UserService; +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.auth.config.CorsProperties; +import com.nalssilog.auth.mobile.guest.MobileGuestCredentialFilter; +import com.nalssilog.auth.mobile.oauth.MobileOAuthAuthorizationRequestRepository; +import com.nalssilog.auth.mobile.oauth.MobileOAuthAuthorizationRequestResolver; +import com.nalssilog.auth.oauth.CustomOAuth2UserService; +import com.nalssilog.auth.oauth.CustomOidcUserService; +import com.nalssilog.auth.oauth.OAuth2LoginFailureHandler; +import com.nalssilog.auth.oauth.OAuth2LoginSuccessHandler; +import com.nalssilog.auth.oauth.apple.AppleAuthorizationResponseFilter; +import com.nalssilog.auth.token.JwtAuthenticationFilter; +import com.nalssilog.common.security.VerifiedRequestCredentials; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfFilter; import org.springframework.security.web.csrf.InvalidCsrfTokenException; import org.springframework.security.web.csrf.MissingCsrfTokenException; import org.springframework.web.cors.CorsConfiguration; @@ -29,22 +46,31 @@ public class AuthSecurityConfig { private final CustomOAuth2UserService customOAuth2UserService; + private final CustomOidcUserService customOidcUserService; private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; private final OAuth2LoginFailureHandler oAuth2LoginFailureHandler; private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final MobileGuestCredentialFilter mobileGuestCredentialFilter; + private final AppleAuthorizationResponseFilter appleAuthorizationResponseFilter; + private final MobileOAuthAuthorizationRequestResolver mobileOAuthAuthorizationRequestResolver; + private final MobileOAuthAuthorizationRequestRepository mobileOAuthAuthorizationRequestRepository; + private final OAuth2AccessTokenResponseClient + authorizationCodeTokenResponseClient; + private final ApiAuthenticationEntryPoint apiAuthenticationEntryPoint; private final AuthProperties authProperties; private final CorsProperties corsProperties; @Bean - // S1130/S112: HttpSecurity.build() 가 checked Exception 을 던져 throws Exception 이 강제됨(프레임워크 API). @SuppressWarnings({"java:S1130", "java:S112"}) public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http - // 쿠키 기반 인증이라 CSRF 방어 필요. SPA 더블 서브밋: XSRF-TOKEN 쿠키(JS 읽기 가능) ↔ X-XSRF-TOKEN 헤더. - // 프론트는 상태변경(POST/PUT/PATCH/DELETE) 요청에 이 헤더를 실어야 한다. (GET·OAuth 리다이렉트는 면제) .csrf(csrf -> csrf .csrfTokenRepository(csrfTokenRepository()) - .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())) + .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()) + .ignoringRequestMatchers( + this::isMobileCredentialEndpoint, + this::isAppleAuthorizationCallback, + VerifiedRequestCredentials::hasNonCookieCredential)) .cors(Customizer.withDefaults()) .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) @@ -52,46 +78,50 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/error", "/api/health", "/oauth2/**", "/login/oauth2/**").permitAll() - // 소셜 로그인 진입·세션 조회·가입 확정·연동 흐름 (JWT 없이 티켓/쿠키로 식별) .requestMatchers("/api/auth/login/**", "/api/auth/me", "/api/auth/signup").permitAll() - // 설정에서 시작하는 소셜 추가 연동은 로그인 상태 전용 (아래 link/** permitAll 보다 먼저 매칭) + .requestMatchers( + "/api/auth/mobile/login/**", + "/api/auth/mobile/token", + "/api/auth/mobile/signup", + "/api/auth/mobile/refresh", + "/api/auth/mobile/logout", + "/api/auth/mobile/link/consent", + "/api/auth/mobile/link/cancel", + "/api/mobile/guests").permitAll() + .requestMatchers("/api/auth/mobile/link/social/**").authenticated() .requestMatchers("/api/auth/link/social/**").authenticated() .requestMatchers("/api/auth/refresh", "/api/auth/logout", "/api/auth/link/**").permitAll() - // 즐겨찾기는 회원 전용 (아래 지역 GET 허용보다 먼저 매칭돼야 함) .requestMatchers("/api/locations/favorites", "/api/locations/favorites/**").authenticated() - // 내 제보 목록은 회원 전용 (아래 report GET 허용보다 먼저 매칭돼야 함) .requestMatchers(HttpMethod.GET, "/api/reports/me").authenticated() .requestMatchers(HttpMethod.GET, "/api/locations/**", "/api/reports/**").permitAll() - // 닉네임 중복확인은 가입(비로그인) 단계에서도 써야 하므로 공개 .requestMatchers(HttpMethod.GET, "/api/members/nickname/availability").permitAll() - // 본인 계정 조회는 인증 필요 (아래 공개 프로필 permitAll 보다 먼저 매칭) .requestMatchers(HttpMethod.GET, "/api/members/me").authenticated() - // 공개 회원 프로필 조회 (단일 세그먼트만 — /me·/me/**·/nickname/** 는 인증 유지) .requestMatchers(HttpMethod.GET, "/api/members/*").permitAll() - // 서비스 피드백은 비로그인도 제출 가능 (memberId 는 있으면 기록) .requestMatchers(HttpMethod.POST, "/api/feedbacks").permitAll() - // 익명 제보·감사해요 허용 (익명 UUID 쿠키로 식별) .requestMatchers(HttpMethod.POST, "/api/reports/**").permitAll() .requestMatchers(HttpMethod.DELETE, "/api/reports/*").permitAll() .requestMatchers(HttpMethod.DELETE, "/api/reports/*/thanks").permitAll() .anyRequest().authenticated()) .oauth2Login(oauth2 -> oauth2 - .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService)) + .authorizationEndpoint(endpoint -> endpoint + .authorizationRequestResolver(mobileOAuthAuthorizationRequestResolver) + .authorizationRequestRepository(mobileOAuthAuthorizationRequestRepository)) + .tokenEndpoint(endpoint -> endpoint + .accessTokenResponseClient(authorizationCodeTokenResponseClient)) + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuth2UserService) + .oidcUserService(customOidcUserService)) .successHandler(oAuth2LoginSuccessHandler) .failureHandler(oAuth2LoginFailureHandler)) .exceptionHandling(exception -> exception - .authenticationEntryPoint((request, response, authException) -> - writeError(response, HttpServletResponse.SC_UNAUTHORIZED, "AUTH_SESSION_EXPIRED", "로그인이 필요합니다.")) - .accessDeniedHandler((request, response, accessDeniedException) -> { - if (accessDeniedException instanceof MissingCsrfTokenException) { - writeError(response, HttpServletResponse.SC_FORBIDDEN, "CSRF_TOKEN_MISSING", "CSRF 토큰이 없습니다."); - } else if (accessDeniedException instanceof InvalidCsrfTokenException) { - writeError(response, HttpServletResponse.SC_FORBIDDEN, "CSRF_TOKEN_INVALID", "CSRF 토큰이 유효하지 않습니다."); - } else { - writeError(response, HttpServletResponse.SC_FORBIDDEN, "ACCESS_DENIED", "접근 권한이 없습니다."); - } - })) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .authenticationEntryPoint(apiAuthenticationEntryPoint) + .accessDeniedHandler((request, response, accessDeniedException) -> + writeAccessDeniedError(response, accessDeniedException))) + .addFilterBefore(jwtAuthenticationFilter, CsrfFilter.class) + .addFilterAfter(mobileGuestCredentialFilter, JwtAuthenticationFilter.class) + .addFilterBefore( + appleAuthorizationResponseFilter, + OAuth2LoginAuthenticationFilter.class) .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class); return http.build(); @@ -100,22 +130,23 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - // credentials=true 라 "*" 불가. env 별 실제 프론트 origin 만 허용(패턴 지원 — *.vercel.app 등). + configuration.setAllowedOriginPatterns(corsProperties.allowedOrigins()); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); configuration.setAllowCredentials(true); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); return source; } - // S3330: 더블서브밋 CSRF 토큰은 프론트 JS 가 읽어 X-XSRF-TOKEN 헤더로 되돌려야 하므로 HttpOnly=false 가 의도된 설계. - // 인증 정보가 담긴 쿠키는 AuthCookieManager 에서 별도로 HttpOnly=true 로 생성한다. @SuppressWarnings("java:S3330") private CookieCsrfTokenRepository csrfTokenRepository() { CookieCsrfTokenRepository repository = new CookieCsrfTokenRepository(); + repository.setCookieName(authProperties.csrf().cookieName()); repository.setCookieCustomizer(cookie -> { cookie.httpOnly(false) @@ -124,6 +155,7 @@ private CookieCsrfTokenRepository csrfTokenRepository() { .path("/"); String domain = authProperties.csrf().cookieDomain(); + if (domain != null && !domain.isBlank()) { cookie.domain(domain); } @@ -132,8 +164,47 @@ private CookieCsrfTokenRepository csrfTokenRepository() { return repository; } - private void writeError(HttpServletResponse response, int status, String code, String message) - throws java.io.IOException { + private boolean isMobileCredentialEndpoint(HttpServletRequest request) { + String path = request.getRequestURI(); + + return path.startsWith("/api/auth/mobile/") + || path.equals("/api/mobile/guests"); + } + + private boolean isAppleAuthorizationCallback(HttpServletRequest request) { + return HttpMethod.POST.matches(request.getMethod()) + && AppleAuthorizationResponseFilter.CALLBACK_PATH.equals( + request.getRequestURI()); + } + + private void writeAccessDeniedError( + HttpServletResponse response, + AccessDeniedException exception + ) throws IOException { + if (exception instanceof MissingCsrfTokenException) { + writeError(response, HttpServletResponse.SC_FORBIDDEN, + "CSRF_TOKEN_MISSING", "CSRF 토큰이 없습니다."); + + return; + } + + if (exception instanceof InvalidCsrfTokenException) { + writeError(response, HttpServletResponse.SC_FORBIDDEN, + "CSRF_TOKEN_INVALID", "CSRF 토큰이 유효하지 않습니다."); + + return; + } + + writeError(response, HttpServletResponse.SC_FORBIDDEN, + "ACCESS_DENIED", "접근 권한이 없습니다."); + } + + private void writeError( + HttpServletResponse response, + int status, + String code, + String message + ) throws IOException { response.setStatus(status); response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setCharacterEncoding("UTF-8"); diff --git a/auth/src/main/java/com/nalssilog/auth/security/CredentialAuthenticationException.java b/auth/src/main/java/com/nalssilog/auth/security/CredentialAuthenticationException.java new file mode 100644 index 0000000..dcab701 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/security/CredentialAuthenticationException.java @@ -0,0 +1,25 @@ +package com.nalssilog.auth.security; + +import com.nalssilog.auth.core.AuthErrorCode; +import lombok.Getter; +import org.springframework.security.core.AuthenticationException; + +@Getter +public class CredentialAuthenticationException extends AuthenticationException { + + private final AuthErrorCode errorCode; + private final boolean bearer; + + public CredentialAuthenticationException(AuthErrorCode errorCode) { + this(errorCode, true); + } + + public CredentialAuthenticationException( + AuthErrorCode errorCode, + boolean bearer + ) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + this.bearer = bearer; + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/config/CsrfCookieFilter.java b/auth/src/main/java/com/nalssilog/auth/security/CsrfCookieFilter.java similarity index 96% rename from auth/src/main/java/com/nalssilog/auth/config/CsrfCookieFilter.java rename to auth/src/main/java/com/nalssilog/auth/security/CsrfCookieFilter.java index 02ac59e..896c733 100644 --- a/auth/src/main/java/com/nalssilog/auth/config/CsrfCookieFilter.java +++ b/auth/src/main/java/com/nalssilog/auth/security/CsrfCookieFilter.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.config; +package com.nalssilog.auth.security; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; diff --git a/auth/src/main/java/com/nalssilog/auth/config/SpaCsrfTokenRequestHandler.java b/auth/src/main/java/com/nalssilog/auth/security/SpaCsrfTokenRequestHandler.java similarity index 97% rename from auth/src/main/java/com/nalssilog/auth/config/SpaCsrfTokenRequestHandler.java rename to auth/src/main/java/com/nalssilog/auth/security/SpaCsrfTokenRequestHandler.java index c2b7272..06f4643 100644 --- a/auth/src/main/java/com/nalssilog/auth/config/SpaCsrfTokenRequestHandler.java +++ b/auth/src/main/java/com/nalssilog/auth/security/SpaCsrfTokenRequestHandler.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.config; +package com.nalssilog.auth.security; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; diff --git a/auth/src/main/java/com/nalssilog/auth/ticket/AuthChannel.java b/auth/src/main/java/com/nalssilog/auth/ticket/AuthChannel.java new file mode 100644 index 0000000..fed2c2f --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/ticket/AuthChannel.java @@ -0,0 +1,6 @@ +package com.nalssilog.auth.ticket; + +public enum AuthChannel { + WEB, + MOBILE +} diff --git a/auth/src/main/java/com/nalssilog/auth/ticket/AuthTicketStore.java b/auth/src/main/java/com/nalssilog/auth/ticket/AuthTicketStore.java new file mode 100644 index 0000000..9a622d7 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/ticket/AuthTicketStore.java @@ -0,0 +1,222 @@ +package com.nalssilog.auth.ticket; + +import com.nalssilog.member.domain.Provider; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Repository; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +/** 확정 전 상태(가입 대기/연동 대기)를 잠깐 들고 있는 티켓 저장소 (Redis, 단기 TTL, JSON 직렬화). */ +@Repository +@RequiredArgsConstructor +public class AuthTicketStore { + + private static final String SIGNUP_PREFIX = "auth:ticket:signup:"; + private static final String LINK_PREFIX = "auth:ticket:link:"; + private static final String LINK_CONSENT_PREFIX = "auth:ticket:link:consent:"; + private static final String LINK_INTENT_PREFIX = "auth:ticket:link-intent:"; + private static final String SIGNUP_LOCK_PREFIX = "auth:ticket:signup-lock:"; + private static final String SIGNUP_COMPLETED_PREFIX = "auth:ticket:signup-completed:"; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static final DefaultRedisScript COMPLETE_SIGNUP_SCRIPT = new DefaultRedisScript<>(""" + if redis.call('GET', KEYS[1]) ~= ARGV[1] then + return 0 + end + redis.call('SET', KEYS[2], ARGV[2], 'PX', ARGV[3]) + redis.call('DEL', KEYS[3]) + redis.call('DEL', KEYS[1]) + return 1 + """, Long.class); + + private static final DefaultRedisScript RELEASE_LOCK_SCRIPT = new DefaultRedisScript<>(""" + if redis.call('GET', KEYS[1]) == ARGV[1] then + return redis.call('DEL', KEYS[1]) + end + return 0 + """, Long.class); + + private final StringRedisTemplate redisTemplate; + + public void saveSignup(String ticketId, SignupTicket ticket, Duration ttl) { + redisTemplate.opsForValue().set(SIGNUP_PREFIX + ticketId, serialize(ticket), ttl); + } + + public Optional findSignup(String ticketId) { + return read(SIGNUP_PREFIX + ticketId, SignupTicket.class); + } + + public void deleteSignup(String ticketId) { + redisTemplate.delete(SIGNUP_PREFIX + ticketId); + } + + public SignupClaim claimSignup(String ticketId, String claimId, Duration ttl) { + Optional completed = findSignupCompletion(ticketId); + + if (completed.isPresent()) { + return SignupClaim.completed(completed.get()); + } + + Boolean acquired = redisTemplate.opsForValue().setIfAbsent( + SIGNUP_LOCK_PREFIX + ticketId, + claimId, + ttl); + + if (!Boolean.TRUE.equals(acquired)) { + return findSignupCompletion(ticketId) + .map(SignupClaim::completed) + .orElseGet(SignupClaim::inProgress); + } + + Optional ticket = findSignup(ticketId); + + if (ticket.isEmpty()) { + releaseSignupClaim(ticketId, claimId); + + return findSignupCompletion(ticketId) + .map(SignupClaim::completed) + .orElseGet(SignupClaim::missing); + } + + return SignupClaim.claimed(ticket.get()); + } + + public void completeSignup( + String ticketId, + String claimId, + SignupCompletion completion, + Duration ttl + ) { + Long completed = redisTemplate.execute( + COMPLETE_SIGNUP_SCRIPT, + List.of( + SIGNUP_LOCK_PREFIX + ticketId, + SIGNUP_COMPLETED_PREFIX + ticketId, + SIGNUP_PREFIX + ticketId), + claimId, + serialize(completion), + String.valueOf(ttl.toMillis())); + + if (completed == null || completed != 1L) { + throw new IllegalStateException("signup ticket claim was lost"); + } + } + + public void releaseSignupClaim(String ticketId, String claimId) { + redisTemplate.execute( + RELEASE_LOCK_SCRIPT, + List.of(SIGNUP_LOCK_PREFIX + ticketId), + claimId); + } + + public Optional findSignupCompletion(String ticketId) { + return read(SIGNUP_COMPLETED_PREFIX + ticketId, SignupCompletion.class); + } + + public void saveLink(String ticketId, LinkTicket ticket, Duration ttl) { + redisTemplate.opsForValue().set(LINK_PREFIX + ticketId, serialize(ticket), ttl); + } + + public Optional findLink(String ticketId) { + return read(LINK_PREFIX + ticketId, LinkTicket.class); + } + + public void deleteLink(String ticketId) { + redisTemplate.delete(LINK_PREFIX + ticketId); + } + + /** '연동' 명시 동의 플래그. 이게 있어야만 재인증 후 실제 연동됨(방치 티켓 자동연동 방지). */ + public void markLinkConsented(String ticketId, Duration ttl) { + redisTemplate.opsForValue().set(LINK_CONSENT_PREFIX + ticketId, "1", ttl); + } + + public boolean isLinkConsented(String ticketId) { + return Boolean.TRUE.equals(redisTemplate.hasKey(LINK_CONSENT_PREFIX + ticketId)); + } + + public void deleteLinkConsent(String ticketId) { + redisTemplate.delete(LINK_CONSENT_PREFIX + ticketId); + } + + /** 설정에서 시작한 '소셜 추가 연동' 의도(memberId 를 잠깐 보관, 성공 핸들러가 연동에 사용). */ + public void saveLinkIntent(String intentId, Long memberId, Duration ttl) { + redisTemplate.opsForValue().set(LINK_INTENT_PREFIX + intentId, String.valueOf(memberId), ttl); + } + + public Optional findLinkIntent(String intentId) { + String value = redisTemplate.opsForValue().get(LINK_INTENT_PREFIX + intentId); + + return Optional.ofNullable(value).map(Long::valueOf); + } + + public void deleteLinkIntent(String intentId) { + redisTemplate.delete(LINK_INTENT_PREFIX + intentId); + } + + private Optional read(String key, Class type) { + String value = redisTemplate.opsForValue().get(key); + + if (value == null) { + return Optional.empty(); + } + + try { + return Optional.of(OBJECT_MAPPER.readValue(value, type)); + } catch (JacksonException e) { + throw new IllegalStateException("인증 티켓 역직렬화 실패", e); + } + } + + private String serialize(Object ticket) { + try { + return OBJECT_MAPPER.writeValueAsString(ticket); + } catch (JacksonException e) { + throw new IllegalStateException("인증 티켓 직렬화 실패", e); + } + } + + public enum SignupClaimStatus { + CLAIMED, + COMPLETED, + IN_PROGRESS, + MISSING + } + + public record SignupCompletion( + Long memberId, + Provider provider, + String accessToken, + String refreshToken, + long refreshTokenMaxAgeMillis, + long completedAtEpochMillis + ) { + } + + public record SignupClaim( + SignupClaimStatus status, + SignupTicket ticket, + SignupCompletion completion + ) { + + private static SignupClaim claimed(SignupTicket ticket) { + return new SignupClaim(SignupClaimStatus.CLAIMED, ticket, null); + } + + private static SignupClaim completed(SignupCompletion completion) { + return new SignupClaim(SignupClaimStatus.COMPLETED, null, completion); + } + + private static SignupClaim inProgress() { + return new SignupClaim(SignupClaimStatus.IN_PROGRESS, null, null); + } + + private static SignupClaim missing() { + return new SignupClaim(SignupClaimStatus.MISSING, null, null); + } + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/domain/LinkTicket.java b/auth/src/main/java/com/nalssilog/auth/ticket/LinkTicket.java similarity index 55% rename from auth/src/main/java/com/nalssilog/auth/domain/LinkTicket.java rename to auth/src/main/java/com/nalssilog/auth/ticket/LinkTicket.java index 264c781..e0e6a14 100644 --- a/auth/src/main/java/com/nalssilog/auth/domain/LinkTicket.java +++ b/auth/src/main/java/com/nalssilog/auth/ticket/LinkTicket.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.domain; +package com.nalssilog.auth.ticket; import com.nalssilog.member.domain.Provider; import java.util.List; @@ -13,6 +13,21 @@ public record LinkTicket( String providerUserId, String email, Long targetMemberId, - List existingProviders + List existingProviders, + AuthChannel channel ) { + + public LinkTicket( + Provider provider, + String providerUserId, + String email, + Long targetMemberId, + List existingProviders + ) { + this(provider, providerUserId, email, targetMemberId, existingProviders, AuthChannel.WEB); + } + + public AuthChannel effectiveChannel() { + return channel == null ? AuthChannel.WEB : channel; + } } diff --git a/auth/src/main/java/com/nalssilog/auth/ticket/SignupTicket.java b/auth/src/main/java/com/nalssilog/auth/ticket/SignupTicket.java new file mode 100644 index 0000000..238d892 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/ticket/SignupTicket.java @@ -0,0 +1,29 @@ +package com.nalssilog.auth.ticket; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.nalssilog.member.domain.Provider; + +/** + * 소셜 인증은 끝났지만 아직 가입을 확정하지 않은 신규 사용자의 임시 상태. (Redis 단기 보관) + */ +public record SignupTicket( + Provider provider, + String providerUserId, + String email, + @JsonAlias("nickname") String socialName, + AuthChannel channel +) { + + public SignupTicket( + Provider provider, + String providerUserId, + String email, + String socialName + ) { + this(provider, providerUserId, email, socialName, AuthChannel.WEB); + } + + public AuthChannel effectiveChannel() { + return channel == null ? AuthChannel.WEB : channel; + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/token/AuthRequestDetails.java b/auth/src/main/java/com/nalssilog/auth/token/AuthRequestDetails.java new file mode 100644 index 0000000..b62fa4a --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/token/AuthRequestDetails.java @@ -0,0 +1,10 @@ +package com.nalssilog.auth.token; + +import com.nalssilog.member.domain.Provider; + +public record AuthRequestDetails( + Provider provider, + String sessionId, + CredentialTransport transport +) { +} diff --git a/auth/src/main/java/com/nalssilog/auth/application/AuthSessionService.java b/auth/src/main/java/com/nalssilog/auth/token/AuthSessionService.java similarity index 75% rename from auth/src/main/java/com/nalssilog/auth/application/AuthSessionService.java rename to auth/src/main/java/com/nalssilog/auth/token/AuthSessionService.java index c4b8c63..4de4e35 100644 --- a/auth/src/main/java/com/nalssilog/auth/application/AuthSessionService.java +++ b/auth/src/main/java/com/nalssilog/auth/token/AuthSessionService.java @@ -1,10 +1,7 @@ -package com.nalssilog.auth.application; +package com.nalssilog.auth.token; -import com.nalssilog.auth.application.dto.SessionData; -import com.nalssilog.auth.application.dto.SessionView; import com.nalssilog.auth.config.AuthProperties; -import com.nalssilog.auth.domain.AuthErrorCode; -import com.nalssilog.auth.repository.RefreshTokenStore; +import com.nalssilog.auth.core.AuthErrorCode; import com.nalssilog.common.exception.NalssiLogException; import java.util.Comparator; import java.util.List; @@ -22,7 +19,7 @@ public class AuthSessionService { private final RefreshTokenStore refreshTokenStore; private final AuthProperties properties; - public List listSessions(Long memberId, String currentTokenHash) { + public List listSessions(Long memberId, String currentSessionId) { return refreshTokenStore.findSessionsByMember(memberId).stream() .sorted(Comparator.comparing(SessionData::lastActiveAt).reversed()) .map(session -> new SessionView( @@ -31,23 +28,22 @@ public List listSessions(Long memberId, String currentTokenHash) { session.ip(), session.loginAt(), session.lastActiveAt(), - session.tokenHash().equals(currentTokenHash))) + session.sessionId().equals(currentSessionId))) .toList(); } /** * 특정 세션(기기) 로그아웃. 대상이 현재 세션이면 true 를 반환해 컨트롤러가 쿠키까지 정리하게 한다. */ - public boolean revokeSession(Long memberId, String sessionId, String currentTokenHash) { + public boolean revokeSession(Long memberId, String sessionId, String currentSessionId) { List sessions = refreshTokenStore.findSessionsByMember(memberId); SessionData target = sessions.stream() .filter(session -> session.sessionId().equals(sessionId)) .findFirst() .orElseThrow(() -> new NalssiLogException(AuthErrorCode.SESSION_NOT_FOUND)); - boolean current = currentTokenHash != null && sessions.stream() - .anyMatch(session -> session.sessionId().equals(sessionId) - && session.tokenHash().equals(currentTokenHash)); + boolean current = currentSessionId != null && sessionId.equals(currentSessionId); + refreshTokenStore.revokeSession(memberId, target.sessionId(), properties.jwt().refreshTokenTtl()); return current; diff --git a/auth/src/main/java/com/nalssilog/auth/application/AuthTokenService.java b/auth/src/main/java/com/nalssilog/auth/token/AuthTokenService.java similarity index 86% rename from auth/src/main/java/com/nalssilog/auth/application/AuthTokenService.java rename to auth/src/main/java/com/nalssilog/auth/token/AuthTokenService.java index a63e346..50e4f11 100644 --- a/auth/src/main/java/com/nalssilog/auth/application/AuthTokenService.java +++ b/auth/src/main/java/com/nalssilog/auth/token/AuthTokenService.java @@ -1,13 +1,11 @@ -package com.nalssilog.auth.application; +package com.nalssilog.auth.token; -import com.nalssilog.auth.application.dto.DeviceInfo; -import com.nalssilog.auth.application.dto.SessionData; -import com.nalssilog.auth.client.MemberClient; import com.nalssilog.auth.config.AuthProperties; -import com.nalssilog.auth.domain.AuthErrorCode; -import com.nalssilog.auth.repository.RefreshTokenStore; -import com.nalssilog.auth.repository.RefreshTokenStore.RotationResult; -import com.nalssilog.auth.repository.RefreshTokenStore.RotationStatus; +import com.nalssilog.auth.core.AuthErrorCode; +import com.nalssilog.auth.device.DeviceInfo; +import com.nalssilog.auth.member.MemberClient; +import com.nalssilog.auth.token.RefreshTokenStore.RotationResult; +import com.nalssilog.auth.token.RefreshTokenStore.RotationStatus; import com.nalssilog.common.exception.NalssiLogException; import com.nalssilog.member.application.dto.MemberInfo; import com.nalssilog.member.domain.MemberStatus; @@ -43,7 +41,9 @@ public class AuthTokenService { public TokenPair issue(Long memberId, MemberStatus status, Provider provider, DeviceInfo device) { String sessionId = UUID.randomUUID().toString(); TokenPair tokens = issueWithSession(memberId, status, provider, sessionId, Instant.now(), device); + log.info("auth.refresh.issued memberId={} sessionId={} provider={}", memberId, sessionId, provider); + return tokens; } @@ -54,6 +54,7 @@ public TokenPair issue(Long memberId, MemberStatus status, Provider provider, De public TokenPair refresh(String refreshToken, DeviceInfo device) { String currentHash = hash(refreshToken); Optional current = refreshTokenStore.findSession(currentHash); + if (current.isEmpty()) { return resolveInactiveRefresh(currentHash); } @@ -83,7 +84,11 @@ public TokenPair refresh(String refreshToken, DeviceInfo device) { log.info("auth.refresh.rotated memberId={} sessionId={} token={}", member.id(), currentSession.sessionId(), fingerprint(currentHash)); yield tokenPair( - member, currentSession.provider(), replacementToken, properties.jwt().refreshTokenTtl()); + member, + currentSession.provider(), + currentSession.sessionId(), + replacementToken, + properties.jwt().refreshTokenTtl()); } case RETRIED -> retry(result, currentHash); case REUSED -> rejectReuse(result, currentHash); @@ -93,11 +98,14 @@ yield tokenPair( public void revoke(String refreshToken) { String tokenHash = hash(refreshToken); + refreshTokenStore.revokeByTokenHash(tokenHash, properties.jwt().refreshTokenTtl()) .ifPresentOrElse( session -> log.info("auth.refresh.revoked memberId={} sessionId={} reason=logout token={}", session.memberId(), session.sessionId(), fingerprint(tokenHash)), - () -> log.info("auth.refresh.revoke_skipped reason=not_found token={}", fingerprint(tokenHash))); + () -> log.info( + "auth.refresh.revoke_skipped reason=not_found token={}", + fingerprint(tokenHash))); } /** refresh 토큰의 세션 키(해시). '현재 세션' 판별용. */ @@ -108,20 +116,24 @@ public String tokenHash(String refreshToken) { /** 해당 회원의 모든 refresh 세션 강제 만료(전 기기). 탈퇴 시 사용. */ public void revokeAllSessions(Long memberId) { long revoked = refreshTokenStore.deleteAllByMember(memberId, properties.jwt().refreshTokenTtl()); + log.info("auth.refresh.revoked_all memberId={} sessions={} reason=withdrawal", memberId, revoked); } private TokenPair resolveInactiveRefresh(String currentHash) { RefreshTokenStore.UsedToken used = refreshTokenStore.findUsedToken(currentHash).orElse(null); + if (used == null) { return rejectExpired(currentHash, "not_found_or_expired"); } + if (refreshTokenStore.isSessionRevoked(used.sessionId())) { return rejectExpired(currentHash, "session_revoked"); } // retry key의 실제 존재 여부와 replacement 활성 여부는 원자 rotate 스크립트가 최종 판정한다. SessionData replacement = refreshTokenStore.findSession(used.replacementHash()).orElse(null); + if (replacement == null) { return rejectReuse( new RotationResult(RotationStatus.REUSED, "", used.replacementHash(), @@ -149,30 +161,38 @@ private TokenPair retry(RotationResult result, String currentHash) { SessionData replacement = refreshTokenStore.findSession(result.replacementHash()) .orElseThrow(() -> expired(currentHash, "replacement_missing")); MemberInfo member = activeMember(replacement.memberId(), replacement.sessionId(), currentHash); + if (result.refreshTokenTtlMillis() < 1_000) { throw expired(currentHash, "replacement_expiring"); } log.info("auth.refresh.retry_replayed memberId={} sessionId={} token={}", member.id(), replacement.sessionId(), fingerprint(currentHash)); + return tokenPair( member, replacement.provider(), + replacement.sessionId(), result.replacementToken(), Duration.ofMillis(result.refreshTokenTtlMillis())); } private TokenPair rejectReuse(RotationResult result, String currentHash) { - if (result.memberId() != null && result.sessionId() != null && !result.sessionId().isBlank()) { - long revoked = refreshTokenStore.revokeSession( - result.memberId(), result.sessionId(), properties.jwt().refreshTokenTtl()); - log.warn("auth.refresh.reuse_detected memberId={} sessionId={} revokedTokens={} token={}", - result.memberId(), result.sessionId(), revoked, fingerprint(currentHash)); - } else { + if (result.memberId() == null + || result.sessionId() == null + || result.sessionId().isBlank()) { log.warn("auth.refresh.reuse_detected memberId=unknown sessionId=unknown token={}", fingerprint(currentHash)); + + throw new NalssiLogException(AuthErrorCode.AUTH_REFRESH_REUSED); } + long revoked = refreshTokenStore.revokeSession( + result.memberId(), result.sessionId(), properties.jwt().refreshTokenTtl()); + + log.warn("auth.refresh.reuse_detected memberId={} sessionId={} revokedTokens={} token={}", + result.memberId(), result.sessionId(), revoked, fingerprint(currentHash)); + throw new NalssiLogException(AuthErrorCode.AUTH_REFRESH_REUSED); } @@ -182,12 +202,14 @@ private TokenPair rejectExpired(String currentHash, String reason) { private NalssiLogException expired(String currentHash, String reason) { log.info("auth.refresh.rejected reason={} token={}", reason, fingerprint(currentHash)); + return new NalssiLogException(AuthErrorCode.AUTH_SESSION_EXPIRED); } private MemberInfo activeMember(Long memberId, String sessionId, String tokenHash) { return memberClient.findMemberInfo(memberId).orElseThrow(() -> { refreshTokenStore.revokeSession(memberId, sessionId, properties.jwt().refreshTokenTtl()); + return expired(tokenHash, "member_missing"); }); } @@ -199,8 +221,9 @@ private TokenPair issueWithSession(Long memberId, MemberStatus status, Provider SessionData session = sessionData(tokenHash, memberId, provider, sessionId, loginAt, device); refreshTokenStore.save(tokenHash, session, properties.jwt().refreshTokenTtl()); + return new TokenPair( - jwtTokenProvider.createAccessToken(memberId, status, provider), + jwtTokenProvider.createAccessToken(memberId, status, provider, sessionId), refreshToken, properties.jwt().refreshTokenTtl()); } @@ -218,16 +241,24 @@ private SessionData sessionData(String tokenHash, Long memberId, Provider provid Instant.now()); } - private TokenPair tokenPair(MemberInfo member, Provider provider, String refreshToken, Duration maxAge) { + private TokenPair tokenPair( + MemberInfo member, + Provider provider, + String sessionId, + String refreshToken, + Duration maxAge + ) { return new TokenPair( - jwtTokenProvider.createAccessToken(member.id(), member.status(), provider), + jwtTokenProvider.createAccessToken(member.id(), member.status(), provider, sessionId), refreshToken, maxAge); } private String generateRefreshToken() { byte[] bytes = new byte[32]; + SECURE_RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); } @@ -238,6 +269,7 @@ private String fingerprint(String tokenHash) { private String hash(String token) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(token.getBytes(StandardCharsets.UTF_8))); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException("SHA-256 not available", e); diff --git a/auth/src/main/java/com/nalssilog/auth/token/CredentialTransport.java b/auth/src/main/java/com/nalssilog/auth/token/CredentialTransport.java new file mode 100644 index 0000000..773f132 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/token/CredentialTransport.java @@ -0,0 +1,6 @@ +package com.nalssilog.auth.token; + +public enum CredentialTransport { + COOKIE, + BEARER +} diff --git a/auth/src/main/java/com/nalssilog/auth/token/JwtAuthenticationFilter.java b/auth/src/main/java/com/nalssilog/auth/token/JwtAuthenticationFilter.java new file mode 100644 index 0000000..d9127d0 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/token/JwtAuthenticationFilter.java @@ -0,0 +1,170 @@ +package com.nalssilog.auth.token; + +import com.nalssilog.auth.core.AuthErrorCode; +import com.nalssilog.auth.mobile.guest.MobileGuestCredentialService; +import com.nalssilog.auth.oauth.SocialAuthPrincipal; +import com.nalssilog.auth.security.ApiAuthenticationEntryPoint; +import com.nalssilog.auth.security.CredentialAuthenticationException; +import com.nalssilog.auth.token.JwtTokenProvider.AccessTokenPayload; +import com.nalssilog.auth.token.JwtTokenProvider.TokenValidation; +import com.nalssilog.auth.token.JwtTokenProvider.TokenValidationStatus; +import com.nalssilog.auth.web.AuthCookieManager; +import com.nalssilog.common.filter.RequestLoggingFilter; +import com.nalssilog.common.security.VerifiedRequestCredentials; +import com.nalssilog.member.domain.MemberStatus; +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.Collections; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final Pattern BEARER_PATTERN = Pattern.compile( + "^Bearer ([A-Za-z0-9\\-._~+/]+=*)$", + Pattern.CASE_INSENSITIVE); + + private final AuthCookieManager cookieManager; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenStore refreshTokenStore; + private final ApiAuthenticationEntryPoint authenticationEntryPoint; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + List authorizationHeaders = + Collections.list(request.getHeaders(HttpHeaders.AUTHORIZATION)); + + if (!authorizationHeaders.isEmpty()) { + authenticateBearer(request, response, authorizationHeaders); + + if (response.isCommitted()) { + return; + } + + filterChain.doFilter(request, response); + + return; + } + + if (allowsCookieFallback(request)) { + authenticateCookie(request); + } + + filterChain.doFilter(request, response); + } + + private void authenticateBearer( + HttpServletRequest request, + HttpServletResponse response, + List values + ) throws IOException { + if (values.size() != 1 || values.getFirst() == null || values.getFirst().contains(",")) { + reject(request, response, AuthErrorCode.AUTH_ACCESS_TOKEN_INVALID); + + return; + } + + Matcher matcher = BEARER_PATTERN.matcher(values.getFirst()); + + if (!matcher.matches()) { + reject(request, response, AuthErrorCode.AUTH_ACCESS_TOKEN_INVALID); + + return; + } + + TokenValidation validation = jwtTokenProvider.validate(matcher.group(1)); + + if (validation.status() == TokenValidationStatus.EXPIRED) { + reject(request, response, AuthErrorCode.AUTH_ACCESS_TOKEN_EXPIRED); + + return; + } + + if (validation.status() != TokenValidationStatus.VALID + || !isUsable(validation.payload())) { + reject(request, response, AuthErrorCode.AUTH_ACCESS_TOKEN_INVALID); + + return; + } + + if (isRevoked(validation.payload())) { + reject(request, response, AuthErrorCode.AUTH_SESSION_EXPIRED); + + return; + } + + setAuthentication(request, validation.payload(), CredentialTransport.BEARER); + VerifiedRequestCredentials.markBearer(request); + } + + private void authenticateCookie(HttpServletRequest request) { + cookieManager.readAccessToken(request) + .flatMap(jwtTokenProvider::parse) + .filter(this::isUsable) + .filter(payload -> !isRevoked(payload)) + .ifPresent(payload -> setAuthentication(request, payload, CredentialTransport.COOKIE)); + } + + private boolean isUsable(AccessTokenPayload payload) { + return payload != null && payload.status() != MemberStatus.WITHDRAWN; + } + + private boolean isRevoked(AccessTokenPayload payload) { + return payload.sessionId() != null + && !payload.sessionId().isBlank() + && refreshTokenStore.isSessionRevoked(payload.sessionId()); + } + + private void setAuthentication( + HttpServletRequest request, + AccessTokenPayload payload, + CredentialTransport transport + ) { + var authentication = UsernamePasswordAuthenticationToken.authenticated( + payload.memberId(), + null, + List.of(new SimpleGrantedAuthority(SocialAuthPrincipal.roleOf(payload.status())))); + + authentication.setDetails(new AuthRequestDetails( + payload.provider(), payload.sessionId(), transport)); + SecurityContextHolder.getContext().setAuthentication(authentication); + request.setAttribute(RequestLoggingFilter.ACTOR_MEMBER_ID, payload.memberId()); + } + + private void reject( + HttpServletRequest request, + HttpServletResponse response, + AuthErrorCode errorCode + ) throws IOException { + SecurityContextHolder.clearContext(); + authenticationEntryPoint.commence( + request, + response, + new CredentialAuthenticationException(errorCode)); + } + + private boolean allowsCookieFallback(HttpServletRequest request) { + String path = request.getRequestURI(); + + return !path.startsWith("/api/auth/mobile/") + && !path.equals("/api/mobile/guests") + && request.getHeader(MobileGuestCredentialService.HEADER) == null; + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/token/JwtTokenProvider.java b/auth/src/main/java/com/nalssilog/auth/token/JwtTokenProvider.java new file mode 100644 index 0000000..90f3812 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/token/JwtTokenProvider.java @@ -0,0 +1,144 @@ +package com.nalssilog.auth.token; + +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.member.domain.MemberStatus; +import com.nalssilog.member.domain.Provider; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.Optional; +import javax.crypto.SecretKey; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +// S2143: jjwt 빌더가 Instant 오버로드를 제공하지 않아 토큰 발급 경계에서만 java.util.Date 로 변환(내부 계산은 java.time). +@SuppressWarnings("java:S2143") +public class JwtTokenProvider { + + private final SecretKey key; + private final Duration accessTokenTtl; + + public JwtTokenProvider(AuthProperties properties) { + String secret = properties.jwt().secret(); + + this.key = resolveKey(secret); + this.accessTokenTtl = properties.jwt().accessTokenTtl(); + } + + private static SecretKey resolveKey(String secret) { + if (secret == null || secret.isBlank()) { + log.warn("JWT secret 이 설정되지 않아 임시 키를 생성합니다. 재시작하면 모든 액세스 토큰이 무효화됩니다."); + + return Jwts.SIG.HS256.key().build(); + } + + return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + public String createAccessToken(Long memberId, MemberStatus status, Provider provider) { + return createAccessToken(memberId, status, provider, null); + } + + public String createAccessToken( + Long memberId, + MemberStatus status, + Provider provider, + String sessionId + ) { + Instant now = Instant.now(); + + var builder = Jwts.builder() + .subject(String.valueOf(memberId)) + .claim("status", status.name()) + .claim("provider", provider.name()) + .claim("token_type", "access") + .issuedAt(Date.from(now)) + .expiration(Date.from(now.plus(accessTokenTtl))); + + if (sessionId != null && !sessionId.isBlank()) { + builder.claim("sid", sessionId); + } + + return builder + .signWith(key) + .compact(); + } + + public Optional parse(String token) { + TokenValidation validation = validate(token); + + return validation.status() == TokenValidationStatus.VALID + ? Optional.of(validation.payload()) + : Optional.empty(); + } + + public TokenValidation validate(String token) { + try { + Claims claims = Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload(); + String status = claims.get("status", String.class); + String provider = claims.get("provider", String.class); + String tokenType = claims.get("token_type", String.class); + + if (status == null || provider == null + || (tokenType != null && !"access".equals(tokenType))) { + return TokenValidation.invalid(); + } + + return TokenValidation.valid(new AccessTokenPayload( + Long.parseLong(claims.getSubject()), + MemberStatus.valueOf(status), + Provider.valueOf(provider), + claims.get("sid", String.class) + )); + } catch (ExpiredJwtException _) { + return TokenValidation.expired(); + } catch (JwtException | IllegalArgumentException _) { + return TokenValidation.invalid(); + } + } + + public enum TokenValidationStatus { + VALID, + EXPIRED, + INVALID + } + + public record TokenValidation(TokenValidationStatus status, AccessTokenPayload payload) { + + private static TokenValidation valid(AccessTokenPayload payload) { + return new TokenValidation(TokenValidationStatus.VALID, payload); + } + + private static TokenValidation expired() { + return new TokenValidation(TokenValidationStatus.EXPIRED, null); + } + + private static TokenValidation invalid() { + return new TokenValidation(TokenValidationStatus.INVALID, null); + } + } + + public record AccessTokenPayload( + Long memberId, + MemberStatus status, + Provider provider, + String sessionId + ) { + + public AccessTokenPayload(Long memberId, MemberStatus status, Provider provider) { + this(memberId, status, provider, null); + } + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/repository/RefreshTokenStore.java b/auth/src/main/java/com/nalssilog/auth/token/RefreshTokenStore.java similarity index 99% rename from auth/src/main/java/com/nalssilog/auth/repository/RefreshTokenStore.java rename to auth/src/main/java/com/nalssilog/auth/token/RefreshTokenStore.java index 0aac44c..2405037 100644 --- a/auth/src/main/java/com/nalssilog/auth/repository/RefreshTokenStore.java +++ b/auth/src/main/java/com/nalssilog/auth/token/RefreshTokenStore.java @@ -1,6 +1,5 @@ -package com.nalssilog.auth.repository; +package com.nalssilog.auth.token; -import com.nalssilog.auth.application.dto.SessionData; import com.nalssilog.member.domain.Provider; import java.time.Duration; import java.time.Instant; @@ -155,10 +154,12 @@ public void save(String tokenHash, SessionData session, Duration ttl) { FIELD_LAST_ACTIVE_AT, String.valueOf(session.lastActiveAt().toEpochMilli())); String key = key(tokenHash); + redisTemplate.opsForHash().putAll(key, fields); redisTemplate.expire(key, ttl); String memberKey = memberKey(session.memberId()); + redisTemplate.opsForSet().add(memberKey, tokenHash); redisTemplate.expire(memberKey, ttl); } @@ -204,12 +205,14 @@ public Optional findSession(String tokenHash) { return Optional.of(toSessionData(tokenHash, raw)); } catch (IllegalArgumentException _) { // provider 필드가 없던 구버전 세션은 실제 인증 수단을 보장할 수 없으므로 재로그인시킨다. + return Optional.empty(); } } public Optional findUsedToken(String tokenHash) { Map raw = redisTemplate.opsForHash().entries(usedKey(tokenHash)); + if (raw.isEmpty()) { return Optional.empty(); } @@ -240,6 +243,7 @@ public Optional revokeByTokenHash(String tokenHash, Duration markerT .map(used -> new SessionRef(used.memberId(), used.sessionId()))); target.ifPresent(session -> revokeSession(session.memberId(), session.sessionId(), markerTtl)); + return target; } @@ -251,6 +255,7 @@ public long revokeSession(Long memberId, String sessionId, Duration markerTtl) { sessionId, String.valueOf(markerTtl.toMillis()), KEY_PREFIX); + return deleted == null ? 0 : deleted; } @@ -264,7 +269,9 @@ public long deleteAllByMember(Long memberId, Duration markerTtl) { for (String sessionId : sessionIds) { deleted += revokeSession(memberId, sessionId, markerTtl); } + redisTemplate.delete(memberKey(memberId)); + return deleted; } @@ -291,6 +298,7 @@ private RotationResult toRotationResult(List raw) { } RotationStatus status; + try { status = RotationStatus.valueOf(value(raw, 0)); } catch (IllegalArgumentException _) { @@ -320,6 +328,7 @@ private SessionData toSessionData(String tokenHash, Map raw) { private String str(Map raw, String field) { Object value = raw.get(field); + return value == null ? "" : value.toString(); } @@ -327,16 +336,19 @@ private String value(List raw, int index) { if (index >= raw.size() || raw.get(index) == null) { return ""; } + return raw.get(index).toString(); } private Long longValue(List raw, int index) { String value = value(raw, index); + return value.isBlank() ? null : Long.valueOf(value); } private long primitiveLongValue(List raw, int index) { String value = value(raw, index); + return value.isBlank() ? 0 : Long.parseLong(value); } diff --git a/auth/src/main/java/com/nalssilog/auth/application/dto/SessionData.java b/auth/src/main/java/com/nalssilog/auth/token/SessionData.java similarity index 92% rename from auth/src/main/java/com/nalssilog/auth/application/dto/SessionData.java rename to auth/src/main/java/com/nalssilog/auth/token/SessionData.java index 7adbbfc..b7f0a9f 100644 --- a/auth/src/main/java/com/nalssilog/auth/application/dto/SessionData.java +++ b/auth/src/main/java/com/nalssilog/auth/token/SessionData.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.application.dto; +package com.nalssilog.auth.token; import com.nalssilog.member.domain.Provider; import java.time.Instant; diff --git a/auth/src/main/java/com/nalssilog/auth/application/dto/SessionView.java b/auth/src/main/java/com/nalssilog/auth/token/SessionView.java similarity index 88% rename from auth/src/main/java/com/nalssilog/auth/application/dto/SessionView.java rename to auth/src/main/java/com/nalssilog/auth/token/SessionView.java index 66f64f6..2846d3e 100644 --- a/auth/src/main/java/com/nalssilog/auth/application/dto/SessionView.java +++ b/auth/src/main/java/com/nalssilog/auth/token/SessionView.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.application.dto; +package com.nalssilog.auth.token; import java.time.Instant; diff --git a/auth/src/main/java/com/nalssilog/auth/application/TokenPair.java b/auth/src/main/java/com/nalssilog/auth/token/TokenPair.java similarity index 85% rename from auth/src/main/java/com/nalssilog/auth/application/TokenPair.java rename to auth/src/main/java/com/nalssilog/auth/token/TokenPair.java index 86fd45a..e86094e 100644 --- a/auth/src/main/java/com/nalssilog/auth/application/TokenPair.java +++ b/auth/src/main/java/com/nalssilog/auth/token/TokenPair.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.application; +package com.nalssilog.auth.token; import java.time.Duration; diff --git a/auth/src/main/java/com/nalssilog/auth/web/AuthController.java b/auth/src/main/java/com/nalssilog/auth/web/AuthController.java new file mode 100644 index 0000000..24a1e7e --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/web/AuthController.java @@ -0,0 +1,176 @@ +package com.nalssilog.auth.web; + +import com.nalssilog.auth.core.AuthService.SignupResult; +import com.nalssilog.auth.core.AuthService.SocialLinkStart; +import com.nalssilog.auth.core.AuthService; +import com.nalssilog.auth.device.DeviceInfoResolver; +import com.nalssilog.auth.token.TokenPair; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import java.io.IOException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.annotation.CurrentSecurityContext; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + private final AuthCookieManager cookieManager; + private final DeviceInfoResolver deviceInfoResolver; + + /** 소셜 로그인 진입. 내부 Spring OAuth 경로를 은닉하고 302 시킨다. */ + @GetMapping("/login/{provider}") + public void login( + @PathVariable String provider, + HttpServletResponse response + ) throws IOException { + response.sendRedirect(authService.oauthAuthorizationUrl(provider)); + } + + /** 인증 상태 조회(stateless — AT/티켓 쿠키로 판단). */ + @GetMapping("/me") + public MeResponse me( + @AuthenticationPrincipal Long memberId, + HttpServletRequest request + ) { + boolean hasAuthenticationCookie = cookieManager.readAccessToken(request).isPresent() + || cookieManager.readRefreshToken(request).isPresent(); + + return MeResponse.from(authService.me( + memberId, + cookieManager.readSignupTicket(request), + cookieManager.readLinkTicket(request), + hasAuthenticationCookie)); + } + + /** 회원가입 확정. OAuth 정보는 signup 티켓에서 읽고, Member 는 여기서 처음 생성된다. */ + @PostMapping("/signup") + public MeResponse signup( + @Valid @RequestBody SignupRequest request, + HttpServletRequest httpRequest, + HttpServletResponse response + ) { + SignupResult result = authService.signup(cookieManager.readSignupTicket(httpRequest).orElse(null), + request.agreedTerms(), deviceInfoResolver.resolve(httpRequest)); + TokenPair tokens = result.tokens(); + + cookieManager.addAuthCookies(response, tokens.accessToken(), tokens.refreshToken(), + tokens.refreshTokenMaxAge()); + cookieManager.clearSignupTicketCookie(response); + + return MeResponse.authenticated(result.member()); + } + + @PostMapping("/refresh") + public void refresh( + HttpServletRequest request, + HttpServletResponse response + ) { + TokenPair tokens = authService.refresh( + cookieManager.readRefreshToken(request).orElse(null), + deviceInfoResolver.resolve(request)); + + cookieManager.addAuthCookies( + response, tokens.accessToken(), tokens.refreshToken(), tokens.refreshTokenMaxAge()); + } + + @PostMapping("/logout") + public void logout( + HttpServletRequest request, + HttpServletResponse response + ) { + authService.logout(cookieManager.readRefreshToken(request)); + cookieManager.clearAuthCookies(response); + } + + /** 회원 탈퇴: 익명화(member) + 제보 익명화(event) + 전 기기 세션 만료 + 쿠키 정리. 세션·쿠키 때문에 auth 소유. */ + @DeleteMapping("/withdraw") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void withdraw( + @AuthenticationPrincipal Long memberId, + HttpServletResponse response + ) { + authService.withdraw(memberId); + cookieManager.clearAuthCookies(response); + } + + /** 로그인된 기기 목록(current=이 기기). */ + @GetMapping("/sessions") + public List sessions( + @AuthenticationPrincipal Long memberId, + @CurrentSecurityContext(expression = "authentication.details.sessionId") String currentSessionId + ) { + return authService.sessions(memberId, currentSessionId).stream() + .map(SessionResponse::from) + .toList(); + } + + /** 특정 기기 로그아웃. 대상이 현재 기기면 인증 쿠키까지 정리한다. */ + @DeleteMapping("/sessions/{sessionId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void revokeSession( + @AuthenticationPrincipal Long memberId, + @CurrentSecurityContext(expression = "authentication.details.sessionId") String currentSessionId, + @PathVariable String sessionId, + HttpServletResponse response + ) { + boolean revokedCurrent = authService.revokeSession( + memberId, sessionId, currentSessionId); + + if (revokedCurrent) { + cookieManager.clearAuthCookies(response); + } + } + + /** 설정에서 소셜 추가 연동 시작. intent 세팅 후 OAuth 진입 URL 반환(실제 연동은 성공 핸들러). */ + @PostMapping("/link/social/{provider}") + public LinkConsentResponse startSocialLink( + @AuthenticationPrincipal Long memberId, + @PathVariable String provider, + HttpServletResponse response + ) { + SocialLinkStart link = authService.startSocialLink(memberId, provider); + + cookieManager.addLinkIntentCookie(response, link.intentId()); + + return new LinkConsentResponse(link.authorizationUrl()); + } + + /** 로그인-시점 연동 동의 + 기존 수단 재인증 URL 반환. */ + @PostMapping("/link/consent") + public LinkConsentResponse consentLink(HttpServletRequest request) { + return new LinkConsentResponse(authService.consentLink( + cookieManager.readLinkTicket(request).orElse(null))); + } + + @GetMapping("/link/reauth/{provider}") + public void linkReauth( + @PathVariable String provider, + HttpServletResponse response + ) throws IOException { + response.sendRedirect(authService.oauthAuthorizationUrl(provider)); + } + + @PostMapping("/link/cancel") + public void cancelLink( + HttpServletRequest request, + HttpServletResponse response + ) { + authService.cancelLink(cookieManager.readLinkTicket(request)); + cookieManager.clearLinkTicketCookie(response); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/config/AuthCookieManager.java b/auth/src/main/java/com/nalssilog/auth/web/AuthCookieManager.java similarity index 98% rename from auth/src/main/java/com/nalssilog/auth/config/AuthCookieManager.java rename to auth/src/main/java/com/nalssilog/auth/web/AuthCookieManager.java index 53c66ae..c07291e 100644 --- a/auth/src/main/java/com/nalssilog/auth/config/AuthCookieManager.java +++ b/auth/src/main/java/com/nalssilog/auth/web/AuthCookieManager.java @@ -1,5 +1,6 @@ -package com.nalssilog.auth.config; +package com.nalssilog.auth.web; +import com.nalssilog.auth.config.AuthProperties; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -34,9 +35,11 @@ public void addAuthCookies(HttpServletResponse response, String accessToken, Str public void addAuthCookies(HttpServletResponse response, String accessToken, String refreshToken, Duration refreshTokenMaxAge) { addAccessTokenCookie(response, accessToken); + Duration maxAge = refreshTokenMaxAge == null ? properties.jwt().refreshTokenTtl() : refreshTokenMaxAge; + addCookie(response, REFRESH_TOKEN_COOKIE, refreshToken, maxAge); } diff --git a/auth/src/main/java/com/nalssilog/auth/api/dto/AuthResult.java b/auth/src/main/java/com/nalssilog/auth/web/AuthResult.java similarity index 86% rename from auth/src/main/java/com/nalssilog/auth/api/dto/AuthResult.java rename to auth/src/main/java/com/nalssilog/auth/web/AuthResult.java index 0500810..19c5137 100644 --- a/auth/src/main/java/com/nalssilog/auth/api/dto/AuthResult.java +++ b/auth/src/main/java/com/nalssilog/auth/web/AuthResult.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.api.dto; +package com.nalssilog.auth.web; /** * 세션 조회로 표현되는 안정 상태. (콜백 전용 전이 상태 LINK_SUCCESS/LINK_FAILED/FAILED 는 여기 없음) diff --git a/auth/src/main/java/com/nalssilog/auth/api/dto/LinkConsentResponse.java b/auth/src/main/java/com/nalssilog/auth/web/LinkConsentResponse.java similarity index 84% rename from auth/src/main/java/com/nalssilog/auth/api/dto/LinkConsentResponse.java rename to auth/src/main/java/com/nalssilog/auth/web/LinkConsentResponse.java index 9dc9c09..db5eb3f 100644 --- a/auth/src/main/java/com/nalssilog/auth/api/dto/LinkConsentResponse.java +++ b/auth/src/main/java/com/nalssilog/auth/web/LinkConsentResponse.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.api.dto; +package com.nalssilog.auth.web; /** * 연동 동의 응답. 프론트는 이 URL 로 브라우저를 이동시키기만 한다(기존 provider 재인증 시작). diff --git a/auth/src/main/java/com/nalssilog/auth/api/dto/MeResponse.java b/auth/src/main/java/com/nalssilog/auth/web/MeResponse.java similarity index 96% rename from auth/src/main/java/com/nalssilog/auth/api/dto/MeResponse.java rename to auth/src/main/java/com/nalssilog/auth/web/MeResponse.java index a632b52..03dde93 100644 --- a/auth/src/main/java/com/nalssilog/auth/api/dto/MeResponse.java +++ b/auth/src/main/java/com/nalssilog/auth/web/MeResponse.java @@ -1,6 +1,6 @@ -package com.nalssilog.auth.api.dto; +package com.nalssilog.auth.web; -import com.nalssilog.auth.application.AuthService.MeState; +import com.nalssilog.auth.core.AuthService.MeState; import com.nalssilog.member.application.dto.MemberInfo; import com.nalssilog.member.domain.AvatarType; import com.nalssilog.member.domain.Provider; diff --git a/auth/src/main/java/com/nalssilog/auth/api/dto/SessionResponse.java b/auth/src/main/java/com/nalssilog/auth/web/SessionResponse.java similarity index 88% rename from auth/src/main/java/com/nalssilog/auth/api/dto/SessionResponse.java rename to auth/src/main/java/com/nalssilog/auth/web/SessionResponse.java index 4bec61d..651b3ef 100644 --- a/auth/src/main/java/com/nalssilog/auth/api/dto/SessionResponse.java +++ b/auth/src/main/java/com/nalssilog/auth/web/SessionResponse.java @@ -1,6 +1,6 @@ -package com.nalssilog.auth.api.dto; +package com.nalssilog.auth.web; -import com.nalssilog.auth.application.dto.SessionView; +import com.nalssilog.auth.token.SessionView; import java.time.Instant; /** diff --git a/auth/src/main/java/com/nalssilog/auth/api/dto/SignupRequest.java b/auth/src/main/java/com/nalssilog/auth/web/SignupRequest.java similarity index 94% rename from auth/src/main/java/com/nalssilog/auth/api/dto/SignupRequest.java rename to auth/src/main/java/com/nalssilog/auth/web/SignupRequest.java index 0d7a0dd..d96a447 100644 --- a/auth/src/main/java/com/nalssilog/auth/api/dto/SignupRequest.java +++ b/auth/src/main/java/com/nalssilog/auth/web/SignupRequest.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.api.dto; +package com.nalssilog.auth.web; import com.nalssilog.member.application.dto.TermsAgreement; import jakarta.validation.Valid; diff --git a/auth/src/test/java/com/nalssilog/auth/core/AuthServiceSignupTest.java b/auth/src/test/java/com/nalssilog/auth/core/AuthServiceSignupTest.java new file mode 100644 index 0000000..463eac6 --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/core/AuthServiceSignupTest.java @@ -0,0 +1,127 @@ +package com.nalssilog.auth.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.auth.device.DeviceInfo; +import com.nalssilog.auth.member.MemberClient; +import com.nalssilog.auth.ticket.AuthTicketStore.SignupClaim; +import com.nalssilog.auth.ticket.AuthTicketStore.SignupClaimStatus; +import com.nalssilog.auth.ticket.AuthTicketStore.SignupCompletion; +import com.nalssilog.auth.ticket.AuthTicketStore; +import com.nalssilog.auth.token.AuthSessionService; +import com.nalssilog.auth.token.AuthTokenService; +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.member.application.dto.MemberInfo; +import com.nalssilog.member.domain.AvatarType; +import com.nalssilog.member.domain.MemberStatus; +import com.nalssilog.member.domain.Provider; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("java:S5960") +class AuthServiceSignupTest { + + private final MemberClient memberClient = mock(MemberClient.class); + private final AuthTokenService tokenService = + mock(AuthTokenService.class); + private final AuthSessionService sessionService = + mock(AuthSessionService.class); + private final AuthTicketStore ticketStore = + mock(AuthTicketStore.class); + private final AuthService service = new AuthService( + memberClient, + tokenService, + sessionService, + ticketStore, + properties()); + + @Test + void completedSignupRetryReturnsTheSameTokenPair() { + SignupCompletion completion = new SignupCompletion( + 7L, + Provider.KAKAO, + "same-access-token", + "same-refresh-token", + Duration.ofDays(14).toMillis(), + Instant.now().toEpochMilli()); + + when(ticketStore.claimSignup( + org.mockito.ArgumentMatchers.eq("ticket"), + anyString(), + org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(10)))) + .thenReturn(new SignupClaim( + SignupClaimStatus.COMPLETED, + null, + completion)); + when(memberClient.getMemberInfo(7L)).thenReturn(member()); + + AuthService.SignupResult result = service.signupMobile( + "ticket", + List.of(), + new DeviceInfo( + "ANDROID · Galaxy · 0.1.0", + "client-a.test")); + + assertThat(result.tokens().accessToken()) + .isEqualTo("same-access-token"); + assertThat(result.tokens().refreshToken()) + .isEqualTo("same-refresh-token"); + assertThat(result.tokens().refreshTokenMaxAge()) + .isLessThanOrEqualTo(Duration.ofDays(14)) + .isGreaterThan(Duration.ofDays(13)); + verifyNoInteractions(tokenService); + } + + @Test + void mobileRefreshFailureDoesNotRequestWebCookieDeletion() { + DeviceInfo device = new DeviceInfo( + "ANDROID · Galaxy · 0.1.0", + "client-a.test"); + + when(tokenService.refresh("refresh-token", device)) + .thenThrow(new NalssiLogException( + AuthErrorCode.AUTH_SESSION_EXPIRED)); + + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> service.refreshMobile("refresh-token", device)); + + assertThat(exception.getErrorCode()) + .isEqualTo(AuthErrorCode.AUTH_SESSION_EXPIRED); + assertThat(exception) + .isNotInstanceOf(RefreshRejectedException.class); + } + + private MemberInfo member() { + return new MemberInfo( + 7L, + "사용자", + "구름산책", + "user@example.com", + AvatarType.PRESET, + "1", + MemberStatus.ACTIVE, + Provider.KAKAO, + List.of(Provider.KAKAO)); + } + + private AuthProperties properties() { + return new AuthProperties( + new AuthProperties.Jwt( + "test-secret-must-be-at-least-thirty-two-bytes", + Duration.ofMinutes(30), + Duration.ofDays(14)), + new AuthProperties.Cookie(false), + new AuthProperties.Ticket(Duration.ofMinutes(10)), + new AuthProperties.Csrf("XSRF-TOKEN", null), + new AuthProperties.Refresh(Duration.ofSeconds(5))); + } +} diff --git a/auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialCleanupServiceTest.java b/auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialCleanupServiceTest.java new file mode 100644 index 0000000..a0302c5 --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialCleanupServiceTest.java @@ -0,0 +1,54 @@ +package com.nalssilog.auth.mobile.guest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.nalssilog.auth.config.AuthProperties; +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class MobileGuestCredentialCleanupServiceTest { + + private final MobileGuestCredentialRepository repository = + mock(MobileGuestCredentialRepository.class); + private final MobileGuestCredentialCleanupService service = + new MobileGuestCredentialCleanupService(repository, properties()); + + @Test + void deletesOnlyCredentialsPastTheConfiguredRetention() { + Instant now = Instant.parse("2026-07-30T00:00:00Z"); + Instant cutoff = now.minus(Duration.ofDays(7)); + + when(repository.deleteByExpiresAtLessThanEqual(cutoff)).thenReturn(12); + + int deleted = service.cleanupExpiredCredentialsAt(now); + + assertThat(deleted).isEqualTo(12); + verify(repository).deleteByExpiresAtLessThanEqual(cutoff); + } + + private AuthProperties properties() { + return new AuthProperties( + new AuthProperties.Jwt( + "test-secret-must-be-at-least-thirty-two-bytes", + Duration.ofMinutes(30), + Duration.ofDays(14)), + new AuthProperties.Cookie(false), + new AuthProperties.Ticket(Duration.ofMinutes(10)), + new AuthProperties.Csrf("XSRF-TOKEN", null), + new AuthProperties.Refresh(Duration.ofSeconds(5)), + null, + new AuthProperties.Guest( + Duration.ofDays(365), + 300, + Duration.ofMinutes(10), + 3_000, + Duration.ofMinutes(1), + Duration.ofDays(7), + Duration.ofHours(6), + Duration.ofMinutes(1))); + } +} diff --git a/auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialServiceTest.java b/auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialServiceTest.java new file mode 100644 index 0000000..95e2d1d --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialServiceTest.java @@ -0,0 +1,77 @@ +package com.nalssilog.auth.mobile.guest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.common.security.SecretFingerprint; +import java.time.Duration; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +@SuppressWarnings("java:S5960") +class MobileGuestCredentialServiceTest { + + private final MobileGuestCredentialRepository repository = + mock(MobileGuestCredentialRepository.class); + private final MobileGuestIssuanceRateLimiter rateLimiter = + mock(MobileGuestIssuanceRateLimiter.class); + private final MobileGuestCredentialService service = + new MobileGuestCredentialService( + repository, + rateLimiter, + properties()); + + @Test + void issueReturnsRawSecretOnlyOnceAndPersistsItsHash() { + var issued = service.issue("client-a.test"); + ArgumentCaptor credentialCaptor = + ArgumentCaptor.forClass(MobileGuestCredential.class); + + verify(rateLimiter).check("client-a.test"); + verify(repository).save(credentialCaptor.capture()); + + MobileGuestCredential persisted = credentialCaptor.getValue(); + + assertThat(issued.token()).hasSize(43); + assertThat(issued.expiresIn()).isEqualTo(Duration.ofDays(365)); + assertThat(persisted.getTokenHash()) + .isEqualTo(SecretFingerprint.sha256(issued.token())) + .doesNotContain(issued.token()); + assertThat(persisted.getAnonymousKey()).isNotBlank(); + } + + @Test + void authenticateResolvesTheInternalAnonymousKey() { + String token = "guest-token"; + MobileGuestCredential credential = + MobileGuestCredential.issue( + SecretFingerprint.sha256(token), + "anonymous-key", + java.time.Instant.now().plus(Duration.ofHours(1))); + + when(repository.findByTokenHash(any())) + .thenReturn(Optional.of(credential)); + + assertThat(service.authenticate(token)) + .isEqualTo("anonymous-key"); + verify(repository).findByTokenHash( + SecretFingerprint.sha256(token)); + } + + private AuthProperties properties() { + return new AuthProperties( + new AuthProperties.Jwt( + "test-secret-must-be-at-least-thirty-two-bytes", + Duration.ofMinutes(30), + Duration.ofDays(14)), + new AuthProperties.Cookie(false), + new AuthProperties.Ticket(Duration.ofMinutes(10)), + new AuthProperties.Csrf("XSRF-TOKEN", null), + new AuthProperties.Refresh(Duration.ofSeconds(5))); + } +} diff --git a/auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestIssuanceRateLimiterTest.java b/auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestIssuanceRateLimiterTest.java new file mode 100644 index 0000000..5495dca --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestIssuanceRateLimiterTest.java @@ -0,0 +1,118 @@ +package com.nalssilog.auth.mobile.guest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.auth.core.AuthErrorCode; +import com.nalssilog.common.exception.NalssiLogException; +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; + +@SuppressWarnings({"unchecked", "java:S5960"}) +class MobileGuestIssuanceRateLimiterTest { + + private final StringRedisTemplate redisTemplate = + mock(StringRedisTemplate.class); + private final MobileGuestIssuanceRateLimiter limiter = + new MobileGuestIssuanceRateLimiter(redisTemplate, properties()); + + @Test + void sharedIpUsesHmacFingerprintAndGenerousHardLimit() { + when(redisTemplate.execute( + any(RedisScript.class), + anyList(), + any(), + any(), + any(), + any())).thenReturn(0L); + + limiter.check("client-a.test"); + + ArgumentCaptor> keys = ArgumentCaptor.forClass(List.class); + + org.mockito.Mockito.verify(redisTemplate).execute( + any(RedisScript.class), + keys.capture(), + org.mockito.ArgumentMatchers.eq("600000"), + org.mockito.ArgumentMatchers.eq("60000"), + org.mockito.ArgumentMatchers.eq("300"), + org.mockito.ArgumentMatchers.eq("3000")); + assertThat(keys.getValue()) + .hasSize(2) + .allMatch(key -> !key.contains("client-a.test")); + assertThat(keys.getValue().getFirst()).startsWith("auth:guest:issue:ip:"); + assertThat(keys.getValue().getLast()).isEqualTo("auth:guest:issue:global"); + } + + @Test + void positiveScriptResultMeansIpHardLimitWasExceeded() { + when(redisTemplate.execute( + any(RedisScript.class), + anyList(), + any(), + any(), + any(), + any())).thenReturn(301L); + + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> limiter.check("client-a.test")); + + assertThat(exception.getErrorCode()) + .isEqualTo(AuthErrorCode.GUEST_ISSUANCE_RATE_LIMITED); + } + + @Test + void negativeScriptResultMeansGlobalSafetyLimitWasExceeded() { + when(redisTemplate.execute( + any(RedisScript.class), + anyList(), + any(), + any(), + any(), + any())).thenReturn(-3_001L); + + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> limiter.check("client-b.test")); + + assertThat(exception.getErrorCode()) + .isEqualTo(AuthErrorCode.GUEST_ISSUANCE_RATE_LIMITED); + } + + private AuthProperties properties() { + return new AuthProperties( + new AuthProperties.Jwt( + "test-secret-must-be-at-least-thirty-two-bytes", + Duration.ofMinutes(30), + Duration.ofDays(14)), + new AuthProperties.Cookie(false), + new AuthProperties.Ticket(Duration.ofMinutes(10)), + new AuthProperties.Csrf("XSRF-TOKEN", null), + new AuthProperties.Refresh(Duration.ofSeconds(5)), + new AuthProperties.Mobile( + List.of("nalssilog-dev://auth/callback"), + Duration.ofMinutes(10), + Duration.ofSeconds(90), + "test-hmac-secret", + List.of()), + new AuthProperties.Guest( + Duration.ofDays(365), + 300, + Duration.ofMinutes(10), + 3_000, + Duration.ofMinutes(1), + Duration.ofDays(7), + Duration.ofHours(6), + Duration.ofMinutes(1))); + } +} diff --git a/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestResolverTest.java b/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestResolverTest.java new file mode 100644 index 0000000..89e2f57 --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestResolverTest.java @@ -0,0 +1,55 @@ +package com.nalssilog.auth.mobile.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; + +class MobileOAuthAuthorizationRequestResolverTest { + + @Test + void appleAuthorizationUsesFormPostResponseMode() { + ClientRegistration apple = ClientRegistration + .withRegistrationId("apple") + .clientId("com.nalssilog.login") + .clientSecret("generated-per-request") + .clientAuthenticationMethod( + ClientAuthenticationMethod.CLIENT_SECRET_POST) + .authorizationGrantType( + AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri( + "{baseUrl}/login/oauth2/code/{registrationId}") + .scope("openid", "email", "name") + .authorizationUri( + "https://appleid.apple.com/auth/authorize") + .tokenUri("https://appleid.apple.com/auth/token") + .jwkSetUri("https://appleid.apple.com/auth/keys") + .userNameAttributeName("sub") + .clientName("Apple") + .build(); + MobileOAuthAuthorizationRequestResolver resolver = + new MobileOAuthAuthorizationRequestResolver( + new InMemoryClientRegistrationRepository(apple), + mock(MobileOAuthTransactionStore.class)); + MockHttpServletRequest request = new MockHttpServletRequest( + "GET", + "/oauth2/authorization/apple"); + + request.setServletPath("/oauth2/authorization/apple"); + + OAuth2AuthorizationRequest authorization = + resolver.resolve(request, "apple"); + + assertThat(authorization).isNotNull(); + assertThat(authorization.getAdditionalParameters()) + .containsEntry("response_mode", "form_post"); + assertThat(authorization.getAuthorizationRequestUri()) + .contains("response_mode=form_post"); + } +} diff --git a/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthServiceTest.java b/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthServiceTest.java new file mode 100644 index 0000000..8cdb282 --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthServiceTest.java @@ -0,0 +1,358 @@ +package com.nalssilog.auth.mobile.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.auth.core.AuthErrorCode; +import com.nalssilog.auth.device.DeviceInfo; +import com.nalssilog.auth.member.MemberClient; +import com.nalssilog.auth.oauth.OAuthUserInfo; +import com.nalssilog.auth.oauth.SocialAuthPrincipal; +import com.nalssilog.auth.ticket.AuthChannel; +import com.nalssilog.auth.ticket.AuthTicketStore; +import com.nalssilog.auth.ticket.LinkTicket; +import com.nalssilog.auth.token.AuthTokenService; +import com.nalssilog.auth.token.TokenPair; +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.member.application.dto.MemberInfo; +import com.nalssilog.member.application.dto.SocialLoginResult; +import com.nalssilog.member.domain.AvatarType; +import com.nalssilog.member.domain.MemberStatus; +import com.nalssilog.member.domain.Provider; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; + +@SuppressWarnings("java:S5960") +class MobileOAuthServiceTest { + + private static final String REDIRECT_URI = + "nalssilog://auth/callback"; + private static final String CHALLENGE = "A".repeat(43); + private static final String STATE = "state-01234567890"; + + private final MobileOAuthTransactionStore transactionStore = + mock(MobileOAuthTransactionStore.class); + private final MobileOAuthCodeStore codeStore = + mock(MobileOAuthCodeStore.class); + private final AuthTicketStore ticketStore = + mock(AuthTicketStore.class); + private final MemberClient memberClient = mock(MemberClient.class); + private final AuthTokenService authTokenService = + mock(AuthTokenService.class); + private final ClientRegistrationRepository registrations = + mock(ClientRegistrationRepository.class); + private final MobileOAuthService service = new MobileOAuthService( + transactionStore, + codeStore, + ticketStore, + memberClient, + authTokenService, + properties(), + registrations); + + @Test + void loginStartStoresServerTransactionAndRedirectsThroughSpringOAuth() { + when(registrations.findByRegistrationId("kakao")) + .thenReturn(mock(ClientRegistration.class)); + + String authorizationUrl = service.startLogin( + "kakao", + REDIRECT_URI, + CHALLENGE, + "S256", + STATE); + + ArgumentCaptor idCaptor = + ArgumentCaptor.forClass(String.class); + ArgumentCaptor transactionCaptor = + ArgumentCaptor.forClass(MobileOAuthTransaction.class); + + verify(transactionStore).save( + idCaptor.capture(), + transactionCaptor.capture()); + + assertThat(idCaptor.getValue()).hasSize(43); + assertThat(transactionCaptor.getValue()) + .isEqualTo(new MobileOAuthTransaction( + MobileOAuthPurpose.LOGIN, + Provider.KAKAO, + REDIRECT_URI, + CHALLENGE, + STATE, + null, + null)); + assertThat(authorizationUrl) + .startsWith("/oauth2/authorization/kakao?mobile_transaction=") + .doesNotContain(REDIRECT_URI, CHALLENGE, STATE); + } + + @Test + void oauthCallbackOnlyIssuesOneTimeCodeAndDoesNotCreateSession() { + MobileOAuthTransaction transaction = new MobileOAuthTransaction( + MobileOAuthPurpose.LOGIN, + Provider.KAKAO, + REDIRECT_URI, + CHALLENGE, + STATE, + null, + null); + SocialAuthPrincipal principal = new SocialAuthPrincipal( + SocialLoginResult.existing(7L, MemberStatus.ACTIVE), + new OAuthUserInfo( + Provider.KAKAO, + "provider-user", + "user@example.com", + "사용자"), + Map.of()); + + when(transactionStore.take("transaction")) + .thenReturn(Optional.of(transaction)); + + String callback = service.complete("transaction", principal); + + ArgumentCaptor codeCaptor = + ArgumentCaptor.forClass(String.class); + ArgumentCaptor grantCaptor = + ArgumentCaptor.forClass(MobileOAuthGrant.class); + + verify(codeStore).save( + codeCaptor.capture(), + grantCaptor.capture(), + org.mockito.ArgumentMatchers.eq(REDIRECT_URI), + org.mockito.ArgumentMatchers.eq(CHALLENGE)); + + assertThat(codeCaptor.getValue()).hasSize(43); + assertThat(grantCaptor.getValue()) + .isEqualTo(MobileOAuthGrant.success( + 7L, + Provider.KAKAO)); + assertThat(callback) + .contains("code=" + codeCaptor.getValue(), "state=" + STATE) + .doesNotContain( + "accessToken", + "refreshToken", + "user@example.com", + "provider-user"); + verifyNoInteractions(authTokenService); + } + + @Test + void tokensAreCreatedOnlyAfterSuccessfulCodeExchange() { + String verifier = "v".repeat(43); + DeviceInfo device = new DeviceInfo( + "ANDROID · Galaxy · 0.1.0", + "client-a.test"); + MemberInfo member = member(); + TokenPair tokens = new TokenPair( + "access-token", + "refresh-token", + Duration.ofDays(14)); + + when(codeStore.consume( + org.mockito.ArgumentMatchers.eq("one-time-code"), + org.mockito.ArgumentMatchers.eq(REDIRECT_URI), + any())) + .thenReturn(MobileOAuthGrant.success( + 7L, + Provider.KAKAO)); + when(memberClient.getMemberInfo(7L)).thenReturn(member); + when(authTokenService.issue( + 7L, + MemberStatus.ACTIVE, + Provider.KAKAO, + device)).thenReturn(tokens); + + MobileOAuthService.ExchangeResult result = service.exchange( + "one-time-code", + verifier, + REDIRECT_URI, + device); + + assertThat(result.result()).isEqualTo(MobileAuthResult.SUCCESS); + assertThat(result.tokens()).isEqualTo(tokens); + assertThat(result.member()).isEqualTo(member); + verify(authTokenService).issue( + 7L, + MemberStatus.ACTIVE, + Provider.KAKAO, + device); + verify(memberClient).recordLogin(7L, Provider.KAKAO); + } + + @Test + void loginLinkIssuesSessionForNewlyLinkedProvider() { + String verifier = "v".repeat(43); + DeviceInfo device = new DeviceInfo( + "IOS · iPhone · 0.1.0", + "client-a.test"); + MobileOAuthTransaction transaction = new MobileOAuthTransaction( + MobileOAuthPurpose.LOGIN_LINK_REAUTH, + Provider.NAVER, + REDIRECT_URI, + CHALLENGE, + STATE, + "link-ticket", + 7L); + LinkTicket ticket = new LinkTicket( + Provider.KAKAO, + "new-kakao-user", + "user@example.com", + 7L, + List.of(Provider.NAVER), + AuthChannel.MOBILE); + OAuthUserInfo linkedUserInfo = new OAuthUserInfo( + Provider.KAKAO, + "new-kakao-user", + "user@example.com", + null); + SocialAuthPrincipal reauthenticatedPrincipal = new SocialAuthPrincipal( + SocialLoginResult.existing(7L, MemberStatus.ACTIVE), + new OAuthUserInfo( + Provider.NAVER, + "existing-naver-user", + "user@example.com", + "사용자"), + Map.of()); + MemberInfo member = linkedMember(); + TokenPair tokens = new TokenPair( + "access-token", + "refresh-token", + Duration.ofDays(14)); + + when(transactionStore.take("transaction")) + .thenReturn(Optional.of(transaction)); + when(ticketStore.findLink("link-ticket")).thenReturn(Optional.of(ticket)); + when(ticketStore.isLinkConsented("link-ticket")).thenReturn(true); + when(memberClient.linkSocial(7L, linkedUserInfo)).thenReturn(member); + + service.complete("transaction", reauthenticatedPrincipal); + + verify(memberClient, never()).recordLogin(any(), any()); + + ArgumentCaptor grantCaptor = + ArgumentCaptor.forClass(MobileOAuthGrant.class); + + verify(codeStore).save( + any(), + grantCaptor.capture(), + org.mockito.ArgumentMatchers.eq(REDIRECT_URI), + org.mockito.ArgumentMatchers.eq(CHALLENGE)); + + MobileOAuthGrant grant = grantCaptor.getValue(); + + assertThat(grant).isEqualTo(MobileOAuthGrant.linkSuccess( + 7L, + Provider.KAKAO, + true)); + + when(codeStore.consume( + org.mockito.ArgumentMatchers.eq("one-time-code"), + org.mockito.ArgumentMatchers.eq(REDIRECT_URI), + any())).thenReturn(grant); + when(memberClient.getMemberInfo(7L)).thenReturn(member); + when(authTokenService.issue( + 7L, + MemberStatus.ACTIVE, + Provider.KAKAO, + device)).thenReturn(tokens); + + MobileOAuthService.ExchangeResult result = service.exchange( + "one-time-code", + verifier, + REDIRECT_URI, + device); + + assertThat(result.result()).isEqualTo(MobileAuthResult.LINK_SUCCESS); + assertThat(result.tokens()).isEqualTo(tokens); + + verify(authTokenService).issue( + 7L, + MemberStatus.ACTIVE, + Provider.KAKAO, + device); + verify(authTokenService, never()).issue( + 7L, + MemberStatus.ACTIVE, + Provider.NAVER, + device); + verify(memberClient).recordLogin(7L, Provider.KAKAO); + verify(memberClient, never()).recordLogin(7L, Provider.NAVER); + } + + @Test + void unregisteredRedirectIsRejectedBeforeOAuthStateIsCreated() { + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> service.startLogin( + "kakao", + "nalssilog://attacker/callback", + CHALLENGE, + "S256", + STATE)); + + assertThat(exception.getErrorCode()) + .isEqualTo(AuthErrorCode.AUTH_REDIRECT_URI_INVALID); + verify(transactionStore, never()).save(any(), any()); + } + + private MemberInfo member() { + return new MemberInfo( + 7L, + "사용자", + "구름산책", + "user@example.com", + AvatarType.PRESET, + "1", + MemberStatus.ACTIVE, + Provider.KAKAO, + List.of(Provider.KAKAO)); + } + + private MemberInfo linkedMember() { + return new MemberInfo( + 7L, + "사용자", + "구름산책", + "user@example.com", + AvatarType.PRESET, + "1", + MemberStatus.ACTIVE, + Provider.KAKAO, + List.of(Provider.NAVER, Provider.KAKAO)); + } + + private AuthProperties properties() { + return new AuthProperties( + new AuthProperties.Jwt( + "test-secret-must-be-at-least-thirty-two-bytes", + Duration.ofMinutes(30), + Duration.ofDays(14)), + new AuthProperties.Cookie(false), + new AuthProperties.Ticket(Duration.ofMinutes(10)), + new AuthProperties.Csrf("XSRF-TOKEN", null), + new AuthProperties.Refresh(Duration.ofSeconds(5)), + new AuthProperties.Mobile( + List.of(REDIRECT_URI), + Duration.ofMinutes(10), + Duration.ofSeconds(90), + "test-hmac-secret", + List.of()), + new AuthProperties.Guest( + Duration.ofDays(365), + 5, + Duration.ofMinutes(10))); + } +} diff --git a/auth/src/test/java/com/nalssilog/auth/application/CustomOAuth2UserServiceTest.java b/auth/src/test/java/com/nalssilog/auth/oauth/CustomOAuth2UserServiceTest.java similarity index 97% rename from auth/src/test/java/com/nalssilog/auth/application/CustomOAuth2UserServiceTest.java rename to auth/src/test/java/com/nalssilog/auth/oauth/CustomOAuth2UserServiceTest.java index 1448ff8..6ea6b9d 100644 --- a/auth/src/test/java/com/nalssilog/auth/application/CustomOAuth2UserServiceTest.java +++ b/auth/src/test/java/com/nalssilog/auth/oauth/CustomOAuth2UserServiceTest.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.application; +package com.nalssilog.auth.oauth; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; diff --git a/auth/src/test/java/com/nalssilog/auth/config/OAuth2LoginFailureHandlerTest.java b/auth/src/test/java/com/nalssilog/auth/oauth/OAuth2LoginFailureHandlerTest.java similarity index 87% rename from auth/src/test/java/com/nalssilog/auth/config/OAuth2LoginFailureHandlerTest.java rename to auth/src/test/java/com/nalssilog/auth/oauth/OAuth2LoginFailureHandlerTest.java index 4dad2b5..d85d8a1 100644 --- a/auth/src/test/java/com/nalssilog/auth/config/OAuth2LoginFailureHandlerTest.java +++ b/auth/src/test/java/com/nalssilog/auth/oauth/OAuth2LoginFailureHandlerTest.java @@ -1,8 +1,9 @@ -package com.nalssilog.auth.config; +package com.nalssilog.auth.oauth; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; -import com.nalssilog.auth.application.CustomOAuth2UserService; +import com.nalssilog.auth.mobile.oauth.MobileOAuthService; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; @@ -15,8 +16,11 @@ class OAuth2LoginFailureHandlerTest { @Test void missingEmailRedirectsWithDedicatedFailureCode() throws Exception { - OAuth2LoginFailureHandler handler = new OAuth2LoginFailureHandler(); + OAuth2LoginFailureHandler handler = new OAuth2LoginFailureHandler( + mock(MobileOAuthService.class)); + ReflectionTestUtils.setField(handler, "frontendBaseUrl", "https://dev.nalssilog.com"); + MockHttpServletResponse response = new MockHttpServletResponse(); handler.onAuthenticationFailure( diff --git a/auth/src/test/java/com/nalssilog/auth/client/OAuthUserInfoTest.java b/auth/src/test/java/com/nalssilog/auth/oauth/OAuthUserInfoTest.java similarity index 72% rename from auth/src/test/java/com/nalssilog/auth/client/OAuthUserInfoTest.java rename to auth/src/test/java/com/nalssilog/auth/oauth/OAuthUserInfoTest.java index 5c4ceb1..cc46330 100644 --- a/auth/src/test/java/com/nalssilog/auth/client/OAuthUserInfoTest.java +++ b/auth/src/test/java/com/nalssilog/auth/oauth/OAuthUserInfoTest.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.client; +package com.nalssilog.auth.oauth; import static org.assertj.core.api.Assertions.assertThat; @@ -66,4 +66,31 @@ void naverFallsBackToNicknameWhenNameIsBlank() { assertThat(userInfo.socialName()).isEqualTo(NAVER_NICKNAME); } + + @Test + void appleOidcClaimsBecomeSocialIdentity() { + OAuthUserInfo userInfo = OAuthUserInfo.of("apple", Map.of( + "sub", "apple-subject", + EMAIL_KEY, USER_EMAIL + )); + + assertThat(userInfo).isEqualTo(new OAuthUserInfo( + Provider.APPLE, + "apple-subject", + USER_EMAIL, + null)); + } + + @Test + void appleUsesTheNameFromTheFirstFormPostAuthorization() { + OAuthUserInfo userInfo = OAuthUserInfo.of( + "apple", + Map.of( + "sub", "apple-subject", + EMAIL_KEY, USER_EMAIL), + "Gil-dong Hong"); + + assertThat(userInfo.socialName()).isEqualTo("Gil-dong Hong"); + assertThat(userInfo.email()).isEqualTo(USER_EMAIL); + } } diff --git a/auth/src/test/java/com/nalssilog/auth/oauth/WebOAuthServiceTest.java b/auth/src/test/java/com/nalssilog/auth/oauth/WebOAuthServiceTest.java new file mode 100644 index 0000000..721abdd --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/oauth/WebOAuthServiceTest.java @@ -0,0 +1,116 @@ +package com.nalssilog.auth.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.auth.device.DeviceInfo; +import com.nalssilog.auth.member.MemberClient; +import com.nalssilog.auth.ticket.AuthChannel; +import com.nalssilog.auth.ticket.AuthTicketStore; +import com.nalssilog.auth.ticket.LinkTicket; +import com.nalssilog.auth.token.AuthTokenService; +import com.nalssilog.auth.token.TokenPair; +import com.nalssilog.member.application.dto.MemberInfo; +import com.nalssilog.member.application.dto.SocialLoginResult; +import com.nalssilog.member.domain.AvatarType; +import com.nalssilog.member.domain.MemberStatus; +import com.nalssilog.member.domain.Provider; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("java:S5960") +class WebOAuthServiceTest { + + private final AuthTokenService authTokenService = mock(AuthTokenService.class); + private final AuthTicketStore ticketStore = mock(AuthTicketStore.class); + private final MemberClient memberClient = mock(MemberClient.class); + private final WebOAuthService service = new WebOAuthService( + authTokenService, + ticketStore, + memberClient, + mock(AuthProperties.class)); + + @Test + void loginLinkIssuesSessionForNewlyLinkedProvider() { + DeviceInfo device = new DeviceInfo("Chrome", "client-a.test"); + LinkTicket ticket = new LinkTicket( + Provider.KAKAO, + "new-kakao-user", + "user@example.com", + 7L, + List.of(Provider.NAVER), + AuthChannel.WEB); + OAuthUserInfo linkedUserInfo = new OAuthUserInfo( + Provider.KAKAO, + "new-kakao-user", + "user@example.com", + null); + SocialAuthPrincipal reauthenticatedPrincipal = new SocialAuthPrincipal( + SocialLoginResult.existing(7L, MemberStatus.ACTIVE), + new OAuthUserInfo( + Provider.NAVER, + "existing-naver-user", + "user@example.com", + "사용자"), + Map.of()); + MemberInfo member = member(); + TokenPair tokens = new TokenPair( + "access-token", + "refresh-token", + Duration.ofDays(14)); + + when(ticketStore.findLink("link-ticket")).thenReturn(Optional.of(ticket)); + when(ticketStore.isLinkConsented("link-ticket")).thenReturn(true); + when(memberClient.linkSocial(7L, linkedUserInfo)).thenReturn(member); + when(authTokenService.issue( + 7L, + MemberStatus.ACTIVE, + Provider.KAKAO, + device)).thenReturn(tokens); + + WebOAuthService.Completion completion = service.complete( + reauthenticatedPrincipal, + Optional.empty(), + Optional.of("link-ticket"), + device); + + assertThat(completion.result()).isEqualTo("LINK_SUCCESS"); + assertThat(completion.tokens()).isEqualTo(tokens); + assertThat(completion.clearLinkTicket()).isTrue(); + + verify(authTokenService).issue( + 7L, + MemberStatus.ACTIVE, + Provider.KAKAO, + device); + verify(authTokenService, never()).issue( + 7L, + MemberStatus.ACTIVE, + Provider.NAVER, + device); + verify(memberClient).recordLogin(7L, Provider.KAKAO); + verify(memberClient, never()).recordLogin(7L, Provider.NAVER); + verify(ticketStore).deleteLink("link-ticket"); + verify(ticketStore).deleteLinkConsent("link-ticket"); + } + + private MemberInfo member() { + return new MemberInfo( + 7L, + "구름산책", + "사용자", + "user@example.com", + AvatarType.PRESET, + "1", + MemberStatus.ACTIVE, + Provider.KAKAO, + List.of(Provider.NAVER, Provider.KAKAO)); + } +} diff --git a/auth/src/test/java/com/nalssilog/auth/oauth/apple/AppleAuthorizationResponseFilterTest.java b/auth/src/test/java/com/nalssilog/auth/oauth/apple/AppleAuthorizationResponseFilterTest.java new file mode 100644 index 0000000..3691bf5 --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/oauth/apple/AppleAuthorizationResponseFilterTest.java @@ -0,0 +1,48 @@ +package com.nalssilog.auth.oauth.apple; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import tools.jackson.databind.ObjectMapper; + +class AppleAuthorizationResponseFilterTest { + + @Test + void exposesFirstAuthorizationNameOnlyWhileCallbackIsProcessed() throws Exception { + AppleAuthorizationUserContext context = + new AppleAuthorizationUserContext(); + AppleAuthorizationResponseFilter filter = + new AppleAuthorizationResponseFilter(new ObjectMapper(), context); + MockHttpServletRequest request = new MockHttpServletRequest( + "POST", + AppleAuthorizationResponseFilter.CALLBACK_PATH); + + request.setParameter( + "user", + """ + { + "name": { + "firstName": "Gil-dong", + "lastName": "Hong" + }, + "email": "must-not-be-trusted@example.com" + } + """); + + AtomicReference nameSeenDuringCallback = + new AtomicReference<>(); + + filter.doFilter( + request, + new MockHttpServletResponse(), + (nestedRequest, nestedResponse) -> + nameSeenDuringCallback.set( + context.currentSocialName().orElse(null))); + + assertThat(nameSeenDuringCallback.get()).isEqualTo("Gil-dong Hong"); + assertThat(context.currentSocialName()).isEmpty(); + } +} diff --git a/auth/src/test/java/com/nalssilog/auth/oauth/apple/AppleClientSecretGeneratorTest.java b/auth/src/test/java/com/nalssilog/auth/oauth/apple/AppleClientSecretGeneratorTest.java new file mode 100644 index 0000000..b9dfc76 --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/oauth/apple/AppleClientSecretGeneratorTest.java @@ -0,0 +1,58 @@ +package com.nalssilog.auth.oauth.apple; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.spec.ECGenParameterSpec; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import org.junit.jupiter.api.Test; + +class AppleClientSecretGeneratorTest { + + @Test + void generatesAndCachesAppleEs256ClientSecret() throws Exception { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC"); + + keyPairGenerator.initialize(new ECGenParameterSpec("secp256r1")); + + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + String privateKey = """ + -----BEGIN PRIVATE KEY----- + %s + -----END PRIVATE KEY----- + """.formatted(Base64.getMimeEncoder(64, new byte[]{'\n'}) + .encodeToString(keyPair.getPrivate().getEncoded())); + AppleOAuthProperties properties = new AppleOAuthProperties( + "TEAM123456", + "KEY1234567", + null, + Base64.getEncoder().encodeToString( + privateKey.getBytes(java.nio.charset.StandardCharsets.UTF_8)), + Duration.ofDays(30), + Duration.ofDays(1)); + AppleClientSecretGenerator generator = + new AppleClientSecretGenerator(properties); + + String first = generator.generate("com.nalssilog.login"); + String second = generator.generate("com.nalssilog.login"); + Jws parsed = Jwts.parser() + .verifyWith(keyPair.getPublic()) + .build() + .parseSignedClaims(first); + + assertThat(second).isEqualTo(first); + assertThat(parsed.getHeader().getKeyId()).isEqualTo("KEY1234567"); + assertThat(parsed.getPayload().getIssuer()).isEqualTo("TEAM123456"); + assertThat(parsed.getPayload().getSubject()).isEqualTo("com.nalssilog.login"); + assertThat(parsed.getPayload().getAudience()) + .containsExactly("https://appleid.apple.com"); + assertThat(parsed.getPayload().getExpiration().toInstant()) + .isAfter(Instant.now().plus(Duration.ofDays(29))); + } +} diff --git a/auth/src/test/java/com/nalssilog/auth/application/AuthTokenServiceTest.java b/auth/src/test/java/com/nalssilog/auth/token/AuthTokenServiceTest.java similarity index 91% rename from auth/src/test/java/com/nalssilog/auth/application/AuthTokenServiceTest.java rename to auth/src/test/java/com/nalssilog/auth/token/AuthTokenServiceTest.java index 29bce94..73c0a38 100644 --- a/auth/src/test/java/com/nalssilog/auth/application/AuthTokenServiceTest.java +++ b/auth/src/test/java/com/nalssilog/auth/token/AuthTokenServiceTest.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.application; +package com.nalssilog.auth.token; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.catchThrowableOfType; @@ -10,15 +10,13 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import com.nalssilog.auth.application.dto.DeviceInfo; -import com.nalssilog.auth.application.dto.SessionData; -import com.nalssilog.auth.client.MemberClient; import com.nalssilog.auth.config.AuthProperties; -import com.nalssilog.auth.domain.AuthErrorCode; -import com.nalssilog.auth.repository.RefreshTokenStore; -import com.nalssilog.auth.repository.RefreshTokenStore.RotationResult; -import com.nalssilog.auth.repository.RefreshTokenStore.RotationStatus; -import com.nalssilog.auth.repository.RefreshTokenStore.UsedToken; +import com.nalssilog.auth.core.AuthErrorCode; +import com.nalssilog.auth.device.DeviceInfo; +import com.nalssilog.auth.member.MemberClient; +import com.nalssilog.auth.token.RefreshTokenStore.RotationResult; +import com.nalssilog.auth.token.RefreshTokenStore.RotationStatus; +import com.nalssilog.auth.token.RefreshTokenStore.UsedToken; import com.nalssilog.common.exception.NalssiLogException; import com.nalssilog.member.application.dto.MemberInfo; import com.nalssilog.member.domain.AvatarType; @@ -60,7 +58,7 @@ void setUp() { 1L, Provider.KAKAO, "Chrome · Windows", - "127.0.0.1", + "loopback.test", Instant.parse("2026-07-01T00:00:00Z"), Instant.parse("2026-07-01T00:00:00Z")); member = new MemberInfo( @@ -73,7 +71,8 @@ void setUp() { MemberStatus.ACTIVE, Provider.KAKAO, List.of(Provider.KAKAO)); - when(jwtTokenProvider.createAccessToken(1L, MemberStatus.ACTIVE, Provider.KAKAO)) + when(jwtTokenProvider.createAccessToken( + 1L, MemberStatus.ACTIVE, Provider.KAKAO, SESSION_ID)) .thenReturn("access-token"); } @@ -90,6 +89,7 @@ void rotatesCurrentRefreshTokenThroughAtomicStoreOperation() { .thenAnswer(invocation -> { String token = invocation.getArgument(1); SessionData replacement = invocation.getArgument(2); + return new RotationResult( RotationStatus.ROTATED, token, @@ -99,7 +99,7 @@ void rotatesCurrentRefreshTokenThroughAtomicStoreOperation() { Duration.ofDays(14).toMillis()); }); - TokenPair tokens = service.refresh(CURRENT_TOKEN, new DeviceInfo("ignored", "203.0.113.1")); + TokenPair tokens = service.refresh(CURRENT_TOKEN, new DeviceInfo("ignored", "client-b.test")); assertThat(tokens.accessToken()).isEqualTo("access-token"); assertThat(tokens.refreshToken()).isNotBlank().isNotEqualTo(CURRENT_TOKEN); @@ -122,9 +122,10 @@ void duplicateWithinGraceReplaysTheFirstReplacementToken() { 1L, Provider.KAKAO, "Chrome · Windows", - "203.0.113.1", + "client-b.test", current.loginAt(), Instant.now()); + when(refreshTokenStore.findSession(CURRENT_HASH)).thenReturn(Optional.empty()); when(refreshTokenStore.findUsedToken(CURRENT_HASH)) .thenReturn(Optional.of(new UsedToken(1L, SESSION_ID, replacementHash, Instant.now()))); @@ -144,7 +145,7 @@ void duplicateWithinGraceReplaysTheFirstReplacementToken() { Duration.ofDays(13).toMillis())); when(memberClient.findMemberInfo(1L)).thenReturn(Optional.of(member)); - TokenPair tokens = service.refresh(CURRENT_TOKEN, new DeviceInfo("ignored", "203.0.113.1")); + TokenPair tokens = service.refresh(CURRENT_TOKEN, new DeviceInfo("ignored", "client-b.test")); assertThat(tokens.refreshToken()).isEqualTo("replacement-token"); assertThat(tokens.accessToken()).isEqualTo("access-token"); @@ -161,9 +162,10 @@ void reuseOutsideGraceRevokesTheWholeDeviceSession() { 1L, Provider.KAKAO, "Chrome · Windows", - "203.0.113.1", + "client-b.test", current.loginAt(), Instant.now()); + when(refreshTokenStore.findSession(CURRENT_HASH)).thenReturn(Optional.empty()); when(refreshTokenStore.findUsedToken(CURRENT_HASH)) .thenReturn(Optional.of(new UsedToken(1L, SESSION_ID, replacementHash, Instant.now()))); @@ -185,7 +187,7 @@ void reuseOutsideGraceRevokesTheWholeDeviceSession() { NalssiLogException exception = catchThrowableOfType( NalssiLogException.class, - () -> service.refresh(CURRENT_TOKEN, new DeviceInfo("ignored", "203.0.113.1"))); + () -> service.refresh(CURRENT_TOKEN, new DeviceInfo("ignored", "client-b.test"))); assertThat(exception.getErrorCode()).isEqualTo(AuthErrorCode.AUTH_REFRESH_REUSED); verify(refreshTokenStore).revokeSession(1L, SESSION_ID, Duration.ofDays(14)); @@ -198,7 +200,7 @@ void unknownRefreshTokenIsRejectedWithoutRevokingUnrelatedSessions() { NalssiLogException exception = catchThrowableOfType( NalssiLogException.class, - () -> service.refresh(CURRENT_TOKEN, new DeviceInfo("ignored", "203.0.113.1"))); + () -> service.refresh(CURRENT_TOKEN, new DeviceInfo("ignored", "client-b.test"))); assertThat(exception.getErrorCode()).isEqualTo(AuthErrorCode.AUTH_SESSION_EXPIRED); verify(refreshTokenStore, never()).revokeSession(any(), anyString(), any()); @@ -219,6 +221,7 @@ private static AuthProperties properties() { private static String sha256(String value) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); diff --git a/auth/src/test/java/com/nalssilog/auth/token/JwtAuthenticationFilterTest.java b/auth/src/test/java/com/nalssilog/auth/token/JwtAuthenticationFilterTest.java new file mode 100644 index 0000000..2f74f2d --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/token/JwtAuthenticationFilterTest.java @@ -0,0 +1,143 @@ +package com.nalssilog.auth.token; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +import com.nalssilog.auth.security.ApiAuthenticationEntryPoint; +import com.nalssilog.auth.web.AuthCookieManager; +import com.nalssilog.common.security.VerifiedRequestCredentials; +import com.nalssilog.member.domain.MemberStatus; +import com.nalssilog.member.domain.Provider; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; +import tools.jackson.databind.ObjectMapper; + +@SuppressWarnings("java:S5960") +class JwtAuthenticationFilterTest { + + private final AuthCookieManager cookieManager = + mock(AuthCookieManager.class); + private final RefreshTokenStore refreshTokenStore = + mock(RefreshTokenStore.class); + private final JwtTokenProvider tokenProvider = + new JwtTokenProvider(TestAuthProperties.create()); + private final JwtAuthenticationFilter filter = + new JwtAuthenticationFilter( + cookieManager, + tokenProvider, + refreshTokenStore, + new ApiAuthenticationEntryPoint(new ObjectMapper())); + + @BeforeEach + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void validBearerIsAuthenticatedWithoutConsultingCookies() throws Exception { + String accessToken = tokenProvider.createAccessToken( + 7L, + MemberStatus.ACTIVE, + Provider.KAKAO, + "session-7"); + MockHttpServletRequest request = + new MockHttpServletRequest("GET", "/api/members/me"); + + request.addHeader( + HttpHeaders.AUTHORIZATION, + "Bearer " + accessToken); + + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicBoolean invoked = new AtomicBoolean(); + + filter.doFilter(request, response, (servletRequest, servletResponse) -> { + invoked.set(true); + + var authentication = + SecurityContextHolder.getContext().getAuthentication(); + + assertThat(authentication.getPrincipal()).isEqualTo(7L); + assertThat(authentication.getDetails()) + .isEqualTo(new AuthRequestDetails( + Provider.KAKAO, + "session-7", + CredentialTransport.BEARER)); + }); + + assertThat(invoked).isTrue(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(VerifiedRequestCredentials.hasBearer(request)).isTrue(); + verifyNoInteractions(cookieManager); + } + + @Test + void malformedAuthorizationNeverFallsBackToCookieAuthentication() + throws Exception { + MockHttpServletRequest request = + new MockHttpServletRequest("GET", "/api/members/me"); + + request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer"); + + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicBoolean invoked = new AtomicBoolean(); + + filter.doFilter( + request, + response, + (servletRequest, servletResponse) -> invoked.set(true)); + + assertThat(invoked).isFalse(); + assertThat(response.getStatus()).isEqualTo(401); + assertThat(response.getContentAsString()) + .contains("\"code\":\"AUTH_ACCESS_TOKEN_INVALID\""); + assertThat(SecurityContextHolder.getContext().getAuthentication()) + .isNull(); + verifyNoInteractions(cookieManager); + } + + @Test + void expiredBearerReturnsTheStableExpiredCode() throws Exception { + JwtTokenProvider expiredTokenProvider = + new JwtTokenProvider(TestAuthProperties.create( + Duration.ofSeconds(-1))); + JwtAuthenticationFilter expiredFilter = + new JwtAuthenticationFilter( + cookieManager, + expiredTokenProvider, + refreshTokenStore, + new ApiAuthenticationEntryPoint(new ObjectMapper())); + String expiredToken = expiredTokenProvider.createAccessToken( + 7L, + MemberStatus.ACTIVE, + Provider.KAKAO, + "session-7"); + MockHttpServletRequest request = + new MockHttpServletRequest("GET", "/api/members/me"); + + request.addHeader( + HttpHeaders.AUTHORIZATION, + "Bearer " + expiredToken); + + MockHttpServletResponse response = new MockHttpServletResponse(); + + expiredFilter.doFilter( + request, + response, + (servletRequest, servletResponse) -> { + throw new AssertionError("expired bearer must stop the chain"); + }); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(response.getContentAsString()) + .contains("\"code\":\"AUTH_ACCESS_TOKEN_EXPIRED\""); + } +} diff --git a/auth/src/test/java/com/nalssilog/auth/application/JwtTokenProviderTest.java b/auth/src/test/java/com/nalssilog/auth/token/JwtTokenProviderTest.java similarity index 69% rename from auth/src/test/java/com/nalssilog/auth/application/JwtTokenProviderTest.java rename to auth/src/test/java/com/nalssilog/auth/token/JwtTokenProviderTest.java index 511b539..cee52b6 100644 --- a/auth/src/test/java/com/nalssilog/auth/application/JwtTokenProviderTest.java +++ b/auth/src/test/java/com/nalssilog/auth/token/JwtTokenProviderTest.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.application; +package com.nalssilog.auth.token; import static org.assertj.core.api.Assertions.assertThat; @@ -22,6 +22,24 @@ void accessTokenPreservesAuthenticatedProvider() { 1L, MemberStatus.ACTIVE, Provider.KAKAO)); } + @Test + void accessTokenCarriesTheServerIssuedSessionId() { + JwtTokenProvider provider = new JwtTokenProvider(properties()); + + String token = provider.createAccessToken( + 1L, + MemberStatus.ACTIVE, + Provider.KAKAO, + "session-1"); + + assertThat(provider.parse(token)) + .contains(new JwtTokenProvider.AccessTokenPayload( + 1L, + MemberStatus.ACTIVE, + Provider.KAKAO, + "session-1")); + } + private AuthProperties properties() { return new AuthProperties( new AuthProperties.Jwt( diff --git a/auth/src/test/java/com/nalssilog/auth/repository/RefreshTokenStoreTest.java b/auth/src/test/java/com/nalssilog/auth/token/RefreshTokenStoreTest.java similarity index 91% rename from auth/src/test/java/com/nalssilog/auth/repository/RefreshTokenStoreTest.java rename to auth/src/test/java/com/nalssilog/auth/token/RefreshTokenStoreTest.java index c3aaaa8..577d341 100644 --- a/auth/src/test/java/com/nalssilog/auth/repository/RefreshTokenStoreTest.java +++ b/auth/src/test/java/com/nalssilog/auth/token/RefreshTokenStoreTest.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.repository; +package com.nalssilog.auth.token; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -6,8 +6,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import com.nalssilog.auth.application.dto.SessionData; -import com.nalssilog.auth.repository.RefreshTokenStore.RotationStatus; +import com.nalssilog.auth.token.RefreshTokenStore.RotationStatus; import com.nalssilog.member.domain.Provider; import java.time.Duration; import java.time.Instant; @@ -32,13 +31,14 @@ void mapsAtomicRotationScriptResultToDomainContract() { "1", "session-1", String.valueOf(Duration.ofDays(14).toMillis()))); + SessionData replacement = new SessionData( "replacement-hash", "session-1", 1L, Provider.KAKAO, "Chrome · Windows", - "203.0.113.1", + "client-b.test", Instant.parse("2026-07-01T00:00:00Z"), Instant.parse("2026-07-24T00:00:00Z")); diff --git a/auth/src/test/java/com/nalssilog/auth/token/TestAuthProperties.java b/auth/src/test/java/com/nalssilog/auth/token/TestAuthProperties.java new file mode 100644 index 0000000..401e312 --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/token/TestAuthProperties.java @@ -0,0 +1,26 @@ +package com.nalssilog.auth.token; + +import com.nalssilog.auth.config.AuthProperties; +import java.time.Duration; + +final class TestAuthProperties { + + private TestAuthProperties() { + } + + static AuthProperties create() { + return create(Duration.ofMinutes(5)); + } + + static AuthProperties create(Duration accessTokenTtl) { + return new AuthProperties( + new AuthProperties.Jwt( + "test-secret-must-be-at-least-thirty-two-bytes", + accessTokenTtl, + Duration.ofDays(14)), + new AuthProperties.Cookie(false), + new AuthProperties.Ticket(Duration.ofMinutes(10)), + new AuthProperties.Csrf("XSRF-TOKEN", null), + new AuthProperties.Refresh(Duration.ofSeconds(5))); + } +} diff --git a/auth/src/test/java/com/nalssilog/auth/api/AuthControllerTest.java b/auth/src/test/java/com/nalssilog/auth/web/AuthControllerTest.java similarity index 91% rename from auth/src/test/java/com/nalssilog/auth/api/AuthControllerTest.java rename to auth/src/test/java/com/nalssilog/auth/web/AuthControllerTest.java index 58f3416..1173cf7 100644 --- a/auth/src/test/java/com/nalssilog/auth/api/AuthControllerTest.java +++ b/auth/src/test/java/com/nalssilog/auth/web/AuthControllerTest.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.api; +package com.nalssilog.auth.web; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.catchThrowableOfType; @@ -6,19 +6,16 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import com.nalssilog.auth.api.dto.AuthResult; -import com.nalssilog.auth.api.dto.MeResponse; -import com.nalssilog.auth.application.AuthService; -import com.nalssilog.auth.application.AuthSessionService; -import com.nalssilog.auth.application.AuthTokenService; -import com.nalssilog.auth.application.TokenPair; -import com.nalssilog.auth.client.MemberClient; -import com.nalssilog.auth.config.AuthCookieManager; import com.nalssilog.auth.config.AuthProperties; -import com.nalssilog.auth.config.DeviceInfoResolver; -import com.nalssilog.auth.domain.AuthErrorCode; -import com.nalssilog.auth.domain.RefreshRejectedException; -import com.nalssilog.auth.repository.AuthTicketStore; +import com.nalssilog.auth.core.AuthErrorCode; +import com.nalssilog.auth.core.AuthService; +import com.nalssilog.auth.core.RefreshRejectedException; +import com.nalssilog.auth.device.DeviceInfoResolver; +import com.nalssilog.auth.member.MemberClient; +import com.nalssilog.auth.ticket.AuthTicketStore; +import com.nalssilog.auth.token.AuthSessionService; +import com.nalssilog.auth.token.AuthTokenService; +import com.nalssilog.auth.token.TokenPair; import com.nalssilog.common.exception.NalssiLogException; import com.nalssilog.member.application.dto.MemberInfo; import com.nalssilog.member.domain.AvatarType; @@ -54,6 +51,7 @@ void setUp() { sessionService, ticketStore, properties); + controller = new AuthController( authService, cookieManager, @@ -71,6 +69,7 @@ void meReturnsNoneOnlyWhenNoAuthenticationCookieExists() { @Test void meReturnsSuccessBeforeInspectingCookiesWhenAccessTokenIsValid() { MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies( new Cookie(AuthCookieManager.ACCESS_TOKEN_COOKIE, "valid-access"), new Cookie(AuthCookieManager.REFRESH_TOKEN_COOKIE, "valid-refresh")); @@ -86,6 +85,7 @@ void meReturnsSuccessBeforeInspectingCookiesWhenAccessTokenIsValid() { @Test void meRequestsRefreshWhenAccessCookieCouldNotBeAuthenticated() { MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie(AuthCookieManager.ACCESS_TOKEN_COOKIE, "expired-access")); NalssiLogException exception = catchThrowableOfType( @@ -98,6 +98,7 @@ void meRequestsRefreshWhenAccessCookieCouldNotBeAuthenticated() { @Test void meRequestsRefreshWhenOnlyRefreshCookieRemains() { MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie(AuthCookieManager.REFRESH_TOKEN_COOKIE, "refresh-token")); NalssiLogException exception = catchThrowableOfType( @@ -110,8 +111,11 @@ void meRequestsRefreshWhenOnlyRefreshCookieRemains() { @Test void terminalRefreshErrorIsClassifiedByAuthService() { MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie(AuthCookieManager.REFRESH_TOKEN_COOKIE, "expired-token")); + MockHttpServletResponse response = new MockHttpServletResponse(); + when(deviceInfoResolver.resolve(request)).thenReturn(null); when(tokenService.refresh("expired-token", null)) .thenThrow(new NalssiLogException(AuthErrorCode.AUTH_SESSION_EXPIRED)); @@ -126,8 +130,11 @@ void terminalRefreshErrorIsClassifiedByAuthService() { @Test void refreshUsesServerRemainingTtlForHardenedHostOnlyCookie() { MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie(AuthCookieManager.REFRESH_TOKEN_COOKIE, "current-token")); + MockHttpServletResponse response = new MockHttpServletResponse(); + when(deviceInfoResolver.resolve(request)).thenReturn(null); when(tokenService.refresh("current-token", null)) .thenReturn(new TokenPair("new-access", "new-refresh", Duration.ofDays(13))); @@ -138,6 +145,7 @@ void refreshUsesServerRemainingTtlForHardenedHostOnlyCookie() { .filter(header -> header.startsWith("refresh_token=")) .findFirst() .orElseThrow(); + assertThat(refreshCookie) .contains("Max-Age=1123200") .contains("Path=/") diff --git a/build.gradle b/build.gradle index cd02e26..886822c 100644 --- a/build.gradle +++ b/build.gradle @@ -8,13 +8,26 @@ ext { querydslVersion = '7.4.0' } +def applicationVersion = providers.gradleProperty('appVersion').get() + +allprojects { + group = 'com.nalssilog' + version = applicationVersion +} + +tasks.register('printVersion') { + group = 'help' + description = 'Prints the application release version.' + + doLast { + println project.version + } +} + subprojects { apply plugin: 'java-library' apply plugin: 'io.spring.dependency-management' - group = 'com.nalssilog' - version = '0.0.1-SNAPSHOT' - java { toolchain { languageVersion = JavaLanguageVersion.of(25) diff --git a/common/build.gradle b/common/build.gradle index dd91b7a..fd405c5 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -6,4 +6,5 @@ dependencies { api "io.github.openfeign.querydsl:querydsl-jpa:${querydslVersion}" implementation 'io.hypersistence:hypersistence-tsid:2.1.4' + implementation 'org.springframework.security:spring-security-web' } diff --git a/common/src/main/java/com/nalssilog/common/filter/RequestLoggingFilter.java b/common/src/main/java/com/nalssilog/common/filter/RequestLoggingFilter.java index dcfd21c..3485a72 100644 --- a/common/src/main/java/com/nalssilog/common/filter/RequestLoggingFilter.java +++ b/common/src/main/java/com/nalssilog/common/filter/RequestLoggingFilter.java @@ -1,7 +1,17 @@ package com.nalssilog.common.filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; - +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; @@ -10,12 +20,6 @@ import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - /** * 요청마다 액세스 로그 한 줄(method·path·status·소요시간·actor). {@link TraceIdFilter} 다음 순서라 traceId 가 이미 MDC 에 있다. * prod JSON 용으로 필드를 MDC 에 잠시 싣는다. 작성자: 인증 필터가 심은 request attribute({@link #ACTOR_MEMBER_ID}), 없으면 쿠키. @@ -24,77 +28,124 @@ @Order(Ordered.HIGHEST_PRECEDENCE + 1) public class RequestLoggingFilter extends OncePerRequestFilter { - /** 인증 필터가 인증된 회원의 memberId 를 실어두는 request attribute 키. */ - public static final String ACTOR_MEMBER_ID = "actorMemberId"; - - /** report 모듈의 익명 식별 쿠키명과의 계약(모듈 역의존 회피용 상수 복제). */ - private static final String ANONYMOUS_COOKIE = "anonymous_id"; - - private static final Logger log = LoggerFactory.getLogger("http.access"); - - @Override - protected void doFilterInternal(HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain) throws ServletException, IOException { - long startNanos = System.nanoTime(); - - try { - filterChain.doFilter(request, response); - } finally { - long durationMs = (System.nanoTime() - startNanos) / 1_000_000; - String actor = resolveActor(request); - String query = request.getQueryString(); - - MDC.put("method", request.getMethod()); - MDC.put("path", request.getRequestURI()); - MDC.put("status", String.valueOf(response.getStatus())); - MDC.put("durationMs", String.valueOf(durationMs)); - MDC.put("actor", actor); - - try { - log.info("{} {}{} -> {} ({}ms) actor={}", - request.getMethod(), - request.getRequestURI(), - query == null ? "" : "?" + query, - response.getStatus(), - durationMs, - actor); - } finally { - MDC.remove("method"); - MDC.remove("path"); - MDC.remove("status"); - MDC.remove("durationMs"); - MDC.remove("actor"); - } - } - } - - @Override - protected boolean shouldNotFilter(HttpServletRequest request) { - String uri = request.getRequestURI(); - - return uri.equals("/api/health") || uri.startsWith("/error"); - } - - private String resolveActor(HttpServletRequest request) { - Object memberId = request.getAttribute(ACTOR_MEMBER_ID); - - if (memberId != null) { - return "member:" + memberId; - } - - Cookie[] cookies = request.getCookies(); - - if (cookies != null) { - for (Cookie cookie : cookies) { - if (ANONYMOUS_COOKIE.equals(cookie.getName())) { - String value = cookie.getValue(); - - return "anon:" + (value.length() > 8 ? value.substring(0, 8) : value); - } - } - } - - return "guest"; - } + /** 인증 필터가 인증된 회원의 memberId 를 실어두는 request attribute 키. */ + public static final String ACTOR_MEMBER_ID = "actorMemberId"; + + /** report 모듈의 익명 식별 쿠키명과의 계약(모듈 역의존 회피용 상수 복제). */ + private static final String ANONYMOUS_COOKIE = "anonymous_id"; + + private static final Set SENSITIVE_QUERY_PARAMETERS = Set.of( + "code", + "state", + "code_challenge", + "codechallenge", + "code_verifier", + "codeverifier", + "mobile_transaction", + "access_token", + "refresh_token", + "refreshtoken", + "guest_token", + "guesttoken", + "signup_ticket", + "signupticket", + "link_ticket", + "linkticket" + ); + + private static final Logger log = LoggerFactory.getLogger("http.access"); + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + long startNanos = System.nanoTime(); + + try { + filterChain.doFilter(request, response); + } finally { + long durationMs = (System.nanoTime() - startNanos) / 1_000_000; + String actor = resolveActor(request); + String query = sanitizeQuery(request.getQueryString()); + + MDC.put("method", request.getMethod()); + MDC.put("path", request.getRequestURI()); + MDC.put("status", String.valueOf(response.getStatus())); + MDC.put("durationMs", String.valueOf(durationMs)); + MDC.put("actor", actor); + + try { + log.info("{} {}{} -> {} ({}ms) actor={}", + request.getMethod(), + request.getRequestURI(), + query == null ? "" : "?" + query, + response.getStatus(), + durationMs, + actor); + } finally { + MDC.remove("method"); + MDC.remove("path"); + MDC.remove("status"); + MDC.remove("durationMs"); + MDC.remove("actor"); + } + } + } + + static String sanitizeQuery(String query) { + if (query == null || query.isBlank()) { + return null; + } + + return Arrays.stream(query.split("&", -1)) + .map(RequestLoggingFilter::sanitizeQueryPart) + .collect(Collectors.joining("&")); + } + + private static String sanitizeQueryPart(String part) { + int separator = part.indexOf('='); + String rawName = separator < 0 ? part : part.substring(0, separator); + String decodedName; + + try { + decodedName = URLDecoder.decode(rawName, StandardCharsets.UTF_8); + } catch (IllegalArgumentException _) { + decodedName = rawName; + } + + if (SENSITIVE_QUERY_PARAMETERS.contains(decodedName.toLowerCase(Locale.ROOT))) { + return rawName + "=***"; + } + + return part; + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String uri = request.getRequestURI(); + + return uri.equals("/api/health") || uri.startsWith("/error"); + } + + private String resolveActor(HttpServletRequest request) { + Object memberId = request.getAttribute(ACTOR_MEMBER_ID); + + if (memberId != null) { + return "member:" + memberId; + } + + Cookie[] cookies = request.getCookies(); + + if (cookies != null) { + for (Cookie cookie : cookies) { + if (ANONYMOUS_COOKIE.equals(cookie.getName())) { + String value = cookie.getValue(); + + return "anon:" + (value.length() > 8 ? value.substring(0, 8) : value); + } + } + } + + return "guest"; + } } diff --git a/common/src/main/java/com/nalssilog/common/filter/TraceIdFilter.java b/common/src/main/java/com/nalssilog/common/filter/TraceIdFilter.java index 373cb64..6e0e976 100644 --- a/common/src/main/java/com/nalssilog/common/filter/TraceIdFilter.java +++ b/common/src/main/java/com/nalssilog/common/filter/TraceIdFilter.java @@ -24,32 +24,32 @@ @Order(Ordered.HIGHEST_PRECEDENCE) public class TraceIdFilter extends OncePerRequestFilter { - public static final String TRACE_ID = "traceId"; - private static final String TRACE_ID_HEADER = "X-Trace-Id"; - - @Override - protected void doFilterInternal(HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain) throws ServletException, IOException { - String traceId = resolveTraceId(request); - - MDC.put(TRACE_ID, traceId); - response.setHeader(TRACE_ID_HEADER, traceId); - - try { - filterChain.doFilter(request, response); - } finally { - MDC.remove(TRACE_ID); - } - } - - private String resolveTraceId(HttpServletRequest request) { - String inbound = request.getHeader(TRACE_ID_HEADER); - - if (StringUtils.hasText(inbound)) { - return inbound; - } - - return UUID.randomUUID().toString().substring(0, 8); - } + public static final String TRACE_ID = "traceId"; + private static final String TRACE_ID_HEADER = "X-Trace-Id"; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String traceId = resolveTraceId(request); + + MDC.put(TRACE_ID, traceId); + response.setHeader(TRACE_ID_HEADER, traceId); + + try { + filterChain.doFilter(request, response); + } finally { + MDC.remove(TRACE_ID); + } + } + + private String resolveTraceId(HttpServletRequest request) { + String inbound = request.getHeader(TRACE_ID_HEADER); + + if (StringUtils.hasText(inbound)) { + return inbound; + } + + return UUID.randomUUID().toString().substring(0, 8); + } } diff --git a/common/src/main/java/com/nalssilog/common/response/PageResponse.java b/common/src/main/java/com/nalssilog/common/response/PageResponse.java new file mode 100644 index 0000000..1f25147 --- /dev/null +++ b/common/src/main/java/com/nalssilog/common/response/PageResponse.java @@ -0,0 +1,67 @@ +package com.nalssilog.common.response; + +import java.util.List; +import java.util.function.Function; +import org.springframework.data.domain.Page; + +/** + * 전체 개수와 페이지 수를 포함하는 번호 기반 페이지 응답. + */ +public record PageResponse( + List items, + int page, + int size, + long totalElements, + int totalPages, + boolean hasPrevious, + boolean hasNext +) { + + public PageResponse { + items = List.copyOf(items); + } + + public static PageResponse from(Page source) { + return new PageResponse<>( + source.getContent(), + source.getNumber(), + source.getSize(), + source.getTotalElements(), + source.getTotalPages(), + source.hasPrevious(), + source.hasNext()); + } + + public static PageResponse of( + List items, + int page, + int size, + long totalElements + ) { + if (page < 0 || size < 1 || totalElements < 0) { + throw new IllegalArgumentException("invalid page metadata"); + } + + int totalPages = Math.toIntExact(Math.ceilDiv(totalElements, size)); + + return new PageResponse<>( + items, + page, + size, + totalElements, + totalPages, + page > 0 && totalPages > 0, + page + 1 < totalPages); + } + + public PageResponse map(Function mapper) { + return new PageResponse<>( + items.stream().map(mapper).toList(), + page, + size, + totalElements, + totalPages, + hasPrevious, + hasNext); + } +} diff --git a/common/src/main/java/com/nalssilog/common/security/SecretFingerprint.java b/common/src/main/java/com/nalssilog/common/security/SecretFingerprint.java new file mode 100644 index 0000000..44c707c --- /dev/null +++ b/common/src/main/java/com/nalssilog/common/security/SecretFingerprint.java @@ -0,0 +1,41 @@ +package com.nalssilog.common.security; + +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +public final class SecretFingerprint { + + private static final String HMAC_ALGORITHM = "HmacSHA256"; + + private SecretFingerprint() { + } + + public static String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 not available", exception); + } + } + + public static String hmacSha256(String secret, String value) { + try { + Mac mac = Mac.getInstance(HMAC_ALGORITHM); + + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM)); + + return HexFormat.of().formatHex(mac.doFinal(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("HmacSHA256 not available", exception); + } catch (InvalidKeyException exception) { + throw new IllegalStateException("HmacSHA256 key is invalid", exception); + } + } +} diff --git a/common/src/main/java/com/nalssilog/common/security/VerifiedRequestCredentials.java b/common/src/main/java/com/nalssilog/common/security/VerifiedRequestCredentials.java new file mode 100644 index 0000000..4d33a69 --- /dev/null +++ b/common/src/main/java/com/nalssilog/common/security/VerifiedRequestCredentials.java @@ -0,0 +1,47 @@ +package com.nalssilog.common.security; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; + +/** + * 서명·저장소 검증이 끝난 비쿠키 credential만 요청 내부에 전달하는 계약. + * 같은 이름의 외부 헤더는 읽지 않고 서버 필터만 request attribute를 설정한다. + */ +public final class VerifiedRequestCredentials { + + private static final String BEARER_ATTRIBUTE = + VerifiedRequestCredentials.class.getName() + ".bearer"; + private static final String GUEST_ATTRIBUTE = + VerifiedRequestCredentials.class.getName() + ".guestAnonymousKey"; + + private VerifiedRequestCredentials() { + } + + public static void markBearer(HttpServletRequest request) { + request.setAttribute(BEARER_ATTRIBUTE, Boolean.TRUE); + } + + public static boolean hasBearer(HttpServletRequest request) { + return Boolean.TRUE.equals(request.getAttribute(BEARER_ATTRIBUTE)); + } + + public static void markGuest(HttpServletRequest request, String anonymousKey) { + request.setAttribute(GUEST_ATTRIBUTE, anonymousKey); + } + + public static Optional guestAnonymousKey(HttpServletRequest request) { + Object value = request.getAttribute(GUEST_ATTRIBUTE); + + return value instanceof String text && !text.isBlank() + ? Optional.of(text) + : Optional.empty(); + } + + public static boolean hasGuest(HttpServletRequest request) { + return guestAnonymousKey(request).isPresent(); + } + + public static boolean hasNonCookieCredential(HttpServletRequest request) { + return hasBearer(request) || hasGuest(request); + } +} diff --git a/common/src/main/java/com/nalssilog/common/web/TrustedProxyChain.java b/common/src/main/java/com/nalssilog/common/web/TrustedProxyChain.java new file mode 100644 index 0000000..c837301 --- /dev/null +++ b/common/src/main/java/com/nalssilog/common/web/TrustedProxyChain.java @@ -0,0 +1,120 @@ +package com.nalssilog.common.web; + +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletRequestWrapper; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import org.springframework.security.web.util.matcher.IpAddressMatcher; +import org.springframework.util.StringUtils; + +/** + * 실제 socket peer가 명시된 trusted proxy 대역일 때만 X-Forwarded-For를 해석한다. + * 체인을 오른쪽부터 따라가 첫 untrusted hop을 실제 클라이언트 주소로 선택한다. + */ +public final class TrustedProxyChain { + + private static final int MAX_WRAPPER_DEPTH = 16; + + private final List trustedProxies; + + public TrustedProxyChain(List trustedProxies) { + List ranges = trustedProxies == null ? List.of() : trustedProxies; + + this.trustedProxies = ranges.stream() + .map(IpAddressMatcher::new) + .toList(); + } + + public String resolve(HttpServletRequest request) { + HttpServletRequest nativeRequest = unwrap(request); + String peer = normalize(nativeRequest.getRemoteAddr()); + + if (!isTrusted(peer)) { + return peer; + } + + String forwardedFor = nativeRequest.getHeader("X-Forwarded-For"); + + if (!StringUtils.hasText(forwardedFor)) { + return peer; + } + + String current = peer; + String[] hops = forwardedFor.split(","); + + for (int index = hops.length - 1; index >= 0 && isTrusted(current); index--) { + String candidate = normalize(hops[index]); + + if (!StringUtils.hasText(candidate)) { + break; + } + + current = candidate; + } + + return current; + } + + private HttpServletRequest unwrap(HttpServletRequest request) { + ServletRequest current = request; + int depth = 0; + + while (current instanceof ServletRequestWrapper wrapper && depth++ < MAX_WRAPPER_DEPTH) { + ServletRequest nested = wrapper.getRequest(); + + if (nested == current) { + break; + } + + current = nested; + } + + return current instanceof HttpServletRequest httpRequest ? httpRequest : request; + } + + private boolean isTrusted(String address) { + if (!StringUtils.hasText(address)) { + return false; + } + + for (IpAddressMatcher matcher : trustedProxies) { + try { + if (matcher.matches(address)) { + return true; + } + } catch (IllegalArgumentException _) { + return false; + } + } + + return false; + } + + private String normalize(String address) { + if (!StringUtils.hasText(address)) { + return "unknown"; + } + + String normalized = address.strip(); + + if (normalized.length() >= 2 && normalized.startsWith("\"") && normalized.endsWith("\"")) { + normalized = normalized.substring(1, normalized.length() - 1); + } + + if (normalized.startsWith("[")) { + int closingBracket = normalized.indexOf(']'); + + if (closingBracket > 1) { + return normalized.substring(1, closingBracket); + } + } + + int colon = normalized.lastIndexOf(':'); + + if (colon > 0 && normalized.indexOf(':') == colon && normalized.substring(0, colon).contains(".")) { + return normalized.substring(0, colon); + } + + return normalized; + } +} diff --git a/common/src/test/java/com/nalssilog/common/filter/RequestLoggingFilterTest.java b/common/src/test/java/com/nalssilog/common/filter/RequestLoggingFilterTest.java new file mode 100644 index 0000000..30c22ef --- /dev/null +++ b/common/src/test/java/com/nalssilog/common/filter/RequestLoggingFilterTest.java @@ -0,0 +1,25 @@ +package com.nalssilog.common.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +@SuppressWarnings("java:S5960") +class RequestLoggingFilterTest { + + @Test + void masksOAuthAndMobileCredentialQueryParameters() { + String sanitized = RequestLoggingFilter.sanitizeQuery( + "code=provider-code&state=app-state&code_challenge=challenge-value&locationId=10"); + + assertThat(sanitized) + .isEqualTo("code=***&state=***&code_challenge=***&locationId=10") + .doesNotContain("provider-code", "app-state", "challenge-value"); + } + + @Test + void recognizesEncodedSensitiveParameterName() { + assertThat(RequestLoggingFilter.sanitizeQuery("co%64e=secret")) + .isEqualTo("co%64e=***"); + } +} diff --git a/common/src/test/java/com/nalssilog/common/response/PageResponseTest.java b/common/src/test/java/com/nalssilog/common/response/PageResponseTest.java new file mode 100644 index 0000000..1095adb --- /dev/null +++ b/common/src/test/java/com/nalssilog/common/response/PageResponseTest.java @@ -0,0 +1,35 @@ +package com.nalssilog.common.response; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("java:S5960") +class PageResponseTest { + + @Test + void calculatesNumberedPageMetadataFromTotalElements() { + PageResponse page = PageResponse.of( + List.of("여섯", "일곱", "여덟", "아홉", "열"), + 1, + 5, + 12); + + assertThat(page.page()).isEqualTo(1); + assertThat(page.size()).isEqualTo(5); + assertThat(page.totalElements()).isEqualTo(12); + assertThat(page.totalPages()).isEqualTo(3); + assertThat(page.hasPrevious()).isTrue(); + assertThat(page.hasNext()).isTrue(); + } + + @Test + void returnsZeroPagesForAnEmptyResult() { + PageResponse page = PageResponse.of(List.of(), 0, 5, 0); + + assertThat(page.totalPages()).isZero(); + assertThat(page.hasPrevious()).isFalse(); + assertThat(page.hasNext()).isFalse(); + } +} diff --git a/config b/config index ba2deb3..f383ce4 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit ba2deb3a89e98e8309fccbed147666ae70ee48b4 +Subproject commit f383ce419e3a4498fa1d5fa3c3e41c73b11dde78 diff --git a/deploy/compose-dev.yml b/deploy/compose-dev.yml index a5b8248..87ab37a 100644 --- a/deploy/compose-dev.yml +++ b/deploy/compose-dev.yml @@ -1,15 +1,15 @@ # dev 환경 compose. 지금은 prod 와 같은 EC2 에서 별도 프로젝트로 실행, 추후 인스턴스 분리 시 이 파일만 옮기면 됨. -# 이미지=ghcr(태그 :dev), Postgres=Neon(dev), Redis=아래 redis-dev 컨테이너, 이미지=R2. +# 이미지=ghcr(배포 시 버전·커밋 태그, 수동 실행 시 :dev), Postgres=Neon(dev), Redis=아래 redis-dev 컨테이너, 이미지=R2. # 설정: ./config 의 yml 마운트 + ./.env.dev 시크릿. HTTPS/라우팅은 호스트 nginx(EC2 에서 직접). name: nalssilog-dev services: app-dev: - image: ghcr.io/nalssilog/api:dev + image: ghcr.io/nalssilog/api:${APP_IMAGE_TAG:-dev} container_name: nalssilog-app-dev restart: unless-stopped environment: - SPRING_PROFILES_ACTIVE: dev + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-dev} SPRING_CONFIG_ADDITIONAL_LOCATION: file:/config/ env_file: - .env.dev diff --git a/deploy/compose-prod.yml b/deploy/compose-prod.yml index 5312a08..bcd2aaa 100644 --- a/deploy/compose-prod.yml +++ b/deploy/compose-prod.yml @@ -1,15 +1,15 @@ # prod 환경 compose. 지금은 dev 와 같은 EC2 에서 별도 프로젝트로 실행, 추후 인스턴스 분리 시 이 파일만 옮기면 됨. -# 이미지=ghcr(태그 :prod), Postgres=Neon(prod), Redis=Upstash(외부, .env.prod 의 PROD_REDIS_*), 이미지=R2. +# 이미지=ghcr(배포 시 버전·커밋 태그, 수동 실행 시 :prod), Postgres=Neon(prod), Redis=Upstash(외부, .env.prod 의 PROD_REDIS_*), 이미지=R2. # 설정: ./config 의 yml 마운트 + ./.env.prod 시크릿. HTTPS/라우팅은 호스트 nginx(EC2 에서 직접). name: nalssilog-prod services: app-prod: - image: ghcr.io/nalssilog/api:prod + image: ghcr.io/nalssilog/api:${APP_IMAGE_TAG:-prod} container_name: nalssilog-app-prod restart: unless-stopped environment: - SPRING_PROFILES_ACTIVE: prod + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-prod} SPRING_CONFIG_ADDITIONAL_LOCATION: file:/config/ env_file: - .env.prod diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..9e6eb6d --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +appVersion=0.2.0 diff --git a/location/src/main/java/com/nalssilog/location/api/LocationController.java b/location/src/main/java/com/nalssilog/location/api/LocationController.java index ebcb865..298ea6c 100644 --- a/location/src/main/java/com/nalssilog/location/api/LocationController.java +++ b/location/src/main/java/com/nalssilog/location/api/LocationController.java @@ -1,9 +1,12 @@ package com.nalssilog.location.api; +import com.nalssilog.common.response.PageResponse; import com.nalssilog.location.api.dto.LocationResponse; +import com.nalssilog.location.api.dto.PopularLocationsResponse; import com.nalssilog.location.application.LocationService; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; -import java.util.List; +import jakarta.validation.constraints.Size; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -19,25 +22,24 @@ public class LocationController { private final LocationService locationService; @GetMapping - public List search(@RequestParam @NotBlank String keyword) { - return locationService.search(keyword).stream() - .map(LocationResponse::from) - .toList(); + public PageResponse search( + @RequestParam @NotBlank @Size(max = 50) String keyword, + @RequestParam(defaultValue = "0") @Min(0) int page + ) { + return locationService.search(keyword, page).map(LocationResponse::from); } @GetMapping("/reverse-geocode") public LocationResponse reverseGeocode( - @RequestParam double lat, - @RequestParam double lng + @RequestParam double lat, + @RequestParam double lng ) { return LocationResponse.from(locationService.reverseGeocode(lat, lng)); } @GetMapping("/popular") - public List popular() { - return locationService.getPopular().stream() - .map(LocationResponse::from) - .toList(); + public PopularLocationsResponse popular() { + return PopularLocationsResponse.from(locationService.getPopular()); } @GetMapping("/{id}") diff --git a/location/src/main/java/com/nalssilog/location/api/LocationFavoriteController.java b/location/src/main/java/com/nalssilog/location/api/LocationFavoriteController.java index 62f0f71..a4de33d 100644 --- a/location/src/main/java/com/nalssilog/location/api/LocationFavoriteController.java +++ b/location/src/main/java/com/nalssilog/location/api/LocationFavoriteController.java @@ -1,10 +1,11 @@ package com.nalssilog.location.api; +import com.nalssilog.common.response.PageResponse; import com.nalssilog.location.api.dto.FavoriteRequest; import com.nalssilog.location.api.dto.LocationResponse; import com.nalssilog.location.application.LocationFavoriteService; import jakarta.validation.Valid; -import java.util.List; +import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -14,6 +15,7 @@ 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.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -25,17 +27,18 @@ public class LocationFavoriteController { private final LocationFavoriteService locationFavoriteService; @GetMapping - public List myFavorites(@AuthenticationPrincipal Long memberId) { - return locationFavoriteService.listFavorites(memberId).stream() - .map(LocationResponse::from) - .toList(); + public PageResponse myFavorites( + @AuthenticationPrincipal Long memberId, + @RequestParam(defaultValue = "0") @Min(0) int page + ) { + return locationFavoriteService.listFavorites(memberId, page).map(LocationResponse::from); } @PostMapping @ResponseStatus(HttpStatus.CREATED) public void addFavorite( - @AuthenticationPrincipal Long memberId, - @Valid @RequestBody FavoriteRequest request + @AuthenticationPrincipal Long memberId, + @Valid @RequestBody FavoriteRequest request ) { locationFavoriteService.addFavorite(memberId, request.locationId()); } @@ -43,8 +46,8 @@ public void addFavorite( @DeleteMapping("/{locationId}") @ResponseStatus(HttpStatus.NO_CONTENT) public void removeFavorite( - @AuthenticationPrincipal Long memberId, - @PathVariable Long locationId + @AuthenticationPrincipal Long memberId, + @PathVariable Long locationId ) { locationFavoriteService.removeFavorite(memberId, locationId); } diff --git a/location/src/main/java/com/nalssilog/location/api/dto/PopularLocationsResponse.java b/location/src/main/java/com/nalssilog/location/api/dto/PopularLocationsResponse.java new file mode 100644 index 0000000..527af9c --- /dev/null +++ b/location/src/main/java/com/nalssilog/location/api/dto/PopularLocationsResponse.java @@ -0,0 +1,73 @@ +package com.nalssilog.location.api.dto; + +import com.nalssilog.location.application.dto.PopularLocationSnapshotInfo; +import com.nalssilog.location.domain.PopularRankMovement; +import java.time.Instant; +import java.util.List; + +/** + * 동일 계산 시점의 인기 지역 top 20 전체 응답. + * 프론트는 {@code pageSize} 단위로 나누어 표시한다. + */ +public record PopularLocationsResponse( + String snapshotId, + Instant calculatedAt, + Instant windowStartedAt, + Instant windowEndedAt, + String algorithmVersion, + int pageSize, + long totalElements, + int totalPages, + List items +) { + + private static final int PAGE_SIZE = 5; + + public PopularLocationsResponse { + items = List.copyOf(items); + } + + public static PopularLocationsResponse from(PopularLocationSnapshotInfo snapshot) { + List items = snapshot.items().stream() + .map(Ranking::from) + .toList(); + int totalPages = items.isEmpty() + ? 0 + : (items.size() + PAGE_SIZE - 1) / PAGE_SIZE; + + return new PopularLocationsResponse( + String.valueOf(snapshot.snapshotId()), + snapshot.calculatedAt(), + snapshot.windowStartedAt(), + snapshot.windowEndedAt(), + snapshot.algorithmVersion(), + PAGE_SIZE, + items.size(), + totalPages, + items); + } + + public record Ranking( + int rank, + Integer previousRank, + Integer rankChange, + PopularRankMovement movement, + long uniqueReporterCount, + long reportCount, + Instant latestReportAt, + LocationResponse location + ) { + + private static Ranking from(PopularLocationSnapshotInfo.Item item) { + return new Ranking( + item.rank(), + item.previousRank(), + item.rankChange(), + item.movement(), + item.uniqueReporterCount(), + item.reportCount(), + item.latestReportAt(), + LocationResponse.from(item.location())); + } + } +} diff --git a/location/src/main/java/com/nalssilog/location/application/LocationFavoriteService.java b/location/src/main/java/com/nalssilog/location/application/LocationFavoriteService.java index 3006977..24e6cb1 100644 --- a/location/src/main/java/com/nalssilog/location/application/LocationFavoriteService.java +++ b/location/src/main/java/com/nalssilog/location/application/LocationFavoriteService.java @@ -1,13 +1,23 @@ package com.nalssilog.location.application; +import java.util.List; + +import org.hibernate.exception.ConstraintViolationException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.common.response.PageResponse; import com.nalssilog.location.application.dto.LocationInfo; +import com.nalssilog.location.domain.LocationErrorCode; import com.nalssilog.location.domain.LocationFavorite; import com.nalssilog.location.repository.LocationFavoriteRepository; import com.nalssilog.location.repository.LocationRepository; -import java.util.List; + import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; /** * 회원 즐겨찾기 지역 관리. @@ -17,26 +27,65 @@ @Transactional(readOnly = true) public class LocationFavoriteService { + private static final int PAGE_SIZE = 5; + private static final String FAVORITE_CONSTRAINT = "uk_location_favorite_member_location"; + private final LocationFavoriteRepository locationFavoriteRepository; private final LocationRepository locationRepository; + private static boolean hasConstraint( + Throwable throwable + ) { + for (Throwable cause = throwable; cause != null; cause = cause.getCause()) { + if (cause instanceof ConstraintViolationException constraintViolation) { + String constraintName = constraintViolation.getConstraintName(); + + return FAVORITE_CONSTRAINT.equalsIgnoreCase(constraintName); + } + } + + return false; + } + @Transactional public void addFavorite(Long memberId, Long locationId) { locationRepository.getById(locationId); - if (!locationFavoriteRepository.exists(memberId, locationId)) { - locationFavoriteRepository.save(LocationFavorite.of(memberId, locationId)); + if (locationFavoriteRepository.existsByMemberIdAndLocationId(memberId, locationId)) { + return; + } + + try { + locationFavoriteRepository.saveAndFlush( + LocationFavorite.of(memberId, locationId)); + } catch (DataIntegrityViolationException exception) { + if (hasConstraint(exception)) { + throw new NalssiLogException( + LocationErrorCode.FAVORITE_ALREADY_EXISTS); + } + + throw exception; } } @Transactional public void removeFavorite(Long memberId, Long locationId) { - locationFavoriteRepository.delete(memberId, locationId); + locationFavoriteRepository.deleteByMemberIdAndLocationId(memberId, locationId); } - public List listFavorites(Long memberId) { - List locationIds = locationFavoriteRepository.findFavoriteLocationIds(memberId); + public PageResponse listFavorites(Long memberId, int page) { + Page favorites = + locationFavoriteRepository.findAllByMemberIdOrderByCreatedAtDescIdDesc( + memberId, + PageRequest.of(page, PAGE_SIZE)); + List favoriteIds = favorites.getContent().stream() + .map(LocationFavorite::getLocationId) + .toList(); - return locationRepository.findByIds(locationIds); + return PageResponse.of( + locationRepository.findByIds(favoriteIds), + page, + PAGE_SIZE, + favorites.getTotalElements()); } } diff --git a/location/src/main/java/com/nalssilog/location/application/LocationService.java b/location/src/main/java/com/nalssilog/location/application/LocationService.java index 091207f..f7a27cb 100644 --- a/location/src/main/java/com/nalssilog/location/application/LocationService.java +++ b/location/src/main/java/com/nalssilog/location/application/LocationService.java @@ -1,17 +1,21 @@ package com.nalssilog.location.application; import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.common.response.PageResponse; import com.nalssilog.location.application.dto.LocationInfo; +import com.nalssilog.location.application.dto.PopularLocationSnapshotData; +import com.nalssilog.location.application.dto.PopularLocationSnapshotInfo; import com.nalssilog.location.client.KakaoMapClient; import com.nalssilog.location.client.KakaoRegion; -import com.nalssilog.location.config.LocationProperties; import com.nalssilog.location.domain.LocationErrorCode; import com.nalssilog.location.repository.LocationRepository; -import java.util.ArrayList; -import java.util.HashSet; import java.util.List; -import java.util.Set; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -24,20 +28,28 @@ @Transactional(readOnly = true) public class LocationService { - private static final int POPULAR_SIZE = 5; + private static final int PAGE_SIZE = 5; private static final String FORMER_JEONBUK = "전라북도"; private static final String CURRENT_JEONBUK = "전북특별자치도"; private static final String FORMER_JEONNAM = "전라남도"; private static final String FORMER_GWANGJU = "광주광역시"; private static final String CURRENT_JEONNAM_GWANGJU = "전남광주통합특별시"; + private static final String FORMER_JEOLLA = "전라도"; + private static final String FORMER_JEOLLA_SHORT = "전라"; + private static final String CURRENT_JEOLLA_PREFIX = "전"; private final LocationRepository locationRepository; private final PopularLocationSource popularLocationSource; - private final LocationProperties properties; private final KakaoMapClient kakaoMapClient; - public List search(String keyword) { - return locationRepository.searchByKeyword(normalizeLegacyRegionName(keyword.strip())); + public PageResponse search(String keyword, int page) { + String normalizedKeyword = normalizeLegacyRegionName( + keyword.strip().replaceAll("\\s+", " ")); + Page result = locationRepository.searchByKeyword( + normalizedKeyword, + PageRequest.of(page, PAGE_SIZE)); + + return PageResponse.from(result); } public LocationInfo getLocation(Long locationId) { @@ -55,33 +67,30 @@ public List getLocations(List locationIds) { @Transactional(propagation = Propagation.NOT_SUPPORTED) public LocationInfo reverseGeocode(double latitude, double longitude) { validateCoordinates(latitude, longitude); + KakaoRegion region = kakaoMapClient.reverseGeocode(latitude, longitude); return locationRepository.findOrCreate(region); } - /** 인기 동네 top5. 최근 제보 지역을 우선하고 부족한 자리는 설정된 대표 지역으로 중복 없이 채운다. */ - public List getPopular() { - List popularIds = popularLocationSource.topLocationIds(POPULAR_SIZE); - List result = new ArrayList<>(locationRepository.findByIds(popularIds)); - - if (result.size() >= POPULAR_SIZE) { - return List.copyOf(result.subList(0, POPULAR_SIZE)); - } - - Set addedIds = new HashSet<>(); - result.forEach(location -> addedIds.add(location.id())); - - for (LocationInfo featured : locationRepository.findByAdminCodes(properties.featuredAdminCodes())) { - if (addedIds.add(featured.id())) { - result.add(featured); - } - if (result.size() == POPULAR_SIZE) { - break; - } - } - - return List.copyOf(result); + public PopularLocationSnapshotInfo getPopular() { + PopularLocationSnapshotData snapshot = popularLocationSource.latestSnapshot(); + List locationIds = snapshot.rankings().stream() + .map(PopularLocationSnapshotData.Rank::locationId) + .toList(); + Map locationsById = locationRepository.findByIds(locationIds).stream() + .collect(Collectors.toMap(LocationInfo::id, Function.identity())); + List items = snapshot.rankings().stream() + .map(rank -> popularItem(rank, locationsById)) + .toList(); + + return new PopularLocationSnapshotInfo( + snapshot.snapshotId(), + snapshot.calculatedAt(), + snapshot.windowStartedAt(), + snapshot.windowEndedAt(), + snapshot.algorithmVersion(), + items); } private static void validateCoordinates(double latitude, double longitude) { @@ -95,6 +104,30 @@ private static String normalizeLegacyRegionName(String keyword) { return keyword .replace(FORMER_JEONBUK, CURRENT_JEONBUK) .replace(FORMER_JEONNAM, CURRENT_JEONNAM_GWANGJU) - .replace(FORMER_GWANGJU, CURRENT_JEONNAM_GWANGJU); + .replace(FORMER_GWANGJU, CURRENT_JEONNAM_GWANGJU) + .replace(FORMER_JEOLLA, CURRENT_JEOLLA_PREFIX) + .replace(FORMER_JEOLLA_SHORT, CURRENT_JEOLLA_PREFIX); + } + + private static PopularLocationSnapshotInfo.Item popularItem( + PopularLocationSnapshotData.Rank rank, + Map locationsById + ) { + LocationInfo location = locationsById.get(rank.locationId()); + + if (location == null) { + throw new IllegalStateException( + "popular snapshot references missing location: " + rank.locationId()); + } + + return new PopularLocationSnapshotInfo.Item( + rank.rank(), + rank.previousRank(), + rank.rankChange(), + rank.movement(), + rank.uniqueReporterCount(), + rank.reportCount(), + rank.latestReportAt(), + location); } } diff --git a/location/src/main/java/com/nalssilog/location/application/PopularLocationSource.java b/location/src/main/java/com/nalssilog/location/application/PopularLocationSource.java index 5ba0cd8..ff67745 100644 --- a/location/src/main/java/com/nalssilog/location/application/PopularLocationSource.java +++ b/location/src/main/java/com/nalssilog/location/application/PopularLocationSource.java @@ -1,13 +1,11 @@ package com.nalssilog.location.application; -import java.util.List; +import com.nalssilog.location.application.dto.PopularLocationSnapshotData; /** - * 인기 지역 랭킹의 공급자(port). 실제 데이터는 제보 활동에 기반하므로 report 모듈이 구현(adapter)한다. - * location 은 report 를 직접 의존하지 못하므로(의존 역방향), 이 인터페이스를 통해 역전시켜 받는다. - * 구현은 최근 활동이 많은 순서로 locationId 를 돌려주고, 데이터가 없으면 빈 목록을 반환한다. + * 인기 지역 랭킹의 공급자(port). 실제 집계와 스냅샷 저장은 report 모듈이 구현한다. */ public interface PopularLocationSource { - List topLocationIds(int size); + PopularLocationSnapshotData latestSnapshot(); } diff --git a/location/src/main/java/com/nalssilog/location/application/dto/PopularLocationSnapshotData.java b/location/src/main/java/com/nalssilog/location/application/dto/PopularLocationSnapshotData.java new file mode 100644 index 0000000..4325527 --- /dev/null +++ b/location/src/main/java/com/nalssilog/location/application/dto/PopularLocationSnapshotData.java @@ -0,0 +1,34 @@ +package com.nalssilog.location.application.dto; + +import com.nalssilog.location.domain.PopularRankMovement; +import java.time.Instant; +import java.util.List; + +/** + * 제보 모듈이 계산·저장한 인기 지역 순위 스냅샷. + */ +public record PopularLocationSnapshotData( + Long snapshotId, + Instant calculatedAt, + Instant windowStartedAt, + Instant windowEndedAt, + String algorithmVersion, + List rankings +) { + + public PopularLocationSnapshotData { + rankings = List.copyOf(rankings); + } + + public record Rank( + Long locationId, + int rank, + Integer previousRank, + Integer rankChange, + PopularRankMovement movement, + long uniqueReporterCount, + long reportCount, + Instant latestReportAt + ) { + } +} diff --git a/location/src/main/java/com/nalssilog/location/application/dto/PopularLocationSnapshotInfo.java b/location/src/main/java/com/nalssilog/location/application/dto/PopularLocationSnapshotInfo.java new file mode 100644 index 0000000..0a5a296 --- /dev/null +++ b/location/src/main/java/com/nalssilog/location/application/dto/PopularLocationSnapshotInfo.java @@ -0,0 +1,34 @@ +package com.nalssilog.location.application.dto; + +import com.nalssilog.location.domain.PopularRankMovement; +import java.time.Instant; +import java.util.List; + +/** + * 인기 순위 스냅샷에 화면 표시용 지역 정보를 결합한 내부 응답. + */ +public record PopularLocationSnapshotInfo( + Long snapshotId, + Instant calculatedAt, + Instant windowStartedAt, + Instant windowEndedAt, + String algorithmVersion, + List items +) { + + public PopularLocationSnapshotInfo { + items = List.copyOf(items); + } + + public record Item( + int rank, + Integer previousRank, + Integer rankChange, + PopularRankMovement movement, + long uniqueReporterCount, + long reportCount, + Instant latestReportAt, + LocationInfo location + ) { + } +} diff --git a/location/src/main/java/com/nalssilog/location/client/KakaoMapClient.java b/location/src/main/java/com/nalssilog/location/client/KakaoMapClient.java index fb221f8..b75ecc7 100644 --- a/location/src/main/java/com/nalssilog/location/client/KakaoMapClient.java +++ b/location/src/main/java/com/nalssilog/location/client/KakaoMapClient.java @@ -28,6 +28,7 @@ public class KakaoMapClient { @Autowired public KakaoMapClient(KakaoMapProperties properties) { SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(properties.connectTimeout()); requestFactory.setReadTimeout(properties.readTimeout()); @@ -101,6 +102,7 @@ boolean hasRequiredValues() { KakaoRegion toRegion() { String legalDongName = ri == null || ri.isBlank() ? dong : dong + " " + ri; + return new KakaoRegion( code, sido, diff --git a/location/src/main/java/com/nalssilog/location/config/KakaoMapProperties.java b/location/src/main/java/com/nalssilog/location/config/KakaoMapProperties.java index 54dc288..3db7511 100644 --- a/location/src/main/java/com/nalssilog/location/config/KakaoMapProperties.java +++ b/location/src/main/java/com/nalssilog/location/config/KakaoMapProperties.java @@ -18,9 +18,11 @@ public record KakaoMapProperties( public KakaoMapProperties { baseUrl = baseUrl == null || baseUrl.isBlank() ? DEFAULT_BASE_URL : baseUrl.strip(); + if (reverseGeocodePath == null || reverseGeocodePath.isBlank()) { throw new IllegalArgumentException("Kakao reverse-geocode-path must be configured"); } + reverseGeocodePath = reverseGeocodePath.strip(); restApiKey = restApiKey == null ? "" : restApiKey.strip(); connectTimeout = connectTimeout == null ? DEFAULT_CONNECT_TIMEOUT : connectTimeout; diff --git a/location/src/main/java/com/nalssilog/location/config/LegalDongCsvReader.java b/location/src/main/java/com/nalssilog/location/config/LegalDongCsvReader.java index 2032aca..82eba64 100644 --- a/location/src/main/java/com/nalssilog/location/config/LegalDongCsvReader.java +++ b/location/src/main/java/com/nalssilog/location/config/LegalDongCsvReader.java @@ -30,14 +30,17 @@ static List read(Resource resource) { try (BufferedReader reader = new BufferedReader( new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) { String header = removeBom(reader.readLine()); + if (!EXPECTED_HEADER.equals(header)) { throw new IllegalStateException("Unexpected legal-dong CSV header: " + header); } String line; int lineNumber = 1; + while ((line = reader.readLine()) != null) { lineNumber++; + if (!line.isBlank()) { sourceRows.add(parse(line, lineNumber)); } @@ -47,6 +50,7 @@ static List read(Resource resource) { } Set parentCodesWithRi = new HashSet<>(); + for (SourceRow row : sourceRows) { if (!row.ri().isBlank()) { parentCodesWithRi.add(row.adminCode().substring(0, 8) + "00"); @@ -62,6 +66,7 @@ static List read(Resource resource) { private static SourceRow parse(String line, int lineNumber) { String[] columns = line.split(",", -1); + if (columns.length != 7) { throw new IllegalStateException( "Invalid legal-dong CSV column count at line " + lineNumber); @@ -69,6 +74,7 @@ private static SourceRow parse(String line, int lineNumber) { String adminCode = columns[0].strip(); String sido = columns[1].strip(); + if (!adminCode.matches("\\d{10}") || sido.isBlank()) { throw new IllegalStateException("Invalid legal-dong CSV row at line " + lineNumber); } @@ -105,6 +111,7 @@ private static String removeBom(String value) { if (value != null && value.startsWith("\uFEFF")) { return value.substring(1); } + return value; } @@ -121,6 +128,7 @@ private record SourceRow( LegalDongRow toLegalDongRow() { String dong = ri.isBlank() ? eupMyeonDong : eupMyeonDong + " " + ri; + return new LegalDongRow(adminCode, sido, sigungu, dong); } } diff --git a/location/src/main/java/com/nalssilog/location/config/LegalDongDataInitializer.java b/location/src/main/java/com/nalssilog/location/config/LegalDongDataInitializer.java index 1dda3a3..aa94a63 100644 --- a/location/src/main/java/com/nalssilog/location/config/LegalDongDataInitializer.java +++ b/location/src/main/java/com/nalssilog/location/config/LegalDongDataInitializer.java @@ -54,6 +54,7 @@ public void run(ApplicationArguments args) { List rows = LegalDongCsvReader.read( new ClassPathResource(DATASET_RESOURCE)); + jdbcTemplate.batchUpdate(UPSERT_SQL, rows, BATCH_SIZE, this::setParameters); jdbcTemplate.update(""" insert into location_dataset_import (dataset_version, source_name, imported_at) diff --git a/location/src/main/java/com/nalssilog/location/config/LocationProperties.java b/location/src/main/java/com/nalssilog/location/config/LocationProperties.java deleted file mode 100644 index b1882e7..0000000 --- a/location/src/main/java/com/nalssilog/location/config/LocationProperties.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.nalssilog.location.config; - -import java.util.List; -import org.springframework.boot.context.properties.ConfigurationProperties; - -/** 지역 관련 설정. featuredAdminCodes 는 제보 0건일 때 인기 동네 자리에 노출할 대표 지역(순서 유지). */ -@ConfigurationProperties("nalssilog.location") -public record LocationProperties(List featuredAdminCodes) { - - public LocationProperties { - featuredAdminCodes = featuredAdminCodes == null ? List.of() : featuredAdminCodes; - } -} diff --git a/location/src/main/java/com/nalssilog/location/config/PopularLocationProperties.java b/location/src/main/java/com/nalssilog/location/config/PopularLocationProperties.java new file mode 100644 index 0000000..ce4c6bc --- /dev/null +++ b/location/src/main/java/com/nalssilog/location/config/PopularLocationProperties.java @@ -0,0 +1,35 @@ +package com.nalssilog.location.config; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "nalssilog.location.popular") +public record PopularLocationProperties( + Duration window, + int limit, + Duration snapshotInterval +) { + + private static final Duration DEFAULT_WINDOW = Duration.ofDays(7); + private static final int DEFAULT_LIMIT = 20; + private static final Duration DEFAULT_SNAPSHOT_INTERVAL = Duration.ofMinutes(10); + private static final int MAX_LIMIT = 20; + + public PopularLocationProperties { + if (window == null || window.isZero() || window.isNegative()) { + window = DEFAULT_WINDOW; + } + + if (limit < 1) { + limit = DEFAULT_LIMIT; + } + + if (limit > MAX_LIMIT) { + throw new IllegalArgumentException("popular location limit must not exceed " + MAX_LIMIT); + } + + if (snapshotInterval == null || snapshotInterval.isZero() || snapshotInterval.isNegative()) { + snapshotInterval = DEFAULT_SNAPSHOT_INTERVAL; + } + } +} diff --git a/location/src/main/java/com/nalssilog/location/domain/Location.java b/location/src/main/java/com/nalssilog/location/domain/Location.java index 6a71d7e..abdaa9c 100644 --- a/location/src/main/java/com/nalssilog/location/domain/Location.java +++ b/location/src/main/java/com/nalssilog/location/domain/Location.java @@ -49,6 +49,7 @@ public class Location extends BaseTimeEntity { public static Location of(String adminCode, String sido, String sigungu, String dong, Double latitude, Double longitude) { Location location = new Location(); + location.adminCode = adminCode; location.sido = sido; location.sigungu = sigungu; diff --git a/location/src/main/java/com/nalssilog/location/domain/LocationErrorCode.java b/location/src/main/java/com/nalssilog/location/domain/LocationErrorCode.java index 283e5fc..c4f69c5 100644 --- a/location/src/main/java/com/nalssilog/location/domain/LocationErrorCode.java +++ b/location/src/main/java/com/nalssilog/location/domain/LocationErrorCode.java @@ -10,6 +10,10 @@ public enum LocationErrorCode implements ErrorCode { LOCATION_NOT_FOUND("LOCATION_NOT_FOUND", "지역을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + FAVORITE_ALREADY_EXISTS( + "FAVORITE_ALREADY_EXISTS", + "이미 즐겨찾기에 추가한 지역입니다.", + HttpStatus.CONFLICT), INVALID_COORDINATES("INVALID_COORDINATES", "위도 또는 경도가 올바르지 않습니다.", HttpStatus.BAD_REQUEST), KAKAO_MAP_API_UNAVAILABLE("KAKAO_MAP_API_UNAVAILABLE", "위치 정보를 불러오지 못했습니다.", HttpStatus.BAD_GATEWAY); diff --git a/location/src/main/java/com/nalssilog/location/domain/LocationFavorite.java b/location/src/main/java/com/nalssilog/location/domain/LocationFavorite.java index fd9bac9..e0cd30a 100644 --- a/location/src/main/java/com/nalssilog/location/domain/LocationFavorite.java +++ b/location/src/main/java/com/nalssilog/location/domain/LocationFavorite.java @@ -37,6 +37,7 @@ public class LocationFavorite extends BaseTimeEntity { public static LocationFavorite of(Long memberId, Long locationId) { LocationFavorite favorite = new LocationFavorite(); + favorite.memberId = memberId; favorite.locationId = locationId; diff --git a/location/src/main/java/com/nalssilog/location/domain/PopularRankMovement.java b/location/src/main/java/com/nalssilog/location/domain/PopularRankMovement.java new file mode 100644 index 0000000..812904f --- /dev/null +++ b/location/src/main/java/com/nalssilog/location/domain/PopularRankMovement.java @@ -0,0 +1,8 @@ +package com.nalssilog.location.domain; + +public enum PopularRankMovement { + NEW, + UP, + DOWN, + SAME +} diff --git a/location/src/main/java/com/nalssilog/location/repository/LocationFavoriteJpaRepository.java b/location/src/main/java/com/nalssilog/location/repository/LocationFavoriteJpaRepository.java deleted file mode 100644 index d5658ce..0000000 --- a/location/src/main/java/com/nalssilog/location/repository/LocationFavoriteJpaRepository.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.nalssilog.location.repository; - -import com.nalssilog.location.domain.LocationFavorite; -import java.util.List; -import org.springframework.data.jpa.repository.JpaRepository; - -/** - * 단순 CRUD와 메서드 이름으로 표현 가능한 조회만 담당한다. - * 인기 지역 집계는 {@link LocationFavoriteRepository}가 QueryDSL로 처리한다. - */ -public interface LocationFavoriteJpaRepository extends JpaRepository { - - boolean existsByMemberIdAndLocationId(Long memberId, Long locationId); - - long deleteByMemberIdAndLocationId(Long memberId, Long locationId); - - List findAllByMemberIdOrderByCreatedAtDesc(Long memberId); -} diff --git a/location/src/main/java/com/nalssilog/location/repository/LocationFavoriteRepository.java b/location/src/main/java/com/nalssilog/location/repository/LocationFavoriteRepository.java index 9412267..e7dcb23 100644 --- a/location/src/main/java/com/nalssilog/location/repository/LocationFavoriteRepository.java +++ b/location/src/main/java/com/nalssilog/location/repository/LocationFavoriteRepository.java @@ -1,49 +1,18 @@ package com.nalssilog.location.repository; -import static com.nalssilog.location.domain.QLocationFavorite.locationFavorite; - import com.nalssilog.location.domain.LocationFavorite; -import com.querydsl.jpa.impl.JPAQueryFactory; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Repository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; /** - * 서비스 호출용 LocationFavorite 저장소. - * 단순 조회는 Spring Data JPA에 위임하고, 인기 지역 집계는 QueryDSL로 처리한다. + * 단순 CRUD와 메서드 이름으로 표현 가능한 조회만 담당한다. */ -@Repository -@RequiredArgsConstructor -public class LocationFavoriteRepository { - - private final LocationFavoriteJpaRepository locationFavoriteJpaRepository; - private final JPAQueryFactory queryFactory; - - public boolean exists(Long memberId, Long locationId) { - return locationFavoriteJpaRepository.existsByMemberIdAndLocationId(memberId, locationId); - } - - public void save(LocationFavorite favorite) { - locationFavoriteJpaRepository.save(favorite); - } +public interface LocationFavoriteRepository extends JpaRepository { - public void delete(Long memberId, Long locationId) { - locationFavoriteJpaRepository.deleteByMemberIdAndLocationId(memberId, locationId); - } + boolean existsByMemberIdAndLocationId(Long memberId, Long locationId); - public List findFavoriteLocationIds(Long memberId) { - return locationFavoriteJpaRepository.findAllByMemberIdOrderByCreatedAtDesc(memberId).stream() - .map(LocationFavorite::getLocationId) - .toList(); - } + long deleteByMemberIdAndLocationId(Long memberId, Long locationId); - public List findPopularLocationIds(int size) { - return queryFactory - .select(locationFavorite.locationId) - .from(locationFavorite) - .groupBy(locationFavorite.locationId) - .orderBy(locationFavorite.id.count().desc()) - .limit(size) - .fetch(); - } + Page findAllByMemberIdOrderByCreatedAtDescIdDesc(Long memberId, Pageable pageable); } diff --git a/location/src/main/java/com/nalssilog/location/repository/LocationJpaRepository.java b/location/src/main/java/com/nalssilog/location/repository/LocationJpaRepository.java index 66ed931..7f7dce7 100644 --- a/location/src/main/java/com/nalssilog/location/repository/LocationJpaRepository.java +++ b/location/src/main/java/com/nalssilog/location/repository/LocationJpaRepository.java @@ -1,7 +1,6 @@ package com.nalssilog.location.repository; import com.nalssilog.location.domain.Location; -import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -11,7 +10,5 @@ */ public interface LocationJpaRepository extends JpaRepository { - List findByAdminCodeIn(List adminCodes); - Optional findByAdminCode(String adminCode); } diff --git a/location/src/main/java/com/nalssilog/location/repository/LocationRepository.java b/location/src/main/java/com/nalssilog/location/repository/LocationRepository.java index 9e94a09..86d8ccb 100644 --- a/location/src/main/java/com/nalssilog/location/repository/LocationRepository.java +++ b/location/src/main/java/com/nalssilog/location/repository/LocationRepository.java @@ -7,19 +7,24 @@ import com.nalssilog.location.client.KakaoRegion; import com.nalssilog.location.domain.Location; import com.nalssilog.location.domain.LocationErrorCode; +import com.querydsl.core.BooleanBuilder; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.CaseBuilder; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.core.types.dsl.StringExpression; import com.querydsl.jpa.impl.JPAQueryFactory; -import jakarta.persistence.EntityManager; -import java.time.Instant; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; -import org.hibernate.Session; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; -import org.springframework.transaction.annotation.Transactional; /** * 서비스 호출용 Location 저장소. @@ -29,35 +34,80 @@ @RequiredArgsConstructor public class LocationRepository { - private static final int SEARCH_LIMIT = 20; - private static final String INSERT_IF_ABSENT = """ - insert Location (createdAt, updatedAt, adminCode, sido, sigungu, dong, latitude, longitude) - values (:now, :now, :adminCode, :sido, :sigungu, :dong, :latitude, :longitude) - on conflict (adminCode) do nothing - """; - private final LocationJpaRepository locationJpaRepository; private final JPAQueryFactory queryFactory; - private final EntityManager entityManager; - - public List searchByKeyword(String keyword) { - BooleanExpression matchesKeyword = location.sido.containsIgnoreCase(keyword) - .or(location.sigungu.containsIgnoreCase(keyword)) - .or(location.dong.containsIgnoreCase(keyword)) - .or(location.sido.concat(" ") - .concat(location.sigungu).concat(" ") - .concat(location.dong) - .containsIgnoreCase(keyword)); - - return queryFactory + + public Page searchByKeyword(String keyword, Pageable pageable) { + List tokens = Arrays.stream(keyword.split("\\s+")) + .filter(token -> !token.isBlank()) + .toList(); + + if (tokens.isEmpty()) { + return Page.empty(pageable); + } + + StringExpression label = location.sido.concat(" ") + .concat(location.sigungu).concat(" ") + .concat(location.dong); + BooleanBuilder matchesKeyword = new BooleanBuilder(); + + tokens.forEach(token -> matchesKeyword.and(matchesToken(label, token))); + + String firstToken = tokens.getFirst(); + NumberExpression relevance = new CaseBuilder() + .when(location.sido.eq(keyword) + .or(location.sigungu.eq(keyword)) + .or(location.dong.eq(keyword)) + .or(label.eq(keyword))) + .then(0) + .when(location.sido.startsWith(firstToken)) + .then(1) + .when(location.sigungu.startsWith(firstToken)) + .then(2) + .when(location.dong.startsWith(firstToken)) + .then(3) + .when(label.startsWith(keyword)) + .then(4) + .otherwise(5); + + List items = queryFactory .selectFrom(location) .where(matchesKeyword) - .orderBy(location.sido.asc(), location.sigungu.asc(), location.dong.asc()) - .limit(SEARCH_LIMIT) + .orderBy( + relevance.asc(), + location.sido.asc(), + location.sigungu.asc(), + location.dong.asc(), + location.id.asc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) .fetch() .stream() .map(LocationInfo::of) .toList(); + Long totalElements = queryFactory + .select(location.count()) + .from(location) + .where(matchesKeyword) + .fetchOne(); + + return new PageImpl<>( + items, + pageable, + totalElements == null ? 0 : totalElements); + } + + private BooleanExpression matchesToken(StringExpression label, String token) { + BooleanExpression prefixMatch = location.sido.startsWith(token) + .or(location.sigungu.startsWith(token)) + .or(location.dong.startsWith(token)) + .or(label.startsWith(token)); + + if (token.length() == 1) { + return prefixMatch; + } + + return prefixMatch.or(label.contains(token)); } public LocationInfo getById(Long id) { @@ -78,18 +128,6 @@ public List findByIds(List ids) { .toList(); } - /** 대표 지역을 admin_code 순서대로 조회한다(설정에 있으나 DB 에 없는 코드는 조용히 제외). */ - public List findByAdminCodes(List adminCodes) { - Map byCode = locationJpaRepository.findByAdminCodeIn(adminCodes).stream() - .collect(Collectors.toMap(Location::getAdminCode, Function.identity())); - - return adminCodes.stream() - .map(byCode::get) - .filter(Objects::nonNull) - .map(LocationInfo::of) - .toList(); - } - public boolean isEmpty() { return locationJpaRepository.count() == 0; } @@ -99,24 +137,33 @@ public void saveAll(List locations) { } /** - * 카카오 법정동 코드로 지역을 원자적으로 등록한 뒤 반환한다. - * QueryDSL JPA가 INSERT를 지원하지 않아 Hibernate HQL upsert를 사용한다. + * 카카오 법정동 코드를 먼저 조회하고 없으면 JPA로 등록한다. + * 동시에 같은 코드가 등록되면 유니크 키가 승자를 정하고 커밋된 행을 다시 조회한다. */ - @Transactional public LocationInfo findOrCreate(KakaoRegion region) { - Instant now = Instant.now(); - entityManager.unwrap(Session.class) - .createMutationQuery(INSERT_IF_ABSENT) - .setParameter("now", now) - .setParameter("adminCode", region.adminCode()) - .setParameter("sido", region.sido()) - .setParameter("sigungu", region.sigungu()) - .setParameter("dong", region.dong()) - .setParameter("latitude", region.latitude()) - .setParameter("longitude", region.longitude()) - .executeUpdate(); - return locationJpaRepository.findByAdminCode(region.adminCode()) + .map(LocationInfo::of) + .orElseGet(() -> saveOrLoadConcurrent(region)); + } + + private LocationInfo saveOrLoadConcurrent(KakaoRegion region) { + try { + Location location = Location.of( + region.adminCode(), + region.sido(), + region.sigungu(), + region.dong(), + region.latitude(), + region.longitude()); + + return LocationInfo.of(locationJpaRepository.saveAndFlush(location)); + } catch (DataIntegrityViolationException exception) { + return findByAdminCodeOrThrow(region.adminCode()); + } + } + + private LocationInfo findByAdminCodeOrThrow(String adminCode) { + return locationJpaRepository.findByAdminCode(adminCode) .map(LocationInfo::of) .orElseThrow(() -> new NalssiLogException(LocationErrorCode.LOCATION_NOT_FOUND)); } diff --git a/location/src/test/java/com/nalssilog/location/api/LocationControllerTest.java b/location/src/test/java/com/nalssilog/location/api/LocationControllerTest.java new file mode 100644 index 0000000..7b4e855 --- /dev/null +++ b/location/src/test/java/com/nalssilog/location/api/LocationControllerTest.java @@ -0,0 +1,106 @@ +package com.nalssilog.location.api; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.nalssilog.common.response.PageResponse; +import com.nalssilog.location.application.LocationService; +import com.nalssilog.location.application.dto.LocationInfo; +import com.nalssilog.location.application.dto.PopularLocationSnapshotInfo; +import com.nalssilog.location.domain.PopularRankMovement; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +@SuppressWarnings("java:S5960") +class LocationControllerTest { + + private final LocationService locationService = mock(LocationService.class); + private final LocationController controller = new LocationController(locationService); + private final MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + + @Test + void returnsAtomicPopularSnapshotWithFiveItemDisplayMetadata() throws Exception { + Instant calculatedAt = Instant.parse("2026-07-30T06:00:00Z"); + Instant windowStartedAt = calculatedAt.minusSeconds(7 * 24 * 60 * 60); + LocationInfo location = new LocationInfo( + 1L, "서울특별시", "강남구", "역삼동", 37.5, 127.0); + PopularLocationSnapshotInfo snapshot = new PopularLocationSnapshotInfo( + 31L, + calculatedAt, + windowStartedAt, + calculatedAt, + "UNIQUE_REPORTERS_V1", + List.of(new PopularLocationSnapshotInfo.Item( + 1, + 3, + 2, + PopularRankMovement.UP, + 4, + 7, + calculatedAt.minusSeconds(60), + location))); + + when(locationService.getPopular()).thenReturn(snapshot); + + mockMvc.perform(get("/api/locations/popular")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.snapshotId").value("31")) + .andExpect(jsonPath("$.algorithmVersion").value("UNIQUE_REPORTERS_V1")) + .andExpect(jsonPath("$.pageSize").value(5)) + .andExpect(jsonPath("$.totalElements").value(1)) + .andExpect(jsonPath("$.totalPages").value(1)) + .andExpect(jsonPath("$.items[0].rank").value(1)) + .andExpect(jsonPath("$.items[0].previousRank").value(3)) + .andExpect(jsonPath("$.items[0].rankChange").value(2)) + .andExpect(jsonPath("$.items[0].movement").value("UP")) + .andExpect(jsonPath("$.items[0].uniqueReporterCount").value(4)) + .andExpect(jsonPath("$.items[0].reportCount").value(7)) + .andExpect(jsonPath("$.items[0].location.id").value("1")); + verify(locationService).getPopular(); + } + + @Test + void usesZeroAsDefaultSearchPageAndReturnsTotalMetadata() throws Exception { + when(locationService.search("서", 0)) + .thenReturn(PageResponse.of(List.of(), 0, 5, 0)); + + mockMvc.perform(get("/api/locations").param("keyword", "서")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items").isArray()) + .andExpect(jsonPath("$.page").value(0)) + .andExpect(jsonPath("$.size").value(5)) + .andExpect(jsonPath("$.totalElements").value(0)) + .andExpect(jsonPath("$.totalPages").value(0)) + .andExpect(jsonPath("$.hasPrevious").value(false)) + .andExpect(jsonPath("$.hasNext").value(false)); + + verify(locationService).search("서", 0); + } + + @Test + void rejectsNegativeSearchPage() throws Exception { + mockMvc.perform(get("/api/locations") + .param("keyword", "서") + .param("page", "-1")) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(locationService); + } + + @Test + void rejectsOversizedSearchKeyword() throws Exception { + mockMvc.perform(get("/api/locations") + .param("keyword", "가".repeat(51))) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(locationService); + } +} diff --git a/location/src/test/java/com/nalssilog/location/api/LocationFavoriteControllerTest.java b/location/src/test/java/com/nalssilog/location/api/LocationFavoriteControllerTest.java new file mode 100644 index 0000000..cb13d65 --- /dev/null +++ b/location/src/test/java/com/nalssilog/location/api/LocationFavoriteControllerTest.java @@ -0,0 +1,47 @@ +package com.nalssilog.location.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.nalssilog.common.response.PageResponse; +import com.nalssilog.location.application.LocationFavoriteService; +import com.nalssilog.location.application.dto.LocationInfo; +import java.util.List; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("java:S5960") +class LocationFavoriteControllerTest { + + private final LocationFavoriteService favoriteService = + mock(LocationFavoriteService.class); + private final LocationFavoriteController controller = + new LocationFavoriteController(favoriteService); + + @Test + void preservesFavoritePageTotalsWhileMappingLocations() { + LocationInfo location = new LocationInfo( + 1L, + "서울특별시", + "강남구", + "역삼동", + 37.5, + 127.0); + + when(favoriteService.listFavorites(7L, 1)) + .thenReturn(PageResponse.of(List.of(location), 1, 5, 8)); + + var result = controller.myFavorites(7L, 1); + + assertThat(result.page()).isEqualTo(1); + assertThat(result.size()).isEqualTo(5); + assertThat(result.totalElements()).isEqualTo(8); + assertThat(result.totalPages()).isEqualTo(2); + assertThat(result.hasPrevious()).isTrue(); + assertThat(result.hasNext()).isFalse(); + assertThat(result.items()).singleElement() + .satisfies(item -> assertThat(item.id()).isEqualTo("1")); + verify(favoriteService).listFavorites(7L, 1); + } +} diff --git a/location/src/test/java/com/nalssilog/location/api/dto/PopularLocationsResponseTest.java b/location/src/test/java/com/nalssilog/location/api/dto/PopularLocationsResponseTest.java new file mode 100644 index 0000000..14bd9e3 --- /dev/null +++ b/location/src/test/java/com/nalssilog/location/api/dto/PopularLocationsResponseTest.java @@ -0,0 +1,51 @@ +package com.nalssilog.location.api.dto; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.nalssilog.location.application.dto.LocationInfo; +import com.nalssilog.location.application.dto.PopularLocationSnapshotInfo; +import com.nalssilog.location.domain.PopularRankMovement; +import java.time.Instant; +import java.util.List; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("java:S5960") +class PopularLocationsResponseTest { + + @Test + void calculatesFrontendPagesWhileReturningOneAtomicSnapshot() { + Instant calculatedAt = Instant.parse("2026-07-30T06:00:00Z"); + List items = IntStream.rangeClosed(1, 6) + .mapToObj(rank -> new PopularLocationSnapshotInfo.Item( + rank, + rank, + 0, + PopularRankMovement.SAME, + 1, + 1, + calculatedAt.minusSeconds(rank), + new LocationInfo( + (long) rank, + "서울특별시", + "강남구", + "동" + rank, + null, + null))) + .toList(); + PopularLocationSnapshotInfo snapshot = new PopularLocationSnapshotInfo( + 10L, + calculatedAt, + calculatedAt.minusSeconds(7 * 24 * 60 * 60), + calculatedAt, + "UNIQUE_REPORTERS_V1", + items); + + PopularLocationsResponse response = PopularLocationsResponse.from(snapshot); + + assertThat(response.pageSize()).isEqualTo(5); + assertThat(response.totalElements()).isEqualTo(6); + assertThat(response.totalPages()).isEqualTo(2); + assertThat(response.items()).hasSize(6); + } +} diff --git a/location/src/test/java/com/nalssilog/location/application/LocationFavoriteServiceTest.java b/location/src/test/java/com/nalssilog/location/application/LocationFavoriteServiceTest.java new file mode 100644 index 0000000..08ad4f5 --- /dev/null +++ b/location/src/test/java/com/nalssilog/location/application/LocationFavoriteServiceTest.java @@ -0,0 +1,101 @@ +package com.nalssilog.location.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.common.response.PageResponse; +import com.nalssilog.location.application.dto.LocationInfo; +import com.nalssilog.location.domain.LocationErrorCode; +import com.nalssilog.location.domain.LocationFavorite; +import com.nalssilog.location.repository.LocationFavoriteRepository; +import com.nalssilog.location.repository.LocationRepository; +import java.sql.SQLException; +import java.util.List; +import org.hibernate.exception.ConstraintViolationException; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +@SuppressWarnings("java:S5960") +class LocationFavoriteServiceTest { + + private final LocationFavoriteRepository favoriteRepository = + mock(LocationFavoriteRepository.class); + private final LocationRepository locationRepository = + mock(LocationRepository.class); + private final LocationFavoriteService service = + new LocationFavoriteService(favoriteRepository, locationRepository); + + @Test + void existingFavoriteIsIdempotent() { + when(favoriteRepository.existsByMemberIdAndLocationId(7L, 11L)) + .thenReturn(true); + + service.addFavorite(7L, 11L); + + verify(locationRepository).getById(11L); + verify(favoriteRepository, never()).saveAndFlush(any()); + } + + @Test + void concurrentFavoriteCollisionBecomesDomainConflict() { + DataIntegrityViolationException collision = new DataIntegrityViolationException( + "duplicate favorite", + new ConstraintViolationException( + "duplicate favorite", + new SQLException(), + "uk_location_favorite_member_location")); + + when(favoriteRepository.existsByMemberIdAndLocationId(7L, 11L)) + .thenReturn(false); + when(favoriteRepository.saveAndFlush(any(LocationFavorite.class))) + .thenThrow(collision); + + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> service.addFavorite(7L, 11L)); + + assertThat(exception.getErrorCode()) + .isEqualTo(LocationErrorCode.FAVORITE_ALREADY_EXISTS); + } + + @Test + void loadsFavoritesInFixedFiveItemPagesWithTotals() { + List ids = List.of(11L, 12L); + List locations = ids.stream() + .map(id -> new LocationInfo( + id, + "서울특별시", + "강남구", + "동" + id, + null, + null)) + .toList(); + List favorites = ids.stream() + .map(id -> LocationFavorite.of(7L, id)) + .toList(); + PageRequest pageable = PageRequest.of(2, 5); + + when(favoriteRepository.findAllByMemberIdOrderByCreatedAtDescIdDesc(7L, pageable)) + .thenReturn(new PageImpl<>(favorites, pageable, 12)); + when(locationRepository.findByIds(ids)).thenReturn(locations); + + PageResponse result = service.listFavorites(7L, 2); + + assertThat(result.items()).containsExactlyElementsOf(locations); + assertThat(result.page()).isEqualTo(2); + assertThat(result.size()).isEqualTo(5); + assertThat(result.totalElements()).isEqualTo(12); + assertThat(result.totalPages()).isEqualTo(3); + assertThat(result.hasPrevious()).isTrue(); + assertThat(result.hasNext()).isFalse(); + verify(favoriteRepository).findAllByMemberIdOrderByCreatedAtDescIdDesc(7L, pageable); + } +} diff --git a/location/src/test/java/com/nalssilog/location/application/LocationServiceTest.java b/location/src/test/java/com/nalssilog/location/application/LocationServiceTest.java index 47ac6a7..0f12a12 100644 --- a/location/src/test/java/com/nalssilog/location/application/LocationServiceTest.java +++ b/location/src/test/java/com/nalssilog/location/application/LocationServiceTest.java @@ -5,18 +5,22 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.when; import com.nalssilog.common.exception.NalssiLogException; import com.nalssilog.location.application.dto.LocationInfo; +import com.nalssilog.location.application.dto.PopularLocationSnapshotData; import com.nalssilog.location.client.KakaoMapClient; import com.nalssilog.location.client.KakaoRegion; -import com.nalssilog.location.config.LocationProperties; import com.nalssilog.location.domain.LocationErrorCode; +import com.nalssilog.location.domain.PopularRankMovement; import com.nalssilog.location.repository.LocationRepository; +import java.time.Instant; import java.util.List; import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; @SuppressWarnings("java:S5960") // 표준 src/test 소스의 AssertJ 검증을 운영 코드 assertion으로 오인하는 경고. class LocationServiceTest { @@ -27,7 +31,6 @@ class LocationServiceTest { private final LocationService service = new LocationService( locationRepository, popularLocationSource, - new LocationProperties(List.of()), kakaoMapClient ); @@ -37,6 +40,7 @@ void resolvesKakaoRegionAndReturnsPersistedLocation() { "1168010100", "서울특별시", "강남구", "역삼동", 37.500622, 127.036456); LocationInfo expected = new LocationInfo( 1L, "서울특별시", "강남구", "역삼동", 37.500622, 127.036456); + when(kakaoMapClient.reverseGeocode(37.5, 127.03)).thenReturn(region); when(locationRepository.findOrCreate(region)).thenReturn(expected); @@ -57,60 +61,132 @@ void rejectsInvalidCoordinatesBeforeCallingKakao() { @Test void searchesJeonbukWithItsCurrentOfficialNameWhenLegacyNameIsEntered() { - service.search("전라북도 전주시"); + PageRequest pageable = PageRequest.of(0, 5); + + when(locationRepository.searchByKeyword("전북특별자치도 전주시", pageable)) + .thenReturn(Page.empty(pageable)); + + service.search("전라북도 전주시", 0); - verify(locationRepository).searchByKeyword("전북특별자치도 전주시"); + verify(locationRepository).searchByKeyword("전북특별자치도 전주시", pageable); } @Test void searchesIntegratedJeonnamGwangjuWhenLegacyNameIsEntered() { - service.search("전라남도 순천시"); + PageRequest pageable = PageRequest.of(2, 5); + + when(locationRepository.searchByKeyword("전남광주통합특별시 순천시", pageable)) + .thenReturn(Page.empty(pageable)); + + service.search("전라남도 순천시", 2); + + verify(locationRepository).searchByKeyword("전남광주통합특별시 순천시", pageable); + } + + @Test + void expandsAmbiguousLegacyJeollaNameToBothCurrentRegions() { + PageRequest pageable = PageRequest.of(0, 5); + + when(locationRepository.searchByKeyword("전", pageable)) + .thenReturn(Page.empty(pageable)); + + service.search("전라", 0); + + verify(locationRepository).searchByKeyword("전", pageable); + } + + @Test + void keepsFollowingTokensWhenLegacyJeollaNameIsEntered() { + PageRequest pageable = PageRequest.of(0, 5); + + when(locationRepository.searchByKeyword("전 전주시", pageable)) + .thenReturn(Page.empty(pageable)); + + service.search("전라도 전주시", 0); + + verify(locationRepository).searchByKeyword("전 전주시", pageable); + } + + @Test + void normalizesRepeatedWhitespaceBeforeSearching() { + PageRequest pageable = PageRequest.of(0, 5); + + when(locationRepository.searchByKeyword("서울 강남구", pageable)) + .thenReturn(Page.empty(pageable)); + + service.search(" 서울 강남구 ", 0); - verify(locationRepository).searchByKeyword("전남광주통합특별시 순천시"); + verify(locationRepository).searchByKeyword("서울 강남구", pageable); } @Test - void fillsPopularLocationsToFiveWithNonDuplicateFeaturedLocations() { - List featuredCodes = List.of("code-2", "code-3", "code-4", "code-5", "code-6"); - LocationService popularService = new LocationService( - locationRepository, - popularLocationSource, - new LocationProperties(featuredCodes), - kakaoMapClient - ); - LocationInfo popularOne = location(1L, "인기동1"); - LocationInfo popularTwo = location(2L, "인기동2"); - LocationInfo featuredThree = location(3L, "대표동3"); - LocationInfo featuredFour = location(4L, "대표동4"); - LocationInfo featuredFive = location(5L, "대표동5"); - LocationInfo featuredSix = location(6L, "대표동6"); - - when(popularLocationSource.topLocationIds(5)).thenReturn(List.of(1L, 2L)); - when(locationRepository.findByIds(List.of(1L, 2L))).thenReturn(List.of(popularOne, popularTwo)); - when(locationRepository.findByAdminCodes(featuredCodes)).thenReturn(List.of( - popularTwo, featuredThree, featuredFour, featuredFive, featuredSix)); - - List result = popularService.getPopular(); - - assertThat(result).containsExactly( - popularOne, popularTwo, featuredThree, featuredFour, featuredFive); + void returnsFiveSearchItemsWithTotalPageMetadata() { + List items = List.of( + location(1L, "동1"), + location(2L, "동2"), + location(3L, "동3"), + location(4L, "동4"), + location(5L, "동5")); + PageRequest pageable = PageRequest.of(1, 5); + + when(locationRepository.searchByKeyword("서", pageable)) + .thenReturn(new PageImpl<>(items, pageable, 12)); + + var result = service.search("서", 1); + + assertThat(result.items()).containsExactlyElementsOf(items); + assertThat(result.page()).isEqualTo(1); + assertThat(result.size()).isEqualTo(5); + assertThat(result.totalElements()).isEqualTo(12); + assertThat(result.totalPages()).isEqualTo(3); + assertThat(result.hasPrevious()).isTrue(); + assertThat(result.hasNext()).isTrue(); } @Test - void doesNotLoadFeaturedLocationsWhenFivePopularLocationsExist() { - List popularIds = List.of(1L, 2L, 3L, 4L, 5L); - List popular = List.of( - location(1L, "인기동1"), - location(2L, "인기동2"), - location(3L, "인기동3"), - location(4L, "인기동4"), - location(5L, "인기동5") - ); - when(popularLocationSource.topLocationIds(5)).thenReturn(popularIds); - when(locationRepository.findByIds(popularIds)).thenReturn(popular); - - assertThat(service.getPopular()).containsExactlyElementsOf(popular); - verify(locationRepository, never()).findByAdminCodes(List.of()); + void enrichesPopularSnapshotWithoutChangingItsRanking() { + Instant calculatedAt = Instant.parse("2026-07-30T06:00:00Z"); + Instant windowStartedAt = calculatedAt.minusSeconds(7 * 24 * 60 * 60); + LocationInfo first = location(2L, "인기동1"); + LocationInfo second = location(1L, "인기동2"); + PopularLocationSnapshotData snapshot = new PopularLocationSnapshotData( + 31L, + calculatedAt, + windowStartedAt, + calculatedAt, + "UNIQUE_REPORTERS_V1", + List.of( + new PopularLocationSnapshotData.Rank( + 2L, + 1, + 3, + 2, + PopularRankMovement.UP, + 4, + 7, + calculatedAt.minusSeconds(60)), + new PopularLocationSnapshotData.Rank( + 1L, + 2, + 1, + -1, + PopularRankMovement.DOWN, + 3, + 5, + calculatedAt.minusSeconds(120)))); + + when(popularLocationSource.latestSnapshot()).thenReturn(snapshot); + when(locationRepository.findByIds(List.of(2L, 1L))).thenReturn(List.of(first, second)); + + var result = service.getPopular(); + + assertThat(result.snapshotId()).isEqualTo(31L); + assertThat(result.items()).extracting(item -> item.location().id()) + .containsExactly(2L, 1L); + assertThat(result.items()).extracting(item -> item.rank()) + .containsExactly(1, 2); + assertThat(result.items().getFirst().movement()).isEqualTo(PopularRankMovement.UP); + assertThat(result.items().getFirst().uniqueReporterCount()).isEqualTo(4); } private LocationInfo location(Long id, String dong) { diff --git a/location/src/test/java/com/nalssilog/location/client/KakaoMapClientTest.java b/location/src/test/java/com/nalssilog/location/client/KakaoMapClientTest.java index 86528b3..69703c4 100644 --- a/location/src/test/java/com/nalssilog/location/client/KakaoMapClientTest.java +++ b/location/src/test/java/com/nalssilog/location/client/KakaoMapClientTest.java @@ -29,6 +29,7 @@ void setUp() { RestClient.Builder builder = RestClient.builder() .baseUrl("https://dapi.kakao.com") .defaultHeader(HttpHeaders.AUTHORIZATION, "KakaoAK test-rest-api-key"); + server = MockRestServiceServer.bindTo(builder).build(); client = new KakaoMapClient(builder.build(), REVERSE_GEOCODE_PATH); } diff --git a/member/src/main/java/com/nalssilog/member/api/MemberController.java b/member/src/main/java/com/nalssilog/member/api/MemberController.java index 6f92430..b4790e4 100644 --- a/member/src/main/java/com/nalssilog/member/api/MemberController.java +++ b/member/src/main/java/com/nalssilog/member/api/MemberController.java @@ -44,7 +44,7 @@ public NicknameAvailabilityResponse checkNickname(@RequestParam @NotBlank String @GetMapping("/me") public MemberMeResponse me( @AuthenticationPrincipal Long memberId, - @CurrentSecurityContext(expression = "authentication.details") Provider currentProvider + @CurrentSecurityContext(expression = "authentication.details.provider") Provider currentProvider ) { return MemberMeResponse.from(memberProfileService.getMe(memberId), currentProvider); } @@ -57,7 +57,7 @@ public MemberPublicProfileResponse publicProfile(@PathVariable Long id) { @PatchMapping("/me/name") public MemberMeResponse changeName( @AuthenticationPrincipal Long memberId, - @CurrentSecurityContext(expression = "authentication.details") Provider currentProvider, + @CurrentSecurityContext(expression = "authentication.details.provider") Provider currentProvider, @Valid @RequestBody ChangeNameRequest request ) { return MemberMeResponse.from(memberProfileService.changeName(memberId, request.name()), currentProvider); @@ -66,19 +66,23 @@ public MemberMeResponse changeName( @PatchMapping("/me/nickname") public MemberMeResponse changeNickname( @AuthenticationPrincipal Long memberId, - @CurrentSecurityContext(expression = "authentication.details") Provider currentProvider, + @CurrentSecurityContext(expression = "authentication.details.provider") Provider currentProvider, @Valid @RequestBody ChangeNicknameRequest request ) { - return MemberMeResponse.from(memberProfileService.changeNickname(memberId, request.nickname()), currentProvider); + return MemberMeResponse.from( + memberProfileService.changeNickname(memberId, request.nickname()), + currentProvider); } @PatchMapping("/me/avatar") public MemberMeResponse changeAvatar( @AuthenticationPrincipal Long memberId, - @CurrentSecurityContext(expression = "authentication.details") Provider currentProvider, + @CurrentSecurityContext(expression = "authentication.details.provider") Provider currentProvider, @Valid @RequestBody ChangeAvatarRequest request ) { - return MemberMeResponse.from(memberProfileService.changeAvatar(memberId, request.type(), request.value()), currentProvider); + return MemberMeResponse.from( + memberProfileService.changeAvatar(memberId, request.type(), request.value()), + currentProvider); } @PostMapping("/me/avatar/presign") @@ -86,7 +90,8 @@ public AvatarPresignResponse presignAvatar( @AuthenticationPrincipal Long memberId, @Valid @RequestBody AvatarPresignRequest request ) { - return AvatarPresignResponse.from(memberProfileService.presignAvatar(memberId, request.contentType(), request.size())); + return AvatarPresignResponse.from( + memberProfileService.presignAvatar(memberId, request.contentType(), request.size())); } @GetMapping("/me/social-accounts") @@ -100,7 +105,7 @@ public List socialAccounts(@AuthenticationPrincipal Long @ResponseStatus(HttpStatus.NO_CONTENT) public void unlinkSocial( @AuthenticationPrincipal Long memberId, - @CurrentSecurityContext(expression = "authentication.details") Provider currentProvider, + @CurrentSecurityContext(expression = "authentication.details.provider") Provider currentProvider, @PathVariable String provider ) { memberProfileService.unlinkSocial(memberId, Provider.from(provider), currentProvider); diff --git a/member/src/main/java/com/nalssilog/member/api/dto/SocialAccountResponse.java b/member/src/main/java/com/nalssilog/member/api/dto/SocialAccountResponse.java index 10f33ff..e8520c5 100644 --- a/member/src/main/java/com/nalssilog/member/api/dto/SocialAccountResponse.java +++ b/member/src/main/java/com/nalssilog/member/api/dto/SocialAccountResponse.java @@ -4,7 +4,7 @@ import com.nalssilog.member.domain.Provider; import java.time.Instant; -/** 추가 연동 후 아직 해당 제공자로 로그인하지 않았다면 lastLoginAt 은 null. */ +/** 설정에서 추가 연동한 뒤 아직 해당 제공자로 로그인하지 않았다면 lastLoginAt 은 null. */ public record SocialAccountResponse(Provider provider, String email, Instant lastLoginAt) { public static SocialAccountResponse from(SocialAccountInfo info) { diff --git a/member/src/main/java/com/nalssilog/member/application/FeedbackRateLimiter.java b/member/src/main/java/com/nalssilog/member/application/FeedbackRateLimiter.java index d840c52..aa9deae 100644 --- a/member/src/main/java/com/nalssilog/member/application/FeedbackRateLimiter.java +++ b/member/src/main/java/com/nalssilog/member/application/FeedbackRateLimiter.java @@ -1,14 +1,10 @@ package com.nalssilog.member.application; import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.common.security.SecretFingerprint; import com.nalssilog.member.config.FeedbackRateLimitProperties; import com.nalssilog.member.domain.MemberErrorCode; -import java.nio.charset.StandardCharsets; -import java.security.NoSuchAlgorithmException; -import java.util.HexFormat; import java.util.List; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataAccessException; @@ -27,7 +23,6 @@ public class FeedbackRateLimiter { private static final String KEY_PREFIX = "feedback:rate:"; - private static final String HMAC_ALGORITHM = "HmacSHA256"; private static final DefaultRedisScript INCREMENT_SCRIPT = new DefaultRedisScript<>(""" local count = redis.call('INCR', KEYS[1]) if count == 1 then @@ -42,6 +37,7 @@ public class FeedbackRateLimiter { public void check(Long memberId, String remoteAddress) { String actor = actor(memberId, remoteAddress); Long count; + try { count = redisTemplate.execute( INCREMENT_SCRIPT, @@ -51,6 +47,7 @@ public void check(Long memberId, String remoteAddress) { // 피드백 저장 자체보다 보조 보호장치 장애의 영향이 커지지 않게 제한기만 fail-open 한다. log.warn("feedback.rate_limit_unavailable actor={} reason={}", safeActor(actor), exception.getClass().getSimpleName()); + return; } @@ -66,25 +63,16 @@ String actor(Long memberId, String remoteAddress) { } String address = StringUtils.hasText(remoteAddress) ? remoteAddress.strip() : "unknown"; - return "guest:" + hmac(address); + + return "guest:" + SecretFingerprint.hmacSha256(properties.ipHmacSecret(), address); } private String safeActor(String actor) { int separator = actor.indexOf(':'); String type = separator < 0 ? "unknown" : actor.substring(0, separator); String identifier = separator < 0 ? actor : actor.substring(separator + 1); + return type + ":" + identifier.substring(0, Math.min(12, identifier.length())); } - private String hmac(String value) { - try { - Mac mac = Mac.getInstance(HMAC_ALGORITHM); - mac.init(new SecretKeySpec(properties.ipHmacSecret().getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM)); - return HexFormat.of().formatHex(mac.doFinal(value.getBytes(StandardCharsets.UTF_8))); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("HmacSHA256 not available", e); - } catch (java.security.InvalidKeyException e) { - throw new IllegalStateException("feedback IP HMAC key is invalid", e); - } - } } diff --git a/member/src/main/java/com/nalssilog/member/application/MemberAccountService.java b/member/src/main/java/com/nalssilog/member/application/MemberAccountService.java index 5066ee2..54eed1e 100644 --- a/member/src/main/java/com/nalssilog/member/application/MemberAccountService.java +++ b/member/src/main/java/com/nalssilog/member/application/MemberAccountService.java @@ -1,7 +1,18 @@ package com.nalssilog.member.application; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +import org.hibernate.exception.ConstraintViolationException; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + import com.nalssilog.common.exception.NalssiLogException; import com.nalssilog.member.application.dto.MemberInfo; +import com.nalssilog.member.application.dto.MemberSummary; import com.nalssilog.member.application.dto.SocialLoginResult; import com.nalssilog.member.domain.Member; import com.nalssilog.member.domain.MemberErrorCode; @@ -10,29 +21,57 @@ import com.nalssilog.member.domain.event.MemberWithdrawnEvent; import com.nalssilog.member.repository.MemberRepository; import com.nalssilog.member.repository.SocialAccountRepository; -import java.util.Optional; + import lombok.RequiredArgsConstructor; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class MemberAccountService { + private static final String PROVIDER_USER_CONSTRAINT = + "uk_social_account_provider_user"; + private static final String MEMBER_PROVIDER_CONSTRAINT = + "uk_social_account_member_provider"; + private final MemberRepository memberRepository; private final SocialAccountRepository socialAccountRepository; private final ApplicationEventPublisher eventPublisher; + private static RuntimeException translateSocialLinkConflict( + DataIntegrityViolationException exception + ) { + String constraintName = constraintName(exception); + + if (PROVIDER_USER_CONSTRAINT.equalsIgnoreCase(constraintName)) { + return new NalssiLogException(MemberErrorCode.SOCIAL_ACCOUNT_IN_USE); + } + + if (MEMBER_PROVIDER_CONSTRAINT.equalsIgnoreCase(constraintName)) { + return new NalssiLogException(MemberErrorCode.ACCOUNT_ALREADY_LINKED); + } + + return exception; + } + + private static String constraintName(Throwable throwable) { + for (Throwable cause = throwable; cause != null; cause = cause.getCause()) { + if (cause instanceof ConstraintViolationException constraintViolation) { + return constraintViolation.getConstraintName(); + } + } + + return null; + } + /** 소셜 인증 결과 분기(생성·병합 안 함). 가입된 소셜=EXISTING, 이메일로 기존 회원 있으면 LINK_REQUIRED, 없으면 NEW. */ - @Transactional public SocialLoginResult resolveSocialLogin(Provider provider, String providerUserId, String email) { - Optional linked = socialAccountRepository.findByProviderAndProviderUserId(provider, providerUserId); + Optional linked = socialAccountRepository.findByProviderAndProviderUserId( + provider, + providerUserId); if (linked.isPresent()) { SocialAccount account = linked.get(); - account.touchLogin(); return SocialLoginResult.existing(account.getMember().getId(), account.getMember().getStatus()); } @@ -42,23 +81,58 @@ public SocialLoginResult resolveSocialLogin(Provider provider, String providerUs } return memberRepository.findMemberInfoByEmail(email) - .map(found -> SocialLoginResult.linkRequired(found.id(), email, found.connectedProviders())) - .orElseGet(() -> SocialLoginResult.newMember(email)); + .map(found -> SocialLoginResult.linkRequired(found.id(), email, found.connectedProviders())) + .orElseGet(() -> SocialLoginResult.newMember(email)); } /** 기존 회원에 새 소셜 계정 연동(호출 전 재인증으로 소유권 증명 전제). */ @Transactional - public MemberInfo linkSocial(Long targetMemberId, Provider provider, String providerUserId, String email) { - if (socialAccountRepository.findByProviderAndProviderUserId(provider, providerUserId).isPresent()) { - throw new NalssiLogException(MemberErrorCode.SOCIAL_ACCOUNT_IN_USE); + public MemberInfo linkSocial( + Long targetMemberId, + Provider provider, + String providerUserId, + String email + ) { + Optional linkedAccount = + socialAccountRepository.findByProviderAndProviderUserId( + provider, + providerUserId); + + if (linkedAccount.isPresent()) { + MemberErrorCode errorCode = linkedAccount.get().getMember().getId().equals(targetMemberId) + ? MemberErrorCode.ACCOUNT_ALREADY_LINKED + : MemberErrorCode.SOCIAL_ACCOUNT_IN_USE; + + throw new NalssiLogException(errorCode); + } + + if (socialAccountRepository.findByMemberIdAndProvider(targetMemberId, provider).isPresent()) { + throw new NalssiLogException(MemberErrorCode.ACCOUNT_ALREADY_LINKED); } Member member = memberRepository.getMember(targetMemberId); - socialAccountRepository.save(SocialAccount.link(member, provider, providerUserId, email)); + + try { + socialAccountRepository.saveAndFlush( + SocialAccount.link(member, provider, providerUserId, email)); + } catch (DataIntegrityViolationException exception) { + throw translateSocialLinkConflict(exception); + } return memberRepository.getMemberInfo(targetMemberId); } + /** 최종 로그인 세션 발급에 사용한 소셜 계정의 로그인 시각만 갱신한다. */ + @Transactional + public void recordLogin(Long memberId, Provider provider) { + SocialAccount account = socialAccountRepository + .findByMemberIdAndProvider(memberId, provider) + .orElseThrow(() -> new NalssiLogException( + MemberErrorCode.SOCIAL_ACCOUNT_NOT_FOUND)); + + account.touchLogin(); + } + /** 회원 탈퇴. 익명화 + 소셜 삭제 + 제보 익명화용 MemberWithdrawnEvent 발행. 세션·쿠키 정리는 auth. */ @Transactional public void withdraw(Long memberId) { @@ -77,4 +151,17 @@ public MemberInfo getMemberInfo(Long memberId) { public Optional findMemberInfo(Long memberId) { return memberRepository.findMemberInfo(memberId); } + + public List findMemberSummaries(Collection memberIds) { + return memberRepository.findSummariesByIds(memberIds); + } + + public Optional findMemberInfo( + Provider provider, + String providerUserId + ) { + return socialAccountRepository + .findByProviderAndProviderUserId(provider, providerUserId) + .map(account -> memberRepository.getMemberInfo(account.getMember().getId())); + } } diff --git a/member/src/main/java/com/nalssilog/member/application/MemberProfileService.java b/member/src/main/java/com/nalssilog/member/application/MemberProfileService.java index cba6a20..c15e220 100644 --- a/member/src/main/java/com/nalssilog/member/application/MemberProfileService.java +++ b/member/src/main/java/com/nalssilog/member/application/MemberProfileService.java @@ -35,6 +35,7 @@ public boolean isNicknameAvailable(String nickname) { @Transactional public MemberInfo changeName(Long memberId, String name) { Member member = memberRepository.getMember(memberId); + member.changeName(name.strip()); return memberRepository.getMemberInfo(memberId); diff --git a/member/src/main/java/com/nalssilog/member/application/MemberRegistrationService.java b/member/src/main/java/com/nalssilog/member/application/MemberRegistrationService.java index 58deab4..39b0ba1 100644 --- a/member/src/main/java/com/nalssilog/member/application/MemberRegistrationService.java +++ b/member/src/main/java/com/nalssilog/member/application/MemberRegistrationService.java @@ -60,6 +60,7 @@ public MemberInfo registerMember(Provider provider, String providerUserId, Strin private MemberInfo registerInTransaction(Provider provider, String providerUserId, String email, String name, String nickname, List agreedTerms) { Member member = memberRepository.saveAndFlush(Member.register(email, name, nickname)); + socialAccountRepository.save(SocialAccount.register(member, provider, providerUserId, email)); consentService.recordOnboardingConsents(member.getId(), agreedTerms); diff --git a/member/src/main/java/com/nalssilog/member/application/dto/MemberSummary.java b/member/src/main/java/com/nalssilog/member/application/dto/MemberSummary.java new file mode 100644 index 0000000..bc97df9 --- /dev/null +++ b/member/src/main/java/com/nalssilog/member/application/dto/MemberSummary.java @@ -0,0 +1,27 @@ +package com.nalssilog.member.application.dto; + +import com.nalssilog.member.domain.AvatarType; +import com.nalssilog.member.domain.Member; +import com.nalssilog.member.domain.MemberStatus; + +/** + * 다른 모듈의 목록 조회에서 사용하는 회원 표시 정보. + * 소셜 계정 컬렉션을 조립하지 않아 여러 회원을 한 번의 쿼리로 조회할 수 있다. + */ +public record MemberSummary( + Long id, + String nickname, + AvatarType avatarType, + String avatarValue, + MemberStatus status +) { + + public static MemberSummary of(Member member) { + return new MemberSummary( + member.getId(), + member.getNickname(), + member.getAvatarType(), + member.getAvatarValue(), + member.getStatus()); + } +} diff --git a/member/src/main/java/com/nalssilog/member/config/FeedbackRateLimitProperties.java b/member/src/main/java/com/nalssilog/member/config/FeedbackRateLimitProperties.java index ecf8d8d..ee0f1df 100644 --- a/member/src/main/java/com/nalssilog/member/config/FeedbackRateLimitProperties.java +++ b/member/src/main/java/com/nalssilog/member/config/FeedbackRateLimitProperties.java @@ -16,16 +16,17 @@ public record FeedbackRateLimitProperties( if (maxSubmissions <= 0) { maxSubmissions = 5; } + if (window == null || window.isNegative() || window.isZero()) { window = Duration.ofMinutes(10); } + if (ipHmacSecret == null || ipHmacSecret.isBlank()) { ipHmacSecret = "local-feedback-rate-limit-key"; } - if (trustedProxies == null || trustedProxies.isEmpty()) { - trustedProxies = List.of("127.0.0.0/8", "::1/128", "172.16.0.0/12"); - } else { - trustedProxies = List.copyOf(trustedProxies); - } + + trustedProxies = trustedProxies == null + ? List.of() + : List.copyOf(trustedProxies); } } diff --git a/member/src/main/java/com/nalssilog/member/config/TrustedProxyClientIpResolver.java b/member/src/main/java/com/nalssilog/member/config/TrustedProxyClientIpResolver.java index 04f1d24..eb40d7b 100644 --- a/member/src/main/java/com/nalssilog/member/config/TrustedProxyClientIpResolver.java +++ b/member/src/main/java/com/nalssilog/member/config/TrustedProxyClientIpResolver.java @@ -1,12 +1,8 @@ package com.nalssilog.member.config; -import jakarta.servlet.ServletRequest; -import jakarta.servlet.ServletRequestWrapper; +import com.nalssilog.common.web.TrustedProxyChain; import jakarta.servlet.http.HttpServletRequest; -import java.util.List; -import org.springframework.security.web.util.matcher.IpAddressMatcher; import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; /** * 실제 socket peer가 명시된 trusted proxy 대역일 때만 X-Forwarded-For를 해석한다. @@ -16,89 +12,13 @@ @Component public class TrustedProxyClientIpResolver { - private static final int MAX_WRAPPER_DEPTH = 16; - - private final List trustedProxies; + private final TrustedProxyChain trustedProxyChain; public TrustedProxyClientIpResolver(FeedbackRateLimitProperties properties) { - this.trustedProxies = properties.trustedProxies().stream() - .map(IpAddressMatcher::new) - .toList(); + this.trustedProxyChain = new TrustedProxyChain(properties.trustedProxies()); } public String resolve(HttpServletRequest request) { - HttpServletRequest nativeRequest = unwrap(request); - String peer = normalize(nativeRequest.getRemoteAddr()); - if (!isTrusted(peer)) { - return peer; - } - - String forwardedFor = nativeRequest.getHeader("X-Forwarded-For"); - if (!StringUtils.hasText(forwardedFor)) { - return peer; - } - - String current = peer; - String[] hops = forwardedFor.split(","); - for (int index = hops.length - 1; index >= 0 && isTrusted(current); index--) { - String candidate = normalize(hops[index]); - if (!StringUtils.hasText(candidate)) { - break; - } - current = candidate; - } - return current; - } - - private HttpServletRequest unwrap(HttpServletRequest request) { - ServletRequest current = request; - int depth = 0; - while (current instanceof ServletRequestWrapper wrapper && depth++ < MAX_WRAPPER_DEPTH) { - ServletRequest nested = wrapper.getRequest(); - if (nested == current) { - break; - } - current = nested; - } - return current instanceof HttpServletRequest httpRequest ? httpRequest : request; - } - - private boolean isTrusted(String address) { - if (!StringUtils.hasText(address)) { - return false; - } - for (IpAddressMatcher matcher : trustedProxies) { - try { - if (matcher.matches(address)) { - return true; - } - } catch (IllegalArgumentException _) { - return false; - } - } - return false; - } - - private String normalize(String address) { - if (!StringUtils.hasText(address)) { - return "unknown"; - } - - String normalized = address.strip(); - if (normalized.length() >= 2 && normalized.startsWith("\"") && normalized.endsWith("\"")) { - normalized = normalized.substring(1, normalized.length() - 1); - } - if (normalized.startsWith("[")) { - int closingBracket = normalized.indexOf(']'); - if (closingBracket > 1) { - return normalized.substring(1, closingBracket); - } - } - - int colon = normalized.lastIndexOf(':'); - if (colon > 0 && normalized.indexOf(':') == colon && normalized.substring(0, colon).contains(".")) { - return normalized.substring(0, colon); - } - return normalized; + return trustedProxyChain.resolve(request); } } diff --git a/member/src/main/java/com/nalssilog/member/domain/Feedback.java b/member/src/main/java/com/nalssilog/member/domain/Feedback.java index bf60834..9ed2f04 100644 --- a/member/src/main/java/com/nalssilog/member/domain/Feedback.java +++ b/member/src/main/java/com/nalssilog/member/domain/Feedback.java @@ -35,6 +35,7 @@ public class Feedback extends BaseTimeEntity { public static Feedback create(Long authorMemberId, String content) { Feedback feedback = new Feedback(); + feedback.authorMemberId = authorMemberId; feedback.content = content; diff --git a/member/src/main/java/com/nalssilog/member/domain/Member.java b/member/src/main/java/com/nalssilog/member/domain/Member.java index 16d002b..d4ff180 100644 --- a/member/src/main/java/com/nalssilog/member/domain/Member.java +++ b/member/src/main/java/com/nalssilog/member/domain/Member.java @@ -66,6 +66,7 @@ public class Member extends BaseTimeEntity { /** 가입 확정 시 ACTIVE 회원을 처음 생성(가입 전엔 signup 티켓에만 존재). */ public static Member register(String email, String name, String nickname) { Member member = new Member(); + member.email = email; member.name = name; member.nickname = nickname; @@ -98,6 +99,7 @@ public void withdraw() { if (this.status == MemberStatus.WITHDRAWN) { throw new NalssiLogException(MemberErrorCode.ALREADY_WITHDRAWN); } + this.status = MemberStatus.WITHDRAWN; this.withdrawnAt = Instant.now(); this.email = null; diff --git a/member/src/main/java/com/nalssilog/member/domain/MemberConsent.java b/member/src/main/java/com/nalssilog/member/domain/MemberConsent.java index 3fed01b..bf03715 100644 --- a/member/src/main/java/com/nalssilog/member/domain/MemberConsent.java +++ b/member/src/main/java/com/nalssilog/member/domain/MemberConsent.java @@ -50,6 +50,7 @@ public class MemberConsent extends BaseTimeEntity { public static MemberConsent agree(Long memberId, TermsType termsType, String version) { MemberConsent consent = new MemberConsent(); + consent.memberId = memberId; consent.termsType = termsType; consent.version = version; diff --git a/member/src/main/java/com/nalssilog/member/domain/Provider.java b/member/src/main/java/com/nalssilog/member/domain/Provider.java index 11bb2a6..d0f89f5 100644 --- a/member/src/main/java/com/nalssilog/member/domain/Provider.java +++ b/member/src/main/java/com/nalssilog/member/domain/Provider.java @@ -3,7 +3,7 @@ import java.util.Locale; public enum Provider { - GOOGLE, KAKAO, NAVER; + GOOGLE, KAKAO, NAVER, APPLE; public static Provider from(String registrationId) { return valueOf(registrationId.toUpperCase(Locale.ROOT)); diff --git a/member/src/main/java/com/nalssilog/member/domain/SocialAccount.java b/member/src/main/java/com/nalssilog/member/domain/SocialAccount.java index f6af53d..b7f83dc 100644 --- a/member/src/main/java/com/nalssilog/member/domain/SocialAccount.java +++ b/member/src/main/java/com/nalssilog/member/domain/SocialAccount.java @@ -22,7 +22,12 @@ @Entity @Table(name = "social_account", uniqueConstraints = { - @UniqueConstraint(name = "uk_social_account_provider_user", columnNames = {"provider", "provider_user_id"}) + @UniqueConstraint( + name = "uk_social_account_provider_user", + columnNames = {"provider", "provider_user_id"}), + @UniqueConstraint( + name = "uk_social_account_member_provider", + columnNames = {"member_id", "provider"}) }) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -54,19 +59,26 @@ public class SocialAccount extends BaseTimeEntity { public static SocialAccount register(Member member, Provider provider, String providerUserId, String providerEmail) { SocialAccount account = create(member, provider, providerUserId, providerEmail); + account.lastLoginAt = Instant.now(); return account; } /** 추가 연동은 로그인이 아니므로 실제로 이 제공자로 로그인하기 전까지 로그인 시각을 비워 둔다. */ - public static SocialAccount link(Member member, Provider provider, String providerUserId, String providerEmail) { + public static SocialAccount link( + Member member, + Provider provider, + String providerUserId, + String providerEmail + ) { return create(member, provider, providerUserId, providerEmail); } private static SocialAccount create(Member member, Provider provider, String providerUserId, String providerEmail) { SocialAccount account = new SocialAccount(); + account.member = member; account.provider = provider; account.providerUserId = providerUserId; diff --git a/member/src/main/java/com/nalssilog/member/repository/MemberRepository.java b/member/src/main/java/com/nalssilog/member/repository/MemberRepository.java index 107bcdd..7fe430f 100644 --- a/member/src/main/java/com/nalssilog/member/repository/MemberRepository.java +++ b/member/src/main/java/com/nalssilog/member/repository/MemberRepository.java @@ -2,10 +2,12 @@ import com.nalssilog.common.exception.NalssiLogException; import com.nalssilog.member.application.dto.MemberInfo; +import com.nalssilog.member.application.dto.MemberSummary; import com.nalssilog.member.domain.Member; import com.nalssilog.member.domain.MemberErrorCode; import com.nalssilog.member.domain.MemberStatus; import com.nalssilog.member.domain.SocialAccount; +import java.util.Collection; import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -41,6 +43,16 @@ public Optional findMemberInfoByEmail(String email) { .map(this::toInfo); } + public List findSummariesByIds(Collection memberIds) { + if (memberIds.isEmpty()) { + return List.of(); + } + + return memberJpaRepository.findAllById(memberIds).stream() + .map(MemberSummary::of) + .toList(); + } + public boolean existsByNickname(String nickname) { return memberJpaRepository.existsByNickname(nickname); } diff --git a/member/src/main/java/com/nalssilog/member/repository/SocialAccountJpaRepository.java b/member/src/main/java/com/nalssilog/member/repository/SocialAccountJpaRepository.java index 365fafc..790db42 100644 --- a/member/src/main/java/com/nalssilog/member/repository/SocialAccountJpaRepository.java +++ b/member/src/main/java/com/nalssilog/member/repository/SocialAccountJpaRepository.java @@ -11,8 +11,6 @@ */ public interface SocialAccountJpaRepository extends JpaRepository { - Optional findByProviderAndProviderUserId(Provider provider, String providerUserId); - Optional findByMemberIdAndProvider(Long memberId, Provider provider); List findAllByMemberId(Long memberId); diff --git a/member/src/main/java/com/nalssilog/member/repository/SocialAccountRepository.java b/member/src/main/java/com/nalssilog/member/repository/SocialAccountRepository.java index 302deef..9d60fdb 100644 --- a/member/src/main/java/com/nalssilog/member/repository/SocialAccountRepository.java +++ b/member/src/main/java/com/nalssilog/member/repository/SocialAccountRepository.java @@ -1,8 +1,12 @@ package com.nalssilog.member.repository; +import static com.nalssilog.member.domain.QMember.member; +import static com.nalssilog.member.domain.QSocialAccount.socialAccount; + import com.nalssilog.member.application.dto.SocialAccountInfo; import com.nalssilog.member.domain.Provider; import com.nalssilog.member.domain.SocialAccount; +import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -17,6 +21,7 @@ public class SocialAccountRepository { private final SocialAccountJpaRepository socialAccountJpaRepository; + private final JPAQueryFactory queryFactory; // ===== 읽기 ===== @@ -33,7 +38,14 @@ public long countByMemberId(Long memberId) { // ===== 쓰기(관리 엔티티) ===== public Optional findByProviderAndProviderUserId(Provider provider, String providerUserId) { - return socialAccountJpaRepository.findByProviderAndProviderUserId(provider, providerUserId); + return Optional.ofNullable(queryFactory + .selectFrom(socialAccount) + .join(socialAccount.member, member) + .fetchJoin() + .where( + socialAccount.provider.eq(provider), + socialAccount.providerUserId.eq(providerUserId)) + .fetchOne()); } public Optional findByMemberIdAndProvider(Long memberId, Provider provider) { @@ -44,6 +56,10 @@ public SocialAccount save(SocialAccount socialAccount) { return socialAccountJpaRepository.save(socialAccount); } + public SocialAccount saveAndFlush(SocialAccount socialAccount) { + return socialAccountJpaRepository.saveAndFlush(socialAccount); + } + public void delete(SocialAccount socialAccount) { socialAccountJpaRepository.delete(socialAccount); } diff --git a/member/src/test/java/com/nalssilog/member/application/ConsentServiceTest.java b/member/src/test/java/com/nalssilog/member/application/ConsentServiceTest.java index 5e8632b..ffee012 100644 --- a/member/src/test/java/com/nalssilog/member/application/ConsentServiceTest.java +++ b/member/src/test/java/com/nalssilog/member/application/ConsentServiceTest.java @@ -29,6 +29,7 @@ void acceptsAndRecordsBothRequiredTerms() { @SuppressWarnings("unchecked") ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(repository).saveAll(captor.capture()); assertThat(captor.getValue()) .extracting(MemberConsent::getTermsType) diff --git a/member/src/test/java/com/nalssilog/member/application/FeedbackRateLimiterTest.java b/member/src/test/java/com/nalssilog/member/application/FeedbackRateLimiterTest.java index 3559d84..f02ea87 100644 --- a/member/src/test/java/com/nalssilog/member/application/FeedbackRateLimiterTest.java +++ b/member/src/test/java/com/nalssilog/member/application/FeedbackRateLimiterTest.java @@ -28,7 +28,7 @@ class FeedbackRateLimiterTest { 5, Duration.ofMinutes(10), "test-feedback-hmac-secret", - List.of("127.0.0.0/8"))); + List.of())); @Test void rejectsSubmissionAboveConfiguredLimit() { @@ -37,7 +37,7 @@ void rejectsSubmissionAboveConfiguredLimit() { NalssiLogException exception = catchThrowableOfType( NalssiLogException.class, - () -> limiter.check(null, "203.0.113.10")); + () -> limiter.check(null, "client-a.test")); assertThat(exception.getErrorCode()).isEqualTo(MemberErrorCode.FEEDBACK_RATE_LIMITED); } @@ -47,18 +47,18 @@ void redisFailureDoesNotBlockFeedbackSubmission() { when(redisTemplate.execute(any(RedisScript.class), anyList(), any())) .thenThrow(new RedisConnectionFailureException("test")); - assertThatCode(() -> limiter.check(null, "203.0.113.10")) + assertThatCode(() -> limiter.check(null, "client-a.test")) .doesNotThrowAnyException(); } @Test void guestActorDoesNotExposeRawAddress() { - String actor = limiter.actor(null, "203.0.113.10"); + String actor = limiter.actor(null, "client-a.test"); assertThat(actor) .startsWith("guest:") - .doesNotContain("203.0.113.10"); - assertThat(limiter.actor(7L, "203.0.113.10")).isEqualTo("member:7"); + .doesNotContain("client-a.test"); + assertThat(limiter.actor(7L, "client-a.test")).isEqualTo("member:7"); } @Test @@ -69,9 +69,9 @@ void guestFingerprintIsKeyedByHmacSecret() { 5, Duration.ofMinutes(10), "different-feedback-hmac-secret", - List.of("127.0.0.0/8"))); + List.of())); - assertThat(otherSecretLimiter.actor(null, "203.0.113.10")) - .isNotEqualTo(limiter.actor(null, "203.0.113.10")); + assertThat(otherSecretLimiter.actor(null, "client-a.test")) + .isNotEqualTo(limiter.actor(null, "client-a.test")); } } diff --git a/member/src/test/java/com/nalssilog/member/application/FeedbackServiceTest.java b/member/src/test/java/com/nalssilog/member/application/FeedbackServiceTest.java index 5501de5..741aed4 100644 --- a/member/src/test/java/com/nalssilog/member/application/FeedbackServiceTest.java +++ b/member/src/test/java/com/nalssilog/member/application/FeedbackServiceTest.java @@ -22,12 +22,14 @@ class FeedbackServiceTest { @Test void anonymousFeedbackIsRateCheckedAndStoredWithoutMemberId() { FeedbackInfo saved = new FeedbackInfo(1L, null, "좋아요", Instant.now()); + when(repository.save(org.mockito.ArgumentMatchers.any(Feedback.class))).thenReturn(saved); - FeedbackInfo result = service.submit(null, "203.0.113.10", " 좋아요 "); + FeedbackInfo result = service.submit(null, "client-a.test", " 좋아요 "); ArgumentCaptor captor = ArgumentCaptor.forClass(Feedback.class); - verify(rateLimiter).check(null, "203.0.113.10"); + + verify(rateLimiter).check(null, "client-a.test"); verify(repository).save(captor.capture()); assertThat(captor.getValue().getAuthorMemberId()).isNull(); assertThat(captor.getValue().getContent()).isEqualTo("좋아요"); diff --git a/member/src/test/java/com/nalssilog/member/application/MemberAccountServiceTest.java b/member/src/test/java/com/nalssilog/member/application/MemberAccountServiceTest.java new file mode 100644 index 0000000..c41cbbb --- /dev/null +++ b/member/src/test/java/com/nalssilog/member/application/MemberAccountServiceTest.java @@ -0,0 +1,132 @@ +package com.nalssilog.member.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.member.application.dto.SocialLoginResult; +import com.nalssilog.member.domain.Member; +import com.nalssilog.member.domain.MemberErrorCode; +import com.nalssilog.member.domain.MemberStatus; +import com.nalssilog.member.domain.Provider; +import com.nalssilog.member.domain.SocialAccount; +import com.nalssilog.member.repository.MemberRepository; +import com.nalssilog.member.repository.SocialAccountRepository; +import java.sql.SQLException; +import java.util.Optional; +import org.hibernate.exception.ConstraintViolationException; +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; + +@SuppressWarnings("java:S5960") +class MemberAccountServiceTest { + + private final MemberRepository memberRepository = mock(MemberRepository.class); + private final SocialAccountRepository socialAccountRepository = + mock(SocialAccountRepository.class); + private final MemberAccountService service = new MemberAccountService( + memberRepository, + socialAccountRepository, + mock(ApplicationEventPublisher.class)); + + @Test + void resolvingExistingSocialIdentityDoesNotRecordServiceLogin() { + Member member = mock(Member.class); + SocialAccount account = mock(SocialAccount.class); + + when(socialAccountRepository.findByProviderAndProviderUserId( + Provider.NAVER, + "naver-user")).thenReturn(Optional.of(account)); + when(account.getMember()).thenReturn(member); + when(member.getId()).thenReturn(7L); + when(member.getStatus()).thenReturn(MemberStatus.ACTIVE); + + SocialLoginResult result = service.resolveSocialLogin( + Provider.NAVER, + "naver-user", + "user@example.com"); + + assertThat(result.outcome()).isEqualTo(SocialLoginResult.Outcome.EXISTING); + assertThat(result.memberId()).isEqualTo(7L); + verify(account, never()).touchLogin(); + } + + @Test + void finalServiceLoginRecordsSelectedSocialProvider() { + SocialAccount account = mock(SocialAccount.class); + + when(socialAccountRepository.findByMemberIdAndProvider( + 7L, + Provider.KAKAO)).thenReturn(Optional.of(account)); + + service.recordLogin(7L, Provider.KAKAO); + + verify(account).touchLogin(); + } + + @Test + void rejectsLinkWhenMemberAlreadyHasProvider() { + when(socialAccountRepository.findByProviderAndProviderUserId( + Provider.KAKAO, + "new-kakao-user")).thenReturn(Optional.empty()); + when(socialAccountRepository.findByMemberIdAndProvider( + 7L, + Provider.KAKAO)).thenReturn(Optional.of(mock(SocialAccount.class))); + + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> service.linkSocial( + 7L, + Provider.KAKAO, + "new-kakao-user", + "user@example.com")); + + assertThat(exception.getErrorCode()) + .isEqualTo(MemberErrorCode.ACCOUNT_ALREADY_LINKED); + verify(socialAccountRepository, never()).saveAndFlush(any()); + } + + @Test + void translatesConcurrentProviderUserCollisionToDomainConflict() { + Member member = mock(Member.class); + + when(socialAccountRepository.findByProviderAndProviderUserId( + Provider.KAKAO, + "kakao-user")).thenReturn(Optional.empty()); + when(socialAccountRepository.findByMemberIdAndProvider( + 7L, + Provider.KAKAO)).thenReturn(Optional.empty()); + when(memberRepository.getMember(7L)).thenReturn(member); + when(socialAccountRepository.saveAndFlush(any(SocialAccount.class))) + .thenThrow(constraintCollision( + "uk_social_account_provider_user")); + + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> service.linkSocial( + 7L, + Provider.KAKAO, + "kakao-user", + "user@example.com")); + + assertThat(exception.getErrorCode()) + .isEqualTo(MemberErrorCode.SOCIAL_ACCOUNT_IN_USE); + } + + private static DataIntegrityViolationException constraintCollision( + String constraintName + ) { + return new DataIntegrityViolationException( + "duplicate social account", + new ConstraintViolationException( + "duplicate social account", + new SQLException(), + constraintName)); + } +} diff --git a/member/src/test/java/com/nalssilog/member/application/MemberProfileServiceTest.java b/member/src/test/java/com/nalssilog/member/application/MemberProfileServiceTest.java index def3d4e..ec70d04 100644 --- a/member/src/test/java/com/nalssilog/member/application/MemberProfileServiceTest.java +++ b/member/src/test/java/com/nalssilog/member/application/MemberProfileServiceTest.java @@ -48,6 +48,7 @@ void changesMemberNameAndReturnsUpdatedProfile() { null, List.of() ); + when(memberRepository.getMember(1L)).thenReturn(member); when(memberRepository.getMemberInfo(1L)).thenReturn(expected); @@ -62,6 +63,7 @@ void rejectsUnlinkingCurrentLoginProvider() { Member member = Member.register("user@example.com", "이름", "인사하는감자123"); SocialAccount current = SocialAccount.register( member, Provider.KAKAO, "kakao-id", "user@example.com"); + when(socialAccountRepository.findByMemberIdAndProvider(1L, Provider.KAKAO)) .thenReturn(Optional.of(current)); @@ -79,6 +81,7 @@ void unlinksProviderThatIsNotUsedByCurrentLogin() { Member member = Member.register("user@example.com", "이름", "인사하는감자123"); SocialAccount linked = SocialAccount.link( member, Provider.NAVER, "naver-id", "user@example.com"); + when(socialAccountRepository.findByMemberIdAndProvider(1L, Provider.NAVER)) .thenReturn(Optional.of(linked)); when(socialAccountRepository.countByMemberId(1L)).thenReturn(2L); diff --git a/member/src/test/java/com/nalssilog/member/application/MemberRegistrationServiceTest.java b/member/src/test/java/com/nalssilog/member/application/MemberRegistrationServiceTest.java index 9f186a5..8d655a3 100644 --- a/member/src/test/java/com/nalssilog/member/application/MemberRegistrationServiceTest.java +++ b/member/src/test/java/com/nalssilog/member/application/MemberRegistrationServiceTest.java @@ -74,6 +74,7 @@ void retriesWholeRegistrationWithNewNicknameOnUniqueConstraintCollision() { assertThat(actual).isSameAs(expected); ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(Member.class); + verify(memberRepository, times(2)).saveAndFlush(memberCaptor.capture()); assertThat(memberCaptor.getAllValues()) .extracting(Member::getNickname) @@ -88,6 +89,7 @@ void storesEmptyNameWhenSocialProviderDoesNotReturnOne() { MemberInfo expected = new MemberInfo( 1L, "인사하는감자123", "", null, AvatarType.PRESET, "avatar-01", MemberStatus.ACTIVE, Provider.NAVER, List.of(Provider.NAVER)); + when(nicknameGenerator.generate()).thenReturn("인사하는감자123"); when(memberRepository.saveAndFlush(any(Member.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -96,6 +98,7 @@ void storesEmptyNameWhenSocialProviderDoesNotReturnOne() { service.registerMember(Provider.NAVER, "provider-id", null, null, List.of()); ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(Member.class); + verify(memberRepository).saveAndFlush(memberCaptor.capture()); assertThat(memberCaptor.getValue().getName()).isEmpty(); } @@ -106,6 +109,7 @@ void stripsAndLimitsSocialNameToThirtyCharacters() { MemberInfo expected = new MemberInfo( 1L, "인사하는감자123", "가".repeat(30), null, AvatarType.PRESET, "avatar-01", MemberStatus.ACTIVE, Provider.GOOGLE, List.of(Provider.GOOGLE)); + when(nicknameGenerator.generate()).thenReturn("인사하는감자123"); when(memberRepository.saveAndFlush(any(Member.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -114,6 +118,7 @@ void stripsAndLimitsSocialNameToThirtyCharacters() { service.registerMember(Provider.GOOGLE, "provider-id", null, longName, List.of()); ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(Member.class); + verify(memberRepository).saveAndFlush(memberCaptor.capture()); assertThat(memberCaptor.getValue().getName()).isEqualTo("가".repeat(30)); } diff --git a/member/src/test/java/com/nalssilog/member/config/TrustedProxyClientIpResolverTest.java b/member/src/test/java/com/nalssilog/member/config/TrustedProxyClientIpResolverTest.java index b9e2cd3..92ab3b8 100644 --- a/member/src/test/java/com/nalssilog/member/config/TrustedProxyClientIpResolverTest.java +++ b/member/src/test/java/com/nalssilog/member/config/TrustedProxyClientIpResolverTest.java @@ -10,37 +10,60 @@ @SuppressWarnings("java:S5960") class TrustedProxyClientIpResolverTest { + private static final String UNTRUSTED_PEER = ipv4(198, 51, 100, 20); + private static final String SPOOFED_CLIENT = ipv4(1, 2, 3, 4); + private static final String TRUSTED_PEER = ipv4(172, 17, 0, 1); + private static final String TRUSTED_HOP = ipv4(172, 18, 0, 2); + private static final String CLIENT = ipv4(203, 0, 113, 10); + private static final String LOOPBACK_RANGE = cidr(ipv4(127, 0, 0, 0), 8); + private static final String PRIVATE_RANGE = cidr(ipv4(172, 16, 0, 0), 12); + private final TrustedProxyClientIpResolver resolver = new TrustedProxyClientIpResolver( new FeedbackRateLimitProperties( 5, Duration.ofMinutes(10), "test-secret", - List.of("127.0.0.0/8", "172.16.0.0/12"))); + List.of(LOOPBACK_RANGE, PRIVATE_RANGE))); @Test void ignoresForwardedHeaderFromUntrustedSocketPeer() { MockHttpServletRequest request = new MockHttpServletRequest(); - request.setRemoteAddr("198.51.100.20"); - request.addHeader("X-Forwarded-For", "1.2.3.4"); - assertThat(resolver.resolve(request)).isEqualTo("198.51.100.20"); + request.setRemoteAddr(UNTRUSTED_PEER); + request.addHeader("X-Forwarded-For", SPOOFED_CLIENT); + + assertThat(resolver.resolve(request)).isEqualTo(UNTRUSTED_PEER); } @Test void selectsRightmostUntrustedHopBehindTrustedProxy() { MockHttpServletRequest request = new MockHttpServletRequest(); - request.setRemoteAddr("172.17.0.1"); - request.addHeader("X-Forwarded-For", "1.2.3.4, 203.0.113.10"); - assertThat(resolver.resolve(request)).isEqualTo("203.0.113.10"); + request.setRemoteAddr(TRUSTED_PEER); + request.addHeader( + "X-Forwarded-For", + String.join(", ", SPOOFED_CLIENT, CLIENT)); + + assertThat(resolver.resolve(request)).isEqualTo(CLIENT); } @Test void walksAcrossMultipleTrustedProxies() { MockHttpServletRequest request = new MockHttpServletRequest(); - request.setRemoteAddr("172.17.0.1"); - request.addHeader("X-Forwarded-For", "203.0.113.10, 172.18.0.2"); - assertThat(resolver.resolve(request)).isEqualTo("203.0.113.10"); + request.setRemoteAddr(TRUSTED_PEER); + request.addHeader( + "X-Forwarded-For", + String.join(", ", CLIENT, TRUSTED_HOP)); + + assertThat(resolver.resolve(request)).isEqualTo(CLIENT); + } + + private static String ipv4(int first, int second, int third, int fourth) { + return "%d.%d.%d.%d".formatted(first, second, third, fourth); + } + + private static String cidr(String address, int prefixLength) { + return address + "/" + prefixLength; } } diff --git a/report/src/main/java/com/nalssilog/report/api/dto/ReportResponse.java b/report/src/main/java/com/nalssilog/report/api/dto/ReportResponse.java index 260e57c..0279c08 100644 --- a/report/src/main/java/com/nalssilog/report/api/dto/ReportResponse.java +++ b/report/src/main/java/com/nalssilog/report/api/dto/ReportResponse.java @@ -87,6 +87,7 @@ private static String anonymousAuthorKey(ReportData data) { if (data.authorAnonymousKey() != null) { return data.authorAnonymousKey(); } + if (data.authorMemberId() != null) { return "member:" + data.authorMemberId(); } diff --git a/report/src/main/java/com/nalssilog/report/api/dto/WeatherStatsResponse.java b/report/src/main/java/com/nalssilog/report/api/dto/WeatherStatsResponse.java index 3cab5ef..54d4b74 100644 --- a/report/src/main/java/com/nalssilog/report/api/dto/WeatherStatsResponse.java +++ b/report/src/main/java/com/nalssilog/report/api/dto/WeatherStatsResponse.java @@ -59,6 +59,7 @@ private static > Axis axis(E[] values, Map counts) { for (E value : values) { long count = counts.getOrDefault(value, 0L); + distribution.put(value.name(), count); if (count > max) { diff --git a/report/src/main/java/com/nalssilog/report/application/PopularLocationSnapshotScheduler.java b/report/src/main/java/com/nalssilog/report/application/PopularLocationSnapshotScheduler.java new file mode 100644 index 0000000..6435649 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/application/PopularLocationSnapshotScheduler.java @@ -0,0 +1,20 @@ +package com.nalssilog.report.application; + +import java.time.Instant; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PopularLocationSnapshotScheduler { + + private final PopularLocationSnapshotService snapshotService; + + @Scheduled( + fixedDelayString = "${nalssilog.location.popular.snapshot-interval:10m}", + initialDelayString = "${nalssilog.location.popular.snapshot-initial-delay:10s}") + public void refreshSnapshot() { + snapshotService.latestOrRefreshAt(Instant.now()); + } +} diff --git a/report/src/main/java/com/nalssilog/report/application/PopularLocationSnapshotService.java b/report/src/main/java/com/nalssilog/report/application/PopularLocationSnapshotService.java new file mode 100644 index 0000000..9e8c08a --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/application/PopularLocationSnapshotService.java @@ -0,0 +1,222 @@ +package com.nalssilog.report.application; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.nalssilog.location.application.dto.PopularLocationSnapshotData; +import com.nalssilog.location.config.PopularLocationProperties; +import com.nalssilog.location.domain.PopularRankMovement; +import com.nalssilog.report.application.dto.PopularLocationAggregate; +import com.nalssilog.report.domain.PopularLocationRank; +import com.nalssilog.report.domain.PopularLocationSnapshot; +import com.nalssilog.report.repository.PopularLocationRankJpaRepository; +import com.nalssilog.report.repository.PopularLocationSnapshotJpaRepository; +import com.nalssilog.report.repository.PopularLocationSnapshotLockRepository; +import com.nalssilog.report.repository.WeatherReportRepository; + +import lombok.RequiredArgsConstructor; + +/** + * 최근 제보 집계를 순위 스냅샷으로 저장하고 최신 스냅샷을 제공한다. + */ +@Service +@RequiredArgsConstructor +public class PopularLocationSnapshotService { + + private static final String ALGORITHM_VERSION = "UNIQUE_REPORTERS_V1"; + + private final WeatherReportRepository reportRepository; + private final PopularLocationSnapshotJpaRepository snapshotRepository; + private final PopularLocationRankJpaRepository rankRepository; + private final PopularLocationSnapshotLockRepository lockRepository; + private final PopularLocationProperties properties; + + private static PopularRankMovement movement(Integer rankChange) { + if (rankChange == null) { + return PopularRankMovement.NEW; + } + + if (rankChange > 0) { + return PopularRankMovement.UP; + } + + if (rankChange < 0) { + return PopularRankMovement.DOWN; + } + + return PopularRankMovement.SAME; + } + + @Transactional + public PopularLocationSnapshotData latestOrRefreshAt(Instant now) { + PopularLocationSnapshot latest = snapshotRepository + .findFirstByOrderByCalculatedAtDescIdDesc() + .orElse(null); + + if (isFresh(latest, now)) { + return snapshotData(latest); + } + + lockRepository.acquire(); + + latest = snapshotRepository + .findFirstByOrderByCalculatedAtDescIdDesc() + .orElse(null); + + if (isFresh(latest, now)) { + return snapshotData(latest); + } + + return createSnapshot(now, latest); + } + + @Transactional + public PopularLocationSnapshotData captureAt(Instant calculatedAt) { + lockRepository.acquire(); + + PopularLocationSnapshot previous = snapshotRepository + .findFirstByOrderByCalculatedAtDescIdDesc() + .orElse(null); + Instant snapshotTime = calculationBucketStart(calculatedAt); + + if (isSameCalculation(previous, snapshotTime)) { + return snapshotData(previous); + } + + return createSnapshot(snapshotTime, previous); + } + + private PopularLocationSnapshotData createSnapshot( + Instant calculatedAt, + PopularLocationSnapshot previous + ) { + Instant snapshotTime = calculationBucketStart(calculatedAt); + Instant windowStartedAt = snapshotTime.minus(properties.window()); + List aggregates = + reportRepository.findPopularLocationAggregates( + windowStartedAt, + snapshotTime, + properties.limit()); + Map previousPositions = previousPositions(previous); + PopularLocationSnapshot snapshot = PopularLocationSnapshot.create( + snapshotTime, + windowStartedAt, + snapshotTime, + properties.limit(), + ALGORITHM_VERSION); + + snapshotRepository.saveAndFlush(snapshot); + + List ranks = new ArrayList<>(aggregates.size()); + + for (int index = 0; index < aggregates.size(); index++) { + PopularLocationAggregate aggregate = aggregates.get(index); + int position = index + 1; + Integer previousPosition = previousPositions.get(aggregate.locationId()); + Integer rankChange = previousPosition == null + ? null + : previousPosition - position; + + ranks.add(PopularLocationRank.create( + snapshot.getId(), + aggregate.locationId(), + position, + previousPosition, + rankChange, + movement(rankChange), + aggregate.uniqueReporterCount(), + aggregate.reportCount(), + aggregate.latestReportAt())); + } + + rankRepository.saveAll(ranks); + + return snapshotData(snapshot, ranks); + } + + private Map previousPositions(PopularLocationSnapshot previous) { + if (!isComparable(previous)) { + return Map.of(); + } + + return rankRepository.findAllBySnapshotIdOrderByPositionAsc(previous.getId()).stream() + .collect(Collectors.toMap( + PopularLocationRank::getLocationId, + PopularLocationRank::getPosition)); + } + + private boolean isComparable(PopularLocationSnapshot previous) { + if (previous == null + || !ALGORITHM_VERSION.equals(previous.getAlgorithmVersion()) + || previous.getRankingLimit() != properties.limit()) { + return false; + } + + Duration previousWindow = Duration.between( + previous.getWindowStartedAt(), + previous.getWindowEndedAt()); + + return previousWindow.equals(properties.window()); + } + + private boolean isFresh( + PopularLocationSnapshot snapshot, + Instant now + ) { + return isComparable(snapshot) + && !snapshot.getCalculatedAt() + .isBefore(now.minus(properties.snapshotInterval())); + } + + private boolean isSameCalculation( + PopularLocationSnapshot snapshot, + Instant calculatedAt + ) { + return isComparable(snapshot) && snapshot.getCalculatedAt().equals(calculatedAt); + } + + private Instant calculationBucketStart(Instant calculatedAt) { + long intervalMillis = Math.max(1L, properties.snapshotInterval().toMillis()); + long bucketMillis = Math.floorDiv(calculatedAt.toEpochMilli(), intervalMillis) * intervalMillis; + + return Instant.ofEpochMilli(bucketMillis); + } + + private PopularLocationSnapshotData snapshotData(PopularLocationSnapshot snapshot) { + List ranks = rankRepository.findAllBySnapshotIdOrderByPositionAsc(snapshot.getId()); + + return snapshotData(snapshot, ranks); + } + + private PopularLocationSnapshotData snapshotData( + PopularLocationSnapshot snapshot, + List ranks + ) { + List rankings = ranks.stream() + .map(rank -> new PopularLocationSnapshotData.Rank( + rank.getLocationId(), + rank.getPosition(), + rank.getPreviousPosition(), + rank.getRankChange(), + rank.getMovement(), + rank.getUniqueReporterCount(), + rank.getReportCount(), + rank.getLatestReportAt())) + .toList(); + + return new PopularLocationSnapshotData( + snapshot.getId(), + snapshot.getCalculatedAt(), + snapshot.getWindowStartedAt(), + snapshot.getWindowEndedAt(), + snapshot.getAlgorithmVersion(), + rankings); + } +} diff --git a/report/src/main/java/com/nalssilog/report/application/ReportPopularLocationSource.java b/report/src/main/java/com/nalssilog/report/application/ReportPopularLocationSource.java index 909b9b2..03ddaaf 100644 --- a/report/src/main/java/com/nalssilog/report/application/ReportPopularLocationSource.java +++ b/report/src/main/java/com/nalssilog/report/application/ReportPopularLocationSource.java @@ -1,27 +1,22 @@ package com.nalssilog.report.application; import com.nalssilog.location.application.PopularLocationSource; -import com.nalssilog.report.repository.WeatherReportRepository; -import java.time.Duration; +import com.nalssilog.location.application.dto.PopularLocationSnapshotData; import java.time.Instant; -import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; /** - * location 의 인기 지역 port 를 제보 활동 기반으로 구현(adapter). 최근 {@link #POPULAR_WINDOW} 내 - * 제보가 많은 순으로 locationId 를 돌려준다. (report → location 의존이라 location 인터페이스 구현 가능) + * location의 인기 지역 port를 최근 제보 순위 스냅샷 기반으로 구현한다. */ @Component @RequiredArgsConstructor public class ReportPopularLocationSource implements PopularLocationSource { - private static final Duration POPULAR_WINDOW = Duration.ofDays(7); - - private final WeatherReportRepository reportRepository; + private final PopularLocationSnapshotService snapshotService; @Override - public List topLocationIds(int size) { - return reportRepository.topLocationIds(Instant.now().minus(POPULAR_WINDOW), size); + public PopularLocationSnapshotData latestSnapshot() { + return snapshotService.latestOrRefreshAt(Instant.now()); } } diff --git a/report/src/main/java/com/nalssilog/report/application/ReportService.java b/report/src/main/java/com/nalssilog/report/application/ReportService.java index 5f887b8..06f3a86 100644 --- a/report/src/main/java/com/nalssilog/report/application/ReportService.java +++ b/report/src/main/java/com/nalssilog/report/application/ReportService.java @@ -50,6 +50,7 @@ public class ReportService { @Transactional public ReportResponse create(ReportActor actor, CreateReportCommand command) { LocationSummary location = locationClient.getLocation(command.locationId()); + imageStorageClient.validateImageCount(command.imageKeys().size()); command.imageKeys().forEach(imageStorageClient::validateKey); command.imageKeys().forEach(imageStorageClient::verifyUploaded); @@ -59,6 +60,7 @@ public ReportResponse create(ReportActor actor, CreateReportCommand command) { command.temperature(), command.precipitation(), command.sunlight(), command.comment()) : WeatherReport.ofAnonymous(command.locationId(), actor.anonymousKey(), command.temperature(), command.precipitation(), command.sunlight(), command.comment()); + report.addImages(command.imageKeys()); ReportData data = reportRepository.save(report); @@ -83,9 +85,15 @@ public CursorPage list(Long locationId, String cursor, ReportAct List reportIds = page.stream().map(ReportData::id).toList(); Map counts = thanksRepository.countByReportIds(reportIds); Set thanked = thanksRepository.thankedReportIds(reportIds, viewer); + Map authors = memberClient.findActiveAuthors( + page.stream() + .filter(data -> data.authorType() == ActorType.MEMBER) + .map(ReportData::authorMemberId) + .distinct() + .toList()); List items = page.stream() - .map(data -> ReportResponse.of(data, location, resolveAuthor(data), + .map(data -> ReportResponse.of(data, location, authors.get(data.authorMemberId()), counts.getOrDefault(data.id(), 0L), thanked.contains(data.id()), isAuthor(data, ownershipActors), diff --git a/report/src/main/java/com/nalssilog/report/application/dto/PopularLocationAggregate.java b/report/src/main/java/com/nalssilog/report/application/dto/PopularLocationAggregate.java new file mode 100644 index 0000000..1ba7f09 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/application/dto/PopularLocationAggregate.java @@ -0,0 +1,14 @@ +package com.nalssilog.report.application.dto; + +import java.time.Instant; + +/** + * 한 계산 구간에서 지역별로 집계한 인기 순위 원본 지표. + */ +public record PopularLocationAggregate( + Long locationId, + long uniqueReporterCount, + long reportCount, + Instant latestReportAt +) { +} diff --git a/report/src/main/java/com/nalssilog/report/client/MemberClient.java b/report/src/main/java/com/nalssilog/report/client/MemberClient.java index e684a69..8e40b54 100644 --- a/report/src/main/java/com/nalssilog/report/client/MemberClient.java +++ b/report/src/main/java/com/nalssilog/report/client/MemberClient.java @@ -1,9 +1,15 @@ package com.nalssilog.report.client; import com.nalssilog.member.application.MemberAccountService; +import com.nalssilog.member.application.dto.MemberSummary; import com.nalssilog.member.domain.MemberStatus; import com.nalssilog.report.application.dto.AuthorInfo; +import java.util.Collection; +import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -18,9 +24,24 @@ public class MemberClient { private final MemberAccountService memberAccountService; public Optional findActiveAuthor(Long memberId) { - return memberAccountService.findMemberInfo(memberId) + return Optional.ofNullable( + findActiveAuthors(List.of(memberId)).get(memberId)); + } + + public Map findActiveAuthors(Collection memberIds) { + return memberAccountService.findMemberSummaries(memberIds).stream() .filter(member -> member.status() == MemberStatus.ACTIVE) - .map(member -> new AuthorInfo( - member.id(), member.nickname(), member.avatarType(), member.avatarValue())); + .map(MemberClient::toAuthorInfo) + .collect(Collectors.toUnmodifiableMap( + AuthorInfo::id, + Function.identity())); + } + + private static AuthorInfo toAuthorInfo(MemberSummary member) { + return new AuthorInfo( + member.id(), + member.nickname(), + member.avatarType(), + member.avatarValue()); } } diff --git a/report/src/main/java/com/nalssilog/report/config/ReportActorResolver.java b/report/src/main/java/com/nalssilog/report/config/ReportActorResolver.java index 327b78d..099f73b 100644 --- a/report/src/main/java/com/nalssilog/report/config/ReportActorResolver.java +++ b/report/src/main/java/com/nalssilog/report/config/ReportActorResolver.java @@ -1,5 +1,6 @@ package com.nalssilog.report.config; +import com.nalssilog.common.security.VerifiedRequestCredentials; import com.nalssilog.report.application.dto.ReportActor; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -23,6 +24,12 @@ public ReportActor resolveForWrite(Long memberId, HttpServletRequest request, Ht return ReportActor.member(memberId); } + var mobileGuest = VerifiedRequestCredentials.guestAnonymousKey(request); + + if (mobileGuest.isPresent()) { + return ReportActor.anonymous(mobileGuest.get()); + } + return ReportActor.anonymous(anonymousIdManager.getOrIssue(request, response)); } @@ -31,6 +38,12 @@ public ReportActor resolveForRead(Long memberId, HttpServletRequest request) { return ReportActor.member(memberId); } + var mobileGuest = VerifiedRequestCredentials.guestAnonymousKey(request); + + if (mobileGuest.isPresent()) { + return ReportActor.anonymous(mobileGuest.get()); + } + return anonymousIdManager.read(request) .map(ReportActor::anonymous) .orElse(null); @@ -46,10 +59,17 @@ public List resolveForOwnership(Long memberId, HttpServletRequest r if (memberId != null) { actors.add(ReportActor.member(memberId)); } - anonymousIdManager.read(request) + + VerifiedRequestCredentials.guestAnonymousKey(request) .map(ReportActor::anonymous) .ifPresent(actors::add); + if (!VerifiedRequestCredentials.hasNonCookieCredential(request)) { + anonymousIdManager.read(request) + .map(ReportActor::anonymous) + .ifPresent(actors::add); + } + return List.copyOf(actors); } } diff --git a/report/src/main/java/com/nalssilog/report/domain/PopularLocationRank.java b/report/src/main/java/com/nalssilog/report/domain/PopularLocationRank.java new file mode 100644 index 0000000..f1daa81 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/domain/PopularLocationRank.java @@ -0,0 +1,96 @@ +package com.nalssilog.report.domain; + +import com.nalssilog.location.domain.PopularRankMovement; +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; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.Instant; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 인기 지역 스냅샷의 개별 순위와 당시 집계 지표. + */ +@Entity +@Table(name = "popular_location_rank", + indexes = @Index( + name = "idx_popular_location_rank_snapshot", + columnList = "snapshot_id, ranking_position"), + uniqueConstraints = { + @UniqueConstraint( + name = "uk_popular_location_rank_snapshot_position", + columnNames = {"snapshot_id", "ranking_position"}), + @UniqueConstraint( + name = "uk_popular_location_rank_snapshot_location", + columnNames = {"snapshot_id", "location_id"}) + }) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PopularLocationRank { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "snapshot_id", nullable = false) + private Long snapshotId; + + @Column(name = "location_id", nullable = false) + private Long locationId; + + @Column(name = "ranking_position", nullable = false) + private int position; + + @Column(name = "previous_ranking_position") + private Integer previousPosition; + + @Column(name = "rank_change") + private Integer rankChange; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private PopularRankMovement movement; + + @Column(name = "unique_reporter_count", nullable = false) + private long uniqueReporterCount; + + @Column(name = "report_count", nullable = false) + private long reportCount; + + @Column(name = "latest_report_at", nullable = false) + private Instant latestReportAt; + + public static PopularLocationRank create( + Long snapshotId, + Long locationId, + int position, + Integer previousPosition, + Integer rankChange, + PopularRankMovement movement, + long uniqueReporterCount, + long reportCount, + Instant latestReportAt + ) { + PopularLocationRank rank = new PopularLocationRank(); + + rank.snapshotId = snapshotId; + rank.locationId = locationId; + rank.position = position; + rank.previousPosition = previousPosition; + rank.rankChange = rankChange; + rank.movement = movement; + rank.uniqueReporterCount = uniqueReporterCount; + rank.reportCount = reportCount; + rank.latestReportAt = latestReportAt; + + return rank; + } +} diff --git a/report/src/main/java/com/nalssilog/report/domain/PopularLocationSnapshot.java b/report/src/main/java/com/nalssilog/report/domain/PopularLocationSnapshot.java new file mode 100644 index 0000000..69cc1d8 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/domain/PopularLocationSnapshot.java @@ -0,0 +1,72 @@ +package com.nalssilog.report.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.Instant; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 특정 계산 시점의 인기 지역 순위 헤더. + */ +@Entity +@Table(name = "popular_location_snapshot", + indexes = @Index( + name = "idx_popular_location_snapshot_calculated", + columnList = "calculated_at"), + uniqueConstraints = @UniqueConstraint( + name = "uk_popular_location_snapshot_calculation", + columnNames = { + "calculated_at", + "window_started_at", + "ranking_limit", + "algorithm_version" + })) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PopularLocationSnapshot { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "calculated_at", nullable = false) + private Instant calculatedAt; + + @Column(name = "window_started_at", nullable = false) + private Instant windowStartedAt; + + @Column(name = "window_ended_at", nullable = false) + private Instant windowEndedAt; + + @Column(name = "ranking_limit", nullable = false) + private int rankingLimit; + + @Column(name = "algorithm_version", nullable = false, length = 40) + private String algorithmVersion; + + public static PopularLocationSnapshot create( + Instant calculatedAt, + Instant windowStartedAt, + Instant windowEndedAt, + int rankingLimit, + String algorithmVersion + ) { + PopularLocationSnapshot snapshot = new PopularLocationSnapshot(); + + snapshot.calculatedAt = calculatedAt; + snapshot.windowStartedAt = windowStartedAt; + snapshot.windowEndedAt = windowEndedAt; + snapshot.rankingLimit = rankingLimit; + snapshot.algorithmVersion = algorithmVersion; + + return snapshot; + } +} diff --git a/report/src/main/java/com/nalssilog/report/domain/PopularLocationSnapshotLock.java b/report/src/main/java/com/nalssilog/report/domain/PopularLocationSnapshotLock.java new file mode 100644 index 0000000..ec457f3 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/domain/PopularLocationSnapshotLock.java @@ -0,0 +1,23 @@ +package com.nalssilog.report.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 여러 애플리케이션 인스턴스의 인기 지역 스냅샷 갱신을 직렬화하는 DB 잠금 행. + */ +@Entity +@Table(name = "popular_location_snapshot_lock") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PopularLocationSnapshotLock { + + @Id + @Column(name = "id", nullable = false) + private Long id; +} diff --git a/report/src/main/java/com/nalssilog/report/domain/ReportErrorCode.java b/report/src/main/java/com/nalssilog/report/domain/ReportErrorCode.java index fcf8112..20991c3 100644 --- a/report/src/main/java/com/nalssilog/report/domain/ReportErrorCode.java +++ b/report/src/main/java/com/nalssilog/report/domain/ReportErrorCode.java @@ -12,6 +12,10 @@ public enum ReportErrorCode implements ErrorCode { REPORT_NOT_FOUND("REPORT_NOT_FOUND", "제보를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), REPORT_DELETE_FORBIDDEN("REPORT_DELETE_FORBIDDEN", "본인이 작성한 제보만 삭제할 수 있습니다.", HttpStatus.FORBIDDEN), + POPULAR_SNAPSHOT_LOCK_UNAVAILABLE( + "POPULAR_SNAPSHOT_LOCK_UNAVAILABLE", + "인기 지역 순위를 갱신할 수 없습니다.", + HttpStatus.SERVICE_UNAVAILABLE), INVALID_CURSOR("INVALID_CURSOR", "잘못된 커서입니다.", HttpStatus.BAD_REQUEST), UNSUPPORTED_IMAGE_TYPE("UNSUPPORTED_IMAGE_TYPE", "지원하지 않는 이미지 형식입니다.", HttpStatus.BAD_REQUEST), IMAGE_TOO_LARGE("IMAGE_TOO_LARGE", "이미지 한 장은 5MB 이하여야 합니다.", HttpStatus.BAD_REQUEST), diff --git a/report/src/main/java/com/nalssilog/report/domain/Thanks.java b/report/src/main/java/com/nalssilog/report/domain/Thanks.java index 4aba313..1907832 100644 --- a/report/src/main/java/com/nalssilog/report/domain/Thanks.java +++ b/report/src/main/java/com/nalssilog/report/domain/Thanks.java @@ -44,6 +44,7 @@ public class Thanks extends BaseTimeEntity { public static Thanks create(Long reportId, ActorType actorType, String actorKey) { Thanks thanks = new Thanks(); + thanks.reportId = reportId; thanks.actorType = actorType; thanks.actorKey = actorKey; diff --git a/report/src/main/java/com/nalssilog/report/domain/WeatherReport.java b/report/src/main/java/com/nalssilog/report/domain/WeatherReport.java index 3d41767..4667a16 100644 --- a/report/src/main/java/com/nalssilog/report/domain/WeatherReport.java +++ b/report/src/main/java/com/nalssilog/report/domain/WeatherReport.java @@ -67,6 +67,7 @@ public class WeatherReport extends BaseTimeEntity { public static WeatherReport ofMember(Long locationId, Long memberId, Temperature temperature, Precipitation precipitation, Sunlight sunlight, String comment) { WeatherReport report = base(locationId, temperature, precipitation, sunlight, comment); + report.authorType = ActorType.MEMBER; report.authorMemberId = memberId; @@ -76,6 +77,7 @@ public static WeatherReport ofMember(Long locationId, Long memberId, Temperature public static WeatherReport ofAnonymous(Long locationId, String anonymousKey, Temperature temperature, Precipitation precipitation, Sunlight sunlight, String comment) { WeatherReport report = base(locationId, temperature, precipitation, sunlight, comment); + report.authorType = ActorType.ANONYMOUS; report.authorAnonymousKey = anonymousKey; @@ -91,6 +93,7 @@ public void addImages(List storageKeys) { private static WeatherReport base(Long locationId, Temperature temperature, Precipitation precipitation, Sunlight sunlight, String comment) { WeatherReport report = new WeatherReport(); + report.locationId = locationId; report.temperature = temperature; report.precipitation = precipitation; diff --git a/report/src/main/java/com/nalssilog/report/domain/WeatherReportImage.java b/report/src/main/java/com/nalssilog/report/domain/WeatherReportImage.java index 07e2488..0592d64 100644 --- a/report/src/main/java/com/nalssilog/report/domain/WeatherReportImage.java +++ b/report/src/main/java/com/nalssilog/report/domain/WeatherReportImage.java @@ -42,6 +42,7 @@ public class WeatherReportImage extends BaseTimeEntity { public static WeatherReportImage create(WeatherReport report, String storageKey, int displayOrder) { WeatherReportImage image = new WeatherReportImage(); + image.report = report; image.storageKey = storageKey; image.displayOrder = displayOrder; diff --git a/report/src/main/java/com/nalssilog/report/repository/PopularLocationRankJpaRepository.java b/report/src/main/java/com/nalssilog/report/repository/PopularLocationRankJpaRepository.java new file mode 100644 index 0000000..d761fce --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/repository/PopularLocationRankJpaRepository.java @@ -0,0 +1,11 @@ +package com.nalssilog.report.repository; + +import com.nalssilog.report.domain.PopularLocationRank; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PopularLocationRankJpaRepository + extends JpaRepository { + + List findAllBySnapshotIdOrderByPositionAsc(Long snapshotId); +} diff --git a/report/src/main/java/com/nalssilog/report/repository/PopularLocationSnapshotJpaRepository.java b/report/src/main/java/com/nalssilog/report/repository/PopularLocationSnapshotJpaRepository.java new file mode 100644 index 0000000..4d4f9dc --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/repository/PopularLocationSnapshotJpaRepository.java @@ -0,0 +1,11 @@ +package com.nalssilog.report.repository; + +import com.nalssilog.report.domain.PopularLocationSnapshot; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PopularLocationSnapshotJpaRepository + extends JpaRepository { + + Optional findFirstByOrderByCalculatedAtDescIdDesc(); +} diff --git a/report/src/main/java/com/nalssilog/report/repository/PopularLocationSnapshotLockRepository.java b/report/src/main/java/com/nalssilog/report/repository/PopularLocationSnapshotLockRepository.java new file mode 100644 index 0000000..4f08e1e --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/repository/PopularLocationSnapshotLockRepository.java @@ -0,0 +1,30 @@ +package com.nalssilog.report.repository; + +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.report.domain.PopularLocationSnapshotLock; +import com.nalssilog.report.domain.ReportErrorCode; +import jakarta.persistence.EntityManager; +import jakarta.persistence.LockModeType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class PopularLocationSnapshotLockRepository { + + private static final Long LOCK_ID = 1L; + + private final EntityManager entityManager; + + public void acquire() { + PopularLocationSnapshotLock lock = entityManager.find( + PopularLocationSnapshotLock.class, + LOCK_ID, + LockModeType.PESSIMISTIC_WRITE); + + if (lock == null) { + throw new NalssiLogException( + ReportErrorCode.POPULAR_SNAPSHOT_LOCK_UNAVAILABLE); + } + } +} diff --git a/report/src/main/java/com/nalssilog/report/repository/WeatherReportRepository.java b/report/src/main/java/com/nalssilog/report/repository/WeatherReportRepository.java index a6b16a1..c204560 100644 --- a/report/src/main/java/com/nalssilog/report/repository/WeatherReportRepository.java +++ b/report/src/main/java/com/nalssilog/report/repository/WeatherReportRepository.java @@ -1,8 +1,10 @@ package com.nalssilog.report.repository; import static com.nalssilog.report.domain.QWeatherReport.weatherReport; +import static com.nalssilog.report.domain.QWeatherReportImage.weatherReportImage; import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.report.application.dto.PopularLocationAggregate; import com.nalssilog.report.application.dto.ReportData; import com.nalssilog.report.application.dto.WeatherStatsData; import com.nalssilog.report.domain.ActorType; @@ -13,14 +15,18 @@ import com.nalssilog.report.domain.WeatherReport; import com.querydsl.core.Tuple; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.DateTimeExpression; import com.querydsl.core.types.dsl.EnumPath; import com.querydsl.core.types.dsl.NumberExpression; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.EntityManager; import java.time.Instant; import java.util.EnumMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -47,6 +53,7 @@ public ReportData save(WeatherReport report) { */ public int anonymizeAuthor(Long memberId) { entityManager.flush(); + long affectedRows = queryFactory .update(weatherReport) .set(weatherReport.authorType, ActorType.ANONYMOUS) @@ -57,6 +64,7 @@ public int anonymizeAuthor(Long memberId) { weatherReport.authorMemberId.eq(memberId) ) .execute(); + entityManager.clear(); return Math.toIntExact(affectedRows); @@ -68,8 +76,19 @@ public ReportData getReport(Long reportId) { /** 삭제처럼 관리 엔티티가 필요한 쓰기 유스케이스 전용. 반드시 트랜잭션 안에서 사용한다. */ public WeatherReport getReportEntity(Long reportId) { - return weatherReportJpaRepository.findById(reportId) - .orElseThrow(() -> new NalssiLogException(ReportErrorCode.REPORT_NOT_FOUND)); + WeatherReport report = queryFactory + .selectFrom(weatherReport) + .distinct() + .leftJoin(weatherReport.images, weatherReportImage) + .fetchJoin() + .where(weatherReport.id.eq(reportId)) + .fetchOne(); + + if (report == null) { + throw new NalssiLogException(ReportErrorCode.REPORT_NOT_FOUND); + } + + return report; } public void delete(WeatherReport report) { @@ -82,7 +101,7 @@ public List findPage(Long locationId, Instant cursorTime, Long curso ? weatherReportJpaRepository.findAllByLocationIdOrderByCreatedAtDescIdDesc(locationId, pageable) : findAfterLocationCursor(locationId, cursorTime, cursorId, limit); - return reports.stream() + return fetchImages(reports).stream() .map(ReportData::of) .toList(); } @@ -94,21 +113,46 @@ public List findMemberPage(Long memberId, Instant cursorTime, Long c ActorType.MEMBER, memberId, pageable) : findAfterMemberCursor(memberId, cursorTime, cursorId, limit); - return reports.stream() + return fetchImages(reports).stream() .map(ReportData::of) .toList(); } - /** 최근({@code since} 이후) 제보 수가 많은 순으로 상위 locationId 목록(인기 지역 랭킹용). */ - public List topLocationIds(Instant since, int size) { - return queryFactory - .select(weatherReport.locationId) + public List findPopularLocationAggregates( + Instant windowStartedAt, + Instant windowEndedAt, + int limit + ) { + NumberExpression uniqueReporterCount = weatherReport.authorMemberId.countDistinct() + .add(weatherReport.authorAnonymousKey.countDistinct()); + NumberExpression reportCount = weatherReport.id.count(); + DateTimeExpression latestReportAt = weatherReport.createdAt.max(); + List rows = queryFactory + .select( + weatherReport.locationId, + uniqueReporterCount, + reportCount, + latestReportAt) .from(weatherReport) - .where(weatherReport.createdAt.goe(since)) + .where( + weatherReport.createdAt.goe(windowStartedAt), + weatherReport.createdAt.lt(windowEndedAt)) .groupBy(weatherReport.locationId) - .orderBy(weatherReport.id.count().desc()) - .limit(size) + .orderBy( + uniqueReporterCount.desc(), + reportCount.desc(), + latestReportAt.desc(), + weatherReport.locationId.asc()) + .limit(limit) .fetch(); + + return rows.stream() + .map(row -> new PopularLocationAggregate( + row.get(weatherReport.locationId), + value(row.get(uniqueReporterCount)), + value(row.get(reportCount)), + row.get(latestReportAt))) + .toList(); } /** 최근({@code since} 이후) 제보의 3축 분포 + 제보 수 집계. */ @@ -151,6 +195,37 @@ private List findAfterMemberCursor( .fetch(); } + /** + * 컬렉션 fetch join에 페이지 제한을 직접 적용하면 메모리 페이징이 발생할 수 있다. + * 먼저 루트 엔티티를 제한한 뒤 선택된 ID만 이미지와 fetch join하고 기존 순서를 복원한다. + */ + private List fetchImages(List reports) { + if (reports.isEmpty()) { + return List.of(); + } + + List reportIds = reports.stream() + .map(WeatherReport::getId) + .toList(); + Map fetchedById = queryFactory + .selectFrom(weatherReport) + .distinct() + .leftJoin(weatherReport.images, weatherReportImage) + .fetchJoin() + .where(weatherReport.id.in(reportIds)) + .fetch() + .stream() + .collect(Collectors.toMap( + WeatherReport::getId, + Function.identity(), + (first, ignored) -> first, + LinkedHashMap::new)); + + return reportIds.stream() + .map(fetchedById::get) + .toList(); + } + private BooleanExpression beforeCursor(Instant cursorTime, Long cursorId) { return weatherReport.createdAt.lt(cursorTime) .or(weatherReport.createdAt.eq(cursorTime).and(weatherReport.id.lt(cursorId))); @@ -170,8 +245,13 @@ private > Map countByAxis( .fetch(); Map counts = new EnumMap<>(type); + rows.forEach(row -> counts.put(row.get(axis), row.get(count))); return counts; } + + private static long value(Long value) { + return value == null ? 0 : value; + } } diff --git a/report/src/test/java/com/nalssilog/report/application/PopularLocationSnapshotServiceTest.java b/report/src/test/java/com/nalssilog/report/application/PopularLocationSnapshotServiceTest.java new file mode 100644 index 0000000..f86d0d2 --- /dev/null +++ b/report/src/test/java/com/nalssilog/report/application/PopularLocationSnapshotServiceTest.java @@ -0,0 +1,279 @@ +package com.nalssilog.report.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.nalssilog.location.config.PopularLocationProperties; +import com.nalssilog.location.domain.PopularRankMovement; +import com.nalssilog.report.application.dto.PopularLocationAggregate; +import com.nalssilog.report.domain.PopularLocationRank; +import com.nalssilog.report.domain.PopularLocationSnapshot; +import com.nalssilog.report.repository.PopularLocationRankJpaRepository; +import com.nalssilog.report.repository.PopularLocationSnapshotJpaRepository; +import com.nalssilog.report.repository.PopularLocationSnapshotLockRepository; +import com.nalssilog.report.repository.WeatherReportRepository; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +@SuppressWarnings("java:S5960") +class PopularLocationSnapshotServiceTest { + + private static final PopularLocationProperties PROPERTIES = + new PopularLocationProperties( + Duration.ofDays(7), + 20, + Duration.ofMinutes(10)); + + private final WeatherReportRepository reportRepository = + mock(WeatherReportRepository.class); + private final PopularLocationSnapshotJpaRepository snapshotRepository = + mock(PopularLocationSnapshotJpaRepository.class); + private final PopularLocationRankJpaRepository rankRepository = + mock(PopularLocationRankJpaRepository.class); + private final PopularLocationSnapshotLockRepository lockRepository = + mock(PopularLocationSnapshotLockRepository.class); + private final PopularLocationSnapshotService service = + new PopularLocationSnapshotService( + reportRepository, + snapshotRepository, + rankRepository, + lockRepository, + PROPERTIES); + + @Test + void createsFirstSnapshotWithNewMovementsAndDetailedMetrics() { + Instant calculatedAt = Instant.parse("2026-07-30T06:00:00Z"); + Instant windowStartedAt = calculatedAt.minus(Duration.ofDays(7)); + + when(snapshotRepository.findFirstByOrderByCalculatedAtDescIdDesc()) + .thenReturn(Optional.empty()); + when(reportRepository.findPopularLocationAggregates(windowStartedAt, calculatedAt, 20)) + .thenReturn(List.of( + new PopularLocationAggregate( + 11L, + 4, + 7, + calculatedAt.minusSeconds(60)), + new PopularLocationAggregate( + 12L, + 2, + 3, + calculatedAt.minusSeconds(120)))); + assignSnapshotId(100L); + + var result = service.captureAt(calculatedAt); + + assertThat(result.snapshotId()).isEqualTo(100L); + assertThat(result.windowStartedAt()).isEqualTo(windowStartedAt); + assertThat(result.rankings()).hasSize(2); + assertThat(result.rankings().getFirst().rank()).isEqualTo(1); + assertThat(result.rankings().getFirst().previousRank()).isNull(); + assertThat(result.rankings().getFirst().rankChange()).isNull(); + assertThat(result.rankings().getFirst().movement()).isEqualTo(PopularRankMovement.NEW); + assertThat(result.rankings().getFirst().uniqueReporterCount()).isEqualTo(4); + assertThat(result.rankings().getFirst().reportCount()).isEqualTo(7); + verify(rankRepository).saveAll(any()); + } + + @Test + void comparesNewSnapshotWithPreviousRanking() { + Instant calculatedAt = Instant.parse("2026-07-30T06:00:00Z"); + PopularLocationSnapshot previous = snapshot( + 90L, + calculatedAt.minus(Duration.ofMinutes(10))); + List previousRanks = List.of( + rank(90L, 11L, 1), + rank(90L, 12L, 2)); + + when(snapshotRepository.findFirstByOrderByCalculatedAtDescIdDesc()) + .thenReturn(Optional.of(previous)); + when(rankRepository.findAllBySnapshotIdOrderByPositionAsc(90L)) + .thenReturn(previousRanks); + when(reportRepository.findPopularLocationAggregates( + calculatedAt.minus(Duration.ofDays(7)), + calculatedAt, + 20)) + .thenReturn(List.of( + aggregate(12L, calculatedAt), + aggregate(11L, calculatedAt), + aggregate(13L, calculatedAt))); + assignSnapshotId(100L); + + var result = service.captureAt(calculatedAt); + + assertThat(result.rankings()) + .extracting( + ranking -> ranking.locationId(), + ranking -> ranking.previousRank(), + ranking -> ranking.rankChange(), + ranking -> ranking.movement()) + .containsExactly( + org.assertj.core.groups.Tuple.tuple( + 12L, 2, 1, PopularRankMovement.UP), + org.assertj.core.groups.Tuple.tuple( + 11L, 1, -1, PopularRankMovement.DOWN), + org.assertj.core.groups.Tuple.tuple( + 13L, null, null, PopularRankMovement.NEW)); + } + + @Test + void reusesFreshSnapshotWithoutRunningAggregation() { + Instant now = Instant.parse("2026-07-30T06:00:00Z"); + PopularLocationSnapshot latest = snapshot( + 100L, + now.minus(Duration.ofMinutes(5))); + PopularLocationRank storedRank = rank(100L, 11L, 1); + + when(snapshotRepository.findFirstByOrderByCalculatedAtDescIdDesc()) + .thenReturn(Optional.of(latest)); + when(rankRepository.findAllBySnapshotIdOrderByPositionAsc(100L)) + .thenReturn(List.of(storedRank)); + + var result = service.latestOrRefreshAt(now); + + assertThat(result.snapshotId()).isEqualTo(100L); + assertThat(result.rankings()).singleElement() + .satisfies(ranking -> assertThat(ranking.locationId()).isEqualTo(11L)); + verify(reportRepository, never()) + .findPopularLocationAggregates(any(), any(), anyInt()); + verify(snapshotRepository, never()).saveAndFlush(any()); + verify(lockRepository, never()).acquire(); + } + + @Test + void rechecksLatestSnapshotAfterAcquiringDistributedLock() { + Instant now = Instant.parse("2026-07-30T06:05:00Z"); + PopularLocationSnapshot concurrentSnapshot = snapshot( + 100L, + Instant.parse("2026-07-30T06:00:00Z")); + PopularLocationRank storedRank = rank(100L, 11L, 1); + + when(snapshotRepository.findFirstByOrderByCalculatedAtDescIdDesc()) + .thenReturn( + Optional.empty(), + Optional.of(concurrentSnapshot)); + when(rankRepository.findAllBySnapshotIdOrderByPositionAsc(100L)) + .thenReturn(List.of(storedRank)); + + var result = service.latestOrRefreshAt(now); + + assertThat(result.snapshotId()).isEqualTo(100L); + verify(lockRepository).acquire(); + verify(reportRepository, never()) + .findPopularLocationAggregates(any(), any(), anyInt()); + } + + @Test + void reusesSnapshotFromSameCalculationBucket() { + Instant requestedAt = Instant.parse("2026-07-30T06:07:00Z"); + PopularLocationSnapshot existing = snapshot( + 100L, + Instant.parse("2026-07-30T06:00:00Z")); + PopularLocationRank storedRank = rank(100L, 11L, 1); + + when(snapshotRepository.findFirstByOrderByCalculatedAtDescIdDesc()) + .thenReturn(Optional.of(existing)); + when(rankRepository.findAllBySnapshotIdOrderByPositionAsc(100L)) + .thenReturn(List.of(storedRank)); + + var result = service.captureAt(requestedAt); + + assertThat(result.snapshotId()).isEqualTo(100L); + assertThat(result.calculatedAt()) + .isEqualTo(Instant.parse("2026-07-30T06:00:00Z")); + verify(lockRepository).acquire(); + verify(reportRepository, never()) + .findPopularLocationAggregates(any(), any(), anyInt()); + verify(snapshotRepository, never()).saveAndFlush(any()); + } + + @Test + void startsNewMovementBaselineWhenAlgorithmChanges() { + Instant calculatedAt = Instant.parse("2026-07-30T06:00:00Z"); + PopularLocationSnapshot previous = snapshot( + 90L, + calculatedAt.minus(Duration.ofMinutes(10)), + "RECENT_ACTIVITY_V0"); + + when(snapshotRepository.findFirstByOrderByCalculatedAtDescIdDesc()) + .thenReturn(Optional.of(previous)); + when(reportRepository.findPopularLocationAggregates( + calculatedAt.minus(Duration.ofDays(7)), + calculatedAt, + 20)) + .thenReturn(List.of(aggregate(11L, calculatedAt))); + assignSnapshotId(100L); + + var result = service.captureAt(calculatedAt); + + assertThat(result.rankings()).singleElement() + .satisfies(ranking -> { + assertThat(ranking.previousRank()).isNull(); + assertThat(ranking.rankChange()).isNull(); + assertThat(ranking.movement()).isEqualTo(PopularRankMovement.NEW); + }); + verify(rankRepository, never()).findAllBySnapshotIdOrderByPositionAsc(90L); + } + + private void assignSnapshotId(Long id) { + when(snapshotRepository.saveAndFlush(any(PopularLocationSnapshot.class))) + .thenAnswer(invocation -> { + PopularLocationSnapshot snapshot = invocation.getArgument(0); + + ReflectionTestUtils.setField(snapshot, "id", id); + + return snapshot; + }); + } + + private static PopularLocationSnapshot snapshot(Long id, Instant calculatedAt) { + return snapshot(id, calculatedAt, "UNIQUE_REPORTERS_V1"); + } + + private static PopularLocationSnapshot snapshot( + Long id, + Instant calculatedAt, + String algorithmVersion + ) { + PopularLocationSnapshot snapshot = PopularLocationSnapshot.create( + calculatedAt, + calculatedAt.minus(Duration.ofDays(7)), + calculatedAt, + 20, + algorithmVersion); + + ReflectionTestUtils.setField(snapshot, "id", id); + + return snapshot; + } + + private static PopularLocationRank rank(Long snapshotId, Long locationId, int position) { + return PopularLocationRank.create( + snapshotId, + locationId, + position, + position, + 0, + PopularRankMovement.SAME, + 1, + 1, + Instant.parse("2026-07-30T05:00:00Z")); + } + + private static PopularLocationAggregate aggregate(Long locationId, Instant calculatedAt) { + return new PopularLocationAggregate( + locationId, + 1, + 1, + calculatedAt.minusSeconds(60)); + } +} diff --git a/report/src/test/java/com/nalssilog/report/application/ReportImageCleanupListenerTest.java b/report/src/test/java/com/nalssilog/report/application/ReportImageCleanupListenerTest.java index 292811e..7a68a66 100644 --- a/report/src/test/java/com/nalssilog/report/application/ReportImageCleanupListenerTest.java +++ b/report/src/test/java/com/nalssilog/report/application/ReportImageCleanupListenerTest.java @@ -15,6 +15,7 @@ class ReportImageCleanupListenerTest { void continuesCleanupWhenDeletingOneImageFails() { ImageStorageClient imageStorageClient = mock(ImageStorageClient.class); ReportImageCleanupListener listener = new ReportImageCleanupListener(imageStorageClient); + doThrow(new IllegalStateException("R2 unavailable")) .when(imageStorageClient).delete("reports/one.jpg"); diff --git a/report/src/test/java/com/nalssilog/report/application/ReportPopularLocationSourceTest.java b/report/src/test/java/com/nalssilog/report/application/ReportPopularLocationSourceTest.java new file mode 100644 index 0000000..59fe111 --- /dev/null +++ b/report/src/test/java/com/nalssilog/report/application/ReportPopularLocationSourceTest.java @@ -0,0 +1,38 @@ +package com.nalssilog.report.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.nalssilog.location.application.dto.PopularLocationSnapshotData; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("java:S5960") +class ReportPopularLocationSourceTest { + + private final PopularLocationSnapshotService snapshotService = + mock(PopularLocationSnapshotService.class); + private final ReportPopularLocationSource source = + new ReportPopularLocationSource(snapshotService); + + @Test + void returnsLatestPersistedSnapshot() { + Instant calculatedAt = Instant.parse("2026-07-30T06:00:00Z"); + PopularLocationSnapshotData snapshot = new PopularLocationSnapshotData( + 10L, + calculatedAt, + calculatedAt.minusSeconds(60), + calculatedAt, + "UNIQUE_REPORTERS_V1", + List.of()); + + when(snapshotService.latestOrRefreshAt(any(Instant.class))).thenReturn(snapshot); + + assertThat(source.latestSnapshot()).isSameAs(snapshot); + verify(snapshotService).latestOrRefreshAt(any(Instant.class)); + } +} diff --git a/report/src/test/java/com/nalssilog/report/application/ReportServiceTest.java b/report/src/test/java/com/nalssilog/report/application/ReportServiceTest.java index 453fc3a..bb7fd3e 100644 --- a/report/src/test/java/com/nalssilog/report/application/ReportServiceTest.java +++ b/report/src/test/java/com/nalssilog/report/application/ReportServiceTest.java @@ -13,6 +13,7 @@ import com.nalssilog.common.exception.NalssiLogException; import com.nalssilog.common.response.CursorPage; import com.nalssilog.report.api.dto.ReportResponse; +import com.nalssilog.report.application.dto.AuthorInfo; import com.nalssilog.report.application.dto.LocationSummary; import com.nalssilog.report.application.dto.ReportActor; import com.nalssilog.report.application.dto.ReportData; @@ -65,6 +66,7 @@ void setUp() { @Test void memberAuthorDeletesReportAndRelatedData() { WeatherReport report = memberReport(1L); + report.addImages(List.of("reports/2026/07/one.jpg", "reports/2026/07/two.jpg")); when(reportRepository.getReportEntity(10L)).thenReturn(report); @@ -74,6 +76,7 @@ void memberAuthorDeletesReportAndRelatedData() { verify(reportRepository).delete(report); ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(ReportDeletedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); assertThat(eventCaptor.getValue().imageKeys()) .containsExactly("reports/2026/07/one.jpg", "reports/2026/07/two.jpg"); @@ -82,6 +85,7 @@ void memberAuthorDeletesReportAndRelatedData() { @Test void anonymousAuthorCanDeleteWithExistingAnonymousCookieIdentity() { WeatherReport report = anonymousReport("anonymous-key"); + when(reportRepository.getReportEntity(10L)).thenReturn(report); service.delete(10L, List.of( @@ -96,6 +100,7 @@ void anonymousAuthorCanDeleteWithExistingAnonymousCookieIdentity() { void nonAuthorCannotDeleteReport() { WeatherReport report = memberReport(1L); List actors = List.of(ReportActor.member(2L)); + when(reportRepository.getReportEntity(10L)).thenReturn(report); NalssiLogException exception = catchThrowableOfType( @@ -112,6 +117,7 @@ void nonAuthorCannotDeleteReport() { @Test void detailMarksPreLoginAnonymousReportAsMineAfterLogin() { ReportData data = anonymousData("anonymous-key"); + when(reportRepository.getReport(10L)).thenReturn(data); when(locationClient.getLocation(1L)).thenReturn(location()); @@ -127,6 +133,7 @@ void detailMarksPreLoginAnonymousReportAsMineAfterLogin() { @Test void listIncludesOwnershipCalculatedFromAllAvailableActors() { ReportData data = anonymousData("anonymous-key"); + when(reportRepository.findPage(eq(1L), isNull(), isNull(), eq(21))) .thenReturn(List.of(data)); when(locationClient.getLocation(1L)).thenReturn(location()); @@ -145,9 +152,38 @@ void listIncludesOwnershipCalculatedFromAllAvailableActors() { .isEqualTo(true); } + @Test + void listLoadsDistinctMemberAuthorsInOneBulkCall() { + ReportData first = memberData(10L, 7L); + ReportData second = memberData(11L, 8L); + AuthorInfo firstAuthor = new AuthorInfo(7L, "first", null, null); + AuthorInfo secondAuthor = new AuthorInfo(8L, "second", null, null); + + when(reportRepository.findPage(eq(1L), isNull(), isNull(), eq(21))) + .thenReturn(List.of(first, second)); + when(locationClient.getLocation(1L)).thenReturn(location()); + when(thanksRepository.countByReportIds(List.of(10L, 11L))).thenReturn(Map.of()); + when(thanksRepository.thankedReportIds(List.of(10L, 11L), null)).thenReturn(Set.of()); + when(memberClient.findActiveAuthors(List.of(7L, 8L))) + .thenReturn(Map.of(7L, firstAuthor, 8L, secondAuthor)); + + CursorPage response = service.list( + 1L, + null, + null, + List.of()); + + assertThat(response.items()) + .extracting(item -> item.author().id()) + .containsExactly("7", "8"); + verify(memberClient).findActiveAuthors(List.of(7L, 8L)); + verify(memberClient, never()).findActiveAuthor(any()); + } + @Test void statsAggregatesReportsFromTheLastThreeHours() { WeatherStatsData stats = new WeatherStatsData(0L, Map.of(), Map.of(), Map.of()); + when(locationClient.getLocation(1L)).thenReturn(location()); when(reportRepository.statsSince(eq(1L), any(Instant.class))) .thenReturn(stats); @@ -156,6 +192,7 @@ void statsAggregatesReportsFromTheLastThreeHours() { service.stats(1L); ArgumentCaptor sinceCaptor = ArgumentCaptor.forClass(Instant.class); + verify(reportRepository).statsSince(eq(1L), sinceCaptor.capture()); assertThat(sinceCaptor.getValue()) .isBetween(lowerBound, Instant.now().minusSeconds(3 * 60 * 60)); @@ -187,6 +224,22 @@ private ReportData anonymousData(String anonymousKey) { ); } + private ReportData memberData(Long reportId, Long memberId) { + return new ReportData( + reportId, + 1L, + ActorType.MEMBER, + memberId, + null, + Temperature.FRESH, + Precipitation.NONE, + Sunlight.MODERATE, + "맑아요", + List.of(), + Instant.parse("2026-07-24T00:00:00Z") + ); + } + private LocationSummary location() { return new LocationSummary( 1L, "서울특별시", "강남구", "역삼동", "서울특별시 강남구 역삼동", "강남구 역삼동"); diff --git a/report/src/test/java/com/nalssilog/report/config/ReportActorResolverTest.java b/report/src/test/java/com/nalssilog/report/config/ReportActorResolverTest.java new file mode 100644 index 0000000..6f9157a --- /dev/null +++ b/report/src/test/java/com/nalssilog/report/config/ReportActorResolverTest.java @@ -0,0 +1,49 @@ +package com.nalssilog.report.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +import com.nalssilog.common.security.VerifiedRequestCredentials; +import com.nalssilog.report.application.dto.ReportActor; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +@SuppressWarnings("java:S5960") +class ReportActorResolverTest { + + private final AnonymousIdManager anonymousIdManager = + mock(AnonymousIdManager.class); + private final ReportActorResolver resolver = + new ReportActorResolver(anonymousIdManager); + + @Test + void verifiedMobileGuestIsUsedInsteadOfTheWebCookieFallback() { + MockHttpServletRequest request = new MockHttpServletRequest(); + + VerifiedRequestCredentials.markGuest(request, "mobile-anonymous-key"); + + ReportActor actor = resolver.resolveForWrite( + null, + request, + new MockHttpServletResponse()); + + assertThat(actor) + .isEqualTo(ReportActor.anonymous("mobile-anonymous-key")); + verifyNoInteractions(anonymousIdManager); + } + + @Test + void authenticatedMemberCanRetainGuestOwnershipCandidate() { + MockHttpServletRequest request = new MockHttpServletRequest(); + + VerifiedRequestCredentials.markGuest(request, "mobile-anonymous-key"); + + assertThat(resolver.resolveForOwnership(7L, request)) + .containsExactly( + ReportActor.member(7L), + ReportActor.anonymous("mobile-anonymous-key")); + verifyNoInteractions(anonymousIdManager); + } +} diff --git a/report/src/test/java/com/nalssilog/report/repository/PopularLocationSnapshotLockRepositoryTest.java b/report/src/test/java/com/nalssilog/report/repository/PopularLocationSnapshotLockRepositoryTest.java new file mode 100644 index 0000000..8b8a1e6 --- /dev/null +++ b/report/src/test/java/com/nalssilog/report/repository/PopularLocationSnapshotLockRepositoryTest.java @@ -0,0 +1,50 @@ +package com.nalssilog.report.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.report.domain.PopularLocationSnapshotLock; +import com.nalssilog.report.domain.ReportErrorCode; +import jakarta.persistence.EntityManager; +import jakarta.persistence.LockModeType; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("java:S5960") +class PopularLocationSnapshotLockRepositoryTest { + + private final EntityManager entityManager = mock(EntityManager.class); + private final PopularLocationSnapshotLockRepository repository = + new PopularLocationSnapshotLockRepository(entityManager); + + @Test + void acquiresDatabasePessimisticWriteLock() { + PopularLocationSnapshotLock lock = + mock(PopularLocationSnapshotLock.class); + + when(entityManager.find( + PopularLocationSnapshotLock.class, + 1L, + LockModeType.PESSIMISTIC_WRITE)).thenReturn(lock); + + repository.acquire(); + + verify(entityManager).find( + PopularLocationSnapshotLock.class, + 1L, + LockModeType.PESSIMISTIC_WRITE); + } + + @Test + void failsWithDomainErrorWhenLockRowIsMissing() { + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + repository::acquire); + + assertThat(exception.getErrorCode()) + .isEqualTo(ReportErrorCode.POPULAR_SNAPSHOT_LOCK_UNAVAILABLE); + } +} diff --git a/storage/src/main/java/com/nalssilog/storage/StorageConfig.java b/storage/src/main/java/com/nalssilog/storage/StorageConfig.java index c07a76e..b4411e1 100644 --- a/storage/src/main/java/com/nalssilog/storage/StorageConfig.java +++ b/storage/src/main/java/com/nalssilog/storage/StorageConfig.java @@ -23,7 +23,7 @@ public S3Presigner s3Presigner(StorageProperties properties) { StorageProperties.R2 r2 = properties.r2(); return S3Presigner.builder() - .endpointOverride(URI.create(r2.endpoint())) + .endpointOverride(URI.create(r2.effectivePresignEndpoint())) .region(Region.of("auto")) .credentialsProvider(credentials(r2)) .serviceConfiguration(S3Configuration.builder() diff --git a/storage/src/main/java/com/nalssilog/storage/StorageProperties.java b/storage/src/main/java/com/nalssilog/storage/StorageProperties.java index 4ff3658..29ac9fc 100644 --- a/storage/src/main/java/com/nalssilog/storage/StorageProperties.java +++ b/storage/src/main/java/com/nalssilog/storage/StorageProperties.java @@ -15,6 +15,23 @@ public record StorageProperties( boolean verifyUpload ) { - public record R2(String endpoint, String accessKey, String secretKey, String bucket) { + /** + * {@code endpoint} is used by the backend for HEAD/delete calls. When the backend runs inside + * Docker, {@code presignEndpoint} can point at the LAN-visible host used in the signed URL. + * If omitted, both operations use {@code endpoint}. + */ + public record R2( + String endpoint, + String presignEndpoint, + String accessKey, + String secretKey, + String bucket + ) { + + public String effectivePresignEndpoint() { + return presignEndpoint == null || presignEndpoint.isBlank() + ? endpoint + : presignEndpoint; + } } }