diff --git a/apps/api/app/api/routes/auth.py b/apps/api/app/api/routes/auth.py index b59414db2..4b1ab9169 100644 --- a/apps/api/app/api/routes/auth.py +++ b/apps/api/app/api/routes/auth.py @@ -13,8 +13,14 @@ from pydantic import BaseModel, EmailStr from packages.application.auth.login_use_case import LoginRequest as LoginUseCaseRequest from packages.application.auth.login_use_case import LoginUseCase +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 +from packages.application.auth.register_user_use_case import RegisterUserUseCase, VerifyEmailRequest, VerifyEmailUseCase from packages.ports.user_repository import UserRepository router = APIRouter(prefix="/auth", tags=["认证"]) @@ -58,6 +64,23 @@ class CurrentUserResponse(BaseModel): 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, @@ -112,6 +135,62 @@ async def login( ) +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) +@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), +): + success, error = RequestPasswordResetUseCase( + user_repository=user_repository, + base_url="http://localhost:3000", + email_service=_NoopEmailService(), + ).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) +@router.post("/reset-password", 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.get("/me", response_model=CurrentUserResponse) async def get_current_user_info( authenticated_user: AuthenticatedUser = Depends(get_current_user), @@ -135,6 +214,9 @@ class _NoopEmailService: def send_verification_email(self, **kwargs): return False, "Email delivery is disabled for compatibility auth routes" + def send_password_reset_email(self, **kwargs): + return False, "Email delivery is disabled for compatibility auth routes" + def _translate_auth_error(error: str | None) -> str: translations = { diff --git a/docs/全面代码审计报告-2026-06-21.md b/docs/全面代码审计报告-2026-06-21.md index 6f94655a9..b6a62cbe7 100644 --- a/docs/全面代码审计报告-2026-06-21.md +++ b/docs/全面代码审计报告-2026-06-21.md @@ -298,6 +298,22 @@ python -m pytest tests/unit/test_login_use_case.py tests/unit/test_register_user 2. 根据产品需要补齐 password reset / verify email 的正式 route。 3. 接入真实邮件 adapter 前继续保持 no-op email delivery。 +### P1:认证扩展路由缺失(已补齐) + +已完成: +- `GET /api/v1/auth/verify-email?token=...` +- `POST /api/v1/auth/verify-email` +- `POST /api/v1/auth/password/forgot` +- `POST /api/v1/auth/forgot-password` +- `POST /api/v1/auth/password/reset` +- `POST /api/v1/auth/reset-password` + +说明: +- 同时保留后端历史路径和前端现有路径,避免前后端命名漂移导致功能不可用。 +- 路由仍通过 `UserRepository + UseCase`,不在 route 层写业务逻辑。 +- 邮件发送在当前 compatibility route 中仍使用 no-op email service,接入真实 SMTP adapter 前不会对外发信。 +- 已修复 password reset token 过期判断中的 naive/aware datetime 比较风险。 + ### P1:Domain 层存在外部基础设施依赖 涉及: @@ -337,8 +353,7 @@ python -m pytest tests/unit/test_login_use_case.py tests/unit/test_register_user ## 五、下一步建议修复顺序 -1. 补齐认证扩展:password reset / verify email 正式 route。 -2. 外部服务 adapter 化收尾:接入真实 Redis session / SMTP email 生产配置。 -3. 生产 Alembic 准备:备份、回滚、首次生产 stamp/upgrade 演练。 -4. 继续清理旧术语:MinIO/OSS 混合文档、Postgres psycopg 历史文档。 -5. 全量测试和 CI:后端 unit/integration + 前端 type-check/build + staging smoke。 +1. 外部服务 adapter 化收尾:接入真实 Redis session / SMTP email 生产配置。 +2. 生产 Alembic 准备:备份、回滚、首次生产 stamp/upgrade 演练。 +3. 继续清理旧术语:MinIO/OSS 混合文档、Postgres psycopg 历史文档。 +4. 全量测试和 CI:后端 unit/integration + 前端 type-check/build + staging smoke。 diff --git a/packages/application/auth/password_reset_use_case.py b/packages/application/auth/password_reset_use_case.py index f43b9e17b..b5803757c 100644 --- a/packages/application/auth/password_reset_use_case.py +++ b/packages/application/auth/password_reset_use_case.py @@ -127,7 +127,10 @@ class ResetPasswordUseCase: # 检查令牌是否过期 if user.password_reset_expires_at: - if datetime.now(timezone.utc) > user.password_reset_expires_at: + expires_at = user.password_reset_expires_at + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + if datetime.now(timezone.utc) > expires_at: return False, "Reset token has expired" # 哈希新密码 diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index f448a050e..97c7ad01d 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -12,6 +12,14 @@ API_ROOT = ROOT / "apps" / "api" if str(API_ROOT) not in sys.path: sys.path.insert(0, str(API_ROOT)) +from app.api.routes.auth import ( + PasswordResetRequestModel, + ResetPasswordModel, + VerifyEmailRequestModel, + forgot_password, + reset_password, + verify_email_post, +) from app.auth import get_current_user from app.config import settings @@ -38,10 +46,10 @@ class InMemoryUserRepository: return next((user for user in self.users.values() if user.username == username), None) def find_by_verification_token(self, token): - return None + return next((user for user in self.users.values() if user.email_verification_token == token), None) def find_by_password_reset_token(self, token): - return None + return next((user for user in self.users.values() if user.password_reset_token == token), None) def delete(self, user_id): return self.users.pop(user_id, None) is not None @@ -184,3 +192,46 @@ def test_current_user_dependency_rejects_wrong_token_type(): ) assert error.value.status_code == 401 + + +def test_verify_email_route_uses_use_case(): + repo = InMemoryUserRepository() + user = User( + id="user-1", + email="user@example.com", + username="user", + display_name="User", + password_hash=password_hasher.hash_password("Password1"), + email_verification_token="verify-token", + ) + repo.save(user) + + response = asyncio.run(verify_email_post(VerifyEmailRequestModel(token="verify-token"), repo)) + + assert response.message == "邮箱验证成功" + assert user.email_verified is True + assert user.email_verification_token is None + + +def test_password_reset_routes_use_use_cases(): + repo = InMemoryUserRepository() + user = User( + id="user-1", + email="user@example.com", + username="user", + display_name="User", + password_hash=password_hasher.hash_password("Password1"), + ) + repo.save(user) + + forgot_response = asyncio.run(forgot_password(PasswordResetRequestModel(email="user@example.com"), repo)) + assert forgot_response.message == "如果账户存在,密码重置邮件已发送" + assert user.password_reset_token + + reset_response = asyncio.run( + reset_password(ResetPasswordModel(token=user.password_reset_token, new_password="NewPassword1"), repo) + ) + + assert reset_response.message == "密码重置成功" + assert password_hasher.verify_password("NewPassword1", user.password_hash) + assert user.password_reset_token is None diff --git a/tests/unit/test_password_reset_use_case.py b/tests/unit/test_password_reset_use_case.py index ced1f05b0..930281b30 100644 --- a/tests/unit/test_password_reset_use_case.py +++ b/tests/unit/test_password_reset_use_case.py @@ -135,6 +135,17 @@ class TestResetPasswordUseCase: # 验证保存了用户 mock_user_repo.save.assert_called_once() + def test_reset_password_success_with_naive_database_datetime(self, use_case, mock_user_repo, test_user): + """测试数据库返回 naive datetime 时仍可重置密码""" + test_user.password_reset_expires_at = (datetime.now(timezone.utc) + timedelta(hours=1)).replace(tzinfo=None) + mock_user_repo.find_by_password_reset_token.return_value = test_user + + success, error = use_case.execute(ResetPasswordRequest(token="valid-token", new_password="NewSecurePass123")) + + assert success is True + assert error is None + mock_user_repo.save.assert_called_once() + def test_reset_password_weak_password(self, use_case, mock_user_repo, test_user): """测试弱密码""" mock_user_repo.find_by_password_reset_token.return_value = test_user