feat(auth): require verified email for approval

This commit is contained in:
2026-05-06 20:57:54 +08:00
parent f2554c7e56
commit 6afc5817a7
26 changed files with 944 additions and 28 deletions
+34 -2
View File
@@ -11,6 +11,29 @@ logger = logging.getLogger(__name__)
class AdminService:
"""管理员服务"""
@staticmethod
def approval_warning_for_user(
user: User, allow_unverified_email: bool = False, next_email: str | None = None
) -> Dict[str, Any] | None:
from backend.services.email_settings_service import EmailSettingsService
email_changed = next_email is not None and next_email != user.email
has_verified_email = bool(user.email and user.email_verified_at and not email_changed)
should_warn = (
EmailSettingsService.should_warn_unverified_email_before_approval()
and not has_verified_email
)
if should_warn and not allow_unverified_email:
return {
"success": False,
"message": "用户邮箱未验证,确认后仍可继续审批",
"requires_override": True,
"warning_code": "UNVERIFIED_EMAIL",
}
if should_warn:
return {"warning_code": "UNVERIFIED_EMAIL"}
return None
@staticmethod
def get_pending_users(db: Session) -> List[User]:
"""获取待审批用户列表"""
@@ -24,7 +47,9 @@ class AdminService:
return users
@staticmethod
def approve_user(user_id: int, db: Session) -> Dict[str, Any]:
def approve_user(
user_id: int, db: Session, allow_unverified_email: bool = False
) -> Dict[str, Any]:
"""审批通过用户"""
user = db.query(User).filter(User.id == user_id).first()
@@ -34,13 +59,20 @@ class AdminService:
if user.is_approved:
return {"success": False, "message": "用户已经通过审批"}
warning = AdminService.approval_warning_for_user(user, allow_unverified_email)
if warning and warning.get("requires_override"):
return warning
user.is_approved = True
user.updated_at = datetime.now()
db.commit()
logger.info(f"管理员审批通过用户: {user.alias} (ID: {user.id})")
return {"success": True, "message": "审批成功", "user_id": user.id}
result = {"success": True, "message": "审批成功", "user_id": user.id}
if warning and warning.get("warning_code"):
result["warning_code"] = warning["warning_code"]
return result
@staticmethod
def reject_user(user_id: int, db: Session) -> Dict[str, Any]:
+4 -1
View File
@@ -226,13 +226,16 @@ class AuthService:
return {"status": "error", "message": "注册失败:用户名已被占用,请更换用户名"}
# 创建新用户(待审批状态)
from backend.services.email_settings_service import EmailSettingsService
requires_approval = EmailSettingsService.is_registration_approval_required()
new_user = User(
jwt_sub=jwt_sub,
alias=alias,
authorization=pure_token, # 存储清理后的 token
jwt_exp=jwt_exp,
role="user",
is_approved=False, # 待审批
is_approved=not requires_approval,
)
db.add(new_user)
+13
View File
@@ -159,6 +159,16 @@ class EmailService:
return EmailService.send_email(admin_emails, subject, body_html)
@staticmethod
def send_email_verification_code(to_email: str, alias: str, code: str) -> bool:
subject = f"【接龙自动打卡系统】邮箱验证码 - {alias}"
body_html = (
"<p>您正在验证接龙自动打卡系统账号邮箱。</p>"
f"<p>验证码:<strong>{_email_text(code)}</strong></p>"
"<p>验证码 10 分钟内有效。如非本人操作,请忽略本邮件。</p>"
)
return EmailService.send_email([to_email], subject, body_html)
@staticmethod
def notify_user_approved(user: User) -> bool:
"""
@@ -174,6 +184,9 @@ class EmailService:
if user_email is None:
logger.info(f"用户 {user.alias} 未设置邮箱,跳过审批通知")
return False
if not user.email_verified_at:
logger.info(f"用户 {user.alias} 邮箱未验证,跳过审批通知")
return False
subject = f"【接龙自动打卡系统】账户审批通过 - {user.alias}"
body_html = render_email_template(
@@ -27,6 +27,8 @@ class EmailSettingsSnapshot:
smtp_use_ssl: bool
notify_token_expiring: bool
notify_check_in_success: bool
require_admin_approval_for_registration: bool
warn_unverified_email_before_approval: bool
has_smtp_sender_password: bool
created_at: datetime | None = None
updated_at: datetime | None = None
@@ -46,6 +48,8 @@ class EmailSettingsService:
smtp_use_ssl=settings.SMTP_USE_SSL,
notify_token_expiring=True,
notify_check_in_success=True,
require_admin_approval_for_registration=True,
warn_unverified_email_before_approval=True,
)
@staticmethod
@@ -60,6 +64,8 @@ class EmailSettingsService:
smtp_use_ssl=settings.SMTP_USE_SSL,
notify_token_expiring=True,
notify_check_in_success=True,
require_admin_approval_for_registration=True,
warn_unverified_email_before_approval=True,
has_smtp_sender_password=bool(password),
created_at=None,
updated_at=None,
@@ -93,6 +99,10 @@ class EmailSettingsService:
smtp_use_ssl=bool(row.smtp_use_ssl),
notify_token_expiring=bool(row.notify_token_expiring),
notify_check_in_success=bool(row.notify_check_in_success),
require_admin_approval_for_registration=bool(
row.require_admin_approval_for_registration
),
warn_unverified_email_before_approval=bool(row.warn_unverified_email_before_approval),
has_smtp_sender_password=bool(password),
created_at=row.created_at,
updated_at=row.updated_at,
@@ -108,6 +118,8 @@ class EmailSettingsService:
smtp_use_ssl=snapshot.smtp_use_ssl,
notify_token_expiring=snapshot.notify_token_expiring,
notify_check_in_success=snapshot.notify_check_in_success,
require_admin_approval_for_registration=snapshot.require_admin_approval_for_registration,
warn_unverified_email_before_approval=snapshot.warn_unverified_email_before_approval,
has_smtp_sender_password=snapshot.has_smtp_sender_password,
created_at=snapshot.created_at,
updated_at=snapshot.updated_at,
@@ -132,6 +144,10 @@ class EmailSettingsService:
row.smtp_use_ssl = payload.smtp_use_ssl
row.notify_token_expiring = payload.notify_token_expiring
row.notify_check_in_success = payload.notify_check_in_success
row.require_admin_approval_for_registration = (
payload.require_admin_approval_for_registration
)
row.warn_unverified_email_before_approval = payload.warn_unverified_email_before_approval
if payload.clear_smtp_sender_password:
row.smtp_sender_password = ""
@@ -192,3 +208,29 @@ class EmailSettingsService:
return EmailSettingsService._default_snapshot().notify_check_in_success
finally:
db.close()
@staticmethod
def is_registration_approval_required() -> bool:
db = SessionLocal()
try:
try:
return EmailSettingsService.get_snapshot(db).require_admin_approval_for_registration
except SQLAlchemyError:
return (
EmailSettingsService._default_snapshot().require_admin_approval_for_registration
)
finally:
db.close()
@staticmethod
def should_warn_unverified_email_before_approval() -> bool:
db = SessionLocal()
try:
try:
return EmailSettingsService.get_snapshot(db).warn_unverified_email_before_approval
except SQLAlchemyError:
return (
EmailSettingsService._default_snapshot().warn_unverified_email_before_approval
)
finally:
db.close()
+88 -5
View File
@@ -1,8 +1,12 @@
import logging
import secrets
from datetime import datetime, timedelta, timezone
from typing import List, Optional
from datetime import datetime
from sqlalchemy.orm import Session
import bcrypt
from email_validator import validate_email, EmailNotValidError
from sqlalchemy import or_
from sqlalchemy.orm import Session
from backend.models import User
from backend.schemas.user import UserCreate, UserUpdate, UserUpdateProfile
@@ -26,6 +30,19 @@ def escape_like_pattern(text: str) -> str:
class UserService:
"""用户服务"""
@staticmethod
def _clear_email_verification(user: User) -> None:
user.email_verified_at = None
user.email_verification_code_hash = None
user.email_verification_expires_at = None
@staticmethod
def _normalize_email(email: str) -> str:
try:
return str(validate_email(email.strip(), check_deliverability=False).normalized)
except EmailNotValidError as exc:
raise ValueError("邮箱格式无效") from exc
@staticmethod
def create_user(user_data: UserCreate, db: Session) -> User:
"""
@@ -47,7 +64,7 @@ class UserService:
user = User(
jwt_sub=None, # NULL 表示未绑定 QQ
alias=user_data.alias,
email=user_data.email,
email=str(user_data.email) if user_data.email is not None else None,
role=user_data.role or "user",
is_approved=user_data.is_approved
if user_data.is_approved is not None
@@ -192,7 +209,14 @@ class UserService:
logger.info(f"管理员修改用户 {user.alias} (ID: {user_id}) 的密码")
# 更新其他字段(排除密码相关字段)
excluded_fields = {"password", "reset_password"}
excluded_fields = {"password", "reset_password", "allow_unverified_email"}
if "email" in update_data:
next_email = update_data["email"]
next_email = str(next_email) if next_email is not None else None
if next_email != user.email:
UserService._clear_email_verification(user)
update_data["email"] = next_email
for key, value in update_data.items():
if key not in excluded_fields:
setattr(user, key, value)
@@ -235,7 +259,10 @@ class UserService:
# 更新邮箱
if "email" in update_data:
user.email = update_data["email"]
next_email = str(update_data["email"]) if update_data["email"] is not None else None
if next_email != user.email:
UserService._clear_email_verification(user)
user.email = next_email
logger.info(f"用户 ID {user_id} 邮箱更新: {user.email}")
# 更新密码
@@ -262,6 +289,62 @@ class UserService:
logger.info(f"✅ 更新用户个人信息成功: {user.alias} (ID: {user.id})")
return user
@staticmethod
def set_email_for_verification(user_id: int, email: str, db: Session) -> User:
from backend.services.email_service import EmailService
user = UserService.get_user_by_id(user_id, db)
if not user:
raise ValueError(f"用户 ID {user_id} 不存在")
normalized_email = UserService._normalize_email(email)
if normalized_email != user.email:
user.email = normalized_email
UserService._clear_email_verification(user)
code = f"{secrets.randbelow(1_000_000):06d}"
code_hash = bcrypt.hashpw(code.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
user.email_verification_code_hash = code_hash
user.email_verification_expires_at = datetime.now(timezone.utc) + timedelta(minutes=10)
user.updated_at = datetime.now(timezone.utc)
sent = EmailService.send_email_verification_code(normalized_email, user.alias, code)
if not sent:
db.rollback()
raise ValueError("验证码邮件发送失败,请检查邮件配置后重试")
db.commit()
db.refresh(user)
logger.info("用户 ID %s 已请求邮箱验证码: %s", user_id, normalized_email)
return user
@staticmethod
def verify_email_code(user_id: int, code: str, db: Session) -> User:
user = UserService.get_user_by_id(user_id, db)
if not user:
raise ValueError(f"用户 ID {user_id} 不存在")
code_hash = user.email_verification_code_hash
expires_at = user.email_verification_expires_at
now = datetime.now(timezone.utc)
if expires_at and expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if not code_hash or not expires_at or expires_at <= now:
raise ValueError("验证码无效或已过期")
if not bcrypt.checkpw(code.encode("utf-8"), code_hash.encode("utf-8")):
raise ValueError("验证码无效或已过期")
user.email_verified_at = now
user.email_verification_code_hash = None
user.email_verification_expires_at = None
user.updated_at = now
db.commit()
db.refresh(user)
logger.info("用户 ID %s 邮箱验证成功: %s", user_id, user.email)
return user
@staticmethod
def delete_user(user_id: int, db: Session) -> bool:
"""