feat(auth): add verification and password reset routes
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user