feat(auth): 添加手机短信和邮箱注册功能
本次提交完成了用户认证模块的注册扩展: 1. 新增阿里云短信和异步邮件发送工具类 2. 新增短信/邮箱相关配置类与环境变量支持 3. 添加短信验证码、待注册数据的Redis缓存工具方法 4. 扩展用户登录逻辑,支持用户名/邮箱/手机号多方式登录 5. 实现手机短信注册和邮箱验证注册完整流程 6. 更新权限初始化数据与系统配置项 7. 补充相关Pydantic请求响应模型 8. 新增依赖包并完善requirements.txt
This commit is contained in:
parent
5353e690de
commit
81972f1328
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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": "是否允许通过邮箱验证链接注册"
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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 = "注册成功"
|
||||
71
backend/auth/utils/email.py
Normal file
71
backend/auth/utils/email.py
Normal 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="邮件发送失败,请稍后重试",
|
||||
)
|
||||
@ -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
40
backend/auth/utils/sms.py
Normal 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="短信发送失败,请稍后重试",
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user