FastAPI 使用 session 认证

前言

FastAPI借助底层 Starlette 的 SessionMiddleware 可以很方便地实现基于签名 cookie 的session 认证。基本原理是将 Session 数据序列化并签名后存入客户端的 Cookie 中,服务器在后续请求中验证签名并还原数据,从而"记住"用户状态。

使用 SesionMiddleware 之前,还要额外安装 itsdangerous。这个库用于安全地签名和序列化数据,确保数据在传输过程中没有被篡改。

NOTE: 签名只能防篡改,不能防读取。cookie 中的 payload 只是 base64 编码,任何人解一下就能看到内容,因此不要在其中存放敏感信息。

cookie 上限是 4kb,不要存太多信息。

服务端无法单方面主动撤销session。

shell 复制代码
python -m pip install itsdangerous

main.py

python 复制代码
from http import HTTPStatus
from fastapi import FastAPI, Request, Depends, HTTPException
from fastapi.responses import JSONResponse
from starlette.middleware.sessions import SessionMiddleware
from pydantic import BaseModel, Field
import uvicorn

app = FastAPI()

app.add_middleware(
    SessionMiddleware,
    secret_key="your-secret-key-here",  # 务必替换
    max_age=300,                        # Session 有效期(秒)
    same_site="lax",                    # CSRF 防护
    https_only=False,                   # 生产环境使用 HTTPS 时应设为 True,对应 cookie 的 Secure 标志,表示仅通过 HTTPS 发送
)

mock_db = {
    "users": [
        {
            "username": "zhangsan",
            "password": "123456",
        },
        {
            "username": "lisi",
            "password": "lisi_pass",
        },
    ],
}

class LoginRequest(BaseModel):
    username: str = Field(..., description="用户名")
    password: str = Field(..., description="密码")

@app.post("/login")
async def login(request: Request, payload: LoginRequest):
    # 模拟登录逻辑
    flag = False
    for user in mock_db["users"]:
        if user["username"] == payload.username and user["password"] == payload.password:
            flag = True
            break
    if not flag:
        return JSONResponse(content={"error": {"code": HTTPStatus.UNAUTHORIZED, "description": "登录失败"}}, status_code=HTTPStatus.UNAUTHORIZED)

    # 登录成功后,将用户名存储到 session 中
    request.session["user"] = payload.username

    return JSONResponse(content={"message": "登录成功"}, status_code=HTTPStatus.OK)

async def get_current_user(request: Request):
    user_id = request.session.get("user")
    if not user_id:
        raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="未登录")

    return {"user_id": user_id}

@app.get("/profile")
async def get_profile(current_user: dict = Depends(get_current_user)):
    return JSONResponse(content={"user_id": current_user["user_id"]}, status_code=HTTPStatus.OK)

@app.post("/logout")
async def logout(request: Request, current_user: dict = Depends(get_current_user)):
    # 退出登录后,清除 session 中的用户信息
    # 对于签名 cookie,cookie 实际上还是存在的,客户端依旧可以用未过期的 cookie 来访问资源
    request.session.clear()
    return JSONResponse(content={"message": "退出成功"}, status_code=HTTPStatus.OK)

if __name__ == "__main__":
    uvicorn.run(app, host="127.0.0.1", port=8000)

用curl请求测试

shell 复制代码
# 登录
$ curl -v -X POST http://127.0.0.1:8000/login -H 'Content-Type:application/json' -d '{"username": "zhangsan", "password": "123456"}'                             
Note: Unnecessary use of -X or --request, POST is already inferred.
*   Trying 127.0.0.1:8000...
* Connected to 127.0.0.1 (127.0.0.1) port 8000
* using HTTP/1.x
> POST /login HTTP/1.1
> Host: 127.0.0.1:8000
> User-Agent: curl/8.14.1
> Accept: */*
> Content-Type:application/json
> Content-Length: 46
> 
* upload completely sent off: 46 bytes
< HTTP/1.1 200 OK
< date: Tue, 15 Sep 2026 13:01:34 GMT
< server: uvicorn
< content-length: 26
< content-type: application/json
< vary: Cookie
< set-cookie: session=eyJ1c2VyIjogInpoYW5nc2FuIn0=.aqlBrg.dCAxuZomyvLQiaL-dZP9zYL1dVg; path=/; Max-Age=300; httponly; samesite=lax
< 
* Connection #0 to host 127.0.0.1 left intact
{"message":"登录成功"}

访问受保护接口,不带 cookie 会认证失败

shell 复制代码
$ curl -v http://127.0.0.1:8000/profile 
*   Trying 127.0.0.1:8000...
* Connected to 127.0.0.1 (127.0.0.1) port 8000
* using HTTP/1.x
> GET /profile HTTP/1.1
> Host: 127.0.0.1:8000
> User-Agent: curl/8.14.1
> Accept: */*
> 
* Request completely sent off
< HTTP/1.1 401 Unauthorized
< date: Tue, 15 Sep 2026 13:01:40 GMT
< server: uvicorn
< content-length: 22
< content-type: application/json
< vary: Cookie
< 
* Connection #0 to host 127.0.0.1 left intact
{"detail":"未登录"}

