9f69fde30b
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (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 / Build Staging Web Image (push) Successful in 40s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m16s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m36s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m43s
CI/CD Pipeline / Unit Tests (push) Successful in 2m34s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m42s
CI/CD Pipeline / Integration Tests (push) Successful in 1m2s
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
fix(#558): 微信登录3个后端修复 - 验证码500 + 绑定500 + state校验
589 lines
19 KiB
Python
Executable File
589 lines
19 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
|
||
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 返回值补充字段实现
|