mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-09 19:22:26 +08:00
feat: 实现邮箱验证注册功能
添加完整的邮箱验证注册系统,包括验证码发送、验证和限流控制: - 新增邮箱验证服务模块(email_sender, email_template, email_verification) - 更新认证API支持邮箱验证注册流程 - 添加注册对话框和验证码输入组件 - 完善IP限流器支持邮箱验证场景 - 修复前端类型定义问题,升级esbuild依赖 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -119,6 +119,13 @@ async def import_users(request: Request, db: Session = Depends(get_db)):
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/smtp/test")
|
||||
async def test_smtp(request: Request, db: Session = Depends(get_db)):
|
||||
"""测试 SMTP 连接(管理员)"""
|
||||
adapter = AdminTestSmtpAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# -------- 系统设置适配器 --------
|
||||
|
||||
|
||||
@@ -1084,3 +1091,63 @@ class AdminImportUsersAdapter(AdminApiAdapter):
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise InvalidRequestException(f"导入失败: {str(e)}")
|
||||
|
||||
|
||||
class AdminTestSmtpAdapter(AdminApiAdapter):
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
"""测试 SMTP 连接"""
|
||||
from src.services.system.config import ConfigService
|
||||
from src.services.verification.email_sender import EmailSenderService
|
||||
|
||||
db = context.db
|
||||
payload = context.ensure_json_body() or {}
|
||||
|
||||
# 前端可传入未保存的配置,优先使用前端值,否则回退数据库
|
||||
config = {
|
||||
"smtp_host": payload.get("smtp_host") or ConfigService.get_config(db, "smtp_host"),
|
||||
"smtp_port": payload.get("smtp_port") or ConfigService.get_config(db, "smtp_port", default=587),
|
||||
"smtp_user": payload.get("smtp_user") or ConfigService.get_config(db, "smtp_user"),
|
||||
"smtp_password": payload.get("smtp_password") or ConfigService.get_config(db, "smtp_password"),
|
||||
"smtp_use_tls": payload.get("smtp_use_tls")
|
||||
if payload.get("smtp_use_tls") is not None
|
||||
else ConfigService.get_config(db, "smtp_use_tls", default=True),
|
||||
"smtp_use_ssl": payload.get("smtp_use_ssl")
|
||||
if payload.get("smtp_use_ssl") is not None
|
||||
else ConfigService.get_config(db, "smtp_use_ssl", default=False),
|
||||
"smtp_from_email": payload.get("smtp_from_email")
|
||||
or ConfigService.get_config(db, "smtp_from_email"),
|
||||
"smtp_from_name": payload.get("smtp_from_name")
|
||||
or ConfigService.get_config(db, "smtp_from_name", default="Aether"),
|
||||
}
|
||||
|
||||
# 验证必要配置
|
||||
missing_fields = [
|
||||
field for field in ["smtp_host", "smtp_user", "smtp_password", "smtp_from_email"] if not config.get(field)
|
||||
]
|
||||
if missing_fields:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"SMTP 配置不完整,请检查 {', '.join(missing_fields)}"
|
||||
}
|
||||
|
||||
# 测试连接
|
||||
try:
|
||||
success, error_msg = await EmailSenderService.test_smtp_connection(
|
||||
db=db, override_config=config
|
||||
)
|
||||
|
||||
if success:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "SMTP 连接测试成功"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"SMTP 连接测试失败: {error_msg}"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"SMTP 连接测试失败: {str(e)}"
|
||||
}
|
||||
|
||||
@@ -23,12 +23,19 @@ from src.models.api import (
|
||||
RefreshTokenResponse,
|
||||
RegisterRequest,
|
||||
RegisterResponse,
|
||||
RegistrationSettingsResponse,
|
||||
SendVerificationCodeRequest,
|
||||
SendVerificationCodeResponse,
|
||||
VerifyEmailRequest,
|
||||
VerifyEmailResponse,
|
||||
)
|
||||
from src.models.database import AuditEventType, User, UserRole
|
||||
from src.services.auth.service import AuthService
|
||||
from src.services.rate_limit.ip_limiter import IPRateLimiter
|
||||
from src.services.system.audit import AuditService
|
||||
from src.services.system.config import ConfigService
|
||||
from src.services.user.service import UserService
|
||||
from src.services.verification import EmailSenderService, EmailVerificationService
|
||||
from src.utils.request_utils import get_client_ip, get_user_agent
|
||||
|
||||
|
||||
@@ -38,6 +45,13 @@ pipeline = ApiRequestPipeline()
|
||||
|
||||
|
||||
# API端点
|
||||
@router.get("/registration-settings", response_model=RegistrationSettingsResponse)
|
||||
async def registration_settings(request: Request, db: Session = Depends(get_db)):
|
||||
"""公开获取注册相关配置"""
|
||||
adapter = AuthRegistrationSettingsAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(request: Request, db: Session = Depends(get_db)):
|
||||
adapter = AuthLoginAdapter()
|
||||
@@ -75,6 +89,20 @@ async def logout(request: Request, db: Session = Depends(get_db)):
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/send-verification-code", response_model=SendVerificationCodeResponse)
|
||||
async def send_verification_code(request: Request, db: Session = Depends(get_db)):
|
||||
"""发送邮箱验证码"""
|
||||
adapter = AuthSendVerificationCodeAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/verify-email", response_model=VerifyEmailResponse)
|
||||
async def verify_email(request: Request, db: Session = Depends(get_db)):
|
||||
"""验证邮箱验证码"""
|
||||
adapter = AuthVerifyEmailAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# ============== 适配器实现 ==============
|
||||
|
||||
|
||||
@@ -209,6 +237,24 @@ class AuthRefreshAdapter(AuthPublicAdapter):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="刷新令牌失败")
|
||||
|
||||
|
||||
class AuthRegistrationSettingsAdapter(AuthPublicAdapter):
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
"""公开返回注册相关配置"""
|
||||
db = context.db
|
||||
|
||||
enable_registration = ConfigService.get_config(db, "enable_registration", default=False)
|
||||
require_verification = ConfigService.get_config(db, "require_email_verification", default=False)
|
||||
expire_minutes = ConfigService.get_config(
|
||||
db, "verification_code_expire_minutes", default=30
|
||||
)
|
||||
|
||||
return RegistrationSettingsResponse(
|
||||
enable_registration=bool(enable_registration),
|
||||
require_email_verification=bool(require_verification),
|
||||
verification_code_expire_minutes=expire_minutes,
|
||||
).model_dump()
|
||||
|
||||
|
||||
class AuthRegisterAdapter(AuthPublicAdapter):
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
from src.models.database import SystemConfig
|
||||
@@ -241,6 +287,19 @@ class AuthRegisterAdapter(AuthPublicAdapter):
|
||||
db.commit()
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="系统暂不开放注册")
|
||||
|
||||
# 检查是否需要邮箱验证
|
||||
require_verification = ConfigService.get_config(db, "require_email_verification", default=False)
|
||||
|
||||
if require_verification:
|
||||
# 检查邮箱是否已验证
|
||||
is_verified = await EmailVerificationService.is_email_verified(register_request.email)
|
||||
if not is_verified:
|
||||
logger.warning(f"注册失败:邮箱未验证: {register_request.email}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="请先完成邮箱验证。请发送验证码并验证后再注册。",
|
||||
)
|
||||
|
||||
try:
|
||||
user = UserService.create_user(
|
||||
db=db,
|
||||
@@ -258,7 +317,13 @@ class AuthRegisterAdapter(AuthPublicAdapter):
|
||||
user_agent=user_agent,
|
||||
metadata={"email": user.email, "username": user.username, "role": user.role.value},
|
||||
)
|
||||
|
||||
# 注册成功后清除验证状态 - 在 commit 之前清理,避免竞态条件
|
||||
if require_verification:
|
||||
await EmailVerificationService.clear_verification(register_request.email)
|
||||
|
||||
db.commit()
|
||||
|
||||
return RegisterResponse(
|
||||
user_id=user.id,
|
||||
email=user.email,
|
||||
@@ -351,3 +416,124 @@ class AuthLogoutAdapter(AuthenticatedApiAdapter):
|
||||
else:
|
||||
logger.warning(f"用户登出失败(Redis不可用): {user.email}")
|
||||
return LogoutResponse(message="登出成功(降级模式)", success=False).model_dump()
|
||||
|
||||
|
||||
class AuthSendVerificationCodeAdapter(AuthPublicAdapter):
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
"""发送邮箱验证码"""
|
||||
db = context.db
|
||||
payload = context.ensure_json_body()
|
||||
|
||||
try:
|
||||
send_request = SendVerificationCodeRequest.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
errors = []
|
||||
for error in exc.errors():
|
||||
field = " -> ".join(str(x) for x in error["loc"])
|
||||
errors.append(f"{field}: {error['msg']}")
|
||||
raise InvalidRequestException("输入验证失败: " + "; ".join(errors))
|
||||
|
||||
client_ip = get_client_ip(context.request)
|
||||
email = send_request.email
|
||||
|
||||
# IP 速率限制检查(验证码发送:3次/分钟)
|
||||
allowed, remaining, reset_after = await IPRateLimiter.check_limit(
|
||||
client_ip, "verification_send"
|
||||
)
|
||||
if not allowed:
|
||||
logger.warning(f"验证码发送请求超过速率限制: IP={client_ip}, 剩余={remaining}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"请求过于频繁,请在 {reset_after} 秒后重试",
|
||||
)
|
||||
|
||||
# 获取验证码过期时间配置
|
||||
expire_minutes = ConfigService.get_config(
|
||||
db, "verification_code_expire_minutes", default=30
|
||||
)
|
||||
|
||||
# 检查邮箱是否已注册 - 静默处理,不暴露邮箱注册状态
|
||||
existing_user = db.query(User).filter(User.email == email).first()
|
||||
if existing_user:
|
||||
# 不发送验证码,但返回成功信息,防止邮箱枚举攻击
|
||||
logger.warning(f"尝试为已注册邮箱发送验证码: {email}")
|
||||
return SendVerificationCodeResponse(
|
||||
success=True,
|
||||
message="验证码已发送",
|
||||
expire_minutes=expire_minutes,
|
||||
)
|
||||
|
||||
# 生成并发送验证码
|
||||
success, code_or_error, error_detail = await EmailVerificationService.send_verification_code(
|
||||
email, expire_minutes=expire_minutes
|
||||
)
|
||||
|
||||
if not success:
|
||||
logger.error(f"发送验证码失败: {email}, 错误: {code_or_error}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error_detail or code_or_error,
|
||||
)
|
||||
|
||||
# 发送邮件
|
||||
email_success, email_error = await EmailSenderService.send_verification_code(
|
||||
db=db, to_email=email, code=code_or_error, expire_minutes=expire_minutes
|
||||
)
|
||||
|
||||
if not email_success:
|
||||
logger.error(f"发送验证码邮件失败: {email}, 错误: {email_error}")
|
||||
# 不向用户暴露 SMTP 详细错误信息,防止信息泄露
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="发送验证码失败,请稍后重试",
|
||||
)
|
||||
|
||||
logger.info(f"验证码已发送: {email}")
|
||||
|
||||
return SendVerificationCodeResponse(
|
||||
message="验证码已发送,请查收邮件",
|
||||
success=True,
|
||||
expire_minutes=expire_minutes,
|
||||
).model_dump()
|
||||
|
||||
|
||||
class AuthVerifyEmailAdapter(AuthPublicAdapter):
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
"""验证邮箱验证码"""
|
||||
db = context.db
|
||||
payload = context.ensure_json_body()
|
||||
|
||||
try:
|
||||
verify_request = VerifyEmailRequest.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
errors = []
|
||||
for error in exc.errors():
|
||||
field = " -> ".join(str(x) for x in error["loc"])
|
||||
errors.append(f"{field}: {error['msg']}")
|
||||
raise InvalidRequestException("输入验证失败: " + "; ".join(errors))
|
||||
|
||||
client_ip = get_client_ip(context.request)
|
||||
email = verify_request.email
|
||||
code = verify_request.code
|
||||
|
||||
# IP 速率限制检查(验证码验证:10次/分钟)
|
||||
allowed, remaining, reset_after = await IPRateLimiter.check_limit(
|
||||
client_ip, "verification_verify"
|
||||
)
|
||||
if not allowed:
|
||||
logger.warning(f"验证码验证请求超过速率限制: IP={client_ip}, 剩余={remaining}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"请求过于频繁,请在 {reset_after} 秒后重试",
|
||||
)
|
||||
|
||||
# 验证验证码
|
||||
success, message = await EmailVerificationService.verify_code(email, code)
|
||||
|
||||
if not success:
|
||||
logger.warning(f"验证码验证失败: {email}, 原因: {message}")
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
|
||||
|
||||
logger.info(f"邮箱验证成功: {email}")
|
||||
|
||||
return VerifyEmailResponse(message="邮箱验证成功", success=True).model_dump()
|
||||
|
||||
Reference in New Issue
Block a user