auth.py 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
  1. import asyncio
  2. from datetime import datetime, timedelta, timezone
  3. import uvicorn
  4. from fastapi import APIRouter, HTTPException, status, Depends
  5. from pydantic import BaseModel, Field
  6. from sqlalchemy.ext.asyncio import AsyncSession
  7. from sqlalchemy import text
  8. from jose import jwt
  9. from passlib.context import CryptContext
  10. from backend.config import get_settings
  11. from backend.dependencies import get_db,get_current_user
  12. from backend.core.logger import get_logger
  13. # ── 兼容性补丁:passlib 1.7.4 要读 bcrypt.__about__.__version__,而 bcrypt>=4 删了它 ──
  14. import bcrypt as _bcrypt_mod, types as _types
  15. if not hasattr(_bcrypt_mod, "__about__"):
  16. _about = _types.SimpleNamespace(__version__=getattr(_bcrypt_mod, "__version__", "4.x"))
  17. _bcrypt_mod.__about__ = _about # 注入假的 __about__,让 passlib 能探测到版本
  18. router = APIRouter() # 本模块的路由集合
  19. logger = get_logger(__name__)
  20. pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") # 密码哈希上下文
  21. class LoginRequest(BaseModel):
  22. """登录请求体。"""
  23. username: str = Field(..., description="用户名或邮箱")
  24. password: str = Field(..., description="密码")
  25. class TokenResponse(BaseModel):
  26. """登录成功的响应体。"""
  27. access_token: str # JWT 令牌
  28. token_type: str = "bearer" # 令牌类型,固定 bearer
  29. expires_in: int # 有效期(秒)
  30. role: str # 用户角色
  31. user_id: str # 用户ID
  32. def _create_access_token(data: dict, expires_minutes: int) -> str:
  33. """把身份信息 + 过期时间打包,用密钥签名成 JWT 字符串。"""
  34. settings = get_settings()
  35. payload = data.copy() # 拷一份,避免改到原字典
  36. expire = datetime.now(timezone.utc) + timedelta(minutes=expires_minutes)
  37. payload["exp"] = expire # exp 是 JWT 标准的过期字段
  38. return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
  39. @router.post("/login", response_model=TokenResponse)
  40. async def login(
  41. req: LoginRequest, # 请求体自动解析为 LoginRequest
  42. db: AsyncSession = Depends(get_db), # 注入数据库会话
  43. ):
  44. # print(f'req-->{req}')
  45. # print(f'req-->{req.model_dump()}')
  46. """用户登录,返回 JWT Access Token(支持用户名或邮箱登录)。"""
  47. settings = get_settings()
  48. # 查用户:用户名或邮箱都行(:val 同时匹配两列),参数化查询防注入(回顾 2.6)
  49. result = await db.execute(
  50. text(
  51. "SELECT id, password_hash, role, tenant_id, is_active "
  52. "FROM users WHERE username = :val OR email = :val LIMIT 1"
  53. ),
  54. {"val": req.username},
  55. )
  56. row = result.fetchone()
  57. # print(f'row-->{row}')
  58. if not row: # 用户不存在
  59. raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
  60. if not row.is_active: # 账号被禁用
  61. raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已被禁用,请联系管理员")
  62. # 密码校验是 CPU 密集型(~100ms),用线程池避免阻塞事件循环(回顾 2.1.4)
  63. loop = asyncio.get_running_loop()
  64. password_ok = await loop.run_in_executor(
  65. None, pwd_context.verify, req.password, row.password_hash
  66. )
  67. if not password_ok: # 密码不对
  68. raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
  69. # 校验通过,签发 Token(把用户身份装进去)
  70. token = _create_access_token(
  71. data={"sub": str(row.id), "role": row.role, "tenant_id": row.tenant_id},
  72. expires_minutes=settings.jwt_access_token_expire_minutes,
  73. )
  74. logger.info("auth.login_success", user_id=str(row.id), role=row.role)
  75. return TokenResponse(
  76. access_token=token,
  77. expires_in=settings.jwt_access_token_expire_minutes * 60, # 分钟转秒
  78. role=row.role,
  79. user_id=str(row.id),
  80. )
  81. @router.get("/me")
  82. async def get_me(
  83. current_user: dict = Depends(get_current_user), # 注入当前用户(顺带完成鉴权)
  84. ):
  85. """获取当前登录用户信息(用于验证 Token 是否有效)。"""
  86. return current_user
  87. if __name__ == '__main__':
  88. from fastapi import FastAPI
  89. app = FastAPI()
  90. app.include_router(router)
  91. uvicorn.run(app, host="0.0.0.0", port=8000)