# 带 cookie 访问受保护接口
$ curl -v http://127.0.0.1:8000/profile -b 'session=eyJ1c2VyIjogInpoYW5nc2FuIn0=.aqlBrg.dCAxuZomyvLQiaL-dZP9zYL1dVg; path=/; Max-Age=300; httponly; samesite=lax'
*   Trying 127.0.0.1:8000...
* Connected to 127.0.0.1 (127.0.0.1) port 8000
* using HTTP/1.x
> GET /profile HTTP/1.1
> Host: 127.0.0.1:8000
> User-Agent: curl/8.14.1
> Accept: */*
> Cookie: session=eyJ1c2VyIjogInpoYW5nc2FuIn0=.aqlBrg.dCAxuZomyvLQiaL-dZP9zYL1dVg; path=/; Max-Age=300; httponly; samesite=lax
> 
* Request completely sent off
< HTTP/1.1 200 OK
< date: Tue, 15 Sep 2026 13:01:45 GMT
< server: uvicorn
< content-length: 22
< content-type: application/json
< vary: Cookie
< 
* Connection #0 to host 127.0.0.1 left intact
{"user_id":"zhangsan"}

退出登录。这里因为用的是签名cookie,服务端无法单方面作废,实际上客户端依旧可以用未过期的cookie来访问资源,需要客户端自行删除。合规的浏览器会删除该cookie,但不合规的客户端嘛......

shell 复制代码
$ curl -v -X POST http://127.0.0.1:8000/logout -b 'session=eyJ1c2VyIjogInpoYW5nc2FuIn0=.aqlBrg.dCAxuZomyvLQiaL-dZP9zYL1dVg; path=/; Max-Age=300; httponly; samesite=lax'
*   Trying 127.0.0.1:8000...
* Connected to 127.0.0.1 (127.0.0.1) port 8000
* using HTTP/1.x
> POST /logout HTTP/1.1
> Host: 127.0.0.1:8000
> User-Agent: curl/8.14.1
> Accept: */*
> Cookie: session=eyJ1c2VyIjogInpoYW5nc2FuIn0=.aqlBrg.dCAxuZomyvLQiaL-dZP9zYL1dVg; path=/; Max-Age=300; httponly; samesite=lax
> 
* Request completely sent off
< HTTP/1.1 200 OK
< date: Tue, 15 Sep 2026 13:01:57 GMT
< server: uvicorn
< content-length: 26
< content-type: application/json
< vary: Cookie
< set-cookie: session=null; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; httponly; samesite=lax
< 
* Connection #0 to host 127.0.0.1 left intact
{"message":"退出成功"}

服务端内存session

上面签名 cookie 只能做到认证,做不到主动作废session,这就需要服务端Session来解决了。

服务端内存中存放 session 信息,一般仅用于单进程部署或本地开发测试。为了演示,下面的示例代码写得很简单,其中 MemorySession 的 session 生命周期管理应当更加严谨。

生产环境中,通常还是用数据库来存放 session 信息,以便多实例间数据共享。

如果需要把用户登录状态从多端同时踢出,可以把用户名或用户ID 设置为key,session id等信息放到一个集合中。多端踢出时,根据用户ID的key找到这个集合,然后清空这个集合即可。

python 复制代码
import time
from http import HTTPStatus
from typing import Dict
from uuid import uuid4

import uvicorn
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from starlette.middleware.sessions import SessionMiddleware

app = FastAPI()

SESSION_TTL = 300
SESSION_SECRET_KEY = "your-secret-key-here"  # 务必替换

app.add_middleware(
    SessionMiddleware,
    secret_key=SESSION_SECRET_KEY,  # 务必替换
    max_age=SESSION_TTL,  # Session 有效期(秒)
    same_site="lax",  # CSRF 防护
    https_only=False,  # 生产环境使用 HTTPS 时应设为 True,对应 cookie 的 Secure 标志,表示仅通过 HTTPS 发送
)

mock_db = {
    "users": [
        {
            "username": "zhangsan",
            "password": "123456",  # 在生产环境中,数据传输和存储都不应当使用明文密码
        },
        {
            "username": "lisi",
            "password": "lisi_pass",
        },
    ],
}


