Files

97 lines
4.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
用户认证模块。
职责:
- bcrypt 密码哈希与校验(禁止明文保存密码)
- JWT 令牌的生成与解析(载荷包含用户 id、角色、过期时间)
- FastAPI 依赖 get_current_user:从请求头解析当前登录用户
"""
import os
from datetime import datetime, timedelta, timezone
import bcrypt
import jwt
from dotenv import load_dotenv
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from .database import PROJECT_ROOT, get_db
from .models import User
# 加载项目根目录下的 .env,确保 JWT_SECRET 可用(重复调用无副作用)
load_dotenv(PROJECT_ROOT / ".env")
# JWT 签名密钥:必须在 .env 中配置。
# 缺失时直接报错(fail-fast),禁止回退到硬编码默认密钥,避免生产环境使用弱密钥。
JWT_SECRET = os.getenv("JWT_SECRET", "").strip()
if not JWT_SECRET:
raise RuntimeError(
"缺少 JWT_SECRET 环境变量:请在项目根目录 .env 中配置强随机密钥后重试"
)
JWT_ALGORITHM = "HS256"
# 令牌默认有效期:7 天,可通过 .env 的 JWT_EXPIRE_MINUTES 覆盖
JWT_EXPIRE_MINUTES = int(os.getenv("JWT_EXPIRE_MINUTES") or str(7 * 24 * 60))
# HTTP Bearer 令牌认证方案(auto_error=False:缺失令牌时不自动报错,便于统一错误返回格式)
bearer_scheme = HTTPBearer(auto_error=False)
def hash_password(password: str) -> str:
"""使用 bcrypt 生成密码哈希,返回可直接存入数据库的字符串。"""
salt = bcrypt.gensalt()
return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8")
def verify_password(password: str, password_hash: str) -> bool:
"""校验明文密码与 bcrypt 哈希是否匹配。"""
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
def create_access_token(user: User) -> str:
"""生成 JWT 令牌:载荷包含用户 id、角色与过期时间。"""
now = datetime.now(timezone.utc)
payload = {
"sub": str(user.id), # 用户 idsubject
"role": user.role,
"ver": user.token_version, # 令牌版本(重置密码后旧令牌失效)
"iat": now, # 签发时间
"exp": now + timedelta(minutes=JWT_EXPIRE_MINUTES), # 过期时间
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
def decode_token(token: str) -> dict:
"""解析并校验 JWT 令牌,返回载荷;令牌无效或已过期时抛出 401。"""
try:
return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
except jwt.PyJWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="登录状态无效或已过期,请重新登录",
)
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
db: Session = Depends(get_db),
) -> User:
"""FastAPI 依赖:从 Authorization: Bearer <token> 解析并返回当前用户。"""
if credentials is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录,请先登录")
payload = decode_token(credentials.credentials)
user_id = payload.get("sub")
try:
user_id = int(user_id)
except (TypeError, ValueError):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="令牌无效")
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
# 令牌版本校验:重置密码后 token_version 自增,旧令牌立即失效(吊销机制)
if (payload.get("ver") or 0) != (user.token_version or 0):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录状态已失效,请重新登录")
return user