diff --git a/.env.example b/.env.example index ade8d74..5b24faf 100644 --- a/.env.example +++ b/.env.example @@ -12,7 +12,6 @@ SOCIAL_RUNTIME__SQL_LOG_QUERIES=false ############ # Web 服务器配置(显式参数控制) ############ -SOCIAL_WEB__SERVER=gunicorn # uvicorn | gunicorn (新键优先于 runtime.environment) SOCIAL_WEB__HOST=0.0.0.0 SOCIAL_WEB__PORT=8000 SOCIAL_WEB__RELOAD=false @@ -115,8 +114,16 @@ SOCIAL_SUPABASE__SMTP_PORT= SOCIAL_SUPABASE__SMTP_USER= SOCIAL_SUPABASE__SMTP_PASS= SOCIAL_SUPABASE__SMTP_SENDER_NAME= -SOCIAL_SUPABASE__MAILER_SUBJECTS_CONFIRMATION=Your verification code -SOCIAL_SUPABASE__MAILER_SUBJECTS_RECOVERY=Reset your password + +####### +# Auth 邮件模板 URL(本地默认走 mail-templates 静态服务) +SOCIAL_SUPABASE__MAILER_TEMPLATES_CONFIRMATION=http://mail-templates/confirmation.html +SOCIAL_SUPABASE__MAILER_TEMPLATES_RECOVERY=http://mail-templates/recovery.html + +####### +# Auth 邮件主题(仅保留注册确认与重置密码) +SOCIAL_SUPABASE__MAILER_SUBJECTS_CONFIRMATION=请确认你的注册邮箱 +SOCIAL_SUPABASE__MAILER_SUBJECTS_RECOVERY=重置你的账户密码 SOCIAL_SUPABASE__MAILER_OTP_LENGTH=6 SOCIAL_SUPABASE__MAILER_OTP_EXP=300 diff --git a/backend/src/core/config/settings.py b/backend/src/core/config/settings.py index 92c65ee..9c0e872 100644 --- a/backend/src/core/config/settings.py +++ b/backend/src/core/config/settings.py @@ -78,66 +78,6 @@ class CelerySettings(BaseModel): task_max_retries: int = 3 -class WebSettings(BaseModel): - server: Literal["uvicorn", "gunicorn"] = "gunicorn" - host: str = "0.0.0.0" - port: int = Field(default=8000, ge=1, le=65535) - reload: bool = False - workers: int = Field(default=2, ge=1, le=64) - worker_class: str = "uvicorn.workers.UvicornWorker" - timeout: int = Field(default=60, ge=1, le=600) - keepalive: int = Field(default=5, ge=1, le=120) - log_level: Literal["debug", "info", "warning", "error", "critical"] = "info" - - -class GunicornSettings(BaseModel): - enabled_in_prod: bool = True - workers: int = 2 - worker_class: str = "uvicorn.workers.UvicornWorker" - worker_connections: int = 1000 - timeout: int = 60 - graceful_timeout: int = 30 - keepalive: int = 5 - max_requests: int = 1000 - max_requests_jitter: int = 50 - preload_app: bool = False - - -class WorkerGroupSettings(BaseModel): - concurrency: int = Field(default=2, ge=1, le=32) - pool: Literal["prefork", "threads", "solo", "eventlet", "gevent"] = "prefork" - time_limit: int = Field(default=300, ge=1, le=7200) - soft_time_limit: int = Field(default=240, ge=1, le=3600) - max_tasks_per_child: int = Field(default=200, ge=1, le=1000) - prefetch_multiplier: int = Field(default=1, ge=1, le=10) - - -class WorkerSettings(BaseModel): - groups: dict[str, WorkerGroupSettings] = Field( - default_factory=lambda: { - "critical": WorkerGroupSettings( - concurrency=2, - prefetch_multiplier=1, - time_limit=300, - ), - "default": WorkerGroupSettings( - concurrency=2, - prefetch_multiplier=4, - time_limit=600, - ), - "bulk": WorkerGroupSettings( - concurrency=1, - prefetch_multiplier=1, - time_limit=3600, - max_tasks_per_child=100, - ), - } - ) - - def get_group_config(self, group_name: str) -> WorkerGroupSettings: - return self.groups.get(group_name, WorkerGroupSettings()) - - class CorsSettings(BaseModel): allow_origins: list[str] = Field( default_factory=lambda: [ @@ -220,14 +160,11 @@ def _resolve_env_file() -> str: class Settings(BaseSettings): runtime: RuntimeSettings = RuntimeSettings() - web: WebSettings = WebSettings() - gunicorn: GunicornSettings = GunicornSettings() cors: CorsSettings = CorsSettings() redis: RedisSettings = RedisSettings() supabase: SupabaseSettings = SupabaseSettings() celery: CelerySettings = CelerySettings() database: DatabaseSettings = DatabaseSettings() - worker: WorkerSettings = WorkerSettings() @computed_field @property diff --git a/backend/src/v1/auth/gateway.py b/backend/src/v1/auth/gateway.py index a003bc3..6aa2b3e 100644 --- a/backend/src/v1/auth/gateway.py +++ b/backend/src/v1/auth/gateway.py @@ -9,12 +9,16 @@ from supabase import AuthError, create_client from core.config.settings import SupabaseSettings, config from core.logging import get_logger from v1.auth.schemas import ( + AuthResendCodeResponse, + AuthSignupStartResponse, AuthTokenResponse, AuthUser, AuthUserByEmailResponse, LoginRequest, RefreshRequest, - SignupRequest, + SignupResendRequest, + SignupStartRequest, + SignupVerifyRequest, ) from v1.auth.service import AuthServiceGateway @@ -30,22 +34,53 @@ class SupabaseAuthGateway(AuthServiceGateway): self._client = create_client(settings.url, settings.anon_key) self._admin_client = create_client(settings.url, settings.service_role_key) - async def signup(self, request: SignupRequest) -> AuthTokenResponse: + async def signup_start( + self, request: SignupStartRequest + ) -> AuthSignupStartResponse: payload: dict[str, Any] = { "email": request.email, "password": request.password, "data": {"username": request.username}, } + if request.redirect_to: + payload["options"] = {"email_redirect_to": request.redirect_to} try: sign_up = cast(Any, self._client.auth.sign_up) - response = await asyncio.to_thread(sign_up, payload) - return _map_auth_response(response, "Authentication failed") + await asyncio.to_thread(sign_up, payload) + return AuthSignupStartResponse(email=request.email) except AuthError as exc: logger.warning("Signup failed", error_type=type(exc).__name__) raise HTTPException( - status_code=401, detail="Authentication failed" + status_code=422, detail="Invalid signup request" ) from exc + async def signup_verify(self, request: SignupVerifyRequest) -> AuthTokenResponse: + payload: dict[str, Any] = { + "type": "signup", + "email": request.email, + "token": request.token, + } + try: + verify_otp = cast(Any, self._client.auth.verify_otp) + response = await asyncio.to_thread(verify_otp, payload) + return _map_auth_response(response, "Invalid verification code") + except AuthError as exc: + logger.warning("Signup verify failed", error_type=type(exc).__name__) + raise HTTPException( + status_code=401, detail="Invalid verification code" + ) from exc + + async def signup_resend( + self, request: SignupResendRequest + ) -> AuthResendCodeResponse: + payload: dict[str, Any] = {"type": "signup", "email": request.email} + try: + resend = cast(Any, self._client.auth.resend) + await asyncio.to_thread(resend, payload) + except AuthError as exc: + logger.warning("Signup resend failed", error_type=type(exc).__name__) + return AuthResendCodeResponse() + async def login(self, request: LoginRequest) -> AuthTokenResponse: payload: dict[str, Any] = {"email": request.email, "password": request.password} try: diff --git a/backend/src/v1/auth/rate_limit.py b/backend/src/v1/auth/rate_limit.py new file mode 100644 index 0000000..680c706 --- /dev/null +++ b/backend/src/v1/auth/rate_limit.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from collections import deque +from threading import Lock +from time import monotonic + +from fastapi import HTTPException + +_BUCKETS: dict[str, deque[float]] = {} +_LOCK = Lock() + + +async def enforce_rate_limit( + *, + scope: str, + identifier: str, + limit: int, + window_seconds: int, +) -> None: + _enforce_rate_limit_in_memory( + key=f"auth:rate_limit:{scope}:{identifier.lower()}", + limit=limit, + window_seconds=window_seconds, + ) + + +def _enforce_rate_limit_in_memory( + *, + key: str, + limit: int, + window_seconds: int, +) -> None: + now = monotonic() + with _LOCK: + bucket = _BUCKETS.setdefault(key, deque()) + cutoff = now - float(window_seconds) + while bucket and bucket[0] <= cutoff: + bucket.popleft() + if len(bucket) >= limit: + raise HTTPException(status_code=429, detail="Too many requests") + bucket.append(now) + + +def reset_rate_limit_state() -> None: + with _LOCK: + _BUCKETS.clear() diff --git a/backend/src/v1/auth/router.py b/backend/src/v1/auth/router.py index 05067be..ca376ab 100644 --- a/backend/src/v1/auth/router.py +++ b/backend/src/v1/auth/router.py @@ -6,15 +6,20 @@ from fastapi import APIRouter, Depends, Response from fastapi import HTTPException from core.auth.models import CurrentUser +from v1.auth.rate_limit import enforce_rate_limit from v1.auth.dependencies import get_auth_service from v1.profile.dependencies import get_current_user from v1.auth.schemas import ( + AuthResendCodeResponse, + AuthSignupStartResponse, AuthTokenResponse, AuthUserByEmailResponse, LoginRequest, LogoutRequest, RefreshRequest, - SignupRequest, + SignupResendRequest, + SignupStartRequest, + SignupVerifyRequest, ) from v1.auth.service import AuthService @@ -22,12 +27,46 @@ from v1.auth.service import AuthService router = APIRouter(prefix="/auth", tags=["auth"]) -@router.post("/signup", response_model=AuthTokenResponse) -async def signup( - payload: SignupRequest, +@router.post("/signup/start", response_model=AuthSignupStartResponse, status_code=202) +async def signup_start( + payload: SignupStartRequest, + service: AuthService = Depends(get_auth_service), +) -> AuthSignupStartResponse: + await enforce_rate_limit( + scope="signup_start", + identifier=payload.email, + limit=5, + window_seconds=60, + ) + return await service.signup_start(payload) + + +@router.post("/signup/verify", response_model=AuthTokenResponse) +async def signup_verify( + payload: SignupVerifyRequest, service: AuthService = Depends(get_auth_service), ) -> AuthTokenResponse: - return await service.signup(payload) + await enforce_rate_limit( + scope="signup_verify", + identifier=payload.email, + limit=10, + window_seconds=600, + ) + return await service.signup_verify(payload) + + +@router.post("/signup/resend", response_model=AuthResendCodeResponse) +async def signup_resend( + payload: SignupResendRequest, + service: AuthService = Depends(get_auth_service), +) -> AuthResendCodeResponse: + await enforce_rate_limit( + scope="signup_resend", + identifier=payload.email, + limit=3, + window_seconds=60, + ) + return await service.signup_resend(payload) @router.post("/login", response_model=AuthTokenResponse) diff --git a/backend/src/v1/auth/schemas.py b/backend/src/v1/auth/schemas.py index fbf37fd..1c6798e 100644 --- a/backend/src/v1/auth/schemas.py +++ b/backend/src/v1/auth/schemas.py @@ -5,13 +5,22 @@ from typing import Literal from pydantic import BaseModel, EmailStr, Field -class SignupRequest(BaseModel): +class SignupStartRequest(BaseModel): username: str = Field(min_length=3, max_length=30) email: EmailStr password: str = Field(min_length=6) redirect_to: str | None = None +class SignupVerifyRequest(BaseModel): + email: EmailStr + token: str = Field(pattern=r"^\d{6}$") + + +class SignupResendRequest(BaseModel): + email: EmailStr + + class LoginRequest(BaseModel): email: EmailStr password: str = Field(min_length=6) @@ -45,10 +54,14 @@ class AuthUserByEmailResponse(BaseModel): email_confirmed_at: str | None = None -class SignupPendingResponse(BaseModel): +class AuthSignupStartResponse(BaseModel): status: Literal["pending_verification"] = "pending_verification" - user: AuthUser - message: str = "Email confirmation required" + email: EmailStr + message: str = "Verification code sent" + + +class AuthResendCodeResponse(BaseModel): + message: str = "If the email exists, a verification code has been sent" class PasswordResetRequest(BaseModel): diff --git a/backend/src/v1/auth/service.py b/backend/src/v1/auth/service.py index 6d5a7e3..9d9840f 100644 --- a/backend/src/v1/auth/service.py +++ b/backend/src/v1/auth/service.py @@ -3,16 +3,30 @@ from __future__ import annotations from typing import Protocol from v1.auth.schemas import ( + AuthResendCodeResponse, + AuthSignupStartResponse, AuthTokenResponse, AuthUserByEmailResponse, LoginRequest, RefreshRequest, - SignupRequest, + SignupResendRequest, + SignupStartRequest, + SignupVerifyRequest, ) class AuthServiceGateway(Protocol): - async def signup(self, request: SignupRequest) -> AuthTokenResponse: + async def signup_start( + self, request: SignupStartRequest + ) -> AuthSignupStartResponse: + raise NotImplementedError + + async def signup_verify(self, request: SignupVerifyRequest) -> AuthTokenResponse: + raise NotImplementedError + + async def signup_resend( + self, request: SignupResendRequest + ) -> AuthResendCodeResponse: raise NotImplementedError async def login(self, request: LoginRequest) -> AuthTokenResponse: @@ -34,8 +48,18 @@ class AuthService: def __init__(self, gateway: AuthServiceGateway) -> None: self._gateway = gateway - async def signup(self, request: SignupRequest) -> AuthTokenResponse: - return await self._gateway.signup(request) + async def signup_start( + self, request: SignupStartRequest + ) -> AuthSignupStartResponse: + return await self._gateway.signup_start(request) + + async def signup_verify(self, request: SignupVerifyRequest) -> AuthTokenResponse: + return await self._gateway.signup_verify(request) + + async def signup_resend( + self, request: SignupResendRequest + ) -> AuthResendCodeResponse: + return await self._gateway.signup_resend(request) async def login(self, request: LoginRequest) -> AuthTokenResponse: return await self._gateway.login(request) diff --git a/backend/tests/e2e/test_auth_flow.py b/backend/tests/e2e/test_auth_flow.py index 8777d0e..b775724 100644 --- a/backend/tests/e2e/test_auth_flow.py +++ b/backend/tests/e2e/test_auth_flow.py @@ -11,11 +11,15 @@ import uvicorn from app import app from v1.auth.dependencies import get_auth_service from v1.auth.schemas import ( + AuthResendCodeResponse, + AuthSignupStartResponse, AuthTokenResponse, AuthUser, LoginRequest, RefreshRequest, - SignupRequest, + SignupResendRequest, + SignupStartRequest, + SignupVerifyRequest, ) from v1.auth.service import AuthService @@ -24,7 +28,12 @@ class FakeE2EAuthService(AuthService): def __init__(self) -> None: self._user = AuthUser(id="user-1", email="user@example.com") - async def signup(self, request: SignupRequest) -> AuthTokenResponse: + async def signup_start( + self, request: SignupStartRequest + ) -> AuthSignupStartResponse: + return AuthSignupStartResponse(email=request.email) + + async def signup_verify(self, request: SignupVerifyRequest) -> AuthTokenResponse: return AuthTokenResponse( access_token="access-1", refresh_token="refresh-1", @@ -33,6 +42,11 @@ class FakeE2EAuthService(AuthService): user=self._user, ) + async def signup_resend( + self, request: SignupResendRequest + ) -> AuthResendCodeResponse: + return AuthResendCodeResponse() + async def login(self, request: LoginRequest) -> AuthTokenResponse: return AuthTokenResponse( access_token="access-2", @@ -93,7 +107,7 @@ def test_auth_flow_e2e() -> None: ) try: signup = request_context.post( - "/api/v1/auth/signup", + "/api/v1/auth/signup/start", data=json.dumps( { "username": "demo", @@ -103,8 +117,20 @@ def test_auth_flow_e2e() -> None: ), headers={"Content-Type": "application/json"}, ) - assert signup.status == 200 - assert signup.json()["access_token"] == "access-1" + assert signup.status == 202 + + verify = request_context.post( + "/api/v1/auth/signup/verify", + data=json.dumps( + { + "email": "user@example.com", + "token": "123456", + } + ), + headers={"Content-Type": "application/json"}, + ) + assert verify.status == 200 + assert verify.json()["access_token"] == "access-1" login = request_context.post( "/api/v1/auth/login", diff --git a/backend/tests/integration/test_auth_routes.py b/backend/tests/integration/test_auth_routes.py index adaaa70..340834b 100644 --- a/backend/tests/integration/test_auth_routes.py +++ b/backend/tests/integration/test_auth_routes.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Callable from uuid import UUID +import pytest from fastapi import HTTPException from fastapi.testclient import TestClient @@ -10,24 +11,48 @@ from app import app from core.auth.models import CurrentUser from v1.auth.dependencies import get_auth_service from v1.profile.dependencies import get_current_user +from v1.auth.rate_limit import reset_rate_limit_state from v1.auth.schemas import ( + AuthResendCodeResponse, + AuthSignupStartResponse, AuthTokenResponse, AuthUserByEmailResponse, AuthUser, LoginRequest, RefreshRequest, - SignupRequest, + SignupResendRequest, + SignupStartRequest, + SignupVerifyRequest, ) from v1.auth.service import AuthService +@pytest.fixture(autouse=True) +def reset_auth_rate_limit_state() -> None: + reset_rate_limit_state() + + class FakeAuthService(AuthService): def __init__(self, token_response: AuthTokenResponse) -> None: self._token_response = token_response - async def signup(self, request: SignupRequest) -> AuthTokenResponse: + async def signup_start( + self, request: SignupStartRequest + ) -> AuthSignupStartResponse: + if request.email == "exists@example.com": + raise HTTPException(status_code=422, detail="Invalid signup request") + return AuthSignupStartResponse(email=request.email) + + async def signup_verify(self, request: SignupVerifyRequest) -> AuthTokenResponse: + if request.token == "000000": + raise HTTPException(status_code=401, detail="Invalid verification code") return self._token_response + async def signup_resend( + self, request: SignupResendRequest + ) -> AuthResendCodeResponse: + return AuthResendCodeResponse() + async def login(self, request: LoginRequest) -> AuthTokenResponse: raise HTTPException(status_code=401, detail="Invalid credentials") @@ -55,7 +80,7 @@ def _override_auth_service(service: AuthService) -> Callable[[], AuthService]: return _get_service -def test_signup_returns_token_response() -> None: +def test_signup_start_returns_pending_response() -> None: user = AuthUser(id="user-1", email="user@example.com") token_response = AuthTokenResponse( access_token="access", @@ -71,13 +96,40 @@ def test_signup_returns_token_response() -> None: client = TestClient(app) try: response = client.post( - "/api/v1/auth/signup", + "/api/v1/auth/signup/start", json={ "username": "demo", "email": "user@example.com", "password": "secret123", }, ) + assert response.status_code == 202 + body = response.json() + assert body["status"] == "pending_verification" + assert body["email"] == "user@example.com" + finally: + app.dependency_overrides = {} + + +def test_signup_verify_returns_token_response() -> None: + user = AuthUser(id="user-1", email="user@example.com") + token_response = AuthTokenResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + app.dependency_overrides[get_auth_service] = _override_auth_service( + FakeAuthService(token_response) + ) + + client = TestClient(app) + try: + response = client.post( + "/api/v1/auth/signup/verify", + json={"email": "user@example.com", "token": "123456"}, + ) assert response.status_code == 200 body = response.json() assert body["access_token"] == "access" @@ -87,6 +139,200 @@ def test_signup_returns_token_response() -> None: app.dependency_overrides = {} +def test_signup_resend_returns_generic_message() -> None: + user = AuthUser(id="user-1", email="user@example.com") + token_response = AuthTokenResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + app.dependency_overrides[get_auth_service] = _override_auth_service( + FakeAuthService(token_response) + ) + + client = TestClient(app) + try: + response = client.post( + "/api/v1/auth/signup/resend", + json={"email": "user@example.com"}, + ) + assert response.status_code == 200 + body = response.json() + assert ( + body["message"] == "If the email exists, a verification code has been sent" + ) + finally: + app.dependency_overrides = {} + + +def test_signup_verify_invalid_token_returns_problem_details() -> None: + user = AuthUser(id="user-1", email="user@example.com") + token_response = AuthTokenResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + app.dependency_overrides[get_auth_service] = _override_auth_service( + FakeAuthService(token_response) + ) + + client = TestClient(app) + try: + response = client.post( + "/api/v1/auth/signup/verify", + json={"email": "user@example.com", "token": "000000"}, + ) + assert response.status_code == 401 + assert response.headers["content-type"].startswith("application/problem+json") + body = response.json() + assert body["title"] == "Unauthorized" + assert body["status"] == 401 + assert body["detail"] == "Invalid verification code" + finally: + app.dependency_overrides = {} + + +def test_signup_start_existing_email_returns_problem_details() -> None: + user = AuthUser(id="user-1", email="user@example.com") + token_response = AuthTokenResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + app.dependency_overrides[get_auth_service] = _override_auth_service( + FakeAuthService(token_response) + ) + + client = TestClient(app) + try: + response = client.post( + "/api/v1/auth/signup/start", + json={ + "username": "demo", + "email": "exists@example.com", + "password": "secret123", + }, + ) + assert response.status_code == 422 + assert response.headers["content-type"].startswith("application/problem+json") + body = response.json() + assert body["title"] == "Unprocessable Content" + assert body["status"] == 422 + assert body["detail"] == "Invalid signup request" + finally: + app.dependency_overrides = {} + + +def test_signup_verify_rate_limited_after_too_many_attempts() -> None: + user = AuthUser(id="user-1", email="user@example.com") + token_response = AuthTokenResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + app.dependency_overrides[get_auth_service] = _override_auth_service( + FakeAuthService(token_response) + ) + + client = TestClient(app) + try: + for _ in range(10): + ok = client.post( + "/api/v1/auth/signup/verify", + json={"email": "user@example.com", "token": "123456"}, + ) + assert ok.status_code == 200 + + blocked = client.post( + "/api/v1/auth/signup/verify", + json={"email": "user@example.com", "token": "123456"}, + ) + assert blocked.status_code == 429 + assert blocked.headers["content-type"].startswith("application/problem+json") + finally: + app.dependency_overrides = {} + + +def test_signup_resend_rate_limited_after_too_many_attempts() -> None: + user = AuthUser(id="user-1", email="user@example.com") + token_response = AuthTokenResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + app.dependency_overrides[get_auth_service] = _override_auth_service( + FakeAuthService(token_response) + ) + + client = TestClient(app) + try: + for _ in range(3): + ok = client.post( + "/api/v1/auth/signup/resend", + json={"email": "user@example.com"}, + ) + assert ok.status_code == 200 + + blocked = client.post( + "/api/v1/auth/signup/resend", + json={"email": "user@example.com"}, + ) + assert blocked.status_code == 429 + assert blocked.headers["content-type"].startswith("application/problem+json") + finally: + app.dependency_overrides = {} + + +def test_signup_start_rate_limited_after_too_many_attempts() -> None: + user = AuthUser(id="user-1", email="user@example.com") + token_response = AuthTokenResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + app.dependency_overrides[get_auth_service] = _override_auth_service( + FakeAuthService(token_response) + ) + + client = TestClient(app) + try: + for _ in range(5): + ok = client.post( + "/api/v1/auth/signup/start", + json={ + "username": "demo", + "email": "user@example.com", + "password": "secret123", + }, + ) + assert ok.status_code == 202 + + blocked = client.post( + "/api/v1/auth/signup/start", + json={ + "username": "demo", + "email": "user@example.com", + "password": "secret123", + }, + ) + assert blocked.status_code == 429 + assert blocked.headers["content-type"].startswith("application/problem+json") + finally: + app.dependency_overrides = {} + + def test_login_invalid_returns_problem_details() -> None: user = AuthUser(id="user-1", email="user@example.com") token_response = AuthTokenResponse( @@ -170,7 +416,7 @@ def test_logout_returns_no_content() -> None: app.dependency_overrides = {} -def test_signup_validation_error_returns_problem_details() -> None: +def test_signup_start_validation_error_returns_problem_details() -> None: user = AuthUser(id="user-1", email="user@example.com") token_response = AuthTokenResponse( access_token="access", @@ -185,7 +431,7 @@ def test_signup_validation_error_returns_problem_details() -> None: client = TestClient(app) try: - response = client.post("/api/v1/auth/signup", json={}) + response = client.post("/api/v1/auth/signup/start", json={}) assert response.status_code == 422 assert response.headers["content-type"].startswith("application/problem+json") body = response.json() @@ -196,7 +442,7 @@ def test_signup_validation_error_returns_problem_details() -> None: app.dependency_overrides = {} -def test_signup_missing_username_returns_problem_details() -> None: +def test_signup_start_missing_username_returns_problem_details() -> None: user = AuthUser(id="user-1", email="user@example.com") token_response = AuthTokenResponse( access_token="access", @@ -212,7 +458,7 @@ def test_signup_missing_username_returns_problem_details() -> None: client = TestClient(app) try: response = client.post( - "/api/v1/auth/signup", + "/api/v1/auth/signup/start", json={"email": "user@example.com", "password": "secret123"}, ) assert response.status_code == 422 diff --git a/backend/tests/unit/v1/auth/test_auth_models.py b/backend/tests/unit/v1/auth/test_auth_models.py index d94827a..694beca 100644 --- a/backend/tests/unit/v1/auth/test_auth_models.py +++ b/backend/tests/unit/v1/auth/test_auth_models.py @@ -8,22 +8,34 @@ from v1.auth.schemas import ( AuthUser, LoginRequest, RefreshRequest, - SignupRequest, + SignupStartRequest, + SignupVerifyRequest, + SignupResendRequest, ) def test_signup_requires_valid_email() -> None: with pytest.raises(ValidationError): - SignupRequest(username="demo", email="not-an-email", password="secret123") + SignupStartRequest(username="demo", email="not-an-email", password="secret123") def test_signup_requires_username() -> None: with pytest.raises(ValidationError): - SignupRequest.model_validate( + SignupStartRequest.model_validate( {"email": "user@example.com", "password": "secret123"} ) +def test_signup_verify_requires_six_digit_token() -> None: + with pytest.raises(ValidationError): + SignupVerifyRequest(email="user@example.com", token="abc123") + + +def test_signup_resend_requires_valid_email() -> None: + with pytest.raises(ValidationError): + SignupResendRequest(email="invalid") + + def test_login_requires_valid_email() -> None: with pytest.raises(ValidationError): LoginRequest(email="invalid", password="secret123") diff --git a/backend/tests/unit/v1/auth/test_auth_service.py b/backend/tests/unit/v1/auth/test_auth_service.py index 1981964..76e99a8 100644 --- a/backend/tests/unit/v1/auth/test_auth_service.py +++ b/backend/tests/unit/v1/auth/test_auth_service.py @@ -5,11 +5,15 @@ import pytest import v1.auth.gateway as auth_gateway_module from v1.auth.schemas import ( AuthTokenResponse, + AuthResendCodeResponse, + AuthSignupStartResponse, AuthUserByEmailResponse, AuthUser, LoginRequest, RefreshRequest, - SignupRequest, + SignupResendRequest, + SignupStartRequest, + SignupVerifyRequest, ) from v1.auth.service import AuthService, AuthServiceGateway @@ -18,9 +22,19 @@ class FakeGateway(AuthServiceGateway): def __init__(self, response: AuthTokenResponse) -> None: self._response = response - async def signup(self, request: SignupRequest) -> AuthTokenResponse: + async def signup_start( + self, request: SignupStartRequest + ) -> AuthSignupStartResponse: + return AuthSignupStartResponse(email=request.email) + + async def signup_verify(self, request: SignupVerifyRequest) -> AuthTokenResponse: return self._response + async def signup_resend( + self, request: SignupResendRequest + ) -> AuthResendCodeResponse: + return AuthResendCodeResponse() + async def login(self, request: LoginRequest) -> AuthTokenResponse: return self._response @@ -51,8 +65,16 @@ async def test_signup_maps_response() -> None: ) service = AuthService(gateway=FakeGateway(token_response)) - result = await service.signup( - SignupRequest(username="demo", email="user@example.com", password="secret123") + start_result = await service.signup_start( + SignupStartRequest( + username="demo", email="user@example.com", password="secret123" + ) + ) + assert start_result.status == "pending_verification" + assert start_result.email == "user@example.com" + + result = await service.signup_verify( + SignupVerifyRequest(email="user@example.com", token="123456") ) assert result.access_token == "access" @@ -64,7 +86,17 @@ class LogoutAssertingGateway(AuthServiceGateway): def __init__(self, expected_refresh_token: str) -> None: self._expected_refresh_token = expected_refresh_token - async def signup(self, request: SignupRequest) -> AuthTokenResponse: + async def signup_start( + self, request: SignupStartRequest + ) -> AuthSignupStartResponse: + raise NotImplementedError + + async def signup_verify(self, request: SignupVerifyRequest) -> AuthTokenResponse: + raise NotImplementedError + + async def signup_resend( + self, request: SignupResendRequest + ) -> AuthResendCodeResponse: raise NotImplementedError async def login(self, request: LoginRequest) -> AuthTokenResponse: @@ -104,6 +136,23 @@ async def test_get_user_by_email_forwards_to_gateway() -> None: assert result.email == "user@example.com" +@pytest.mark.asyncio +async def test_signup_resend_returns_generic_message() -> None: + user = AuthUser(id="user-1", email="user@example.com") + token_response = AuthTokenResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + service = AuthService(gateway=FakeGateway(token_response)) + + result = await service.signup_resend(SignupResendRequest(email="user@example.com")) + + assert result.message == "If the email exists, a verification code has been sent" + + @pytest.mark.asyncio async def test_supabase_signup_passes_username_in_metadata( monkeypatch: pytest.MonkeyPatch, @@ -126,7 +175,7 @@ async def test_supabase_signup_passes_username_in_metadata( class _Response: user = _User() - session = _Session() + session = None return _Response() @@ -136,8 +185,8 @@ async def test_supabase_signup_passes_username_in_metadata( monkeypatch.setattr(auth_gateway_module, "create_client", lambda *_: FakeClient()) gateway = auth_gateway_module.SupabaseAuthGateway() - await gateway.signup( - SignupRequest( + await gateway.signup_start( + SignupStartRequest( username="demo", email="user@example.com", password="secret123", diff --git a/docs/plans/2026-02-25-auth-signup-otp-implementation.md b/docs/plans/2026-02-25-auth-signup-otp-implementation.md new file mode 100644 index 0000000..edd421d --- /dev/null +++ b/docs/plans/2026-02-25-auth-signup-otp-implementation.md @@ -0,0 +1,85 @@ +# Auth Signup OTP Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 将注册流程改为两阶段 OTP(start/verify/resend),并移除旧 `/auth/signup` 路由。 + +**Architecture:** 后端继续作为 Supabase Auth 的薄封装层。`signup/start` 只创建待验证用户并触发验证码邮件;`signup/verify` 通过 `verifyOtp(type=signup)` 完成验证并返回 token;`signup/resend` 负责重发验证码。保留现有 token 响应模型,最小化客户端和网关改造。 + +**Tech Stack:** FastAPI, Pydantic, supabase-py, pytest + +--- + +### Task 1: 更新认证 Schema + +**Files:** +- Modify: `backend/src/v1/auth/schemas.py` +- Test: `backend/tests/unit/v1/auth/test_auth_models.py` + +**Step 1: Write the failing test** +- 为 `SignupStartRequest`、`SignupVerifyRequest`、`SignupResendRequest` 增加字段校验测试。 + +**Step 2: Run test to verify it fails** +- Run: `PYTHONPATH=backend/src uv run python -m pytest backend/tests/unit/v1/auth/test_auth_models.py -q` + +**Step 3: Write minimal implementation** +- 新增 start/verify/resend 的请求与响应模型。 + +**Step 4: Run test to verify it passes** +- Run: `PYTHONPATH=backend/src uv run python -m pytest backend/tests/unit/v1/auth/test_auth_models.py -q` + +### Task 2: 改造 Service/Gateway 为三阶段 OTP + +**Files:** +- Modify: `backend/src/v1/auth/service.py` +- Modify: `backend/src/v1/auth/gateway.py` +- Test: `backend/tests/unit/v1/auth/test_auth_service.py` + +**Step 1: Write the failing test** +- 为 `signup_start/signup_verify/signup_resend` 增加 service 转发与 gateway Supabase 调用行为测试。 + +**Step 2: Run test to verify it fails** +- Run: `PYTHONPATH=backend/src uv run python -m pytest backend/tests/unit/v1/auth/test_auth_service.py -q` + +**Step 3: Write minimal implementation** +- 删除旧 `signup` 入口,新增三个方法。 +- `signup_verify` 使用 `verify_otp` 并返回 `AuthTokenResponse`。 +- `signup_resend` 调用 `resend(type=signup)` 并返回通用消息。 + +**Step 4: Run test to verify it passes** +- Run: `PYTHONPATH=backend/src uv run python -m pytest backend/tests/unit/v1/auth/test_auth_service.py -q` + +### Task 3: 替换 Router 路由并删除旧 signup + +**Files:** +- Modify: `backend/src/v1/auth/router.py` +- Test: `backend/tests/integration/test_auth_routes.py` +- Test: `backend/tests/e2e/test_auth_flow.py` + +**Step 1: Write the failing test** +- 集成测试改为 `/auth/signup/start`、`/auth/signup/verify`、`/auth/signup/resend`。 +- 删除对旧 `/auth/signup` 的断言。 + +**Step 2: Run test to verify it fails** +- Run: `PYTHONPATH=backend/src uv run python -m pytest backend/tests/integration/test_auth_routes.py -q` + +**Step 3: Write minimal implementation** +- Router 新增三条路由并移除旧 `/signup`。 +- 保持 RFC7807 错误映射行为。 + +**Step 4: Run test to verify it passes** +- Run: `PYTHONPATH=backend/src uv run python -m pytest backend/tests/integration/test_auth_routes.py -q` + +### Task 4: 全量验证 + +**Files:** +- Test: `backend/tests/unit/v1/auth/test_auth_models.py` +- Test: `backend/tests/unit/v1/auth/test_auth_service.py` +- Test: `backend/tests/integration/test_auth_routes.py` +- Test: `backend/tests/e2e/test_auth_flow.py` + +**Step 1: Run focused suite** +- Run: `PYTHONPATH=backend/src uv run python -m pytest backend/tests/unit/v1/auth backend/tests/integration/test_auth_routes.py backend/tests/e2e/test_auth_flow.py -q` + +**Step 2: Report evidence** +- 记录通过/失败数量与关键行为验证结果。 diff --git a/docs/runtime/runtime-runbook.md b/docs/runtime/runtime-runbook.md index 7f1bfe9..e421472 100644 --- a/docs/runtime/runtime-runbook.md +++ b/docs/runtime/runtime-runbook.md @@ -102,7 +102,6 @@ tmux kill-session -t social-dev | 环境变量 | 说明 | 默认值 | 有效范围 | |----------|------|--------|----------| -| `SOCIAL_WEB__SERVER` | Web 服务器类型 | gunicorn | uvicorn/gunicorn | | `SOCIAL_WEB__HOST` | 监听地址 | 0.0.0.0 | - | | `SOCIAL_WEB__PORT` | 监听端口 | 8000 | 1-65535 | | `SOCIAL_WEB__RELOAD` | 开发模式热重载 | false | true/false | @@ -143,11 +142,24 @@ docker compose --env-file .env -f infra/docker/docker-compose.yml --profile job ## Auth/Profile 验证 ```bash -# signup: username + email + password -curl -sS -X POST http://127.0.0.1:8000/api/v1/auth/signup \ +# 注意:默认模板地址 http://mail-templates/* 仅在 Docker Compose 内网可用。 +# 生产环境请替换为 gotrue 可访问的模板 URL。 + +# signup start: username + email + password(发送验证码) +curl -sS -X POST http://127.0.0.1:8000/api/v1/auth/signup/start \ -H 'Content-Type: application/json' \ -d '{"username":"demo","email":"demo@example.com","password":"secret123"}' +# signup verify: email + token(6位验证码) +curl -sS -X POST http://127.0.0.1:8000/api/v1/auth/signup/verify \ + -H 'Content-Type: application/json' \ + -d '{"email":"demo@example.com","token":"123456"}' + +# signup resend: email(重发验证码) +curl -sS -X POST http://127.0.0.1:8000/api/v1/auth/signup/resend \ + -H 'Content-Type: application/json' \ + -d '{"email":"demo@example.com"}' + # login: email + password curl -sS -X POST http://127.0.0.1:8000/api/v1/auth/login \ -H 'Content-Type: application/json' \ @@ -174,3 +186,5 @@ curl -sS -X PATCH http://127.0.0.1:8000/api/v1/profile/me \ | 2026-02-24 | 开发阶段 compose 暂不编排 web/worker,仅保留 redis/supabase 与 init-job | | 2026-02-24 | 新增 dev-app-up 脚本:手动基础设施后,一键 bootstrap + tmux 拉起 web/worker | | 2026-02-25 | 补充迁移防遗漏规则:容器迁移命令统一追加 --build;开发调试优先使用本地 CLI 一次性迁移脚本 | +| 2026-02-25 | Auth 注册切换为 OTP 三段式:signup/start、signup/verify、signup/resend;邮件模板改为纯验证码展示 | +| 2026-02-25 | 清理未使用配置类:删除 WebSettings/GunicornSettings/WorkerSettings/WorkerGroupSettings(脚本仍使用环境变量启动服务) | diff --git a/infra/docker/docker-compose.yml b/infra/docker/docker-compose.yml index 7e32552..dd85f68 100644 --- a/infra/docker/docker-compose.yml +++ b/infra/docker/docker-compose.yml @@ -80,6 +80,18 @@ services: DASHBOARD_PASSWORD: ${SOCIAL_SUPABASE__DASHBOARD_PASSWORD} entrypoint: bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && /docker-entrypoint.sh kong docker-start' + mail-templates: + container_name: supabase-mail-templates + image: nginx:1.27-alpine + restart: unless-stopped + volumes: + - ../mail-templates:/usr/share/nginx/html:ro + healthcheck: + test: ["CMD", "sh", "-c", "wget --no-verbose --tries=1 --spider http://localhost/confirmation.html && wget --no-verbose --tries=1 --spider http://localhost/recovery.html"] + timeout: 5s + interval: 10s + retries: 3 + auth: container_name: supabase-auth image: supabase/gotrue:v2.184.0 @@ -94,6 +106,8 @@ services: condition: service_healthy analytics: condition: service_healthy + mail-templates: + condition: service_healthy environment: GOTRUE_API_HOST: 0.0.0.0 GOTRUE_API_PORT: 9999 @@ -110,7 +124,7 @@ services: GOTRUE_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET} GOTRUE_EXTERNAL_EMAIL_ENABLED: ${SOCIAL_SUPABASE__ENABLE_EMAIL_SIGNUP:-true} GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${SOCIAL_SUPABASE__ENABLE_ANONYMOUS_USERS:-false} - GOTRUE_MAILER_AUTOCONFIRM: ${SOCIAL_SUPABASE__ENABLE_EMAIL_AUTOCONFIRM:-true} + GOTRUE_MAILER_AUTOCONFIRM: ${SOCIAL_SUPABASE__ENABLE_EMAIL_AUTOCONFIRM:-false} GOTRUE_SMTP_ADMIN_EMAIL: ${SOCIAL_SUPABASE__SMTP_ADMIN_EMAIL:-} GOTRUE_SMTP_HOST: ${SOCIAL_SUPABASE__SMTP_HOST:-} GOTRUE_SMTP_PORT: ${SOCIAL_SUPABASE__SMTP_PORT:-} @@ -121,6 +135,12 @@ services: GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${SOCIAL_SUPABASE__MAILER_URLPATHS_CONFIRMATION:-/auth/v1/verify} GOTRUE_MAILER_URLPATHS_RECOVERY: ${SOCIAL_SUPABASE__MAILER_URLPATHS_RECOVERY:-/auth/v1/recover} GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${SOCIAL_SUPABASE__MAILER_URLPATHS_EMAIL_CHANGE:-/auth/v1/verify} + GOTRUE_MAILER_TEMPLATES_CONFIRMATION: ${SOCIAL_SUPABASE__MAILER_TEMPLATES_CONFIRMATION:-} + GOTRUE_MAILER_TEMPLATES_RECOVERY: ${SOCIAL_SUPABASE__MAILER_TEMPLATES_RECOVERY:-} + GOTRUE_MAILER_SUBJECTS_CONFIRMATION: ${SOCIAL_SUPABASE__MAILER_SUBJECTS_CONFIRMATION:-} + GOTRUE_MAILER_SUBJECTS_RECOVERY: ${SOCIAL_SUPABASE__MAILER_SUBJECTS_RECOVERY:-} + GOTRUE_MAILER_OTP_LENGTH: ${SOCIAL_SUPABASE__MAILER_OTP_LENGTH:-6} + GOTRUE_MAILER_OTP_EXP: ${SOCIAL_SUPABASE__MAILER_OTP_EXP:-300} GOTRUE_EXTERNAL_PHONE_ENABLED: ${SOCIAL_SUPABASE__ENABLE_PHONE_SIGNUP:-false} GOTRUE_SMS_AUTOCONFIRM: ${SOCIAL_SUPABASE__ENABLE_PHONE_AUTOCONFIRM:-false} diff --git a/infra/mail-templates/confirmation.html b/infra/mail-templates/confirmation.html new file mode 100644 index 0000000..5af7bb0 --- /dev/null +++ b/infra/mail-templates/confirmation.html @@ -0,0 +1,21 @@ + + + + + + 确认邮箱 + + + + + + +
+

请确认你的邮箱

+

你好,{{ .Email }}:

+

请输入以下 6 位验证码完成注册:

+

{{ .Token }}

+

验证码有效期较短,请尽快完成验证。

+
+ + diff --git a/infra/mail-templates/recovery.html b/infra/mail-templates/recovery.html new file mode 100644 index 0000000..ce4d0a4 --- /dev/null +++ b/infra/mail-templates/recovery.html @@ -0,0 +1,21 @@ + + + + + + 重置密码 + + + + + + +
+

重置你的账户密码

+

你好,{{ .Email }}:

+

如果你使用验证码方式,请输入以下 6 位验证码:

+

{{ .Token }}

+

验证码有效期较短,请尽快完成重置流程。

+
+ +