feat(auth): 实现用户认证与授权功能

- 添加用户登录、登出及令牌管理功能
- 实现基于JWT和Redis的认证系统
- 完善用户和角色管理API
- 添加密码加密与验证功能
- 配置数据库连接和Redis客户端
- 实现中间件进行权限验证
- 初始化管理员和测试用户数据
- 更新模型和接口文档
- 添加测试API和配置文件
This commit is contained in:
jayhgq 2026-02-28 18:08:53 +08:00
parent 98f53cbaaa
commit f1da855793
39 changed files with 551 additions and 14 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

Binary file not shown.

Binary file not shown.

View File

View File

@ -0,0 +1,65 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from fastcrud import FastCRUD
from database import get_db
from models import Role, User
from schemas import RoleCreate, RoleResponse
from middleware import get_current_user
router = APIRouter(prefix="/roles", tags=["roles"])
# 创建FastCRUD实例
role_crud = FastCRUD(Role, RoleResponse)
@router.post("/", response_model=RoleResponse)
async def create_role(role_data: RoleCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""创建角色"""
# 创建角色实例
db_role = Role(
name=role_data.name,
creator=role_data.creator
)
# 使用FastCRUD创建角色
created_role = await role_crud.create(db, db_role)
return created_role
@router.get("/", response_model=list[RoleResponse])
async def get_roles(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""获取所有角色"""
roles = await role_crud.get_multi(db)
return roles
@router.get("/{role_id}", response_model=RoleResponse)
async def get_role(role_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""获取单个角色"""
role = await role_crud.get(db, role_id)
if not role:
raise HTTPException(status_code=404, detail="角色不存在")
return role
@router.put("/{role_id}", response_model=RoleResponse)
async def update_role(role_id: int, role_data: RoleCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""更新角色"""
# 准备更新数据
update_data = role_data.dict()
# 使用FastCRUD更新角色
updated_role = await role_crud.update(db, role_id, update_data)
if not updated_role:
raise HTTPException(status_code=404, detail="角色不存在")
return updated_role
@router.delete("/{role_id}")
async def delete_role(role_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""删除角色"""
deleted = await role_crud.delete(db, role_id)
if not deleted:
raise HTTPException(status_code=404, detail="角色不存在")
return {"message": "角色删除成功"}

10
backend/auth/apps/urls.py Normal file
View File

@ -0,0 +1,10 @@
from fastapi import APIRouter
from .users.urls import router as users_router
from .roles.urls import router as roles_router
# 创建主路由
api_router = APIRouter()
# 包含子路由
api_router.include_router(users_router)
api_router.include_router(roles_router)

View File

View File

@ -0,0 +1,127 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from fastcrud import FastCRUD
from datetime import timedelta
import redis.asyncio as redis
from database import get_db
from models import User
from schemas import UserCreate, UserResponse, UserLogin, Token
from utils.password import verify_password, get_password_hash
from utils.jwt import create_access_token
from config import app_settings
from utils.redis_client import get_redis, set_token_in_redis
from middleware import get_current_user
router = APIRouter(prefix="/users", tags=["users"])
# 创建FastCRUD实例
user_crud = FastCRUD(User, UserResponse)
@router.post("/login", response_model=Token)
async def login(user_data: UserLogin, db: AsyncSession = Depends(get_db), redis_conn: redis.Redis = Depends(get_redis)):
"""用户登录"""
# 查找用户
user = await db.query(User).filter(User.username == user_data.username).first()
# 验证用户是否存在且密码正确
if not user or not verify_password(user_data.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
headers={"WWW-Authenticate": "Bearer"},
)
# 创建访问令牌
access_token_expires = timedelta(minutes=app_settings.access_token_expire_minutes)
access_token = create_access_token(
data={"sub": str(user.id), "username": user.username},
expires_delta=access_token_expires
)
# 将令牌存储到Redis
expire_seconds = int(access_token_expires.total_seconds())
await set_token_in_redis(redis_conn, user.id, access_token, expire_seconds)
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/", response_model=UserResponse)
async def create_user(user_data: UserCreate, db: AsyncSession = Depends(get_db)):
"""创建用户"""
# 生成密码哈希值
hashed_password = get_password_hash(user_data.password)
# 创建用户实例,使用密码哈希值
db_user = User(
username=user_data.username,
nickname=user_data.nickname,
email=user_data.email,
phone=user_data.phone,
wx_openid=user_data.wx_openid,
avatar=user_data.avatar,
password_hash=hashed_password,
role_id=user_data.role_id
)
# 使用FastCRUD创建用户
created_user = await user_crud.create(db, db_user)
return created_user
@router.get("/", response_model=list[UserResponse])
async def get_users(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""获取所有用户"""
users = await user_crud.get_multi(db)
return users
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(user_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""获取单个用户"""
user = await user_crud.get(db, user_id)
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
return user
@router.put("/{user_id}", response_model=UserResponse)
async def update_user(user_id: int, user_data: UserCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""更新用户"""
# 生成密码哈希值
hashed_password = get_password_hash(user_data.password)
# 准备更新数据
update_data = user_data.dict()
update_data["password_hash"] = hashed_password
del update_data["password"] # 删除明文密码
# 使用FastCRUD更新用户
updated_user = await user_crud.update(db, user_id, update_data)
if not updated_user:
raise HTTPException(status_code=404, detail="用户不存在")
return updated_user
@router.delete("/{user_id}")
async def delete_user(user_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""删除用户"""
deleted = await user_crud.delete(db, user_id)
if not deleted:
raise HTTPException(status_code=404, detail="用户不存在")
return {"message": "用户删除成功"}
@router.post("/logout")
async def logout(current_user: User = Depends(get_current_user), redis_conn: redis.Redis = Depends(get_redis)):
"""用户登出"""
from utils.redis_client import delete_token_from_redis
await delete_token_from_redis(redis_conn, current_user.id)
return {"message": "登出成功"}
@router.get("/me", response_model=UserResponse)
async def get_current_user_info(current_user: User = Depends(get_current_user)):
"""获取当前用户信息"""
return current_user

View File

@ -1,21 +1,23 @@
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from typing import Optional from typing import Optional
from urllib.parse import quote_plus
class PostgreSQLSettings(BaseSettings): class PostgreSQLSettings(BaseSettings):
"""PostgreSQL数据库配置""" """PostgreSQL数据库配置"""
host: str = "localhost" host: str = "127.0.0.1"
port: int = 5432 port: int = 5432
user: str = "postgres" user: str = "admin"
password: str = "postgres" password: str = "PostgresAdmin@123"
database: str = "book_system" database: str = "booksystem"
min_size: int = 5 min_size: int = 5
max_size: int = 20 max_size: int = 20
@property @property
def url(self) -> str: def url(self) -> str:
"""生成数据库连接URL""" """生成数据库连接URL"""
return f"postgresql+asyncpg://{self.user}:{self.password}@{self.host}:{self.port}/{self.database}" encoded_password = quote_plus(self.password)
return f"postgresql+asyncpg://{self.user}:{encoded_password}@{self.host}:{self.port}/{self.database}"
class Config: class Config:
env_prefix = "POSTGRES_" env_prefix = "POSTGRES_"
@ -23,9 +25,9 @@ class PostgreSQLSettings(BaseSettings):
class RedisSettings(BaseSettings): class RedisSettings(BaseSettings):
"""Redis配置""" """Redis配置"""
host: str = "localhost" host: str = "127.0.0.1"
port: int = 6379 port: int = 6379
password: Optional[str] = None password: Optional[str] = "RedisAdmin@123"
db: int = 0 db: int = 0
@property @property
@ -44,7 +46,7 @@ class AppSettings(BaseSettings):
name: str = "Book System API" name: str = "Book System API"
version: str = "1.0.0" version: str = "1.0.0"
debug: bool = False debug: bool = False
secret_key: str = "your-secret-key" secret_key: str = "BookSystemAPIQRAdmin123"
algorithm: str = "HS256" algorithm: str = "HS256"
access_token_expire_minutes: int = 30 access_token_expire_minutes: int = 30

26
backend/auth/database.py Normal file
View File

@ -0,0 +1,26 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from config import postgres_settings
# 创建异步数据库引擎
engine = create_async_engine(
postgres_settings.url,
echo=False,
future=True
)
# 创建异步会话工厂
AsyncSessionLocal = sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
async def get_db():
"""获取数据库会话"""
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()

102
backend/auth/init_data.py Normal file
View File

@ -0,0 +1,102 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from models import Role, User
from utils.password import get_password_hash
async def init_roles(db: AsyncSession):
"""初始化角色数据"""
# 检查是否已经存在角色数据
result = await db.execute(select(Role))
existing_roles = result.scalars().all()
if existing_roles:
print("角色数据已存在,跳过初始化")
return
# 创建初始角色
roles = [
Role(name="管理员", creator="system"),
Role(name="普通用户", creator="system"),
Role(name="访客", creator="system"),
]
for role in roles:
db.add(role)
await db.commit()
print("角色数据初始化完成")
async def init_admin_user(db: AsyncSession):
"""初始化管理员用户"""
# 检查是否已经存在管理员用户
result = await db.execute(select(User).filter(User.username == "admin"))
existing_admin = result.scalar_one_or_none()
if existing_admin:
print("管理员用户已存在,跳过初始化")
return
# 获取管理员角色
result = await db.execute(select(Role).filter(Role.name == "管理员"))
admin_role = result.scalar_one_or_none()
if not admin_role:
print("管理员角色不存在,请先初始化角色数据")
return
# 创建管理员用户
admin_user = User(
username="admin",
nickname="系统管理员",
email="admin@example.com",
phone="13800138000",
password_hash=get_password_hash("admin"),
role_id=admin_role.id,
avatar="",
wx_openid=""
)
db.add(admin_user)
await db.commit()
print("管理员用户初始化完成")
async def init_test_user(db: AsyncSession):
"""初始化测试用户"""
# 检查是否已经存在测试用户
result = await db.execute(select(User).filter(User.username == "test"))
existing_test_user = result.scalar_one_or_none()
if existing_test_user:
print("测试用户已存在,跳过初始化")
return
# 获取普通用户角色
result = await db.execute(select(Role).filter(Role.name == "普通用户"))
user_role = result.scalar_one_or_none()
if not user_role:
print("普通用户角色不存在,请先初始化角色数据")
return
# 创建测试用户
test_user = User(
username="test",
nickname="测试用户",
email="test@example.com",
phone="13800138001",
password_hash=get_password_hash("test"),
role_id=user_role.id,
avatar="",
wx_openid=""
)
db.add(test_user)
await db.commit()
print("测试用户初始化完成")
async def init_all_data(db: AsyncSession):
"""初始化所有数据"""
print("开始初始化数据库数据...")
await init_roles(db)
await init_admin_user(db)
await init_test_user(db)
print("数据库数据初始化完成")

View File

@ -1,7 +1,39 @@
from fastapi import FastAPI from fastapi import FastAPI
from contextlib import asynccontextmanager
from apps.urls import api_router
from database import engine, get_db
from models import Base
from init_data import init_all_data
#实例化FastAPI
app = FastAPI() @asynccontextmanager
async def lifespan(app: FastAPI):
# 启动时初始化数据库
async with engine.begin() as conn:
# 创建所有表
await conn.run_sync(Base.metadata.create_all)
# 初始化数据
async for db in get_db():
await init_all_data(db)
break
yield
# 关闭时的清理工作
await engine.dispose()
# 实例化FastAPI
app = FastAPI(
title="Book System API",
description="图书系统API",
version="1.0.0",
lifespan=lifespan
)
# 包含路由
app.include_router(api_router)
# 声明装饰器方法和路径 # 声明装饰器方法和路径
@app.get("/") @app.get("/")
@ -15,4 +47,4 @@ if __name__ == "__main__":
import uvicorn import uvicorn
import os import os
name = f"{os.path.splitext(os.path.basename(os.path.abspath(__file__)))[0]}:app" name = f"{os.path.splitext(os.path.basename(os.path.abspath(__file__)))[0]}:app"
uvicorn.run(name, host="0.0.0.0", port=8000, reload=True, reload_dirs=["."]) uvicorn.run(name, host="0.0.0.0", port=8000, reload=True, reload_dirs=["_"])

View File

@ -0,0 +1,46 @@
from fastapi import Request, HTTPException, status, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
import redis.asyncio as redis
from database import get_db
from utils.jwt import decode_token
from utils.redis_client import get_redis, check_token_in_redis
security = HTTPBearer()
async def get_current_user(request: Request, credentials: HTTPAuthorizationCredentials = Depends(security), db: AsyncSession = Depends(get_db), redis_conn: redis.Redis = Depends(get_redis)):
"""获取当前用户"""
token = credentials.credentials
# 解码令牌
payload = decode_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的认证凭据",
headers={"WWW-Authenticate": "Bearer"},
)
user_id = int(payload.get("sub"))
# 检查令牌是否在Redis中
is_valid = await check_token_in_redis(redis_conn, user_id, token)
if not is_valid:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="令牌已过期或已登出",
headers={"WWW-Authenticate": "Bearer"},
)
# 查找用户
from models import User
user = await db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="用户不存在"
)
return user

View File

@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, func
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime from datetime import datetime
@ -12,6 +12,7 @@ class Role(Base):
id = Column(Integer, primary_key=True, index=True, comment="角色ID") id = Column(Integer, primary_key=True, index=True, comment="角色ID")
name = Column(String(50), nullable=False, unique=True, comment="角色名称") name = Column(String(50), nullable=False, unique=True, comment="角色名称")
createtime = Column(DateTime, default=datetime.utcnow, comment="创建时间") createtime = Column(DateTime, default=datetime.utcnow, comment="创建时间")
updatetime = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间")
creator = Column(String(50), nullable=True, comment="创建人") creator = Column(String(50), nullable=True, comment="创建人")
# 关联到User # 关联到User
@ -27,9 +28,11 @@ class User(Base):
email = Column(String(50), nullable=True, comment="电子邮箱") email = Column(String(50), nullable=True, comment="电子邮箱")
phone = Column(String(11), nullable=True, comment="手机号码") phone = Column(String(11), nullable=True, comment="手机号码")
wx_openid = Column(String(100), nullable=True, comment="微信OpenID") wx_openid = Column(String(100), nullable=True, comment="微信OpenID")
password = Column(String(100), nullable=False, comment="密码") password_hash = Column(String(128), nullable=False, comment="密码哈希值")
avatar = Column(String(255), nullable=True, comment="头像路径")
role_id = Column(Integer, ForeignKey("role.id"), default=2, comment="角色ID") role_id = Column(Integer, ForeignKey("role.id"), default=2, comment="角色ID")
createtime = Column(DateTime, default=datetime.utcnow, comment="创建时间") createtime = Column(DateTime, default=datetime.utcnow, comment="创建时间")
updatetime = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间")
lastlogintime = Column(DateTime, nullable=True, comment="最后登录时间") lastlogintime = Column(DateTime, nullable=True, comment="最后登录时间")
# 关联到Role # 关联到Role

View File

@ -1,11 +1,21 @@
# FastAPI核心 # FastAPI核心
fastapi>=0.133.0 fastapi>=0.133.0
uvicorn[standard]>=0.41.0 uvicorn[standard]>=0.41.0
fastapi_cdn_host==0.10.0
# Pydantic数据验证 # Pydantic数据验证
pydantic>=2.5.0 pydantic>=2.5.0
pydantic-settings>=2.1.0 pydantic-settings>=2.1.0
# 密码加密
passlib[bcrypt]>=1.7.4
# FastCRUD
fastcrud==0.21.0
# JWT
python-jose[cryptography]>=3.3.0
# PostgreSQL + SQLAlchemy ORM包含异步支持 # PostgreSQL + SQLAlchemy ORM包含异步支持
sqlalchemy>=2.0.46 sqlalchemy>=2.0.46
asyncpg>=0.31.0 asyncpg>=0.31.0

View File

@ -10,6 +10,7 @@ class UserBase(BaseModel):
email: Optional[EmailStr] = Field(None, max_length=50, description="电子邮箱") email: Optional[EmailStr] = Field(None, max_length=50, description="电子邮箱")
phone: Optional[str] = Field(None, max_length=11, description="手机号码") phone: Optional[str] = Field(None, max_length=11, description="手机号码")
wx_openid: Optional[str] = Field(None, max_length=100, description="微信OpenID") wx_openid: Optional[str] = Field(None, max_length=100, description="微信OpenID")
avatar: Optional[str] = Field(None, max_length=255, description="头像路径")
role_id: int = Field(2, description="角色ID") role_id: int = Field(2, description="角色ID")
@ -24,10 +25,18 @@ class UserLogin(BaseModel):
password: str = Field(..., description="密码") password: str = Field(..., description="密码")
class UserResponse(UserBase): class UserResponse(BaseModel):
"""用户响应模型""" """用户响应模型"""
id: int = Field(..., description="用户ID") id: int = Field(..., description="用户ID")
username: str = Field(..., description="登录名")
nickname: str = Field(..., description="昵称")
email: Optional[EmailStr] = Field(None, description="电子邮箱")
phone: Optional[str] = Field(None, description="手机号码")
wx_openid: Optional[str] = Field(None, description="微信OpenID")
avatar: Optional[str] = Field(None, description="头像路径")
role_id: int = Field(..., description="角色ID")
createtime: datetime = Field(..., description="创建时间") createtime: datetime = Field(..., description="创建时间")
updatetime: datetime = Field(..., description="更新时间")
lastlogintime: datetime = Field(..., description="最后登录时间") lastlogintime: datetime = Field(..., description="最后登录时间")
class Config: class Config:
@ -60,6 +69,7 @@ class RoleResponse(RoleBase):
"""角色响应模型""" """角色响应模型"""
id: int = Field(..., description="角色ID") id: int = Field(..., description="角色ID")
createtime: datetime = Field(..., description="创建时间") createtime: datetime = Field(..., description="创建时间")
updatetime: datetime = Field(..., description="更新时间")
creator: Optional[str] = Field(None, description="创建人") creator: Optional[str] = Field(None, description="创建人")
class Config: class Config:

19
backend/auth/test_api.py Normal file
View File

@ -0,0 +1,19 @@
import urllib.request
# 测试根路径
print("Testing root path...")
try:
response = urllib.request.urlopen('http://127.0.0.1:8000/')
print(f"Status code: {response.status}")
print(f"Response: {response.read().decode('utf-8')}")
except Exception as e:
print(f"Error: {e}")
# 测试/docs路径
print("\nTesting /docs path...")
try:
response = urllib.request.urlopen('http://127.0.0.1:8000/docs')
print(f"Status code: {response.status}")
print(f"Response length: {len(response.read().decode('utf-8'))} characters")
except Exception as e:
print(f"Error: {e}")

View File

Binary file not shown.

25
backend/auth/utils/jwt.py Normal file
View File

@ -0,0 +1,25 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from config import app_settings
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
"""创建访问令牌"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=app_settings.access_token_expire_minutes)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, app_settings.secret_key, algorithm=app_settings.algorithm)
return encoded_jwt
def decode_token(token: str):
"""解码令牌"""
try:
payload = jwt.decode(token, app_settings.secret_key, algorithms=[app_settings.algorithm])
return payload
except JWTError:
return None

View File

@ -0,0 +1,14 @@
from passlib.context import CryptContext
# 创建密码加密上下文
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""验证密码"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""获取密码哈希值"""
return pwd_context.hash(password)

View File

@ -0,0 +1,41 @@
import redis.asyncio as redis
from config import redis_settings
# 创建Redis连接池
redis_pool = redis.ConnectionPool.from_url(
redis_settings.url,
decode_responses=True
)
async def get_redis():
"""获取Redis连接"""
async with redis.Redis(connection_pool=redis_pool) as conn:
try:
yield conn
finally:
await conn.close()
async def set_token_in_redis(redis_conn, user_id: int, token: str, expire_seconds: int):
"""将令牌存储到Redis"""
key = f"user:token:{user_id}"
await redis_conn.set(key, token, ex=expire_seconds)
async def get_token_from_redis(redis_conn, user_id: int):
"""从Redis获取令牌"""
key = f"user:token:{user_id}"
return await redis_conn.get(key)
async def delete_token_from_redis(redis_conn, user_id: int):
"""从Redis删除令牌"""
key = f"user:token:{user_id}"
await redis_conn.delete(key)
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 B

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long