Initial commit: MyBlog full stack blog
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
用户认证与资料路由。
|
||||
|
||||
实现接口(统一响应格式 {success, data, message}):
|
||||
- POST /api/register 注册:邮箱 + 用户名 + 密码(≥6 位),密码 bcrypt 加密存储
|
||||
- POST /api/login 登录:校验密码,成功返回 JWT 令牌与用户基础信息(带失败限流)
|
||||
- GET /api/user/level 查询当前登录用户的权限级别(角色)
|
||||
- GET /api/user/me 查询当前登录用户的完整资料(头像、简介)
|
||||
- GET /api/user/blogger 查询博主公开资料(我的简介页面,无需登录)
|
||||
- PUT /api/user/profile 更新当前用户资料(头像 / 简介)
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import create_access_token, get_current_user, hash_password, verify_password
|
||||
from ..database import get_db
|
||||
from ..models import PURPOSE_REGISTER, ROLE_BLOGGER, ROLE_VISITOR, User
|
||||
from ..schemas import (
|
||||
LoginRequest,
|
||||
ProfileOut,
|
||||
ProfileUpdate,
|
||||
TokenOut,
|
||||
UnifiedResponse,
|
||||
UserCreate,
|
||||
UserLevelOut,
|
||||
VerifyCodeRequest,
|
||||
)
|
||||
from ..security import (
|
||||
get_client_ip,
|
||||
is_valid_email,
|
||||
login_failure_limiter,
|
||||
login_ip_limiter,
|
||||
register_ip_limiter,
|
||||
)
|
||||
from .email import verify_code
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["auth"])
|
||||
|
||||
# 密码长度限制(可通过 .env 的 PASSWORD_MIN_LENGTH 调整,默认 6;前端同步校验)
|
||||
PASSWORD_MIN_LENGTH = int(os.getenv("PASSWORD_MIN_LENGTH") or "6")
|
||||
PASSWORD_MAX_LENGTH = 128
|
||||
|
||||
|
||||
@router.post("/register", response_model=UnifiedResponse)
|
||||
def register(payload: UserCreate, request: Request, db: Session = Depends(get_db)) -> UnifiedResponse:
|
||||
"""注册新用户:邮箱、用户名唯一,密码以 bcrypt 哈希存储,角色固定为 visitor。
|
||||
|
||||
安全要求:必须携带邮箱验证码(防垃圾注册与邮箱盗用),并按 IP 限流。
|
||||
"""
|
||||
email = payload.email.strip().lower()
|
||||
username = payload.username.strip()
|
||||
password = payload.password
|
||||
|
||||
# 注册限流(IP 维度):同一 IP 每小时最多 3 次,防止批量注册
|
||||
client_ip = get_client_ip(request)
|
||||
if register_ip_limiter.is_blocked(client_ip):
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="注册过于频繁,请稍后再试")
|
||||
|
||||
# 格式校验(与前端规则保持一致)
|
||||
if not is_valid_email(email):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="邮箱格式不正确")
|
||||
if len(username) < 2:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名至少 2 个字符")
|
||||
if len(username) > 50:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名不能超过 50 个字符")
|
||||
if len(password) < PASSWORD_MIN_LENGTH:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="密码至少 6 位")
|
||||
if len(password) > PASSWORD_MAX_LENGTH:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="密码不能超过 128 位")
|
||||
|
||||
# 邮箱验证码校验:未提供或错误时拒绝注册(校验通过后验证码一次性作废)
|
||||
if not payload.code.strip():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请先获取并填写邮箱验证码")
|
||||
verify_code(VerifyCodeRequest(email=email, code=payload.code, purpose=PURPOSE_REGISTER), db)
|
||||
|
||||
# 唯一性校验:邮箱或用户名任一冲突即返回统一提示,不区分具体是哪一项,
|
||||
# 防止攻击者通过注册接口枚举已注册的邮箱 / 用户名
|
||||
email_taken = db.query(User).filter(User.email == email).first() is not None
|
||||
username_taken = db.query(User).filter(User.username == username).first() is not None
|
||||
if email_taken or username_taken:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该邮箱或用户名已被使用,请直接登录或更换后重试",
|
||||
)
|
||||
|
||||
# 注册用户固定为 visitor 角色,防止通过注册接口越权提升权限
|
||||
user = User(
|
||||
email=email,
|
||||
username=username,
|
||||
password_hash=hash_password(password),
|
||||
role=ROLE_VISITOR,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
register_ip_limiter.hit(client_ip)
|
||||
|
||||
data = {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
}
|
||||
return UnifiedResponse(success=True, data=data, message="注册成功")
|
||||
|
||||
|
||||
@router.post("/login", response_model=UnifiedResponse)
|
||||
def login(payload: LoginRequest, request: Request, db: Session = Depends(get_db)) -> UnifiedResponse:
|
||||
"""登录:校验邮箱与密码,成功后返回 JWT 令牌;失败过多时按邮箱与 IP 双重限流。"""
|
||||
email = payload.email.strip().lower()
|
||||
client_ip = get_client_ip(request)
|
||||
|
||||
# IP 维度限流:同一 IP 在窗口内尝试过多直接拒绝(防分布式爆破)
|
||||
if login_ip_limiter.is_blocked(client_ip):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="尝试次数过多,请稍后再试",
|
||||
)
|
||||
# 邮箱维度限流:同一邮箱 15 分钟内失败 5 次后直接拒绝,防止暴力破解
|
||||
if login_failure_limiter.is_blocked(email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="尝试次数过多,请 15 分钟后再试",
|
||||
)
|
||||
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
|
||||
# 统一提示,避免泄露用户是否存在;失败同时记录邮箱与 IP 维度计数
|
||||
if user is None or not verify_password(payload.password, user.password_hash):
|
||||
login_failure_limiter.hit(email)
|
||||
login_ip_limiter.hit(client_ip)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="邮箱或密码错误")
|
||||
|
||||
# 登录成功:重置两类失败计数并返回令牌与用户信息
|
||||
login_failure_limiter.reset(email)
|
||||
login_ip_limiter.reset(client_ip)
|
||||
token = create_access_token(user)
|
||||
data = TokenOut(
|
||||
token=token,
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
role=user.role,
|
||||
).model_dump()
|
||||
return UnifiedResponse(success=True, data=data, message="登录成功")
|
||||
|
||||
|
||||
@router.get("/user/level", response_model=UnifiedResponse)
|
||||
def get_user_level(current_user: User = Depends(get_current_user)) -> UnifiedResponse:
|
||||
"""查询当前登录用户的权限级别(角色)。"""
|
||||
level = UserLevelOut(user_id=current_user.id, role=current_user.role)
|
||||
return UnifiedResponse(success=True, data=level.model_dump(), message="获取成功")
|
||||
|
||||
|
||||
@router.get("/user/me", response_model=UnifiedResponse)
|
||||
def get_my_profile(current_user: User = Depends(get_current_user)) -> UnifiedResponse:
|
||||
"""查询当前登录用户的完整资料(含头像、简介),供个人设置使用。"""
|
||||
profile = ProfileOut.model_validate(current_user)
|
||||
return UnifiedResponse(success=True, data=profile.model_dump(), message="获取成功")
|
||||
|
||||
|
||||
@router.get("/user/blogger", response_model=UnifiedResponse)
|
||||
def get_blogger_profile(db: Session = Depends(get_db)) -> UnifiedResponse:
|
||||
"""查询博主公开资料(我的简介页面,无需登录)。"""
|
||||
blogger = db.query(User).filter(User.role == ROLE_BLOGGER).order_by(User.id.asc()).first()
|
||||
if blogger is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="博主资料不存在")
|
||||
data = {
|
||||
"username": blogger.username,
|
||||
"avatar": blogger.avatar,
|
||||
"bio": blogger.bio,
|
||||
"role": blogger.role,
|
||||
}
|
||||
return UnifiedResponse(success=True, data=data, message="获取成功")
|
||||
|
||||
|
||||
@router.put("/user/profile", response_model=UnifiedResponse)
|
||||
def update_profile(
|
||||
payload: ProfileUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UnifiedResponse:
|
||||
"""更新当前用户资料:头像路径与个人简介(博主简介展示在“我的简介”页面)。"""
|
||||
if payload.avatar is not None:
|
||||
avatar = payload.avatar.strip() or None
|
||||
# 头像仅允许站内上传路径(禁 http/https 外链,防止追踪与钓鱼图片)
|
||||
if avatar and not avatar.startswith("/uploads/"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="头像地址不合法,请使用站内上传的头像")
|
||||
current_user.avatar = avatar
|
||||
if payload.bio is not None:
|
||||
bio = payload.bio.strip()
|
||||
current_user.bio = bio or None
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
profile = ProfileOut.model_validate(current_user)
|
||||
return UnifiedResponse(success=True, data=profile.model_dump(), message="资料已更新")
|
||||
Reference in New Issue
Block a user