fix: 增强云端 Supabase 认证可靠性,修复验证码失败可观测性
- JWT 验证器新增 apikey 参数,支持云端 JWKS 认证头 - Auth 网关新增上游超时/错误映射为 503 状态码 - Auth 网关新增重定向 URL 校验,阻断开放重定向风险 - 用户依赖传递 anon_key 给 JWT 验证器 - 新增相关单元测试覆盖 JWKS 头、503 映射、重定向校验 - 新增实现计划文档
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user