feat(auth): 添加手机短信和邮箱注册功能

本次提交完成了用户认证模块的注册扩展:
1. 新增阿里云短信和异步邮件发送工具类
2. 新增短信/邮箱相关配置类与环境变量支持
3. 添加短信验证码、待注册数据的Redis缓存工具方法
4. 扩展用户登录逻辑,支持用户名/邮箱/手机号多方式登录
5. 实现手机短信注册和邮箱验证注册完整流程
6. 更新权限初始化数据与系统配置项
7. 补充相关Pydantic请求响应模型
8. 新增依赖包并完善requirements.txt
This commit is contained in:
jayhgq 2026-06-14 22:19:33 +08:00
parent 5353e690de
commit 81972f1328
8 changed files with 490 additions and 28 deletions

View File

@ -1,13 +1,15 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status, Request from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
from fastapi.responses import Response from fastapi.responses import Response, HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_ from sqlalchemy import select, and_, or_
from fastcrud import FastCRUD from fastcrud import FastCRUD
from datetime import timedelta, datetime from datetime import timedelta, datetime
from uuid import uuid4
import json
import redis.asyncio as redis import redis.asyncio as redis
from database import get_db from database import get_db
from models import User from models import User, SystemSetting
from schemas import ( from schemas import (
UserCreate, UserCreate,
UserResponse, UserResponse,
@ -15,15 +17,27 @@ from schemas import (
Token, Token,
UserCreateRequest, UserCreateRequest,
UserUpdate, UserUpdate,
PhoneSendCodeRequest,
PhoneSendCodeResponse,
PhoneRegisterRequest,
EmailRegisterInitRequest,
EmailRegisterInitResponse,
RegisterSuccessResponse,
) )
from utils.password import verify_password, get_password_hash from utils.password import verify_password, get_password_hash
from utils.jwt import create_access_token from utils.jwt import create_access_token, decode_token
from config import app_settings from config import app_settings
from utils.redis_client import get_redis, set_token_in_redis, delete_token_from_redis from utils.redis_client import (
get_redis, set_token_in_redis, delete_token_from_redis,
save_sms_code, get_and_delete_sms_code,
save_pending_registration, get_and_delete_pending_registration,
)
from utils.captcha import create_captcha, validate_captcha from utils.captcha import create_captcha, validate_captcha
from middleware import get_current_user from middleware import get_current_user
from utils.like_filter import ilike_contains from utils.like_filter import ilike_contains
from utils.log_utils import get_client_ip, get_user_agent, save_login_log, create_operation_log from utils.log_utils import get_client_ip, get_user_agent, save_login_log, create_operation_log
from utils.sms import generate_sms_code, send_sms_code
from utils.email import send_verification_email
router = APIRouter(prefix="/users", tags=["users"]) router = APIRouter(prefix="/users", tags=["users"])
@ -67,13 +81,22 @@ async def login(
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# 查找用户 # 查找用户(支持用户名/邮箱/手机号)
result = await db.execute(select(User).filter(User.username == user_data.username)) login_id = user_data.username
result = await db.execute(
select(User).filter(
or_(
User.username == login_id,
User.email == login_id,
User.phone == login_id,
)
)
)
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
# 验证用户是否存在且密码正确 # 验证用户是否存在且密码正确
if not user or not verify_password(user_data.password, user.password_hash): if not user or not verify_password(user_data.password, user.password_hash):
await save_login_log(db, user_data.username, ip, ua, "failure", "用户名或密码错误") await save_login_log(db, login_id, ip, ua, "failure", "用户名或密码错误")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误", detail="用户名或密码错误",
@ -306,6 +329,195 @@ async def delete_user(
return {"message": "用户删除成功"} return {"message": "用户删除成功"}
# ========================================
# 用户注册
# ========================================
@router.post("/register/send-code", response_model=PhoneSendCodeResponse)
async def send_register_sms_code(
req: PhoneSendCodeRequest,
db: AsyncSession = Depends(get_db),
redis_conn: redis.Redis = Depends(get_redis),
):
"""发送手机注册验证码"""
# 检查短信注册开关
result = await db.execute(select(SystemSetting).filter(SystemSetting.key == "启用手机短信注册"))
sms_setting = result.scalar_one_or_none()
if not sms_setting or sms_setting.value != "true":
raise HTTPException(status_code=403, detail="手机短信注册功能未启用")
# 检查手机号是否已被注册
result = await db.execute(select(User).filter(User.phone == req.phone))
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="该手机号已被注册")
# 频率限制60秒内不能重复发送
ttl = await redis_conn.ttl(f"sms_code:{req.phone}")
if ttl > 240:
raise HTTPException(status_code=429, detail="验证码已发送请60秒后再试")
# 生成验证码并存入 Redis
code = generate_sms_code()
await save_sms_code(redis_conn, req.phone, code)
# 发送短信
await send_sms_code(req.phone, code)
return PhoneSendCodeResponse()
@router.post("/register/phone", response_model=RegisterSuccessResponse)
async def register_by_phone(
req: PhoneRegisterRequest,
db: AsyncSession = Depends(get_db),
redis_conn: redis.Redis = Depends(get_redis),
):
"""通过手机号注册"""
# 检查短信注册开关
result = await db.execute(select(SystemSetting).filter(SystemSetting.key == "启用手机短信注册"))
sms_setting = result.scalar_one_or_none()
if not sms_setting or sms_setting.value != "true":
raise HTTPException(status_code=403, detail="手机短信注册功能未启用")
# 检查用户名是否已存在
result = await db.execute(select(User).filter(User.username == req.username))
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="用户名已存在")
# 检查手机号是否已被注册
result = await db.execute(select(User).filter(User.phone == req.phone))
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="该手机号已被注册")
# 验证短信验证码
stored_code = await get_and_delete_sms_code(redis_conn, req.phone)
if stored_code is None:
raise HTTPException(status_code=400, detail="验证码已过期,请重新发送")
if stored_code != req.code:
raise HTTPException(status_code=400, detail="验证码错误")
# 创建用户
hashed_pw = get_password_hash(req.password)
new_user = User(
username=req.username,
nickname=req.nickname,
phone=req.phone,
password_hash=hashed_pw,
role_id=2,
)
db.add(new_user)
await db.commit()
await db.refresh(new_user)
return RegisterSuccessResponse(
id=new_user.id, username=new_user.username, nickname=new_user.nickname
)
@router.post("/register/email/init", response_model=EmailRegisterInitResponse)
async def init_email_registration(
req: EmailRegisterInitRequest,
db: AsyncSession = Depends(get_db),
redis_conn: redis.Redis = Depends(get_redis),
):
"""初始化邮箱注册(发送验证邮件)"""
# 检查邮箱注册开关
result = await db.execute(select(SystemSetting).filter(SystemSetting.key == "启用邮箱注册"))
email_setting = result.scalar_one_or_none()
if not email_setting or email_setting.value != "true":
raise HTTPException(status_code=403, detail="邮箱注册功能未启用")
# 检查用户名是否已存在
result = await db.execute(select(User).filter(User.username == req.username))
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="用户名已存在")
# 检查邮箱是否已被注册
result = await db.execute(select(User).filter(User.email == req.email))
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="该邮箱已被注册")
# 哈希密码后存储待注册数据
hashed_pw = get_password_hash(req.password)
token_id = str(uuid4())
pending_data = json.dumps({
"username": req.username,
"nickname": req.nickname,
"email": req.email,
"password_hash": hashed_pw,
})
await save_pending_registration(redis_conn, token_id, pending_data)
# 生成 JWT 验证令牌
jwt_token = create_access_token(
data={"sub": token_id, "type": "email_verify", "email": req.email},
expires_delta=timedelta(minutes=30),
)
# 发送验证邮件
await send_verification_email(req.email, jwt_token)
return EmailRegisterInitResponse()
@router.get("/verify-email")
async def verify_email(
token: str = Query(..., description="验证令牌"),
db: AsyncSession = Depends(get_db),
redis_conn: redis.Redis = Depends(get_redis),
):
"""验证邮箱并完成注册"""
# 检查邮箱注册开关
result = await db.execute(select(SystemSetting).filter(SystemSetting.key == "启用邮箱注册"))
email_setting = result.scalar_one_or_none()
if not email_setting or email_setting.value != "true":
return HTMLResponse("<h2>邮箱注册功能未启用</h2>", status_code=403)
# 解析 JWT
payload = decode_token(token)
if payload is None:
return HTMLResponse("<h2>验证链接无效或已过期</h2>", status_code=400)
if payload.get("type") != "email_verify":
return HTMLResponse("<h2>无效的验证链接</h2>", status_code=400)
token_id = payload.get("sub")
pending_json = await get_and_delete_pending_registration(redis_conn, token_id)
if pending_json is None:
return HTMLResponse("<h2>验证链接已过期或已使用</h2>", status_code=400)
data = json.loads(pending_json)
# 再次检查用户名和邮箱唯一性
result = await db.execute(select(User).filter(User.username == data["username"]))
if result.scalar_one_or_none():
return HTMLResponse("<h2>该用户名已被注册</h2>", status_code=400)
result = await db.execute(select(User).filter(User.email == data["email"]))
if result.scalar_one_or_none():
return HTMLResponse("<h2>该邮箱已被注册</h2>", status_code=400)
# 创建用户
new_user = User(
username=data["username"],
nickname=data["nickname"],
email=data["email"],
password_hash=data["password_hash"],
role_id=2,
)
db.add(new_user)
await db.commit()
await db.refresh(new_user)
return HTMLResponse(f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"></head>
<body style="font-family:Arial,sans-serif;text-align:center;padding:60px 20px;">
<h1 style="color:#67c23a;"> 注册成功</h1>
<p>欢迎 <b>{new_user.username}</b>您的账号已创建完成</p>
<p>请返回应用使用 <b>用户名邮箱或手机号</b> 登录</p>
</body></html>""")
@router.post("/logout") @router.post("/logout")
async def logout( async def logout(
request: Request, request: Request,

View File

@ -57,7 +57,36 @@ class AppSettings(BaseSettings):
env_prefix = "APP_" env_prefix = "APP_"
class AliyunSMSSettings(BaseSettings):
"""阿里云短信配置"""
access_key: str = ""
access_secret: str = ""
sign_name: str = ""
template_code: str = ""
class Config:
env_prefix = "ALIYUN_SMS_"
class EmailSettings(BaseSettings):
"""邮件发送配置"""
smtp_host: str = ""
smtp_port: int = 465
smtp_user: str = ""
smtp_password: str = ""
use_tls: bool = True
from_addr: str = ""
base_url: str = "http://localhost:8000"
class Config:
env_prefix = "EMAIL_"
# 创建设置实例 # 创建设置实例
postgres_settings = PostgreSQLSettings() postgres_settings = PostgreSQLSettings()
redis_settings = RedisSettings() redis_settings = RedisSettings()
app_settings = AppSettings() app_settings = AppSettings()
aliyun_sms_settings = AliyunSMSSettings()
email_settings = EmailSettings()

View File

@ -10,21 +10,21 @@ async def init_permissions(db: AsyncSession):
# parentid: 0 = 顶级模块, >0 = 子权限 # parentid: 0 = 顶级模块, >0 = 子权限
permission_tree = [ permission_tree = [
# ======================================== # ========================================
# 顶级模块 # 顶级模块 (DB ID: 1,2,3)
# ======================================== # ========================================
{"name": "权限管理", "code": "perm", "parentid": 0, "route": "/admin/permissions", "description": "权限管理模块"}, {"name": "权限管理", "code": "perm", "parentid": 0, "route": "/admin/permissions", "description": "权限管理模块"},
{"name": "系统设置", "code": "system", "parentid": 0, "route": "/admin/settings", "description": "系统设置模块"}, {"name": "系统设置", "code": "system", "parentid": 0, "route": "/admin/settings", "description": "系统设置模块"},
{"name": "日志管理", "code": "log", "parentid": 0, "route": "/admin/logs", "description": "日志管理模块"}, {"name": "日志管理", "code": "log", "parentid": 0, "route": "/admin/logs", "description": "日志管理模块"},
# ======================================== # ========================================
# 权限管理 -> 二级子模块 # 权限管理 -> 二级子模块 (parentid=1, DB ID: 4,5,6)
# ======================================== # ========================================
{"name": "用户管理", "code": "perm:user", "parentid": 1, "route": "/admin/users", "description": "用户管理子模块"}, {"name": "用户管理", "code": "perm:user", "parentid": 1, "route": "/admin/users", "description": "用户管理子模块"},
{"name": "角色管理", "code": "perm:role", "parentid": 1, "route": "/admin/roles", "description": "角色管理子模块"}, {"name": "角色管理", "code": "perm:role", "parentid": 1, "route": "/admin/roles", "description": "角色管理子模块"},
{"name": "菜单管理", "code": "perm:menu", "parentid": 1, "route": "/admin/menu", "description": "菜单管理子模块"}, {"name": "菜单管理", "code": "perm:menu", "parentid": 1, "route": "/admin/menu", "description": "菜单管理子模块"},
# ======================================== # ========================================
# 用户管理操作parentid=4 # 用户管理操作 (parentid=4, DB ID: 7-14)
# ======================================== # ========================================
{"name": "查看列表", "code": "perm:user:list", "parentid": 4, "description": "查看用户列表"}, {"name": "查看列表", "code": "perm:user:list", "parentid": 4, "description": "查看用户列表"},
{"name": "查看详情", "code": "perm:user:detail", "parentid": 4, "description": "查看用户详情"}, {"name": "查看详情", "code": "perm:user:detail", "parentid": 4, "description": "查看用户详情"},
@ -34,9 +34,9 @@ async def init_permissions(db: AsyncSession):
{"name": "删除", "code": "perm:user:delete", "parentid": 4, "description": "删除用户"}, {"name": "删除", "code": "perm:user:delete", "parentid": 4, "description": "删除用户"},
{"name": "导出", "code": "perm:user:export", "parentid": 4, "description": "导出用户数据"}, {"name": "导出", "code": "perm:user:export", "parentid": 4, "description": "导出用户数据"},
{"name": "批量操作", "code": "perm:user:batch", "parentid": 4, "description": "批量操作用户"}, {"name": "批量操作", "code": "perm:user:batch", "parentid": 4, "description": "批量操作用户"},
# ======================================== # ========================================
# 角色管理操作parentid=5 # 角色管理操作 (parentid=5, DB ID: 15-22)
# ======================================== # ========================================
{"name": "查看列表", "code": "perm:role:list", "parentid": 5, "description": "查看角色列表"}, {"name": "查看列表", "code": "perm:role:list", "parentid": 5, "description": "查看角色列表"},
{"name": "查看详情", "code": "perm:role:detail", "parentid": 5, "description": "查看角色详情"}, {"name": "查看详情", "code": "perm:role:detail", "parentid": 5, "description": "查看角色详情"},
@ -46,9 +46,9 @@ async def init_permissions(db: AsyncSession):
{"name": "删除", "code": "perm:role:delete", "parentid": 5, "description": "删除角色"}, {"name": "删除", "code": "perm:role:delete", "parentid": 5, "description": "删除角色"},
{"name": "导出", "code": "perm:role:export", "parentid": 5, "description": "导出角色数据"}, {"name": "导出", "code": "perm:role:export", "parentid": 5, "description": "导出角色数据"},
{"name": "分配权限", "code": "perm:role:assign", "parentid": 5, "description": "为角色分配权限"}, {"name": "分配权限", "code": "perm:role:assign", "parentid": 5, "description": "为角色分配权限"},
# ======================================== # ========================================
# 菜单管理操作parentid=6 # 菜单管理操作 (parentid=6, DB ID: 23-29)
# ======================================== # ========================================
{"name": "查看列表", "code": "perm:menu:list", "parentid": 6, "description": "查看菜单列表"}, {"name": "查看列表", "code": "perm:menu:list", "parentid": 6, "description": "查看菜单列表"},
{"name": "查看详情", "code": "perm:menu:detail", "parentid": 6, "description": "查看菜单详情"}, {"name": "查看详情", "code": "perm:menu:detail", "parentid": 6, "description": "查看菜单详情"},
@ -59,25 +59,30 @@ async def init_permissions(db: AsyncSession):
{"name": "导出", "code": "perm:menu:export", "parentid": 6, "description": "导出菜单数据"}, {"name": "导出", "code": "perm:menu:export", "parentid": 6, "description": "导出菜单数据"},
# ======================================== # ========================================
# 日志管理 -> 子模块parentid=3 # 系统设置 -> 子权限 (parentid=2, DB ID: 30)
# ========================================
{"name": "系统设置", "code": "system:view", "parentid": 2, "description": "查看和修改系统设置"},
# ========================================
# 日志管理 -> 子模块 (parentid=3, DB ID: 31,32)
# ======================================== # ========================================
{"name": "登录日志", "code": "log:login", "parentid": 3, "route": "/admin/login-logs", "description": "登录日志查看"}, {"name": "登录日志", "code": "log:login", "parentid": 3, "route": "/admin/login-logs", "description": "登录日志查看"},
{"name": "操作日志", "code": "log:operation", "parentid": 3, "route": "/admin/operation-logs", "description": "操作日志查看"}, {"name": "操作日志", "code": "log:operation", "parentid": 3, "route": "/admin/operation-logs", "description": "操作日志查看"},
# ======================================== # ========================================
# 登录日志操作parentid=30 # 登录日志操作 (parentid=31, DB ID: 33,34)
# ======================================== # ========================================
{"name": "查看列表", "code": "log:login:list", "parentid": 30, "description": "查看登录日志列表"}, {"name": "查看列表", "code": "log:login:list", "parentid": 31, "description": "查看登录日志列表"},
{"name": "搜索", "code": "log:login:search", "parentid": 30, "description": "搜索登录日志"}, {"name": "搜索", "code": "log:login:search", "parentid": 31, "description": "搜索登录日志"},
# ======================================== # ========================================
# 操作日志操作parentid=31 # 操作日志操作 (parentid=32, DB ID: 35,36)
# ======================================== # ========================================
{"name": "查看列表", "code": "log:operation:list", "parentid": 31, "description": "查看操作日志列表"}, {"name": "查看列表", "code": "log:operation:list", "parentid": 32, "description": "查看操作日志列表"},
{"name": "搜索", "code": "log:operation:search", "parentid": 31, "description": "搜索操作日志"}, {"name": "搜索", "code": "log:operation:search", "parentid": 32, "description": "搜索操作日志"},
# ======================================== # ========================================
# 日志清理parentid=3 # 日志清理 (parentid=3, DB ID: 37)
# ======================================== # ========================================
{"name": "清理日志", "code": "log:clean", "parentid": 3, "description": "清理旧日志"}, {"name": "清理日志", "code": "log:clean", "parentid": 3, "description": "清理旧日志"},
] ]
@ -372,6 +377,18 @@ async def init_system_settings(db: AsyncSession):
"value": "京ICP备2023000000号", "value": "京ICP备2023000000号",
"type": "string", "type": "string",
"description": "ICP备案信息" "description": "ICP备案信息"
},
{
"key": "启用手机短信注册",
"value": "false",
"type": "boolean",
"description": "是否允许通过手机短信验证码注册"
},
{
"key": "启用邮箱注册",
"value": "false",
"type": "boolean",
"description": "是否允许通过邮箱验证链接注册"
} }
] ]

View File

@ -31,4 +31,10 @@ redis>=7.2.0
captcha>=0.7.1 captcha>=0.7.1
# 文件上传 # 文件上传
python-multipart>=0.0.9 python-multipart>=0.0.9
# 阿里云短信SDK
aliyun-python-sdk-core>=2.15.0
# 异步邮件发送
aiosmtplib>=2.0.2

View File

@ -69,7 +69,7 @@ class UserUpdate(BaseModel):
class UserLogin(BaseModel): class UserLogin(BaseModel):
"""用户登录模型""" """用户登录模型"""
username: str = Field(..., description="登录名") username: str = Field(..., description="用户名/邮箱/手机号")
password: str = Field(..., description="密码") password: str = Field(..., description="密码")
captcha_id: str = Field(..., description="验证码ID") captcha_id: str = Field(..., description="验证码ID")
captcha_code: str = Field(..., description="验证码") captcha_code: str = Field(..., description="验证码")
@ -285,4 +285,51 @@ class OperationLogSearch(BaseModel):
action_type: Optional[str] = Field(None, description="操作类型") action_type: Optional[str] = Field(None, description="操作类型")
target_type: Optional[str] = Field(None, description="目标类型") target_type: Optional[str] = Field(None, description="目标类型")
start_time: Optional[datetime] = Field(None, description="开始时间") start_time: Optional[datetime] = Field(None, description="开始时间")
end_time: Optional[datetime] = Field(None, description="结束时间") end_time: Optional[datetime] = Field(None, description="结束时间")
# ========================================
# 用户注册相关模型
# ========================================
class PhoneSendCodeRequest(BaseModel):
"""发送短信验证码请求"""
phone: str = Field(..., pattern=r"^\d{11}$", description="手机号11位数字")
class PhoneSendCodeResponse(BaseModel):
"""发送短信验证码响应"""
message: str = "验证码已发送"
expire_seconds: int = 300
class PhoneRegisterRequest(BaseModel):
"""手机号注册请求"""
username: str = Field(..., min_length=3, max_length=50, description="用户名")
phone: str = Field(..., pattern=r"^\d{11}$", description="手机号")
code: str = Field(..., min_length=6, max_length=6, description="短信验证码")
password: str = Field(..., min_length=6, max_length=50, description="密码")
nickname: str = Field(..., max_length=50, description="昵称")
class EmailRegisterInitRequest(BaseModel):
"""邮箱注册初始化请求"""
username: str = Field(..., min_length=3, max_length=50, description="用户名")
email: str = Field(..., max_length=50, description="电子邮箱")
password: str = Field(..., min_length=6, max_length=50, description="密码")
nickname: str = Field(..., max_length=50, description="昵称")
class EmailRegisterInitResponse(BaseModel):
"""邮箱注册初始化响应"""
message: str = "验证邮件已发送,请查收并点击链接完成注册"
expire_minutes: int = 30
class RegisterSuccessResponse(BaseModel):
"""注册成功响应"""
id: int
username: str
nickname: str
message: str = "注册成功"

View File

@ -0,0 +1,71 @@
from fastapi import HTTPException
from config import email_settings
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
def _build_verification_email(token: str) -> str:
"""构建验证邮件HTML内容"""
verify_url = f"{email_settings.base_url.rstrip('/')}/verify-email?token={token}"
return f"""<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: Arial, sans-serif; padding: 20px;">
<h2>📧 邮箱验证</h2>
<p>感谢注册请点击下方按钮完成邮箱验证</p>
<p>
<a href="{verify_url}" style="display:inline-block;padding:12px 24px;background:#409eff;
color:#fff;text-decoration:none;border-radius:4px;font-size:16px;">
点击验证邮箱
</a>
</p>
<p>或者复制以下链接到浏览器打开</p>
<p style="color:#909399;font-size:13px;">{verify_url}</p>
<p style="color:#909399;font-size:13px;">链接有效期为30分钟请尽快完成验证</p>
</body>
</html>"""
async def send_verification_email(to_email: str, token: str) -> bool:
"""发送邮箱验证链接"""
try:
import aiosmtplib
msg = MIMEMultipart("alternative")
msg["Subject"] = "邮箱验证 - 图书管理系统"
msg["From"] = email_settings.from_addr
msg["To"] = to_email
msg.attach(MIMEText(_build_verification_email(token), "html", "utf-8"))
await aiosmtplib.send(
msg,
hostname=email_settings.smtp_host,
port=email_settings.smtp_port,
username=email_settings.smtp_user,
password=email_settings.smtp_password,
use_tls=email_settings.use_tls,
)
return True
except ImportError:
# aiosmtplib 未安装时降级使用标准库 smtplib
import smtplib
msg = MIMEMultipart("alternative")
msg["Subject"] = "邮箱验证 - 图书管理系统"
msg["From"] = email_settings.from_addr
msg["To"] = to_email
msg.attach(MIMEText(_build_verification_email(token), "html", "utf-8"))
if email_settings.use_tls:
server = smtplib.SMTP_SSL(email_settings.smtp_host, email_settings.smtp_port)
else:
server = smtplib.SMTP(email_settings.smtp_host, email_settings.smtp_port)
server.login(email_settings.smtp_user, email_settings.smtp_password)
server.sendmail(email_settings.from_addr, to_email, msg.as_string())
server.quit()
return True
except Exception as e:
raise HTTPException(
status_code=500,
detail="邮件发送失败,请稍后重试",
)

View File

@ -38,4 +38,44 @@ async def delete_token_from_redis(redis_conn, user_id: int):
async def check_token_in_redis(redis_conn, user_id: int, token: str): async def check_token_in_redis(redis_conn, user_id: int, token: str):
"""检查令牌是否在Redis中且有效""" """检查令牌是否在Redis中且有效"""
stored_token = await get_token_from_redis(redis_conn, user_id) stored_token = await get_token_from_redis(redis_conn, user_id)
return stored_token == token return stored_token == token
# ========================================
# 注册相关:短信验证码
# ========================================
async def save_sms_code(redis_conn, phone: str, code: str, expire_seconds: int = 300):
"""存储短信验证码"""
key = f"sms_code:{phone}"
await redis_conn.set(key, code, ex=expire_seconds)
async def get_and_delete_sms_code(redis_conn, phone: str) -> str | None:
"""获取并删除短信验证码(一次性使用)"""
key = f"sms_code:{phone}"
code = await redis_conn.get(key)
if code:
await redis_conn.delete(key)
return code
# ========================================
# 注册相关:待注册数据(邮箱验证)
# ========================================
async def save_pending_registration(redis_conn, token_id: str, data: str, expire_seconds: int = 1800):
"""存储待注册数据"""
key = f"reg_pending:{token_id}"
await redis_conn.set(key, data, ex=expire_seconds)
async def get_and_delete_pending_registration(redis_conn, token_id: str) -> str | None:
"""获取并删除待注册数据(一次性使用)"""
key = f"reg_pending:{token_id}"
data = await redis_conn.get(key)
if data:
await redis_conn.delete(key)
return data

40
backend/auth/utils/sms.py Normal file
View File

@ -0,0 +1,40 @@
import random
from fastapi import HTTPException
from config import aliyun_sms_settings
def generate_sms_code() -> str:
"""生成6位数字验证码"""
return str(random.randint(100000, 999999))
async def send_sms_code(phone: str, code: str) -> bool:
"""通过阿里云发送短信验证码"""
try:
from aliyunsdkcore.client import AcsClient
from aliyunsdkcore.request import CommonRequest
client = AcsClient(
aliyun_sms_settings.access_key,
aliyun_sms_settings.access_secret,
"cn-hangzhou",
)
request = CommonRequest()
request.set_accept_format("json")
request.set_domain("dysmsapi.aliyuncs.com")
request.set_method("POST")
request.set_version("2017-05-25")
request.set_action_name("SendSms")
request.add_query_param("PhoneNumbers", phone)
request.add_query_param("SignName", aliyun_sms_settings.sign_name)
request.add_query_param("TemplateCode", aliyun_sms_settings.template_code)
request.add_query_param("TemplateParam", f'{{"code":"{code}"}}')
response = client.do_action_with_exception(request)
return True
except Exception as e:
raise HTTPException(
status_code=500,
detail="短信发送失败,请稍后重试",
)