fix: 增强云端 Supabase 认证可靠性,修复验证码失败可观测性

- JWT 验证器新增 apikey 参数,支持云端 JWKS 认证头
- Auth 网关新增上游超时/错误映射为 503 状态码
- Auth 网关新增重定向 URL 校验,阻断开放重定向风险
- 用户依赖传递 anon_key 给 JWT 验证器
- 新增相关单元测试覆盖 JWKS 头、503 映射、重定向校验
- 新增实现计划文档
This commit is contained in:
zl-q
2026-03-10 09:11:27 +08:00
parent 6fe2e7b6c3
commit c9a2c75c35
10 changed files with 384 additions and 75 deletions
+99 -29
View File
@@ -28,6 +28,8 @@ from v1.auth.service import AuthServiceGateway
logger = get_logger("v1.auth.gateway")
AUTH_UNAVAILABLE_DETAIL = "Auth service temporarily unavailable"
class SupabaseAuthGateway(AuthServiceGateway):
def _get_client(self) -> Any:
@@ -50,9 +52,7 @@ class SupabaseAuthGateway(AuthServiceGateway):
}
if request.redirect_to:
payload["options"] = {
"email_redirect_to": _validate_redirect_url_or_raise(
request.redirect_to
)
"email_redirect_to": _validate_redirect_url(request.redirect_to)
}
try:
sign_up = cast(Any, client.auth.sign_up)
@@ -60,6 +60,11 @@ class SupabaseAuthGateway(AuthServiceGateway):
return VerificationCreateResponse(email=request.email)
except AuthError as exc:
logger.warning("Signup failed", error_type=type(exc).__name__)
if _is_auth_upstream_unavailable(exc):
raise HTTPException(
status_code=503,
detail=AUTH_UNAVAILABLE_DETAIL,
) from exc
raise HTTPException(
status_code=422, detail="Invalid signup request"
) from exc
@@ -82,6 +87,11 @@ class SupabaseAuthGateway(AuthServiceGateway):
return _map_auth_response(response, "Invalid verification code")
except AuthError as exc:
logger.warning("Signup verify failed", error_type=type(exc).__name__)
if _is_auth_upstream_unavailable(exc):
raise HTTPException(
status_code=503,
detail=AUTH_UNAVAILABLE_DETAIL,
) from exc
raise HTTPException(
status_code=401, detail="Invalid verification code"
) from exc
@@ -103,6 +113,11 @@ class SupabaseAuthGateway(AuthServiceGateway):
await asyncio.to_thread(resend, payload)
except AuthError as exc:
logger.warning("Signup resend failed", error_type=type(exc).__name__)
if _is_auth_upstream_unavailable(exc):
raise HTTPException(
status_code=503,
detail=AUTH_UNAVAILABLE_DETAIL,
) from exc
async def create_session(self, request: SessionCreateRequest) -> SessionResponse:
client = self._get_client()
@@ -113,6 +128,11 @@ class SupabaseAuthGateway(AuthServiceGateway):
return _map_auth_response(response, "Invalid credentials")
except AuthError as exc:
logger.warning("Login failed", error_type=type(exc).__name__)
if _is_auth_upstream_unavailable(exc):
raise HTTPException(
status_code=503,
detail=AUTH_UNAVAILABLE_DETAIL,
) from exc
raise HTTPException(status_code=401, detail="Invalid credentials") from exc
async def refresh_session(self, request: SessionRefreshRequest) -> SessionResponse:
@@ -125,6 +145,11 @@ class SupabaseAuthGateway(AuthServiceGateway):
return _map_auth_response(response, "Invalid refresh token")
except AuthError as exc:
logger.warning("Refresh failed", error_type=type(exc).__name__)
if _is_auth_upstream_unavailable(exc):
raise HTTPException(
status_code=503,
detail=AUTH_UNAVAILABLE_DETAIL,
) from exc
raise HTTPException(
status_code=401, detail="Invalid refresh token"
) from exc
@@ -149,6 +174,11 @@ class SupabaseAuthGateway(AuthServiceGateway):
await asyncio.to_thread(client.auth.sign_out)
except AuthError as exc:
logger.warning("Logout failed", error_type=type(exc).__name__)
if _is_auth_upstream_unavailable(exc):
raise HTTPException(
status_code=503,
detail=AUTH_UNAVAILABLE_DETAIL,
) from exc
raise HTTPException(
status_code=401, detail="Invalid refresh token"
) from exc
@@ -186,7 +216,7 @@ class SupabaseAuthGateway(AuthServiceGateway):
email = _coerce_reset_email(request.email)
if request.redirect_to:
options: dict[str, str] = {
"redirect_to": _validate_redirect_url_or_raise(request.redirect_to)
"redirect_to": _validate_redirect_url(request.redirect_to)
}
await asyncio.to_thread(reset_email, email, options=options)
else:
@@ -196,6 +226,11 @@ class SupabaseAuthGateway(AuthServiceGateway):
"Password reset request failed",
error_type=type(exc).__name__,
)
if _is_auth_upstream_unavailable(exc):
raise HTTPException(
status_code=503,
detail=AUTH_UNAVAILABLE_DETAIL,
) from exc
async def confirm_password_reset(
self, request: PasswordResetConfirmRequest
@@ -226,11 +261,71 @@ class SupabaseAuthGateway(AuthServiceGateway):
logger.warning(
"Password reset confirm failed", error_type=type(exc).__name__
)
if _is_auth_upstream_unavailable(exc):
raise HTTPException(
status_code=503,
detail=AUTH_UNAVAILABLE_DETAIL,
) from exc
raise HTTPException(
status_code=401, detail="Invalid or expired verification code"
) from exc
def _is_auth_upstream_unavailable(exc: AuthError) -> bool:
raw_status = getattr(exc, "status", None)
if raw_status is None:
raw_status = getattr(exc, "status_code", None)
if isinstance(raw_status, int) and 500 <= raw_status < 600:
return True
raw_code = getattr(exc, "code", None)
code = str(raw_code).lower() if raw_code is not None else ""
message = str(exc).lower()
indicators = (
"request_timeout",
"timed out",
"timeout",
"gateway timeout",
"bad_gateway",
"service_unavailable",
"internal_server_error",
"unexpected_failure",
"upstream",
"500",
"502",
"503",
"504",
"5xx",
)
return any(token in code or token in message for token in indicators)
def _validate_redirect_url(url: str) -> str:
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise HTTPException(status_code=422, detail="Invalid redirect URL")
origin = f"{parsed.scheme.lower()}://{parsed.netloc.lower()}"
allowed_origins = {
_normalize_origin(candidate)
for candidate in config.cors.allow_origins
if _is_http_origin(candidate)
}
if origin not in allowed_origins:
raise HTTPException(status_code=422, detail="Invalid redirect URL")
return url
def _normalize_origin(value: str) -> str:
parsed = urlparse(value)
return f"{parsed.scheme.lower()}://{parsed.netloc.lower()}"
def _is_http_origin(value: str) -> bool:
parsed = urlparse(value)
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
def _coerce_reset_email(value: object) -> str:
if isinstance(value, str):
return value
@@ -263,31 +358,6 @@ def _map_auth_response(response: object, failure_message: str) -> SessionRespons
)
def _validate_redirect_url_or_raise(url: str) -> str:
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"}:
raise HTTPException(status_code=422, detail="Invalid redirect URL")
if not parsed.netloc:
raise HTTPException(status_code=422, detail="Invalid redirect URL")
site_origin = _origin_of(config.supabase.site_url)
allowlist = {
site_origin,
*(_origin_of(item) for item in config.supabase.additional_redirect_urls),
}
target_origin = f"{parsed.scheme}://{parsed.netloc}".lower()
if target_origin not in allowlist:
raise HTTPException(status_code=422, detail="Invalid redirect URL")
return url
def _origin_of(url: str) -> str:
parsed = urlparse(url.strip())
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
return ""
return f"{parsed.scheme}://{parsed.netloc}".lower()
def _list_auth_users(client: Any) -> list[Any]:
users: list[Any] = []
page = 1
+6 -1
View File
@@ -41,7 +41,12 @@ def get_jwt_verifier() -> JwtVerifier:
if not jwks_url or not issuer or not audience:
logger.error("JWT validation failed: verifier config not configured")
raise HTTPException(status_code=503, detail="JWT verifier not configured")
_jwt_verifier = JwtVerifier(jwks_url=jwks_url, issuer=issuer, audience=audience)
_jwt_verifier = JwtVerifier(
jwks_url=jwks_url,
issuer=issuer,
audience=audience,
apikey=config.supabase.anon_key,
)
return _jwt_verifier