feat(auth): add verification and password reset routes
Deploy / Deploy Production (push) Failing after 2092h47m39s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 2092h49m51s
Deploy / Deploy Staging (push) Has been cancelled

This commit is contained in:
Xiaoxia AI
2026-06-21 10:40:56 +08:00
parent e36af9e7a9
commit fa19a34f7a
5 changed files with 171 additions and 9 deletions
+83 -1
View File
@@ -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 = {
+20 -5
View File
@@ -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。
@@ -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"
# 哈希新密码
+53 -2
View File
@@ -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
@@ -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