class MemorySessions:
    session_cache: Dict[
        str, tuple[str, int]
    ] = {}  # 这里用类属性当单例用,实际生产环境中应注意避免使用这种方式

    def set(self, username: str) -> str:
        """设置 session

        Returns:
            str: session_id
        """
        session_id = str(uuid4())
        expire_at = int(time.time()) + SESSION_TTL

        self.session_cache[session_id] = (username, expire_at)
        return session_id

    def get(self, sess_id: str) -> str:
        """获取 session

        Returns:
            str: username
        """
        item = self.session_cache.get(sess_id)
        if not item:
            return ""

        username, expire_at = item
        if expire_at < int(time.time()):
            del self.session_cache[sess_id]
            return ""
        # SessionMiddleware 只能滑动设置一个相对过期时间,如果要刷新过期时间,建议另行实现
        # self.session_cache[sess_id] = (username, int(time.time()) + SESSION_TTL)
        return username

    def delete(self, sess_id: str = ""):
        """删除 session"""
        self.session_cache.pop(sess_id, None)

    def clean(self):
        """清除所有 session,踢出所有用户"""
        self.session_cache.clear()


class LoginRequest(BaseModel):
    username: str = Field(..., description="用户名")
    password: str = Field(..., description="密码")


@app.post("/login")
async def login(request: Request, payload: LoginRequest):
    # 模拟登录逻辑
    flag = False
    for user in mock_db["users"]:
        if (
            user["username"] == payload.username
            and user["password"] == payload.password
        ):
            flag = True
            break
    if not flag:
        return JSONResponse(
            content={
                "error": {"code": HTTPStatus.UNAUTHORIZED, "description": "登录失败"}
            },
            status_code=HTTPStatus.UNAUTHORIZED,
        )

    # 登录成功后,将用户名存储到 session 中
    sess = MemorySessions()
    session_id = sess.set(payload.username)
    request.session["session_id"] = session_id

    return JSONResponse(content={"message": "登录成功"}, status_code=HTTPStatus.OK)


async def get_current_user(request: Request) -> dict:
    """获取当前登录用户, 用于依赖注入"""
    session_id = request.session.get("session_id")
    if not session_id:
        raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="未登录")

    sess = MemorySessions()
    user = sess.get(session_id)
    if not user:
        request.session.clear()  # 清除 session 中的用户信息
        raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="未登录")

    return {"user": user, "session_id": session_id}


@app.get("/profile")
async def get_profile(current_user: dict = Depends(get_current_user)):
    return JSONResponse(
        content={"user": current_user["user"]}, status_code=HTTPStatus.OK
    )


@app.post("/logout")
async def logout(request: Request, current_user: dict = Depends(get_current_user)):
    """退出登录"""
    # NOTE: 当用户的 cookie 已经过期时, get_current_user 会抛出异常
    # 这里假装用户在 cookie 还未过期时主动登出

    request.session.clear()

    sess = MemorySessions()
    sess.delete(current_user["session_id"])
    return JSONResponse(content={"message": "退出成功"}, status_code=HTTPStatus.OK)


# @app.get("/sessions")
# async def get_cached():
#     """获取所有 session, 仅用于调试"""
#     sess = MemorySessions()
#     return JSONResponse(
#         content={"sessions": sess.session_cache}, status_code=HTTPStatus.OK
#     )


if __name__ == "__main__":
    uvicorn.run(app, host="127.0.0.1", port=8000)
相关推荐
lsswear1 小时前
Python 并发 线程
开发语言·python
Ivanqhz2 小时前
MLIR OpBuilder
开发语言·python·mlir
威联通安全存储3 小时前
TS-h2287XU-RP 在家电制造总装与质检数据场景的部署
python·制造
泡泡鱼(敲代码中)4 小时前
Python 字符串 str 完整学习笔记
python·学习
troy1284 小时前
Python 基础语法(八):Web 后端开发、数据分析与可视化、网络爬虫、人工智能 / 大模型应用
前端·python·数据分析
我不会起名字3225 小时前
一天一道算法题(34):回溯法的经典例题(子集)
java·数据结构·python·算法·golang·深度优先·力扣
ThornArmor5 小时前
重铸1996|肉体渲染:关节的裂缝:分段模型积木拼装与未成熟的 RSP 矩阵骨骼动画形变
c语言·汇编·c++·python·硬件架构·游戏机
毕业设计7035 小时前
(免费领源码)基于Python的博物馆研学活动管理系统的设计与实现22724- java、PHP、python、C#、小程序、大数据、单片机、网络工程等)
python·mysql·随机森林·pycharm·django·flask·推荐算法
kyrie_sakura6 小时前
python学习笔记15 -- Anaconda,Jupyter,numpy
python·学习·jupyter·numpy