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.responses import Response
from fastapi.responses import Response, HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from sqlalchemy import select, and_, or_
from fastcrud import FastCRUD
from datetime import timedelta, datetime
from uuid import uuid4
import json
import redis.asyncio as redis
from database import get_db
from models import User
from models import User, SystemSetting
from schemas import (
UserCreate,
UserResponse,
@ -15,15 +17,27 @@ from schemas import (
Token,
UserCreateRequest,
UserUpdate,
PhoneSendCodeRequest,
PhoneSendCodeResponse,
PhoneRegisterRequest,
EmailRegisterInitRequest,
EmailRegisterInitResponse,
RegisterSuccessResponse,
)
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 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 middleware import get_current_user
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.sms import generate_sms_code, send_sms_code
from utils.email import send_verification_email
router = APIRouter(prefix="/users", tags=["users"])
@ -67,13 +81,22 @@ async def login(
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()
# 验证用户是否存在且密码正确
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(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
@ -306,6 +329,195 @@ async def delete_user(
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")
async def logout(
request: Request,

View File

@ -57,7 +57,36 @@ class AppSettings(BaseSettings):
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()
redis_settings = RedisSettings()
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 = 子权限
permission_tree = [
# ========================================
# 顶级模块
# 顶级模块 (DB ID: 1,2,3)
# ========================================
{"name": "权限管理", "code": "perm", "parentid": 0, "route": "/admin/permissions", "description": "权限管理模块"},
{"name": "系统设置", "code": "system", "parentid": 0, "route": "/admin/settings", "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:role", "parentid": 1, "route": "/admin/roles", "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: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:export", "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: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:export", "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:detail", "parentid": 6, "description": "查看菜单详情"},
@ -59,25 +59,30 @@ async def init_permissions(db: AsyncSession):
{"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: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:search", "parentid": 30, "description": "搜索登录日志"},
{"name": "查看列表", "code": "log:login:list", "parentid": 31, "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:search", "parentid": 31, "description": "搜索操作日志"},
{"name": "查看列表", "code": "log:operation:list", "parentid": 32, "description": "查看操作日志列表"},
{"name": "搜索", "code": "log:operation:search", "parentid": 32, "description": "搜索操作日志"},
# ========================================
# 日志清理parentid=3
# 日志清理 (parentid=3, DB ID: 37)
# ========================================
{"name": "清理日志", "code": "log:clean", "parentid": 3, "description": "清理旧日志"},
]
@ -372,6 +377,18 @@ async def init_system_settings(db: AsyncSession):
"value": "京ICP备2023000000号",
"type": "string",
"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
# 文件上传
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):
"""用户登录模型"""
username: str = Field(..., description="登录名")
username: str = Field(..., description="用户名/邮箱/手机号")
password: str = Field(..., description="密码")
captcha_id: str = Field(..., description="验证码ID")
captcha_code: str = Field(..., description="验证码")
@ -285,4 +285,51 @@ class OperationLogSearch(BaseModel):
action_type: Optional[str] = Field(None, description="操作类型")
target_type: Optional[str] = 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):
"""检查令牌是否在Redis中且有效"""
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="短信发送失败,请稍后重试",
)