读后笔记 -- FastAPI 构建Python微服务 Chapter7:保护 REST API 的安全
7.2-1 基于用户名/密码 Base64 编码的 Basic 身份验证(实际项目中很少使用)
1)安装库
pip install passlib
2)项目结构
The Application ├── api/ # 接口层 │ └── __init__.py │ └── login.py # router,接口定义,运行事务 │ └── ... ├── database/ # 数据库文件 │ └── __init__.py │ └── soas.sql # 数据库表结构 sql 文件(搭配 Navicat 15) ├── db_config/ # 数据库配置 │ └── __init__.py │ └── sqlalchemy_connect.py # 设置数据库连接 ├── models/ # 模型层 │ └── data │ └── __init__.py │ └── sqlalchemy_models.py # 定义表及关联 │ └── requests │ └── __init__.py │ └── login.py # request 数据结构 │ └── __init__.py ├── repository/ # 存储库层 │ └── __init__.py │ └── login.py # 创建连接 CRUD ├── security/ # 安全层 │ └── __init__.py │ └── secure.py # 验证处理 │── __init__.py └── main.py # 生成 app,app.include_router(api 的 router)
3)具体实现:
3.1)数据库连接
from sqlalchemy import create_engine, text from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import sessionmaker, declarative_base DB_URL = "postgresql://postgres:postgres@localhost:5432/soas" engine = create_engine(DB_URL) SessionFactory = sessionmaker(autocommit=False, autoflush=False, bind=engine) Base = declarative_base() def sess_db(): db = SessionFactory() try: yield db finally: db.close() if __name__ == '__main__': session = SessionFactory() try: # 使用文本 SQL 查询来获取 PostgreSQL 的版本信息 version_query = text("SELECT version();") result = session.execute(version_query) # 获取第一条查询结果并打印 version_info = result.fetchone() print(f"Database Connection Successful! PostgreSQL Version: {version_info[0]}") except SQLAlchemyError as e: print(f"Error connecting to the database: {e}") finally: # 关闭会话以释放资源 session.close()
3.2)创建模型类及表映射关系
from sqlalchemy import Column, Integer, String, Date, Float, ForeignKey, Text from sqlalchemy.orm import relationship from db_config.sqlalchemy_connect import Base class Signup(Base): __tablename__ = "signup" id = Column(Integer, primary_key=True, index=True) username = Column('username', String, unique=False, index=False) password = Column('password',String, unique=False, index=False) class Login(Base): __tablename__ = "login" id = Column(Integer, primary_key=True, index=True) username = Column(String, unique=False, index=False) password = Column(String, unique=False, index=False) passphrase = Column(String, unique=False, index=False) approved_date = Column(Date, unique=False, index=False) profiles = relationship('Profile', back_populates="login", uselist=False) permission_sets = relationship('PermissionSet', back_populates="login") class Permission(Base): __tablename__ = "permission" id = Column(Integer, primary_key=True, index=True, ) name = Column(String, unique=False, index=False) description = Column(String, unique=False, index=False) permission_sets = relationship('PermissionSet', back_populates="permission") class PermissionSet(Base): __tablename__ = "permission_set" id = Column(Integer, primary_key=True, index=True) login_id = Column(Integer, ForeignKey('login.id'), unique=False, index=False) permission_id = Column(Integer, ForeignKey('permission.id'), unique=False, index=False) login = relationship('Login', back_populates="permission_sets") permission = relationship('Permission', back_populates="permission_sets") class Profile(Base): __tablename__ = "profile" id = Column(Integer, primary_key=True, index=True) firstname = Column(String, unique=False, index=False) lastname = Column(String, unique=False, index=False) age = Column(Integer, unique=False, index=False) membership_date = Column(Date, unique=False, index=False) member_type = Column(String, unique=False, index=False) login_id = Column(Integer, ForeignKey('login.id'), unique=False, index=False) status = Column(Integer, unique=False, index=False) login = relationship('Login', back_populates="profiles") bids = relationship('Bids', back_populates="profiles") sold = relationship('Sold', back_populates="profiles") auctions = relationship('Auctions', back_populates="profiles") class ProductType(Base): __tablename__ = "product_type" id = Column(Integer, primary_key=True, index=True) name = Column(String, unique=False, index=False) description = Column(String, unique=False, index=False) auctions = relationship('Auctions', back_populates="prodtypes") class Auctions(Base): __tablename__ = "auctions" id = Column(Integer, primary_key=True, index=True) name = Column(String, unique=False, index=False) details = Column(Text, unique=False, index=False) type_id = Column(Integer, ForeignKey('product_type.id'), unique=False, index=False) max_price = Column(Float, unique=False, index=False) min_price = Column(Float, unique=False, index=False) buyout_price = Column(Float, unique=False, index=False) created_date = Column(Date, unique=False, index=False) updated_date = Column(Date, unique=False, index=False) condition = Column(Text, unique=False, index=False) profile_id = Column(Integer, ForeignKey('profile.id'), unique=False, index=False) profiles = relationship('Profile', back_populates="auctions") prodtypes = relationship('ProductType', back_populates="auctions") bids = relationship('Bids', back_populates="auctions") class Bids(Base): __tablename__ = "bids" id = Column(Integer, primary_key=True, index=True) auction_id = Column(Integer, ForeignKey('auctions.id'), unique=False, index=False) profile_id = Column(Integer, ForeignKey('profile.id'), unique=False, index=False) created_date = Column(Date, unique=False, index=False) updated_date = Column(Date, unique=False, index=False) price = Column(Float, unique=False, index=False) auctions = relationship('Auctions', back_populates="bids") profiles = relationship('Profile', back_populates="bids") sold = relationship('Sold', back_populates="bids") class Sold(Base): __tablename__ = "sold" id = Column(Integer, primary_key=True, index=True) bid_id = Column(Integer, ForeignKey('bids.id'), unique=False, index=False) sold_date = Column(Date, unique=False, index=False) buyer = Column(Integer, ForeignKey('profile.id'), unique=False, index=False) profiles = relationship('Profile', back_populates="sold") bids = relationship('Bids', back_populates="sold")
from pydantic import BaseModel class LoginReq(BaseModel): username: str password: str
3.3)基于 sqlalchemy Session 创建 CRUD 事务
from sqlalchemy.orm import Session from models.data.sqlalchemy_models import Login class LoginRepository: def __init__(self, sess: Session): self.sess: Session = sess def get_all_login(self): return self.sess.query(Login).all() def get_all_login_username(self, username: str): result = self.sess.query(Login).filter(Login.username == username).one_or_none() return result
3.4)基于用户名/密码 Base64 编码的 Basic 身份验证
from passlib.context import CryptContext from fastapi.security import HTTPBasic, HTTPBasicCredentials from models.data.sqlalchemy_models import Login from secrets import compare_digest # CryptContext: passlib 库中的一个类,提供了一个用于密码哈希的上下文。允许指定支持的哈希算法列表、设置默认配置,并能轻松地对密码进行哈希和验证,而不需要管理每个单独算法的具体细节 # schemes: 传递给CryptContext构造函数的一个参数,是一个列表,指定了该上下文应支持的哈希算法 crypt_context = CryptContext(schemes=["sha256_crypt", "md5_crypt"]) # 实例化 HTTPBasic 类并将其注入每个 API 服务以保护端点访问 # http_basic 一旦注入 API 服务,就会使浏览器弹出一个登录表单,输入 username 和 password http_basic = HTTPBasic() def verify_password(plain_password, hashed_password): # 生成 password 对应的 passphrase expect_passphrase = crypt_context.hash(plain_password) return crypt_context.verify(plain_password, hashed_password) # 验证浏览器数据是否真实和正确 def authenticate(credentials: HTTPBasicCredentials, account: Login): try: is_username = compare_digest(credentials.username, account.username) is_password = compare_digest(credentials.password, account.password) verified_password = verify_password(credentials.password, account.passphrase) return verified_password and is_username and is_password except Exception as e: print(e) return False
3.5)API 接口
from fastapi import APIRouter, Depends, HTTPException from fastapi.security import HTTPBasicCredentials from sqlalchemy.orm import Session from db_config.sqlalchemy_connect import sess_db from repository.login import LoginRepository from security.secure import http_basic, authenticate router = APIRouter() @router.get("/login") def login(credentials: HTTPBasicCredentials = Depends(http_basic), sess: Session = Depends(sess_db)): loginrepo = LoginRepository(sess) account = loginrepo.get_all_login_username(credentials.username) if authenticate(credentials, account) and account is not None: return account else: raise HTTPException(status_code=400, detail="Incorrect username or password")
3.6) main.py
import uvicorn from fastapi import FastAPI from api import login app = FastAPI() app.include_router(login.router, prefix="/ch07") if __name__ == '__main__': uvicorn.run(app, host="0.0.0.0", port=8001, log_level="info")
4)操作:
7.2-2 基于 用户名/密码 Hash 处理的 Digest 身份验证 (实际项目中很少使用)
1)项目结构
The Application ├── api/ # 接口层 │ └── __init__.py │ └── login.py # router,接口定义,运行事务 │ └── ... ├── database/ # 数据库文件 │ └── __init__.py │ └── soas.sql # 数据库表结构 sql 文件(搭配 Navicat 15) ├── db_config/ # 数据库配置 │ └── __init__.py │ └── sqlalchemy_connect.py # 设置数据库连接 ├── models/ # 模型层 │ └── data │ └── __init__.py │ └── sqlalchemy_models.py # 定义表及关联 │ └── requests │ └── __init__.py │ └── login.py # request 数据结构 │ └── __init__.py ├── repository/ # 存储库层 │ └── __init__.py │ └── login.py # 创建连接 CRUD ├── security/ # 安全层 │ └── __init__.py │ └── secure.py # 验证处理 |── .config # 身份验证的 用户名/密码 │── __init__.py └── main.py # 生成 app,app.include_router(api 的 router)
2)具体实现:
2.1 ~ 2.3)数据库连接、模型类、CRUD事务 代码与 上面 Basic 相同
2.4)基于 用户名/密码 Hash 的 Digest 身份验证
import os from base64 import standard_b64encode from configparser import ConfigParser from fastapi import Security, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPDigest from secrets import compare_digest from passlib.context import CryptContext crypt_context = CryptContext(schemes=["sha256_crypt", "md5_crypt"]) def get_password_hash(password): return crypt_context.hash(password) def build_map(): """ 解析用户凭证 :return: """ env = os.getenv("ENV", ".config") if env == ".config": config = ConfigParser() config.read(".config") config = config["CREDENTIALS"] else: config = { "USERNAME": os.getenv("USERNAME", "guest"), "PASSWORD": os.getenv("PASSWORD", "guest"), } return config http_digest = HTTPDigest() def authenticate(credentials: HTTPAuthorizationCredentials = Security(http_digest)): """ 使用 HTTPDigest 身份验证, 与 Basic 不同,其身份验证过程发生在 APIRouter 级别上 :param credentials: :return: """ hashed_credentials = credentials.credentials config = build_map() expected_credentials = standard_b64encode( bytes(f"{config['USERNAME']}:{config['PASSWORD']}", encoding="utf-8"), ) is_credentials = compare_digest( bytes(hashed_credentials, encoding="utf-8"), expected_credentials ) if not is_credentials: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect digest token", headers={"WWW-Authenticate": "Digest"}, ) if __name__ == '__main__': hashed_password = get_password_hash("sjctrags") print(hashed_password)
[CREDENTIALS] USERNAME=sjctrags PASSWORD=sjctrags
2.5)API 接口
from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from db_config.sqlalchemy_connect import sess_db from security.secure import authenticate router = APIRouter() @router.get("/login", dependencies=[Depends(authenticate)]) def login(sess: Session = Depends(sess_db)): return {"success": "true"}
3)操作:
1. 启动 main.py 2. 在 terminal 中 (ps) 输入下面的命令,可以启动 login 的 api ```bash Invoke-WebRequest -Uri "http://localhost:8001/ch07/login" ` -Headers @{ 'accept' = 'application/json' 'Authorization' = 'Digest c2pjdHJhZ3M6c2pjdHJhZ3M=' 'Content-Type' = 'application/json' } ``` 其中,"c2pjdHJhZ3M6c2pjdHJhZ3M=" 是 .config 配置文件的 username:password 的 hash 值,可以通过 tools\generate_hash.py 生成对应的值
注意:与 Basic Authentication 不同的是,Digest 的身份验证放在 router 路径上。
7.3 基于密码的 OAuth2 身份验证
Pre:OAuth2 规范:是验证 API 端点访问的首选方案。定义了以下角色:
- resource owner:用户;
- resource server:存储用户或其他资源;
- client:resource owner 及其授权发出受保护资源请求的应用程序;
- authentication server:负责认证 resource owner 的身份,为 resouce owner 提供授权审批流程,并最终颁发访问令牌(access token);
1)项目结构
The Application ├── api/ # 接口层 │ └── __init__.py │ └── admin.py # router,接口定义,运行事务 │ └── login.py # router,接口定义,运行事务,使用 OpenAPI 提供的 OAuth2 表单 ├── db_config/ # 数据库配置 │ └── __init__.py │ └── sqlalchemy_connect.py # 设置数据库连接 ├── models/ # 模型层 │ |── data │ │ └── __init__.py │ │ └── sqlalchemy_models.py # 定义表及关联 │ |── requests │ │ └── __init__.py │ │ └── login.py # 定义请求结构 │ │ └── signup.py # 定义请求结构 │ └── __init__.py ├── repository/ # 存储库层 │ └── __init__.py │ └── login.py # 创建连接 CRUD │ └── signup.py # 创建连接 CRUD ├── security/ # 安全层 │ └── __init__.py │ └── secure.py # 验证处理 │── __init__.py └── main.py # 生成 app,app.include_router(api 的 router)
2)具体实现:
2.1 ~ 2.5)数据库连接、模型类、CRUD事务 代码与 上面 Digest 相同,差异部分如下:
from pydantic import BaseModel class SignupReq(BaseModel): id: int username: str password: str class Config: from_attributes = True
from typing import Dict, Any from sqlalchemy import desc from sqlalchemy.orm import Session from models.data.sqlalchemy_models import Signup class SignupRepository: def __init__(self, sess: Session): self.sess: Session = sess def insert_signup(self, signup: Signup) -> bool: try: self.sess.add(signup) self.sess.commit() print(signup.id) except: return False return True def update_signup(self, id: int, details: Dict[str, Any]) -> bool: try: self.sess.query(Signup).filter(Signup.id == id).update(details) self.sess.commit() except: return False return True def delete_signup(self, id: int) -> bool: try: self.sess.query(Signup).filter(Signup.id == id).delete() self.sess.commit() except: return False return True def get_all_signup(self): return self.sess.query(Signup).all() def get_signup_username(self, username: str): return self.sess.query(Signup).filter(Signup.username == username).one_or_none() def get_all_signup_sorted_desc(self): return self.sess.query(Signup.username, Signup.password).order_by(desc(Signup.username)).all() def get_signup(self, id: int): return self.sess.query(Signup).filter(Signup.id == id).one_or_none()
2.4)OAuth2 身份验证
from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer from passlib.context import CryptContext from sqlalchemy.orm import Session from db_config.sqlalchemy_connect import sess_db from models.data.sqlalchemy_models import Login from repository.login import LoginRepository # CryptContext: passlib 库中的一个类,提供了一个用于密码哈希的上下文。允许指定支持的哈希算法列表、设置默认配置,并能轻松地对密码进行哈希和验证,而不需要管理每个单独算法的具体细节 # schemes: 传递给CryptContext构造函数的一个参数,是一个列表,指定了该上下文应支持的哈希算法 crypt_context = CryptContext(schemes=["sha256_crypt", "md5_crypt"]) # OAuth2PasswordBearer:实现了OAuth2的资源所有者密码凭证授予类型(Resource Owner Password Credentials Grant)。 # 这种授权类型允许用户通过提供他们的用户名和密码直接从客户端获取访问令牌(access token),而不是先获取授权码。 # 这种方式通常用于信任的客户端或者原生应用,因为它涉及到了用户的敏感信息(密码)。 oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/ch07/login/token") def verify_password(plain_password, hashed_password): return crypt_context.verify(plain_password, hashed_password) def authenticate(username, password, account: Login): try: password_check = verify_password(password, account.passphrase) return password_check except Exception as e: print(e) return False def get_current_user(token: str = Depends(oauth2_scheme), sess: Session = Depends(sess_db)): """ 实际上,当你定义 token: str = Depends(oauth2_scheme) 时,它将调用 oauth2_scheme,将通过 OAuth2PasswordBearer,获取 tokenUrl 的令牌 然后在 OAuth2PasswordBearer __call__() 方法中解析返回 token debug 发现,这里的 token 的值就是页面的 "bruce" """ loginrepo = LoginRepository(sess) user = loginrepo.get_all_login_username(token) if user is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication credentials", headers={"WWW-Authenticate": "Bearer"}, ) return user
2.5)实现 API 接口
from fastapi import APIRouter, Depends, HTTPException from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.orm import Session from db_config.sqlalchemy_connect import sess_db from repository.login import LoginRepository from security.secure import authenticate router = APIRouter() @router.post("/login/token") def login(form_data: OAuth2PasswordRequestForm = Depends(), sess: Session = Depends(sess_db)): """ 通过表单方式传递 :param form_data: :param sess: :return: """ username = form_data.username password = form_data.password loginrepo = LoginRepository(sess) account = loginrepo.get_all_login_username(username) if authenticate(username, password, account) and account is not None: return {"access_token": form_data.username, "token_type": "bearer"} else: raise HTTPException(status_code=400, detail="Incorrect username or password")
from typing import List from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse from sqlalchemy.orm import Session from db_config.sqlalchemy_connect import sess_db from models.data.sqlalchemy_models import Signup, Login from models.request.signup import SignupReq from repository.signup import SignupRepository from security.secure import get_current_user router = APIRouter() @router.post("/signup/add") def add_signup(req: SignupReq, sess: Session = Depends(sess_db)): """ 没有调用 Depends get_user,所以 Swagger 页面接口后面没有 授权 Icon :param req: :param sess: :return: """ repo: SignupRepository = SignupRepository(sess) signup = Signup(password=req.password, username=req.username, id=req.id) result = repo.insert_signup(signup) if result is True: return signup else: return JSONResponse(content={'message': 'create signup problem encountered'}, status_code=500) @router.get("/signup/list", response_model=List[SignupReq]) def list_signup(current_user: Login = Depends(get_current_user), sess: Session = Depends(sess_db)): """ 通过这里的 Depends get_user 实现调用 框架的 OAuth2 表单,所以 Swagger 页面接口后面有 授权 Icon :param current_user: :param sess: :return: """ repo: SignupRepository = SignupRepository(sess) result = repo.get_all_signup() return result @router.patch("/signup/update") def update_signup(id: int, req: SignupReq, current_user: Login = Depends(get_current_user), sess: Session = Depends(sess_db)): signup_dict = req.model_dump(exclude_unset=True) repo: SignupRepository = SignupRepository(sess) result = repo.update_signup(id, signup_dict) if result: return JSONResponse(content={'message': 'profile updated successfully'}, status_code=201) else: return JSONResponse(content={'message': 'update profile error'}, status_code=500) @router.delete("/signup/delete") def delete_signup(id: int, current_user: Login = Depends(get_current_user), sess: Session = Depends(sess_db)): repo: SignupRepository = SignupRepository(sess) result = repo.delete_signup(id) if result: return JSONResponse(content={'message': 'profile updated successfully'}, status_code=201) else: return JSONResponse(content={'message': 'update profile error'}, status_code=500) @router.get("/signup/list/{id}", response_model=SignupReq) def get_signup(id: int, current_user: Login = Depends(get_current_user), sess: Session = Depends(sess_db)): repo: SignupRepository = SignupRepository(sess) result = repo.get_signup(id) return result
注意:admin 中,需要有认证的接口,通过依赖注入 get_current_user 进行身份验证;
3)操作:
3.1)运行 main 后,查看 swagger,未通过认证时,
3.2)点击最上面或有认证接口的 icon(效果都一样),如通过认证,所有接口都可后续的请求 (输入用户名、密码并且认证)
=> 背后逻辑是调用 接口 /login/token,通过 OAuth2PasswordRequestForm,获取认证页面输入的用户名、密码,再调用 secure 的 authenticate() 进行认证,认证完成后返回 {令牌,token_type} 字典
注意:这里的接口 /login/token,相当于 OAuth2 规范的 authorization server,返回的 {access_token:xxx, token_type:xxx} 是符合 OAuth2 规范。
3.3)调用 signup 相关接口
=> 背后逻辑:依赖 secure 的 oauth2_scheme,将通过 OAuth2PasswordBearer,获取 tokenUrl 的令牌,然后在 OAuth2PasswordBearer __call__() 方法中解析返回 token
7.4 基于 JWT 的身份验证
方案:
- JWT 是一种为 OAuth2 和 OpenID 规范提供比密码更可靠的令牌的有效方法;
- JWT 有一个 JSON 对象签名和加密标头,描述用于纯文本编码的算法的元数据,而有效负载则是需要编码到令牌中的数据;
- 当客户端请求登录时,授权服务器使用签名对 JWT 进行签名。该算法将标头、有效负载和密钥作为输入,此密钥是 Base64 编码字符串,应存储在授权服务器中;
1)项目结构
The Application ├── api/ # 接口层 │ └── __init__.py │ └── admin.py # router,接口定义,运行事务,基于 JWT │ └── login.py # router,接口定义,运行事务,使用 OpenAPI 提供的 OAuth2 表单 ├── db_config/ # 数据库配置 │ └── __init__.py │ └── sqlalchemy_connect.py # 设置数据库连接 ├── models/ # 模型层 │ |── data │ │ └── __init__.py │ │ └── sqlalchemy_models.py # 定义表及关联 │ |── requests │ │ └── __init__.py │ │ └── login.py # 定义请求结构 │ │ └── signup.py # 定义请求结构 │ │ └── tokens.py # 定义 token 结构 │ └── __init__.py ├── repository/ # 存储库层 │ └── __init__.py │ └── login.py # 创建连接 CRUD │ └── signup.py # 创建连接 CRUD ├── security/ # 安全层 │ └── __init__.py │ └── secure.py # 验证处理 │── __init__.py └── main.py # 生成 app,app.include_router(api 的 router)
2)具体实现:
2.1 ~ 2.5)数据库连接、模型类、CRUD事务 代码与 上面 OAuth2 相同,差异部分如下:
from typing import Optional from pydantic.dataclasses import dataclass @dataclass class Token: access_token: str token_type: str @dataclass class TokenData: username: Optional[str] = None
2.4)JWT 身份验证
from datetime import timedelta, datetime from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer from jose import jwt, JWTError from passlib.context import CryptContext from sqlalchemy.orm import Session from db_config.sqlalchemy_connect import sess_db from models.data.sqlalchemy_models import Login from models.request.tokens import TokenData from repository.login import LoginRepository crypt_context = CryptContext(schemes=["sha256_crypt", "md5_crypt"]) # 在 ps console 处,通过 "openssl rand -hex 32" 生成 SECRET_KEY SECRET_KEY = "882fdc269930e113056ec3f630be0c84214c0d0ed477075f0207369548e513a9" ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 # 通过 api\login.py 接口的端点 "/login/token" 实现 """ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="ch07/login/token") 这行代码定义了一个基于OAuth2密码流(Resource Owner Password Credentials Grant)的认证方案,使用FastAPI的OAuth2PasswordBearer类。它的主要作用如下: 认证流程定义:通过指定tokenUrl参数为"ch07/login/token",指明客户端应该向该URL发送用户名和密码来换取访问令牌(Access Token)。这是OAuth2协议中的一种认证方式,适用于信任的客户端应用直接处理用户凭证的情况。 令牌验证:创建的oauth2_scheme实例可以在需要保护的API端点作为依赖项使用。当客户端携带访问令牌访问这些受保护的路由时,FastAPI会自动使用这个实例验证令牌的有效性。验证包括检查令牌格式、过期时间以及可能的撤销状态等。 简化令牌处理:通过这种方式,开发者无需手动解析和验证每个请求中的令牌。OAuth2PasswordBearer封装了这些逻辑,使得你可以专注于业务逻辑,同时保持应用的安全性。 安全性增强:OAuth2协议和Bearer Tokens机制提供了现代Web应用所需的安全标准,帮助防止未经授权的访问,同时也支持令牌的刷新和撤销机制,增强了应用的安全性。 简而言之,这行代码是FastAPI应用中实施OAuth2认证的基础配置之一,用于确保特定端点的访问需通过有效令牌验证,从而保护资源免受未授权访问。 是的,接口 "ch07/login/token" 的作用通常是接收用户的凭证(如用户名和密码),验证这些凭证后,如果用户身份合法,就会生成一个访问令牌(Access Token)。这个访问令牌随后会被返回给客户端。客户端在之后访问那些需要授权的API端点时, 需要在HTTP请求的Authorization头中携带这个令牌。它会验证从"ch07/login/token"接口获取并被客户端在后续请求中使用的令牌是否有效,从而决定是否允许该请求继续执行。所以,从"ch07/login/token"接口获得的令牌确实是直接用于与oauth2_scheme交互,以实现安全认证的。 """ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="ch07/login/token") def verify_password(plain_password, hashed_password): return crypt_context.verify(plain_password, hashed_password) def authenticate(username, password, account: Login): try: password_check = verify_password(password, account.passphrase) return password_check except Exception as e: print(e) return False def create_access_token(data: dict, expires_after: timedelta): plain_text = data.copy() expire = datetime.utcnow() + expires_after plain_text.update({"exp": expire}) encoded_jwt = jwt.encode(plain_text, SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt def get_current_user(token: str = Depends(oauth2_scheme), sess: Session = Depends(sess_db)): """ 目的是根据提供的JWT令牌验证用户身份,并从数据库中获取当前用户的信息 :param token: 从请求中提取的JWT令牌(默认通过 oauth2_scheme 依赖项注入) :param sess: 数据库会话对象(由 sess_db 依赖项提供) :return: """ credentials_exception = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}) try: # 尝试使用 jwt.decode 解码 JWT 令牌,其中 SECRET_KEY 和 ALGORITHM 分别是解码所需的密钥和算法 payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) username: str = payload.get("sub") if username is None: raise credentials_exception # 成功解码后,创建一个 TokenData 实例存储用户名 token_data = TokenData(username=username) except JWTError: raise credentials_exception loginrepo = LoginRepository(sess) user = loginrepo.get_all_login_username(token_data.username) if user is None: raise credentials_exception return user
2.5)实现 API 接口
from datetime import timedelta from fastapi import APIRouter, Depends, HTTPException from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.orm import Session from db_config.sqlalchemy_connect import sess_db from repository.login import LoginRepository from security.secure import authenticate, create_access_token, ACCESS_TOKEN_EXPIRE_MINUTES router = APIRouter() @router.post("/login/token") def login(form_data: OAuth2PasswordRequestForm = Depends(), sess: Session = Depends(sess_db)): """ 还是基于 自定义表单提交验证信息 :param form_data: :param sess: :return: """ username = form_data.username password = form_data.password loginrepo = LoginRepository(sess) account = loginrepo.get_all_login_username(username) if authenticate(username, password, account): access_token = create_access_token(data={"sub": username}, expires_after=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)) return {"access_token": access_token, "token_type": "bearer"} else: raise HTTPException(status_code=400, detail="Incorrect username or password")
from typing import List from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse from sqlalchemy.orm import Session from db_config.sqlalchemy_connect import sess_db from models.data.sqlalchemy_models import Signup, Login from models.request.signup import SignupReq from repository.signup import SignupRepository from security.secure import get_current_user router = APIRouter() @router.post("/signup/add") def add_signup(req: SignupReq, sess: Session = Depends(sess_db)): """ 没有调用 Depends get_user,所以 Swagger 页面接口后面没有 授权 Icon :param req: :param sess: :return: """ repo: SignupRepository = SignupRepository(sess) signup = Signup(password=req.password, username=req.username, id=req.id) result = repo.insert_signup(signup) if result is True: return signup else: return JSONResponse(content={'message': 'create signup problem encountered'}, status_code=500) @router.get("/signup/list", response_model=List[SignupReq]) def list_signup(current_user: Login = Depends(get_current_user), sess: Session = Depends(sess_db)): """ 通过这里的 Depends get_user 实现调用 框架的 OAuth2 表单,所以 Swagger 页面接口后面有 授权 Icon :param current_user: :param sess: :return: """ repo: SignupRepository = SignupRepository(sess) result = repo.get_all_signup() return result @router.patch("/signup/update") def update_signup(id: int, req: SignupReq, current_user: Login = Depends(get_current_user), sess: Session = Depends(sess_db)): signup_dict = req.model_dump(exclude_unset=True) repo: SignupRepository = SignupRepository(sess) result = repo.update_signup(id, signup_dict) if result: return JSONResponse(content={'message': 'profile updated successfully'}, status_code=201) else: return JSONResponse(content={'message': 'update profile error'}, status_code=500) @router.delete("/signup/delete") def delete_signup(id: int, current_user: Login = Depends(get_current_user), sess: Session = Depends(sess_db)): repo: SignupRepository = SignupRepository(sess) result = repo.delete_signup(id) if result: return JSONResponse(content={'message': 'profile updated successfully'}, status_code=201) else: return JSONResponse(content={'message': 'update profile error'}, status_code=500) @router.get("/signup/list/{id}", response_model=SignupReq) def get_signup(id: int, current_user: Login = Depends(get_current_user), sess: Session = Depends(sess_db)): repo: SignupRepository = SignupRepository(sess) result = repo.get_signup(id) return result
3)操作:
### 运行 1. 启动 main.py 2. 在 swagger 页面,有接口验证的 API 后面,点击解锁 Icon 进行身份验证。对应的接口有调用 Depends(get_current_user),调用了 OpenAPI 的 OAuth2,并调用了 "ch07/login/token" 3. "/ch07/login/token" 是通过 api\login.py 接口的端点 "/login/token" 实现的
4. Execute API 接口时,验证传入的JWT令牌有效性,然后从数据库中查找对应的用户,用于后续的请求处理中确定用户身份
7.5 创建基于作用域的授权
FastAPI 支持基于作用域的授权(scope-based authentication),它使用 OAuth2 协议的 scopes 参数指定一组用户可以访问哪些端点。 scopes 参数是一种放置在令牌中的权限,用于为用户提供额外的细粒度权限。
1)项目结构
The Application ├── api/ # 接口层 │ └── __init__.py │ └── login.py # router,接口定义,运行事务,使用 OAuth2PasswordRequestForm 表单提交 │ └── admin.py # router,接口定义,运行事务,使用 自定义的 OAuth2 进行身份验证 ├── db_config/ # 数据库配置 │ └── __init__.py │ └── sqlalchemy_connect.py # 设置数据库连接 ├── models/ # 模型层 │ |── data │ │ └── __init__.py │ │ └── sqlalchemy_models.py # 定义表及关联 │ |── requests │ │ └── __init__.py │ │ └── login.py # 定义请求结构 │ │ └── signup.py # 定义请求结构 │ │ └── tokens.py # 定义请求结构 │ └── __init__.py ├── repository/ # 存储库层 │ └── __init__.py │ └── login.py # 创建连接 CRUD │ └── signup.py # 创建连接 CRUD ├── security/ # 安全层 │ └── __init__.py │ └── secure.py # 自定义 OAuth2,使用 scopes 参数来指定一组用户可以访问哪些端点 │── __init__.py └── main.py # 生成 app,app.include_router(api 的 router)
2)具体实现:
2.1 ~ 2.5)数据库连接、模型类、CRUD事务 代码与 上面 JWT 相同
from dataclasses import field from typing import Optional, List from pydantic.dataclasses import dataclass @dataclass class Token: access_token: str token_type: str @dataclass class TokenData: username: Optional[str] = None scopes: List[str] = field(default_factory=lambda: [])
2.4)基于作用域的身份验证
from datetime import timedelta, datetime from typing import Optional from fastapi import HTTPException, status, Security, Depends from fastapi.openapi.models import OAuthFlows as OAuthFlowsModel from fastapi.requests import Request from fastapi.security import OAuth2, SecurityScopes from fastapi.security.utils import get_authorization_scheme_param from jose import jwt, JWTError from passlib.context import CryptContext from sqlalchemy.orm import Session from db_config.sqlalchemy_connect import sess_db from models.data.sqlalchemy_models import Login from models.request.tokens import TokenData from repository.login import LoginRepository crypt_context = CryptContext(schemes=["sha256_crypt", "md5_crypt"]) # 在 ps console 处,通过 "openssl rand -hex 32" 生成 SECRET_KEY SECRET_KEY = "882fdc269930e113056ec3f630be0c84214c0d0ed477075f0207369548e513a9" ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 class OAuth2PasswordBearerScopes(OAuth2): # 自定义 OAuth2 类, 添加两个构造函数参数: token_url, scopes 以执行身份验证流程 def __init__(self, tokenUrl: str, scheme_name: str = None, scopes: dict = None, auto_error: bool = True): if not scopes: scopes = {} # OAuthFlowsModel 类结构,password 对应的 OAuthFlowPassword-> OAuthFlow,对应的参数是 tokenUrl 和 scopes flows = OAuthFlowsModel(password={"tokenUrl": tokenUrl, "scopes": scopes}) super().__init__(flows=flows, scheme_name=scheme_name, auto_error=auto_error) """ 在FastAPI应用中,/ch07/login/token端点返回的access_token通过OAuth2PasswordBearerScopes类被消费的方式是通过依赖注入(Dependency Injection)机制实现的。 具体流程如下: 生成Token: 当用户通过/ch07/login/token端点成功登录后,后端会生成一个JWT访问令牌,并以JSON形式返回给客户端,如{"access_token": "generated_token", "token_type": "bearer"}。 客户端存储并使用Token: 客户端接收到此令牌后,通常会存储起来(例如在本地存储中,如果是Web应用),并在后续请求中携带它,尤其是在访问那些需要权限验证的API端点时。携带方式是将Token置于HTTP请求的Authorization头中,格式为Bearer generated_token。 定义依赖项: 在FastAPI应用的其他需要权限控制的端点处,你会看到类似这样的依赖项声明:token: str = Depends(oauth2_scheme)。这里的oauth2_scheme就是之前定义的OAuth2PasswordBearerScopes类的实例。 OAuth2PasswordBearerScopes处理Token: 当一个请求到达这些受保护的端点时,FastAPI会自动调用OAuth2PasswordBearerScopes类的__call__方法。这个方法会从请求的Authorization头中提取Bearer Token(即之前登录时获取的access_token)。 验证Token: 提取出的Token随后被传递给get_current_user函数(或其他你定义的处理逻辑),在这个函数内部,会使用之前定义的密钥(SECRET_KEY)和算法(ALGORITHM)来解码并验证JWT Token的有效性,包括检查过期时间、验证签名,并提取Token中的用户信息和作用域。 权限检查与用户信息返回: 如果Token验证通过,get_current_user会进一步检查Token中的作用域是否满足当前请求所需的权限(通过SecurityScopes参数指定),然后返回用户信息给实际处理请求的视图函数。如果验证失败,则会抛出相应的HTTP异常。 通过以上流程,OAuth2PasswordBearerScopes类有效地消费了由/ch07/login/token端点返回的access_token,实现了对API端点的安全访问控制。 """ async def __call__(self, request: Request) -> Optional[str]: # 对 接口 /ch07/login/token 接口返回的 token 进行解析 header_authorization: str = request.headers.get("Authorization") header_scheme, header_param = get_authorization_scheme_param(header_authorization) scheme = None param = None if header_scheme.lower() == "bearer": authorization = True scheme = header_scheme param = header_param else: authorization = False if not authorization or scheme.lower() != "bearer": if self.auto_error: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authenticated") else: return None else: return param # 构建权限字典 # 由于没有办法将角色权限直连数据库,所以目前直接存储在该构造函数中 """ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="ch07/login/token") 这行代码定义了一个基于OAuth2密码流(Resource Owner Password Credentials Grant)的认证方案,使用FastAPI的OAuth2PasswordBearer类。它的主要作用如下: 认证流程定义:通过指定tokenUrl参数为"ch07/login/token",指明客户端应该向该URL发送用户名和密码来换取访问令牌(Access Token)。这是OAuth2协议中的一种认证方式,适用于信任的客户端应用直接处理用户凭证的情况。 令牌验证:创建的oauth2_scheme实例可以在需要保护的API端点作为依赖项使用。当客户端携带访问令牌访问这些受保护的路由时,FastAPI会自动使用这个实例验证令牌的有效性。验证包括检查令牌格式、过期时间以及可能的撤销状态等。 简化令牌处理:通过这种方式,开发者无需手动解析和验证每个请求中的令牌。OAuth2PasswordBearer封装了这些逻辑,使得你可以专注于业务逻辑,同时保持应用的安全性。 安全性增强:OAuth2协议和Bearer Tokens机制提供了现代Web应用所需的安全标准,帮助防止未经授权的访问,同时也支持令牌的刷新和撤销机制,增强了应用的安全性。 简而言之,这行代码是FastAPI应用中实施OAuth2认证的基础配置之一,用于确保特定端点的访问需通过有效令牌验证,从而保护资源免受未授权访问。 是的,接口 "ch07/login/token" 的作用通常是接收用户的凭证(如用户名和密码),验证这些凭证后,如果用户身份合法,就会生成一个访问令牌(Access Token)。这个访问令牌随后会被返回给客户端。客户端在之后访问那些需要授权的API端点时, 需要在HTTP请求的Authorization头中携带这个令牌。它会验证从"ch07/login/token"接口获取并被客户端在后续请求中使用的令牌是否有效,从而决定是否允许该请求继续执行。所以,从"ch07/login/token"接口获得的令牌确实是直接用于与oauth2_scheme交互,以实现安全认证的。 """ oauth2_scheme = OAuth2PasswordBearerScopes( tokenUrl="/ch07/login/token", scopes={"admin_read": "admin role that has read only role", "admin_write": "admin role that has write only role", "user": "valid user of the application", "guest": "visitor of the site", }, ) def verify_password(plain_password, hashed_password): return crypt_context.verify(plain_password, hashed_password) def authenticate(username, password, account: Login): try: password_check = verify_password(password, account.passphrase) return password_check except Exception as e: print(e) return False def create_access_token(data: dict, expires_delta: timedelta): to_encode = data.copy() expire = datetime.utcnow() + expires_delta to_encode.update({"exp": expire}) encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt def get_current_user(security_scopes: SecurityScopes, token: str = Depends(oauth2_scheme), sess: Session = Depends(sess_db)): """ 实际上,当你定义 token: str = Depends(oauth2_scheme) 时,FastAPI中的OAuth2PasswordBearer并不会直接从你提到的响应 {"access_token": "token_value_xxxx", "token_type": "bearer"} 中提取出仅"access_token"的值"token_value_xxx"。相反,整个token字符串, 通常是包含前缀"Bearer "的部分,会作为参数传递给你的get_current_user函数。 对于响应中的 {"access_token": "token_value_xxx", "token_type": "bearer"},在标准的OAuth2流程中,客户端会将token类型和访问令牌拼接成 Authorization头部,格式如:Authorization: Bearer token_value_xxxx。当FastAPI接收到带有此头部的请求时,oauth2_scheme依赖项实际上会提取 完整的token字符串"Bearer token_value_xxxx",而不仅仅是"token_value_xxxx"。=> 将调用上面自定义的 OAuth2PasswordBearerScopes 的 __call__() 所以在你的get_current_user函数里,token实际上会接收到来自请求的完整的认证头信息,即 "Bearer token_value_xxxx"。但实际操作中,为了使 OAuth2PasswordBearer正常工作,客户端只需发送"Bearer "后面的部分,即"token_value_xxxx",而FastAPI的OAuth2PasswordBearer机制恰能妥善 处理这种情况,它明白需要的是"token_value_xxxx"这一部分来进行后续验证。 debug 发现,这里的 token 的值就是页面的 "token_value_xxxx" """ if security_scopes.scopes: authenticate_value = f'Bearer scope={security_scopes.scope_str}' else: authenticate_value = "Bearer" credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", headers={"WWW-Authenticate": authenticate_value}, ) try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) username: str = payload.get("sub") if username is None: raise credentials_exception token_scopes = payload.get("scopes", []) token_data = TokenData(scopes=token_scopes, username=username) except JWTError: raise credentials_exception loginrepo = LoginRepository(sess) user = loginrepo.get_all_login_username(token_data.username) if user is None: raise credentials_exception for scope in security_scopes.scopes: if scope not in token_data.scopes: raise credentials_exception return user def get_current_valid_user(current_user: Login = Security(get_current_user, scopes=["user"])): if current_user is None: raise HTTPException(status_code=400, detail="Invalid user") return current_user
2.5)实现 API 接口
from datetime import timedelta from fastapi import APIRouter, Depends, HTTPException from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.orm import Session from db_config.sqlalchemy_connect import sess_db from repository.login import LoginRepository from security.secure import authenticate, create_access_token, ACCESS_TOKEN_EXPIRE_MINUTES router = APIRouter() @router.post("/login/token") def login(form_data: OAuth2PasswordRequestForm = Depends(), sess: Session = Depends(sess_db)): username = form_data.username password = form_data.password loginrepo = LoginRepository(sess) account = loginrepo.get_all_login_username(username) if authenticate(username, password, account): access_token = create_access_token(data={"sub": username, "scopes": form_data.scopes}, expires_delta=timedelta(ACCESS_TOKEN_EXPIRE_MINUTES)) # 返回的 信息,给 security\secure.py 的 get_current_user() 调用 # get_current_user 的 token -> oauth2_scheme -> tokenUrl="/ch07/login/token", # 最主要的是 "access_token",并且该键名称不能更改 return {"access_token": access_token, "jwt_token_type": "bearer"} else: raise HTTPException(status_code=400, detail="Incorrect username or password")
from typing import List from fastapi import APIRouter, Depends, Security from fastapi.responses import JSONResponse from sqlalchemy.orm import Session from db_config.sqlalchemy_connect import sess_db from models.data.sqlalchemy_models import Signup, Login from models.request.signup import SignupReq from repository.signup import SignupRepository from security.secure import get_current_valid_user router = APIRouter() @router.post("/signup/add") def add_signup(req: SignupReq, sess: Session = Depends(sess_db)): repo: SignupRepository = SignupRepository(sess) signup = Signup(id=req.id, username=req.username, password=req.password) result = repo.insert_signup(signup) if result is True: return signup else: return JSONResponse(content={"message": "create signup problem encountered"}, status_code=500) @router.get("/signup/list", response_model=List[SignupReq]) # 注入 get_current_valid_user,替换了 Depends 类,且为每个 API 服务分配作用域(通过 scopes 属性,定义了限制用户访问的有效作用域参数列表) def list_signup(current_user: Login = Security(get_current_valid_user, scopes=["admin_read"]), sess: Session = Depends(sess_db)): """ 处理用户注册的,但在处理请求前会进行安全检查,确保操作是由具有特定权限的管理员发起,并且会建立一个数据库会话来执行必要的数据操作 Security装饰器在FastAPI框架中主要用于实现端点的安全访问控制。它的核心作用是确保在执行某个路径操作(如API endpoint)之前,满足特定的安全条件。 这包括但不限于验证用户身份、检查权限范围或者执行任何自定义的安全验证逻辑。 :param current_user: :param sess: :return: """ repo: SignupRepository = SignupRepository(sess) result = repo.get_all_signup() return result @router.patch("/signup/update") def update_signup(id: int, req: SignupReq, current_user: Login = Security(get_current_valid_user, scopes=["admin_write"]), sess: Session = Depends(sess_db)): signup_dict = req.model_dump(exclude_unset=True) repo: SignupRepository = SignupRepository(sess) result = repo.update_signup(id, signup_dict) if result: return JSONResponse(content={'message': 'profile updated successfully'}, status_code=201) else: return JSONResponse(content={'message': 'update profile error'}, status_code=500)
3)操作:
1)swagger 界面,/signup/list 和 /signup/update 两个接口有身份验证
2)以 /signup/list 接口为例
点击接口后面的锁 icon,输入 用户名及密码,及勾选响应的 scopes,这将调用 接口 /login/token,
- 用户名及密码:在 /login/token 接口中引用 security/secure.py 的 authenticate 进行校验成功后,将调用 security/secure.py 的 create_access_token,并返回 jwt 信息
- 通过该 API 的 Security() 的 scopes 及其依赖的 get_current_valid_user 的 scopes,其作用域为 "admin_read" 和 "user",所以在下图的 scopes 中出现了这两个 scope
另外,最上面的 Authorize,其 scope 为 security\secure.py 的 oauth2_scheme 定义的 scopes 的4个 scope
所以流程是:
1). 初始化:每个接口及接口后面的验证 icon(形成表单,包含 username, password, 该接口对应的 scopes 等信息),右上角的验证 icon(表单,包含username, password,所有接口的 scopes 信息等)
2)点击接口的验证 icon,-> 经过 secure.py 的 authenticate() 验证 username, password, 返回 {"access_token": access_token, "jwt_token_type": "bearer"} 给 OAuth2PasswordBearerScopes 类;
3)接口对应的 execute() 时,-> 依赖 get_valid_user -> 依赖 get_current_user -> 依赖 oauth2_scheme (OAuth2PasswordBearerScopes 类的实例),运行该类的 __call__(),解析 request.header,并最终获取到
"Bearer token_value_xxxx" 值,而 OAuth2PasswordBearer 机制可以妥善处理该情况,所以知道处理该信息的后半部分,所以 get_current_user() 的 token 的值其实就是后半部分 "token_value_xxxx"
4)再在 get_current_user() 的后面对 token 进行解码验证,无异常后,调用 loginrepo 对象的 get_all_login_username 方法
7.8 使用内置中间件进行身份验证
对比 JWT 验证方式,这里通过中间件的方式对 API 端点进行身份验证。
- main.py 中通过 Starlette 中间件 AuthenticationMiddleware 实现自定义身份验证,然后在 API 端点加装饰器 @requires(scopes="authenticated")
1)项目结构
The Application ├── api/ # 接口包 │ └── __init__.py │ └── login.py # router,接口定义,运行事务 │ └── admin.py # router,接口定义,运行事务,使用 AuthenticationMiddleware OAuth2 进行身份验证 ├── db_config/ # 数据库配置 │ └── __init__.py │ └── sqlalchemy_connect.py # 设置数据库连接 ├── models/ # 模型层 │ |── data │ │ └── __init__.py │ │ └── sqlalchemy_models.py # 定义表及关联 │ |── requests │ │ └── __init__.py │ │ └── login.py # 定义请求结构 │ │ └── signup.py # 定义请求结构 │ └── __init__.py ├── repository/ # 存储库包 │ └── __init__.py │ └── login.py # 创建连接 CRUD │ └── signup.py # 创建连接 CRUD ├── security/ # 安全层 │ └── __init__.py │ └── secure.py # 自定义 AuthenticationBackend │── __init__.py └── main.py # 生成 app,app.include_router(api 的 router)
2)具体实现:
2.1 ~ 2.5)数据库连接、模型类、CRUD事务 代码与 上面 JWT 相同,差异部分如下:
2.4)身份验证
from fastapi import Depends from fastapi.security import HTTPBasic from passlib.context import CryptContext from starlette.authentication import AuthenticationBackend, AuthCredentials, BaseUser, AuthenticationError, SimpleUser from db_config.sqlalchemy_connect import sess_db from models.data.sqlalchemy_models import Login crypt_context = CryptContext(schemes=["sha256_crypt", "md5_crypt"]) # 实例化 HTTPBasic 类并将其注入每个 API 服务以保护端点访问 # http_basic 一旦注入 API 服务,就会使浏览器弹出一个登录表单,输入 username 和 password http_basic = HTTPBasic() def verify_password(plain_password, hashed_password): return crypt_context.verify(plain_password, hashed_password) def authenticate(username, password, account: Login): try: return verify_password(password, account.passphrase) except Exception as e: print(e) return False class UsernameAuthBackend(AuthenticationBackend): def __init__(self, username, sess=Depends(sess_db)): self.username = username async def authenticate(self, request) -> tuple[AuthCredentials, BaseUser] | None: """ 重写 authenticate(),检查 Authorization 凭据是否为 Bearer 类,并验证 username 令牌是否等效于中间件提供的固定用户名凭据 :param request: :return: """ if "Authorization" not in request.headers: return auth = request.headers["Authorization"] try: scheme, username = auth.split() if scheme.lower().strip() != "basic".strip(): return except Exception as e: raise AuthenticationError(f"Invalid basic auth credentials, {e}") if not username == self.username: return # 这里的 "authenticated" 对应 AuthCredentials 类的入参 scopes return AuthCredentials(["authenticated"]), SimpleUser(username)
2.5)实现 API 接口
from fastapi import APIRouter, Depends, HTTPException from fastapi.security import HTTPBasicCredentials from sqlalchemy.orm import Session from db_config.sqlalchemy_connect import sess_db from repository.login import LoginRepository from security.secure import authenticate, http_basic router = APIRouter() @router.get("/login") def login(credentials: HTTPBasicCredentials = Depends(http_basic), sess: Session = Depends(sess_db)): loginrepo = LoginRepository(sess) account = loginrepo.get_all_login_username(credentials.username) if authenticate(credentials.username, credentials.password, account) and account is not None: return account else: raise HTTPException(status_code=400, detail="Incorrect username or password")
from typing import List from fastapi import APIRouter, Depends from fastapi.requests import Request from sqlalchemy.orm import Session from starlette.authentication import requires from db_config.sqlalchemy_connect import sess_db from models.request.signup import SignupReq from repository.signup import SignupRepository router = APIRouter() @router.get("/signup/list", response_model=List[SignupReq]) # 装饰器确保只有经过身份验证的用户才能访问这个路由。如果用户未通过身份验证,则会被阻止访问此路由。这里的 "authenticated" 是参数 scopes @requires(scopes="authenticated") def list_signup(request: Request, sess: Session = Depends(sess_db)): repo: SignupRepository = SignupRepository(sess) return repo.get_all_signup()
总结:几种不同的身份验证比较
身份验证方式 | 具体方案 | 用途 | 具体实现 | 项目应用 |
Basic 身份验证 | 基于用户名/密码 Base64 编码 | 保护 API 端点 |
1)API 参数上 Depends(http_basic) 2)方法中 authenticate() |
几乎不用 |
Digest 身份验证 | 基于 hash 处理后的 用户名/密码 | 保护 API 端点 | 方法中 Depdens(authenticate) | 几乎不用 |
OAuth2 | 基于密码 | 验证 API 端点 |
OAuth2PasswordBearer:基于密码的身份验证的提供者; OAuth2PasswordRequestForm:表单主体,可获取 用户名、密码; --- 被注入到 login(); login():获取表单的信息,调用 secure.authenticate() 进行 password 验证,并生成用户名、 token_type 的 OAuth2 规范结构; API 端点:参数上 Depends(get_current_user); get_current_user() 在参数上 Depends(oauth2_scheme),并在方法中验证 token(username); oauth2_scheme:通过 OAuth2PasswordBearer 获取 toeknUrl,即 login 接口的身份信息; |
|
JWT (OAuth2规范) |
OAuth2 规范 + JWT 编码/解码的 token 比密码更安全的经数字签名的令牌(token) |
验证 API 端点 |
OAuth2PasswordBearer:基于密码的身份验证的提供者; OAuth2PasswordRequestForm:表单主体,可获取 用户名、密码; --- 被注入到 login(); login():获取表单的信息,调用 secure.authenticate() 进行 password 验证,并生成用户名、expire 的 json 体、 secure_key、algorithm 为参数的 JWT encode 后的 access_token; API 端点:参数上 Depends(get_current_user); get_current_user():在参数上 Depends(oauth2_scheme), 并在方法中 jwt decode token,并验证 username; oauth2_scheme:通过 OAuth2PasswordBearer 获取 toeknUrl(即 login 接口的身份信息); |
|
基于作用域的授权 |
OAuth2 规范 + JWT 编码/解码的 token 比上面 JWT 增加了 OAuth2 的 scopes,实现更细粒度限制 |
验证 API 端点 | OAuth2PasswordBearer:基于密码的身份验证的提供者;
OAuth2PasswordRequestForm:表单主体,可获取 用户名、密码; --- 被注入到 login(); login():获取表单的信息,调用 secure.authenticate() 进行 password 验证,并生成用户名、expire 的 json 体、 scopes、secure_key、algorithm 为参数的 JWT encode 后的 access_token; API 端点:参数上 Security(get_current_valid_user, scopes=["admin_read"]); get_current_valid_user():在参数上 Security(get_current_user, scopes=["user"]); get_current_user():参数上 (security_scopes, Depends(oauth2_scheme)),并在方法中 验证 security_scopes, jwt decode token,并验证 username oauth2_scheme:通过 自定义的 OAuth2PasswordBearerScopes 获取 toeknUrl(即 login 接口的身份信息)、定义 scopes; |
|
使用中间件进行身份验证 |
对比 JWT 验证,使用了中间件方式对API端点进行身份验证 ** 也可以 扩展 UsernameAuthBackend添加角色授权等 |
验证 API 端点 |
main.py: middleware = [Middleware(AuthenticationMiddleware, backend=UsernameAuthBackend("YnJ1Y2U6d2luMQ=="))] secure.py:UsernameAuthenBackend 类继承 AuthenticationBackend,并重写 authenticate(),验证 request.headers["Authorization"] 并返回 AuthCredentials(scopes=["authenticated"]), SimpleUser(username) API 端点:使用装饰器 @requires("authenticated"),即表明 secure.py 的身份验证部分已经通过 |