""" 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 phone: str = "" phone_verified: bool = False binding_complete: bool = False 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), ) -> RegisterResponse: 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), ) -> LoginResponse: 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), ) -> LoginResponse: 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), ) -> MessageResponse: 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), ) -> MessageResponse: return _verify_email_token(request.token, user_repository) @router.post("/forgot-password", 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), ) -> MessageResponse: 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("/reset-password", response_model=MessageResponse) async def reset_password( request: ResetPasswordModel, user_repository: UserRepository = Depends(get_user_repository), ) -> MessageResponse: 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), ) -> MessageResponse: """登出 - 将当前 token 加入黑名单""" 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), ) -> CurrentUserResponse: user = authenticated_user.user binding_complete = user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email return CurrentUserResponse( user_id=user.id, email=user.email, username=user.username, display_name=user.display_name, email_verified=user.email_verified, phone=user.phone or "", phone_verified=user.phone_verified, binding_complete=binding_complete, ) 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.warning("无法读取内部 API 密钥文件,仅依赖环境变量配置", 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), ) -> WechatSyncResponse: """ 微信同步登录/注册(系统级内部接口) 由 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()) # ==================== 微信网页登录(OAuth) ==================== class WechatAuthUrlResponse(BaseModel): auth_url: str state: str class WechatCallbackRequest(BaseModel): code: str state: str = "" class WechatLoginResponse(BaseModel): access_token: str refresh_token: str user_id: str display_name: str avatar_url: str = "" is_new_user: bool binding_complete: bool expires_in: int @router.get("/wechat/url", response_model=WechatAuthUrlResponse) async def get_wechat_auth_url() -> WechatAuthUrlResponse: """获取微信扫码登录授权链接""" from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service oauth_service = get_wechat_oauth_service() auth_url, state = oauth_service.generate_auth_url() return WechatAuthUrlResponse(auth_url=auth_url, state=state) @router.post("/wechat/callback", response_model=WechatLoginResponse) async def wechat_callback( request: WechatCallbackRequest, user_repository: UserRepository = Depends(get_user_repository), ) -> WechatLoginResponse: """微信登录回调处理""" from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service from packages.application.auth.wechat_sync_use_case import WechatSyncRequest as SyncRequest from packages.application.auth.wechat_sync_use_case import WechatSyncUseCase # 1. 用 code 换微信用户信息 oauth_service = get_wechat_oauth_service() wechat_user, err = oauth_service.handle_callback(request.code, request.state) if err: raise HTTPException(status_code=400, detail=err) # 2. 同步登录/注册(复用 wechat-sync 逻辑) use_case = WechatSyncUseCase(user_repository=user_repository) sync_request = SyncRequest( openid=wechat_user.openid, unionid=wechat_user.unionid, nickname=wechat_user.nickname, avatar_url=wechat_user.avatar_url, source="web", ) response, err = use_case.execute(sync_request) if err: raise HTTPException(status_code=400, detail=err) # 3. 判断绑定状态 user = user_repository.find_by_id(response.user_id) binding_complete = False if user: binding_complete = ( user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email ) return WechatLoginResponse( access_token=response.access_token, refresh_token=response.refresh_token, user_id=response.user_id, display_name=response.nickname, avatar_url=response.avatar_url or wechat_user.avatar_url, is_new_user=response.is_new_user, binding_complete=binding_complete, expires_in=response.expires_in, ) # ==================== 验证码 & 绑定 ==================== class SendVerificationCodeRequest(BaseModel): target: str # phone / email value: str purpose: str # bind / login / reset_password class SendVerificationCodeResponse(BaseModel): expires_in: int resend_after: int class BindContactRequest(BaseModel): phone: str = "" phone_code: str = "" email: str = "" email_code: str = "" class BindContactResponse(BaseModel): success: bool user: dict @router.post("/send-verification-code", response_model=SendVerificationCodeResponse) async def send_verification_code( request: SendVerificationCodeRequest, ) -> SendVerificationCodeResponse: """发送验证码(手机或邮箱)""" from app.dependencies import get_db_session from packages.adapters.sms.sms_service import get_sms_service from packages.adapters.smtp import get_email_service from packages.adapters.sqlalchemy_impl.verification_code_repository import ( SQLAlchemyVerificationCodeRepository, ) from packages.application.auth.bind_contact_use_case import SendVerificationCodeRequest as UseCaseRequest from packages.application.auth.bind_contact_use_case import ( SendVerificationCodeUseCase, ) from packages.application.auth.verification_code_service import VerificationCodeService db = next(get_db_session()) repo = SQLAlchemyVerificationCodeRepository(db) vc_service = VerificationCodeService(repo=repo) sms_service = get_sms_service() email_service = get_email_service() use_case = SendVerificationCodeUseCase( verification_code_service=vc_service, sms_service=sms_service, email_service=email_service, ) uc_request = UseCaseRequest( target=request.target, value=request.value, purpose=request.purpose, ) response, err = use_case.execute(uc_request) if err: raise HTTPException(status_code=400, detail=err) return SendVerificationCodeResponse( expires_in=response.expires_in, resend_after=response.resend_after, ) @router.post("/bind-contact", response_model=BindContactResponse) async def bind_contact( request: BindContactRequest, current_user: AuthenticatedUser = Depends(get_current_user), user_repository: UserRepository = Depends(get_user_repository), ) -> BindContactResponse: """绑定手机号和/或邮箱(需登录态)""" from app.dependencies import get_db_session from packages.adapters.sqlalchemy_impl.verification_code_repository import ( SQLAlchemyVerificationCodeRepository, ) from packages.application.auth.bind_contact_use_case import BindContactRequest as UseCaseRequest from packages.application.auth.bind_contact_use_case import ( BindContactUseCase, ) from packages.application.auth.verification_code_service import VerificationCodeService db = next(get_db_session()) vc_repo = SQLAlchemyVerificationCodeRepository(db) vc_service = VerificationCodeService(repo=vc_repo) use_case = BindContactUseCase( user_repository=user_repository, verification_code_service=vc_service, ) uc_request = UseCaseRequest( user_id=current_user.user.id, phone=request.phone, phone_code=request.phone_code, email=request.email, email_code=request.email_code, ) response, err = use_case.execute(uc_request) if err: raise HTTPException(status_code=400, detail=err) return BindContactResponse(success=True, user=response.to_dict()["user"]) # ==================== 当前用户信息扩展 ==================== # 扩展 CurrentUserResponse 增加绑定状态字段(在原响应基础上补充) # 通过给 get_current_user_info 返回值补充字段实现