Files
xiaoxia-saas/apps/api/app/api/routes/auth.py
T
灵应 1b4532cd9c
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 60h2m5s
CI/CD Pipeline / Deploy Staging (push) Failing after 60h3m49s
CI/CD Pipeline / Frontend Lint (push) Failing after 60h4m26s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 60h5m12s
fix(ci): 修复 PR #185 微信同步登录 Validate 失败
- black 格式化 wechat_sync_use_case.py
- isort 排序 auth.py imports
- bandit: try_except_pass 改为 logger.debug

根因:PR #185 合并时未通过代码质量检查(black/isort),
导致 CI Validate 步骤在代码检查阶段即失败。
2026-07-07 00:49:14 +08:00

384 lines
12 KiB
Python
Executable File

"""
Canonical authentication API routes.
The route layer is intentionally thin: repository construction lives in
app.dependencies and authentication behavior lives in application use cases.
"""
import logging
import os
from typing import Optional
import jwt
from app.auth import AuthenticatedUser, blacklist_token, get_current_user
from app.config import settings
from app.dependencies import get_auth_email_service, get_auth_session_store, get_user_repository
from fastapi import APIRouter, Depends, Header, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, EmailStr
from packages.adapters.redis import NoopSessionStore
from packages.adapters.smtp import NoopEmailService
from packages.application.auth.login_use_case import LoginRequest as LoginUseCaseRequest
from packages.application.auth.login_use_case import LoginUseCase
from packages.application.auth.login_use_case import RefreshTokenRequest as RefreshTokenUseCaseRequest
from packages.application.auth.login_use_case import RefreshTokenUseCase
from packages.application.auth.password_reset_use_case import RequestPasswordResetRequest as PasswordResetUseCaseRequest
from packages.application.auth.password_reset_use_case import (
RequestPasswordResetUseCase,
ResetPasswordRequest,
ResetPasswordUseCase,
)
from packages.application.auth.register_user_use_case import RegisterUserRequest as RegisterUseCaseRequest
from packages.application.auth.register_user_use_case import RegisterUserUseCase, VerifyEmailRequest, VerifyEmailUseCase
from packages.ports.user_repository import UserRepository
logger = logging.getLogger(__name__)
bearer_scheme = HTTPBearer(auto_error=False)
router = APIRouter(prefix="/auth", tags=["认证"])
class RegisterRequest(BaseModel):
email: EmailStr
password: str
username: str
display_name: Optional[str] = None
class RegisterResponse(BaseModel):
user_id: str
email: str
username: str
display_name: str
message: str
class LoginRequest(BaseModel):
email: EmailStr
password: str
class RefreshRequest(BaseModel):
refresh_token: str
class LoginResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
user_id: str
email: str
username: str
display_name: str
expires_in: int
class CurrentUserResponse(BaseModel):
user_id: str
email: str
username: str
display_name: str
email_verified: bool
class PasswordResetRequestModel(BaseModel):
email: EmailStr
class ResetPasswordModel(BaseModel):
token: str
new_password: str
class VerifyEmailRequestModel(BaseModel):
token: str
class MessageResponse(BaseModel):
message: str
@router.post("/register", response_model=RegisterResponse, status_code=status.HTTP_201_CREATED)
async def register(
request: RegisterRequest,
user_repository: UserRepository = Depends(get_user_repository),
email_service=Depends(get_auth_email_service),
):
use_case = RegisterUserUseCase(
user_repository=user_repository,
base_url=settings.APP_BASE_URL,
email_service=email_service,
)
response, error = use_case.execute(
RegisterUseCaseRequest(
email=request.email,
password=request.password,
username=request.username,
display_name=request.display_name or request.username,
)
)
if error or response is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=_translate_auth_error(error))
return RegisterResponse(
user_id=response.user_id,
email=response.email,
username=response.username,
display_name=response.display_name,
message="注册成功!",
)
@router.post("/login", response_model=LoginResponse)
async def login(
request: LoginRequest,
user_repository: UserRepository = Depends(get_user_repository),
session_store=Depends(get_auth_session_store),
):
use_case = LoginUseCase(
user_repository=user_repository,
session_store=session_store,
jwt_secret_key=settings.JWT_SECRET_KEY,
)
response, error = use_case.execute(LoginUseCaseRequest(email=request.email, password=request.password))
if error or response is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="邮箱或密码错误")
return LoginResponse(
access_token=response.access_token,
refresh_token=response.refresh_token,
user_id=response.user_id,
email=response.email,
username=response.username,
display_name=response.display_name,
expires_in=response.expires_in,
)
@router.post("/refresh")
async def refresh(
request: RefreshRequest,
user_repository: UserRepository = Depends(get_user_repository),
session_store=Depends(get_auth_session_store),
):
use_case = RefreshTokenUseCase(
user_repository=user_repository,
session_store=session_store,
)
response, error = use_case.execute(RefreshTokenUseCaseRequest(refresh_token=request.refresh_token))
if error or response is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired refresh token")
return LoginResponse(
access_token=response.access_token,
refresh_token=response.refresh_token,
user_id=response.user_id,
email=response.email,
username=response.username,
display_name=response.display_name,
expires_in=response.expires_in,
)
def _verify_email_token(token: str, user_repository: UserRepository) -> MessageResponse:
success, error = VerifyEmailUseCase(user_repository=user_repository).execute(VerifyEmailRequest(token=token))
if not success:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "邮箱验证失败")
return MessageResponse(message="邮箱验证成功")
@router.get("/verify-email", response_model=MessageResponse)
async def verify_email(
token: str,
user_repository: UserRepository = Depends(get_user_repository),
):
return _verify_email_token(token, user_repository)
@router.post("/verify-email", response_model=MessageResponse)
async def verify_email_post(
request: VerifyEmailRequestModel,
user_repository: UserRepository = Depends(get_user_repository),
):
return _verify_email_token(request.token, user_repository)
@router.post("/password/forgot", response_model=MessageResponse, status_code=status.HTTP_202_ACCEPTED)
async def forgot_password(
request: PasswordResetRequestModel,
user_repository: UserRepository = Depends(get_user_repository),
email_service=Depends(get_auth_email_service),
):
success, error = RequestPasswordResetUseCase(
user_repository=user_repository,
base_url=settings.APP_BASE_URL,
email_service=email_service,
).execute(PasswordResetUseCaseRequest(email=request.email))
if not success:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "密码重置请求失败")
return MessageResponse(message="如果账户存在,密码重置邮件已发送")
@router.post("/password/reset", response_model=MessageResponse)
async def reset_password(
request: ResetPasswordModel,
user_repository: UserRepository = Depends(get_user_repository),
):
success, error = ResetPasswordUseCase(user_repository=user_repository).execute(
ResetPasswordRequest(token=request.token, new_password=request.new_password)
)
if not success:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "密码重置失败")
return MessageResponse(message="密码重置成功")
@router.post("/logout")
async def logout(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
current_user: AuthenticatedUser = Depends(get_current_user),
):
"""登出 - 将当前 token 加入黑名单"""
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
if credentials:
try:
payload = jwt.decode(credentials.credentials, settings.JWT_SECRET_KEY, algorithms=["HS256"])
exp = payload.get("exp", 0)
blacklist_token(credentials.credentials, exp)
except Exception as e:
logger.warning(f"Operation failed in apps/api/app/api/routes/auth.py: {e}", exc_info=True)
return MessageResponse(message="已登出")
@router.get("/me", response_model=CurrentUserResponse)
async def get_current_user_info(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
):
user = authenticated_user.user
return CurrentUserResponse(
user_id=user.id,
email=user.email,
username=user.username,
display_name=user.display_name,
email_verified=user.email_verified,
)
class _NoopSessionStore(NoopSessionStore):
pass
class _NoopEmailService(NoopEmailService):
pass
def _translate_auth_error(error: str | None) -> str:
translations = {
"Email already registered": "邮箱已被注册",
"Username already taken": "用户名已被使用",
"Username is required": "用户名不能为空",
"Display name is required": "显示名称不能为空",
}
return translations.get(error or "", error or "注册失败")
class WechatSyncRequest(BaseModel):
openid: str
unionid: Optional[str] = None
nickname: Optional[str] = None
avatar_url: Optional[str] = None
source: str = "miniapp"
class WechatSyncResponse(BaseModel):
access_token: str
token: str
refresh_token: str
user_id: str
user: dict
user_info: dict
is_new_user: bool
expires_in: int
def _get_internal_api_keys() -> list[str]:
"""获取内部 API Key 列表
优先级:
1. INTERNAL_API_KEYS 环境变量
2. /app/generated/internal_api_keys.txt 文件 (volume 持久化)
"""
env_keys = os.environ.get("INTERNAL_API_KEYS", "")
if env_keys:
return [k.strip() for k in env_keys.split(",") if k.strip()]
# 从持久化文件读取
try:
with open("/app/generated/internal_api_keys.txt", "r") as f:
content = f.read().strip()
if content:
return [k.strip() for k in content.split(",") if k.strip()]
except Exception:
logger.debug("Failed to read internal API keys from file", exc_info=True)
return []
def _verify_internal_api_key(x_api_key: str | None = Header(None)) -> bool:
"""验证内部 API Key
- 已配置时:必须匹配 INTERNAL_API_KEYS 中的 key
- 未配置且非生产环境:放行(方便开发)
- 未配置且生产环境:拒绝
"""
env = os.environ.get("APP_ENV", os.environ.get("ENV", "development")).lower()
key_list = _get_internal_api_keys()
if not key_list:
if env in ("production", "prod"):
raise HTTPException(status_code=401, detail="内部接口未配置 API Key")
return True
if x_api_key and x_api_key.strip() in key_list:
return True
raise HTTPException(status_code=401, detail="无效的 API Key")
@router.post("/wechat-sync", response_model=WechatSyncResponse, include_in_schema=False)
async def wechat_sync(
request: WechatSyncRequest,
user_repository: UserRepository = Depends(get_user_repository),
_: bool = Depends(_verify_internal_api_key),
):
"""
微信同步登录/注册(系统级内部接口)
由 BFF 层通过 API Key 调用,不直接面向终端用户。
根据 openid 查找或创建用户,返回 SaaS token。
"""
from packages.application.auth.wechat_sync_use_case import WechatSyncRequest as UseCaseRequest
from packages.application.auth.wechat_sync_use_case import (
WechatSyncUseCase,
)
use_case = WechatSyncUseCase(user_repository=user_repository)
use_case_request = UseCaseRequest(
openid=request.openid,
unionid=request.unionid,
nickname=request.nickname,
avatar_url=request.avatar_url,
source=request.source,
)
response, error = use_case.execute(use_case_request)
if error:
raise HTTPException(status_code=400, detail=error)
return WechatSyncResponse(**response.to_dict())