登录接口与 JWT 发放
本教程共 50 篇 · 第 28 篇 · 更新于 2026-08-12 · 约 8 分钟阅读
本节目标:亲手写出
/token登录接口,从表单里取账号密码、校验哈希,再用 PyJWT 签发一张带用户身份和过期时间的 JWT 令牌返回给前端。
28-1 这一章要拼出什么
前面两章我们准备好了:认证靠密码、密码要靠哈希存。现在差最后一块——把”核对密码”变成一个真正的 HTTP 接口,并给用户发令牌。
我们要做的端点约定叫 /token。前端用表单(不是 JSON)发来 username 和 password,我们核对通过后,返回:
{
"access_token": "eyJhbGci...一段很长的JWT",
"token_type": "bearer"
}
这个返回结构是 OAuth2 规范定的,字段名必须叫 access_token 和 token_type。照着写,各种工具(包括 FastAPI 自带文档)才能认。
Note密码流要求账号密码用表单格式发送,字段名必须叫
username和password。这是 OAuth2 规范,不能改成 email 或别的名字,否则标准文档按钮会失灵。
28-2 先装依赖
登录要哈希密码,还要签发 JWT。对应的依赖是:
pip install "pwdlib[bcrypt]" PyJWT python-multipart
pwdlib[bcrypt]:负责密码哈希与校验(上一章已讲)。PyJWT:负责 JWT 的编码(签发)和解码(校验),是 Python 生态里最常用的 JWT 库,也是 FastAPI 官方示例当前使用的库。python-multipart:负责解析表单数据,OAuth2PasswordRequestForm需要它。
Note旧教程常出现的
python-jose自 2021 年起已停止维护,FastAPI 官方文档也已改用 PyJWT;passlib同样停更且与新版 bcrypt 不兼容,所以本书统一使用 pwdlib + PyJWT。
28-3 用 OAuth2PasswordRequestForm 收表单
FastAPI 提供了一个现成依赖 OAuth2PasswordRequestForm,专门用来接收密码流的标准表单字段。它帮你把 username、password、可选的 scope 等从表单里解析出来。
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from datetime import datetime, timedelta, timezone
app = FastAPI()
在 /token 路径操作里,把它作为依赖注入:
@app.post("/token")
async def login_for_access_token(
form_data: OAuth2PasswordRequestForm = Depends(),
):
...
注入后,form_data.username 和 form_data.password 就是用户发来的账号密码。form_data.scopes 是权限列表(本章先不用,第 30 章再讲)。
Tip
OAuth2PasswordRequestForm只是个普通依赖类,不是安全方案。它不像OAuth2PasswordBearer那样会被写进 OpenAPI。它纯粹是帮你省去手写一堆Form(...)参数的工具。
28-4 写一个 authenticate 校验函数
核对密码要查你的用户来源。为讲解清晰,这里用字典假扮数据库,真实项目换成数据库查询即可。
先准备哈希过的用户数据,并复用第 27 章的 password_hash:
from pwdlib import PasswordHash
from pwdlib.hashers.bcrypt import BcryptHasher
password_hash = PasswordHash((BcryptHasher(),))
fake_users_db = {
"johndoe": {
"username": "johndoe",
"hashed_password": password_hash.hash("secret"),
},
}
再写 authenticate_user,逻辑是:拿到用户 → 没有就失败 → 比对密码哈希 → 不对也失败 → 都过才返回用户:
def verify_password(plain: str, hashed: str) -> bool:
return password_hash.verify(plain, hashed)
def authenticate_user(username: str, password: str):
user = fake_users_db.get(username)
if not user:
return None
if not verify_password(password, user["hashed_password"]):
return None
return user
Note这里账号不存在和密码错误都返回
None,对外统一报”账号或密码错误”。故意不区分,是为了不暴露哪个用户名存在,减少被枚举的风险。
28-5 准备 JWT 的密钥与算法
签发 JWT 需要一个只有你自己知道的密钥(SECRET_KEY),以及签名算法。密钥用随机串,别用示例里的,正式环境要从环境变量读。
生成密钥的命令:
openssl rand -hex 32
代码里这样配置:
SECRET_KEY = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
SECRET_KEY:签名用的密钥,泄露了任何人都能伪造令牌。ALGORITHM = "HS256":用 HMAC + SHA256 签名,对称算法,最常用。ACCESS_TOKEN_EXPIRE_MINUTES:令牌 30 分钟过期,按需调整。
28-6 用 PyJWT 签发 JWT
核心函数 create_access_token。它把”要塞进令牌的数据”编成 JWT 字符串:
import jwt
def create_access_token(
data: dict, expires_delta: timedelta | None = None
) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=15)
)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(
to_encode, SECRET_KEY, algorithm=ALGORITHM
)
return encoded_jwt
exp 是 JWT 标准字段,意思是”过期时间”。到这个点之后令牌作废。datetime.now(timezone.utc) 生成带时区的 UTC 时间,避免时区混乱(旧写法 datetime.utcnow() 在 Python 3.12+ 已弃用)。
Tip
sub(subject)是 JWT 规范里代表”主体”的字段,通常放用户标识。我们在登录时把用户名放进去,后端之后解码就能知道是谁。规范建议sub全局唯一且为字符串。
如果你系统里”用户”和”设备”都可能登录,可以用前缀避免撞 id,比如 "user:johndoe"、"device:abc"。这样解码后一眼知道主体类型。多条业务线共用一套 JWT 时,这个习惯能省掉很多排查时间。
28-7 把登录接口写完
把校验和签发接起来,凑成完整的 /token:
@app.post("/token")
async def login_for_access_token(
form_data: OAuth2PasswordRequestForm = Depends(),
):
user = authenticate_user(form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="账号或密码错误",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user["username"]},
expires_delta=access_token_expires,
)
return {
"access_token": access_token,
"token_type": "bearer",
}
这里发生的事很直白:校验不过就抛 401;过了就造一张 30 分钟有效的 JWT,载荷里写 "sub": 用户名,返回给前端。
注意失败时带了 WWW-Authenticate: Bearer 头,这是 401 的规范写法,告诉客户端”这里要用 Bearer 令牌”。
28-8 跑起来试试
启动服务:
fastapi dev main.py
打开 http://127.0.0.1:8000/docs,点右上角 Authorize,输入 johndoe / secret,点授权。然后直接试这个端点也能看到返回。
更直接的方式,用 curl 试:
curl -X POST "http://127.0.0.1:8000/token" \
-d "username=johndoe&password=secret"
返回类似:
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxxx.yyyy",
"token_type": "bearer"
}
把这段 access_token 复制到 jwt.io 的输入框,能看到它解码后的三段:头说算法是 HS256,载荷里有 "sub": "johndoe" 和 "exp" 过期时间戳。这正是下一章后端要校验的内容。
顺带强调一个安全常识:JWT 的载荷只是 Base64 编码,任何拿到令牌的人都能直接读出里面的内容,它不是加密。签名保证的是”内容没被篡改”,不是”内容不可见”。所以载荷里只放用户名、角色、过期时间这类非敏感标识,绝不要塞密码、身份证号或任何机密数据。
密码流要求表单提交,所以 curl 用
-d而不是-H带 JSON。字段名username、password固定。我们已经在 28-2 安装了python-multipart,它负责解析表单;漏装会报 422 校验错误。
28-9 一个完整的可运行文件
下面把本章所有片段合起来,存成 main.py 就能直接跑:
from datetime import datetime, timedelta, timezone
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from pwdlib import PasswordHash
from pwdlib.hashers.bcrypt import BcryptHasher
import jwt
SECRET_KEY = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
password_hash = PasswordHash((BcryptHasher(),))
app = FastAPI()
fake_users_db = {
"johndoe": {
"username": "johndoe",
"hashed_password": password_hash.hash("secret"),
},
}
def verify_password(plain: str, hashed: str) -> bool:
return password_hash.verify(plain, hashed)
def authenticate_user(username: str, password: str):
user = fake_users_db.get(username)
if not user or not verify_password(password, user["hashed_password"]):
return None
return user
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=15))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
@app.post("/token")
async def login_for_access_token(
form_data: OAuth2PasswordRequestForm = Depends(),
):
user = authenticate_user(form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="账号或密码错误",
headers={"WWW-Authenticate": "Bearer"},
)
token = create_access_token(
data={"sub": user["username"]},
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
)
return {"access_token": token, "token_type": "bearer"}
28-10 小结
登录发令牌这条线已经打通:表单进 OAuth2PasswordRequestForm → authenticate_user 比哈希 → create_access_token 用 PyJWT 签 JWT(带 sub 和 exp)→ 返回 access_token。
下一章,我们写”受保护接口”:前端带着这张令牌来访问,后端用 OAuth2PasswordBearer 取令牌、解码校验,再告诉你”当前是谁”。