feat(auth): switch signup to OTP verification flow

Replace legacy signup with start/verify/resend endpoints, add OTP-focused mail templates and auth rate limits, and align compose/env/runbook for local self-hosted Supabase OTP behavior.
This commit is contained in:
qzl
2026-02-25 13:34:02 +08:00
parent 02e5e52e1f
commit 1cc8fa1abf
16 changed files with 707 additions and 112 deletions
+10 -3
View File
@@ -12,7 +12,6 @@ SOCIAL_RUNTIME__SQL_LOG_QUERIES=false
############ ############
# Web 服务器配置(显式参数控制) # Web 服务器配置(显式参数控制)
############ ############
SOCIAL_WEB__SERVER=gunicorn # uvicorn | gunicorn (新键优先于 runtime.environment)
SOCIAL_WEB__HOST=0.0.0.0 SOCIAL_WEB__HOST=0.0.0.0
SOCIAL_WEB__PORT=8000 SOCIAL_WEB__PORT=8000
SOCIAL_WEB__RELOAD=false SOCIAL_WEB__RELOAD=false
@@ -115,8 +114,16 @@ SOCIAL_SUPABASE__SMTP_PORT=
SOCIAL_SUPABASE__SMTP_USER= SOCIAL_SUPABASE__SMTP_USER=
SOCIAL_SUPABASE__SMTP_PASS= SOCIAL_SUPABASE__SMTP_PASS=
SOCIAL_SUPABASE__SMTP_SENDER_NAME= 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_LENGTH=6
SOCIAL_SUPABASE__MAILER_OTP_EXP=300 SOCIAL_SUPABASE__MAILER_OTP_EXP=300
-63
View File
@@ -78,66 +78,6 @@ class CelerySettings(BaseModel):
task_max_retries: int = 3 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): class CorsSettings(BaseModel):
allow_origins: list[str] = Field( allow_origins: list[str] = Field(
default_factory=lambda: [ default_factory=lambda: [
@@ -220,14 +160,11 @@ def _resolve_env_file() -> str:
class Settings(BaseSettings): class Settings(BaseSettings):
runtime: RuntimeSettings = RuntimeSettings() runtime: RuntimeSettings = RuntimeSettings()
web: WebSettings = WebSettings()
gunicorn: GunicornSettings = GunicornSettings()
cors: CorsSettings = CorsSettings() cors: CorsSettings = CorsSettings()
redis: RedisSettings = RedisSettings() redis: RedisSettings = RedisSettings()
supabase: SupabaseSettings = SupabaseSettings() supabase: SupabaseSettings = SupabaseSettings()
celery: CelerySettings = CelerySettings() celery: CelerySettings = CelerySettings()
database: DatabaseSettings = DatabaseSettings() database: DatabaseSettings = DatabaseSettings()
worker: WorkerSettings = WorkerSettings()
@computed_field @computed_field
@property @property
+40 -5
View File
@@ -9,12 +9,16 @@ from supabase import AuthError, create_client
from core.config.settings import SupabaseSettings, config from core.config.settings import SupabaseSettings, config
from core.logging import get_logger from core.logging import get_logger
from v1.auth.schemas import ( from v1.auth.schemas import (
AuthResendCodeResponse,
AuthSignupStartResponse,
AuthTokenResponse, AuthTokenResponse,
AuthUser, AuthUser,
AuthUserByEmailResponse, AuthUserByEmailResponse,
LoginRequest, LoginRequest,
RefreshRequest, RefreshRequest,
SignupRequest, SignupResendRequest,
SignupStartRequest,
SignupVerifyRequest,
) )
from v1.auth.service import AuthServiceGateway from v1.auth.service import AuthServiceGateway
@@ -30,22 +34,53 @@ class SupabaseAuthGateway(AuthServiceGateway):
self._client = create_client(settings.url, settings.anon_key) self._client = create_client(settings.url, settings.anon_key)
self._admin_client = create_client(settings.url, settings.service_role_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] = { payload: dict[str, Any] = {
"email": request.email, "email": request.email,
"password": request.password, "password": request.password,
"data": {"username": request.username}, "data": {"username": request.username},
} }
if request.redirect_to:
payload["options"] = {"email_redirect_to": request.redirect_to}
try: try:
sign_up = cast(Any, self._client.auth.sign_up) sign_up = cast(Any, self._client.auth.sign_up)
response = await asyncio.to_thread(sign_up, payload) await asyncio.to_thread(sign_up, payload)
return _map_auth_response(response, "Authentication failed") return AuthSignupStartResponse(email=request.email)
except AuthError as exc: except AuthError as exc:
logger.warning("Signup failed", error_type=type(exc).__name__) logger.warning("Signup failed", error_type=type(exc).__name__)
raise HTTPException( raise HTTPException(
status_code=401, detail="Authentication failed" status_code=422, detail="Invalid signup request"
) from exc ) 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: async def login(self, request: LoginRequest) -> AuthTokenResponse:
payload: dict[str, Any] = {"email": request.email, "password": request.password} payload: dict[str, Any] = {"email": request.email, "password": request.password}
try: try:
+46
View File
@@ -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()
+44 -5
View File
@@ -6,15 +6,20 @@ from fastapi import APIRouter, Depends, Response
from fastapi import HTTPException from fastapi import HTTPException
from core.auth.models import CurrentUser 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.auth.dependencies import get_auth_service
from v1.profile.dependencies import get_current_user from v1.profile.dependencies import get_current_user
from v1.auth.schemas import ( from v1.auth.schemas import (
AuthResendCodeResponse,
AuthSignupStartResponse,
AuthTokenResponse, AuthTokenResponse,
AuthUserByEmailResponse, AuthUserByEmailResponse,
LoginRequest, LoginRequest,
LogoutRequest, LogoutRequest,
RefreshRequest, RefreshRequest,
SignupRequest, SignupResendRequest,
SignupStartRequest,
SignupVerifyRequest,
) )
from v1.auth.service import AuthService from v1.auth.service import AuthService
@@ -22,12 +27,46 @@ from v1.auth.service import AuthService
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/signup", response_model=AuthTokenResponse) @router.post("/signup/start", response_model=AuthSignupStartResponse, status_code=202)
async def signup( async def signup_start(
payload: SignupRequest, 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), service: AuthService = Depends(get_auth_service),
) -> AuthTokenResponse: ) -> 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) @router.post("/login", response_model=AuthTokenResponse)
+17 -4
View File
@@ -5,13 +5,22 @@ from typing import Literal
from pydantic import BaseModel, EmailStr, Field from pydantic import BaseModel, EmailStr, Field
class SignupRequest(BaseModel): class SignupStartRequest(BaseModel):
username: str = Field(min_length=3, max_length=30) username: str = Field(min_length=3, max_length=30)
email: EmailStr email: EmailStr
password: str = Field(min_length=6) password: str = Field(min_length=6)
redirect_to: str | None = None 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): class LoginRequest(BaseModel):
email: EmailStr email: EmailStr
password: str = Field(min_length=6) password: str = Field(min_length=6)
@@ -45,10 +54,14 @@ class AuthUserByEmailResponse(BaseModel):
email_confirmed_at: str | None = None email_confirmed_at: str | None = None
class SignupPendingResponse(BaseModel): class AuthSignupStartResponse(BaseModel):
status: Literal["pending_verification"] = "pending_verification" status: Literal["pending_verification"] = "pending_verification"
user: AuthUser email: EmailStr
message: str = "Email confirmation required" message: str = "Verification code sent"
class AuthResendCodeResponse(BaseModel):
message: str = "If the email exists, a verification code has been sent"
class PasswordResetRequest(BaseModel): class PasswordResetRequest(BaseModel):
+28 -4
View File
@@ -3,16 +3,30 @@ from __future__ import annotations
from typing import Protocol from typing import Protocol
from v1.auth.schemas import ( from v1.auth.schemas import (
AuthResendCodeResponse,
AuthSignupStartResponse,
AuthTokenResponse, AuthTokenResponse,
AuthUserByEmailResponse, AuthUserByEmailResponse,
LoginRequest, LoginRequest,
RefreshRequest, RefreshRequest,
SignupRequest, SignupResendRequest,
SignupStartRequest,
SignupVerifyRequest,
) )
class AuthServiceGateway(Protocol): 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 raise NotImplementedError
async def login(self, request: LoginRequest) -> AuthTokenResponse: async def login(self, request: LoginRequest) -> AuthTokenResponse:
@@ -34,8 +48,18 @@ class AuthService:
def __init__(self, gateway: AuthServiceGateway) -> None: def __init__(self, gateway: AuthServiceGateway) -> None:
self._gateway = gateway self._gateway = gateway
async def signup(self, request: SignupRequest) -> AuthTokenResponse: async def signup_start(
return await self._gateway.signup(request) 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: async def login(self, request: LoginRequest) -> AuthTokenResponse:
return await self._gateway.login(request) return await self._gateway.login(request)
+31 -5
View File
@@ -11,11 +11,15 @@ import uvicorn
from app import app from app import app
from v1.auth.dependencies import get_auth_service from v1.auth.dependencies import get_auth_service
from v1.auth.schemas import ( from v1.auth.schemas import (
AuthResendCodeResponse,
AuthSignupStartResponse,
AuthTokenResponse, AuthTokenResponse,
AuthUser, AuthUser,
LoginRequest, LoginRequest,
RefreshRequest, RefreshRequest,
SignupRequest, SignupResendRequest,
SignupStartRequest,
SignupVerifyRequest,
) )
from v1.auth.service import AuthService from v1.auth.service import AuthService
@@ -24,7 +28,12 @@ class FakeE2EAuthService(AuthService):
def __init__(self) -> None: def __init__(self) -> None:
self._user = AuthUser(id="user-1", email="user@example.com") 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( return AuthTokenResponse(
access_token="access-1", access_token="access-1",
refresh_token="refresh-1", refresh_token="refresh-1",
@@ -33,6 +42,11 @@ class FakeE2EAuthService(AuthService):
user=self._user, user=self._user,
) )
async def signup_resend(
self, request: SignupResendRequest
) -> AuthResendCodeResponse:
return AuthResendCodeResponse()
async def login(self, request: LoginRequest) -> AuthTokenResponse: async def login(self, request: LoginRequest) -> AuthTokenResponse:
return AuthTokenResponse( return AuthTokenResponse(
access_token="access-2", access_token="access-2",
@@ -93,7 +107,7 @@ def test_auth_flow_e2e() -> None:
) )
try: try:
signup = request_context.post( signup = request_context.post(
"/api/v1/auth/signup", "/api/v1/auth/signup/start",
data=json.dumps( data=json.dumps(
{ {
"username": "demo", "username": "demo",
@@ -103,8 +117,20 @@ def test_auth_flow_e2e() -> None:
), ),
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"},
) )
assert signup.status == 200 assert signup.status == 202
assert signup.json()["access_token"] == "access-1"
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( login = request_context.post(
"/api/v1/auth/login", "/api/v1/auth/login",
+254 -8
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from typing import Callable from typing import Callable
from uuid import UUID from uuid import UUID
import pytest
from fastapi import HTTPException from fastapi import HTTPException
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -10,24 +11,48 @@ from app import app
from core.auth.models import CurrentUser from core.auth.models import CurrentUser
from v1.auth.dependencies import get_auth_service from v1.auth.dependencies import get_auth_service
from v1.profile.dependencies import get_current_user from v1.profile.dependencies import get_current_user
from v1.auth.rate_limit import reset_rate_limit_state
from v1.auth.schemas import ( from v1.auth.schemas import (
AuthResendCodeResponse,
AuthSignupStartResponse,
AuthTokenResponse, AuthTokenResponse,
AuthUserByEmailResponse, AuthUserByEmailResponse,
AuthUser, AuthUser,
LoginRequest, LoginRequest,
RefreshRequest, RefreshRequest,
SignupRequest, SignupResendRequest,
SignupStartRequest,
SignupVerifyRequest,
) )
from v1.auth.service import AuthService from v1.auth.service import AuthService
@pytest.fixture(autouse=True)
def reset_auth_rate_limit_state() -> None:
reset_rate_limit_state()
class FakeAuthService(AuthService): class FakeAuthService(AuthService):
def __init__(self, token_response: AuthTokenResponse) -> None: def __init__(self, token_response: AuthTokenResponse) -> None:
self._token_response = token_response 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 return self._token_response
async def signup_resend(
self, request: SignupResendRequest
) -> AuthResendCodeResponse:
return AuthResendCodeResponse()
async def login(self, request: LoginRequest) -> AuthTokenResponse: async def login(self, request: LoginRequest) -> AuthTokenResponse:
raise HTTPException(status_code=401, detail="Invalid credentials") raise HTTPException(status_code=401, detail="Invalid credentials")
@@ -55,7 +80,7 @@ def _override_auth_service(service: AuthService) -> Callable[[], AuthService]:
return _get_service 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") user = AuthUser(id="user-1", email="user@example.com")
token_response = AuthTokenResponse( token_response = AuthTokenResponse(
access_token="access", access_token="access",
@@ -71,13 +96,40 @@ def test_signup_returns_token_response() -> None:
client = TestClient(app) client = TestClient(app)
try: try:
response = client.post( response = client.post(
"/api/v1/auth/signup", "/api/v1/auth/signup/start",
json={ json={
"username": "demo", "username": "demo",
"email": "user@example.com", "email": "user@example.com",
"password": "secret123", "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 assert response.status_code == 200
body = response.json() body = response.json()
assert body["access_token"] == "access" assert body["access_token"] == "access"
@@ -87,6 +139,200 @@ def test_signup_returns_token_response() -> None:
app.dependency_overrides = {} 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: def test_login_invalid_returns_problem_details() -> None:
user = AuthUser(id="user-1", email="user@example.com") user = AuthUser(id="user-1", email="user@example.com")
token_response = AuthTokenResponse( token_response = AuthTokenResponse(
@@ -170,7 +416,7 @@ def test_logout_returns_no_content() -> None:
app.dependency_overrides = {} 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") user = AuthUser(id="user-1", email="user@example.com")
token_response = AuthTokenResponse( token_response = AuthTokenResponse(
access_token="access", access_token="access",
@@ -185,7 +431,7 @@ def test_signup_validation_error_returns_problem_details() -> None:
client = TestClient(app) client = TestClient(app)
try: 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.status_code == 422
assert response.headers["content-type"].startswith("application/problem+json") assert response.headers["content-type"].startswith("application/problem+json")
body = response.json() body = response.json()
@@ -196,7 +442,7 @@ def test_signup_validation_error_returns_problem_details() -> None:
app.dependency_overrides = {} 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") user = AuthUser(id="user-1", email="user@example.com")
token_response = AuthTokenResponse( token_response = AuthTokenResponse(
access_token="access", access_token="access",
@@ -212,7 +458,7 @@ def test_signup_missing_username_returns_problem_details() -> None:
client = TestClient(app) client = TestClient(app)
try: try:
response = client.post( response = client.post(
"/api/v1/auth/signup", "/api/v1/auth/signup/start",
json={"email": "user@example.com", "password": "secret123"}, json={"email": "user@example.com", "password": "secret123"},
) )
assert response.status_code == 422 assert response.status_code == 422
+15 -3
View File
@@ -8,22 +8,34 @@ from v1.auth.schemas import (
AuthUser, AuthUser,
LoginRequest, LoginRequest,
RefreshRequest, RefreshRequest,
SignupRequest, SignupStartRequest,
SignupVerifyRequest,
SignupResendRequest,
) )
def test_signup_requires_valid_email() -> None: def test_signup_requires_valid_email() -> None:
with pytest.raises(ValidationError): 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: def test_signup_requires_username() -> None:
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
SignupRequest.model_validate( SignupStartRequest.model_validate(
{"email": "user@example.com", "password": "secret123"} {"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: def test_login_requires_valid_email() -> None:
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
LoginRequest(email="invalid", password="secret123") LoginRequest(email="invalid", password="secret123")
@@ -5,11 +5,15 @@ import pytest
import v1.auth.gateway as auth_gateway_module import v1.auth.gateway as auth_gateway_module
from v1.auth.schemas import ( from v1.auth.schemas import (
AuthTokenResponse, AuthTokenResponse,
AuthResendCodeResponse,
AuthSignupStartResponse,
AuthUserByEmailResponse, AuthUserByEmailResponse,
AuthUser, AuthUser,
LoginRequest, LoginRequest,
RefreshRequest, RefreshRequest,
SignupRequest, SignupResendRequest,
SignupStartRequest,
SignupVerifyRequest,
) )
from v1.auth.service import AuthService, AuthServiceGateway from v1.auth.service import AuthService, AuthServiceGateway
@@ -18,9 +22,19 @@ class FakeGateway(AuthServiceGateway):
def __init__(self, response: AuthTokenResponse) -> None: def __init__(self, response: AuthTokenResponse) -> None:
self._response = response 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 return self._response
async def signup_resend(
self, request: SignupResendRequest
) -> AuthResendCodeResponse:
return AuthResendCodeResponse()
async def login(self, request: LoginRequest) -> AuthTokenResponse: async def login(self, request: LoginRequest) -> AuthTokenResponse:
return self._response return self._response
@@ -51,8 +65,16 @@ async def test_signup_maps_response() -> None:
) )
service = AuthService(gateway=FakeGateway(token_response)) service = AuthService(gateway=FakeGateway(token_response))
result = await service.signup( start_result = await service.signup_start(
SignupRequest(username="demo", email="user@example.com", password="secret123") 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" assert result.access_token == "access"
@@ -64,7 +86,17 @@ class LogoutAssertingGateway(AuthServiceGateway):
def __init__(self, expected_refresh_token: str) -> None: def __init__(self, expected_refresh_token: str) -> None:
self._expected_refresh_token = expected_refresh_token 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 raise NotImplementedError
async def login(self, request: LoginRequest) -> AuthTokenResponse: 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" 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 @pytest.mark.asyncio
async def test_supabase_signup_passes_username_in_metadata( async def test_supabase_signup_passes_username_in_metadata(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
@@ -126,7 +175,7 @@ async def test_supabase_signup_passes_username_in_metadata(
class _Response: class _Response:
user = _User() user = _User()
session = _Session() session = None
return _Response() return _Response()
@@ -136,8 +185,8 @@ async def test_supabase_signup_passes_username_in_metadata(
monkeypatch.setattr(auth_gateway_module, "create_client", lambda *_: FakeClient()) monkeypatch.setattr(auth_gateway_module, "create_client", lambda *_: FakeClient())
gateway = auth_gateway_module.SupabaseAuthGateway() gateway = auth_gateway_module.SupabaseAuthGateway()
await gateway.signup( await gateway.signup_start(
SignupRequest( SignupStartRequest(
username="demo", username="demo",
email="user@example.com", email="user@example.com",
password="secret123", password="secret123",
@@ -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:** 将注册流程改为两阶段 OTPstart/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**
- 记录通过/失败数量与关键行为验证结果。
+17 -3
View File
@@ -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__HOST` | 监听地址 | 0.0.0.0 | - |
| `SOCIAL_WEB__PORT` | 监听端口 | 8000 | 1-65535 | | `SOCIAL_WEB__PORT` | 监听端口 | 8000 | 1-65535 |
| `SOCIAL_WEB__RELOAD` | 开发模式热重载 | false | true/false | | `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 验证 ## Auth/Profile 验证
```bash ```bash
# signup: username + email + password # 注意:默认模板地址 http://mail-templates/* 仅在 Docker Compose 内网可用。
curl -sS -X POST http://127.0.0.1:8000/api/v1/auth/signup \ # 生产环境请替换为 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' \ -H 'Content-Type: application/json' \
-d '{"username":"demo","email":"demo@example.com","password":"secret123"}' -d '{"username":"demo","email":"demo@example.com","password":"secret123"}'
# signup verify: email + token6位验证码)
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 # login: email + password
curl -sS -X POST http://127.0.0.1:8000/api/v1/auth/login \ curl -sS -X POST http://127.0.0.1:8000/api/v1/auth/login \
-H 'Content-Type: application/json' \ -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 | 开发阶段 compose 暂不编排 web/worker,仅保留 redis/supabase 与 init-job |
| 2026-02-24 | 新增 dev-app-up 脚本:手动基础设施后,一键 bootstrap + tmux 拉起 web/worker | | 2026-02-24 | 新增 dev-app-up 脚本:手动基础设施后,一键 bootstrap + tmux 拉起 web/worker |
| 2026-02-25 | 补充迁移防遗漏规则:容器迁移命令统一追加 --build;开发调试优先使用本地 CLI 一次性迁移脚本 | | 2026-02-25 | 补充迁移防遗漏规则:容器迁移命令统一追加 --build;开发调试优先使用本地 CLI 一次性迁移脚本 |
| 2026-02-25 | Auth 注册切换为 OTP 三段式:signup/start、signup/verify、signup/resend;邮件模板改为纯验证码展示 |
| 2026-02-25 | 清理未使用配置类:删除 WebSettings/GunicornSettings/WorkerSettings/WorkerGroupSettings(脚本仍使用环境变量启动服务) |
+21 -1
View File
@@ -80,6 +80,18 @@ services:
DASHBOARD_PASSWORD: ${SOCIAL_SUPABASE__DASHBOARD_PASSWORD} DASHBOARD_PASSWORD: ${SOCIAL_SUPABASE__DASHBOARD_PASSWORD}
entrypoint: bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && /docker-entrypoint.sh kong docker-start' 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: auth:
container_name: supabase-auth container_name: supabase-auth
image: supabase/gotrue:v2.184.0 image: supabase/gotrue:v2.184.0
@@ -94,6 +106,8 @@ services:
condition: service_healthy condition: service_healthy
analytics: analytics:
condition: service_healthy condition: service_healthy
mail-templates:
condition: service_healthy
environment: environment:
GOTRUE_API_HOST: 0.0.0.0 GOTRUE_API_HOST: 0.0.0.0
GOTRUE_API_PORT: 9999 GOTRUE_API_PORT: 9999
@@ -110,7 +124,7 @@ services:
GOTRUE_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET} GOTRUE_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET}
GOTRUE_EXTERNAL_EMAIL_ENABLED: ${SOCIAL_SUPABASE__ENABLE_EMAIL_SIGNUP:-true} GOTRUE_EXTERNAL_EMAIL_ENABLED: ${SOCIAL_SUPABASE__ENABLE_EMAIL_SIGNUP:-true}
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${SOCIAL_SUPABASE__ENABLE_ANONYMOUS_USERS:-false} 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_ADMIN_EMAIL: ${SOCIAL_SUPABASE__SMTP_ADMIN_EMAIL:-}
GOTRUE_SMTP_HOST: ${SOCIAL_SUPABASE__SMTP_HOST:-} GOTRUE_SMTP_HOST: ${SOCIAL_SUPABASE__SMTP_HOST:-}
GOTRUE_SMTP_PORT: ${SOCIAL_SUPABASE__SMTP_PORT:-} 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_CONFIRMATION: ${SOCIAL_SUPABASE__MAILER_URLPATHS_CONFIRMATION:-/auth/v1/verify}
GOTRUE_MAILER_URLPATHS_RECOVERY: ${SOCIAL_SUPABASE__MAILER_URLPATHS_RECOVERY:-/auth/v1/recover} 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_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_EXTERNAL_PHONE_ENABLED: ${SOCIAL_SUPABASE__ENABLE_PHONE_SIGNUP:-false}
GOTRUE_SMS_AUTOCONFIRM: ${SOCIAL_SUPABASE__ENABLE_PHONE_AUTOCONFIRM:-false} GOTRUE_SMS_AUTOCONFIRM: ${SOCIAL_SUPABASE__ENABLE_PHONE_AUTOCONFIRM:-false}
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>确认邮箱</title>
</head>
<body style="margin:0;padding:24px;background:#f5f7fb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:#1f2937;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:560px;margin:0 auto;background:#ffffff;border-radius:12px;padding:24px;">
<tr>
<td>
<h2 style="margin:0 0 12px;font-size:22px;line-height:1.4;">请确认你的邮箱</h2>
<p style="margin:0 0 16px;font-size:14px;line-height:1.7;">你好,{{ .Email }}</p>
<p style="margin:0 0 16px;font-size:14px;line-height:1.7;">请输入以下 6 位验证码完成注册:</p>
<p style="margin:0 0 20px;font-size:28px;letter-spacing:6px;font-weight:700;color:#111827;">{{ .Token }}</p>
<p style="margin:0 0 20px;font-size:13px;line-height:1.7;color:#4b5563;">验证码有效期较短,请尽快完成验证。</p>
</td>
</tr>
</table>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>重置密码</title>
</head>
<body style="margin:0;padding:24px;background:#f5f7fb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:#1f2937;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:560px;margin:0 auto;background:#ffffff;border-radius:12px;padding:24px;">
<tr>
<td>
<h2 style="margin:0 0 12px;font-size:22px;line-height:1.4;">重置你的账户密码</h2>
<p style="margin:0 0 16px;font-size:14px;line-height:1.7;">你好,{{ .Email }}</p>
<p style="margin:0 0 16px;font-size:14px;line-height:1.7;">如果你使用验证码方式,请输入以下 6 位验证码:</p>
<p style="margin:0 0 20px;font-size:28px;letter-spacing:6px;font-weight:700;color:#111827;">{{ .Token }}</p>
<p style="margin:0 0 20px;font-size:13px;line-height:1.7;color:#4b5563;">验证码有效期较短,请尽快完成重置流程。</p>
</td>
</tr>
</table>
</body>
</html>