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