FastAPI 从 0 到 1 增加管理员登录
这篇文档专门写给新人看。
目标很简单:
- 增加一个管理员登录接口
- 登录成功后返回 token
- 以后访问客户、商品、订单、还款、看板这些接口时,必须先带上 token
- 没登录的人,一律返回
401
你可以把这次改造理解成一句话:
先发“门禁卡”,再让所有业务接口检查“你有没有门禁卡”。
1. 先理解这个需求到底在做什么
很多新人一看到“管理员登录”,第一反应就是:
- 写一个
/login - 校验账号密码
- 返回“登录成功”
其实这还不够。
因为真正的需求不是“做一个登录页面”,而是:
- 让管理员能证明“我是管理员”
- 后端能识别这个身份
- 所有业务接口都能统一拦住未登录请求
所以这次改造,必须包含下面 4 个部分:
- 管理员账号密码放哪儿
- 登录成功后返回什么
- 后端怎么校验这个“登录凭证”
- 怎么让所有接口都自动校验
2. 改造前,项目是什么状态
改造前的项目已经有这些业务接口:
- 客户接口
- 商品接口
- 订单接口
- 还款接口
- 看板接口
但是它有一个明显问题:
任何人都可以直接调用这些接口。
比如下面这个请求,在改造前是可以直接访问的:
curl http://localhost:8000/api/customers
这显然不安全。
因为客户数据、订单数据、欠款数据,都不应该裸奔。
3. 我们最后要做成什么效果
改造后,接口访问流程应该是这样的:
第一步:管理员登录
curl -X POST http://localhost:8000/api/admin/login \
-H "Content-Type: application/json" \
-d "{\"username\":\"admin\",\"password\":\"admin123456\"}"
返回:
{
"access_token": "xxxx.yyyy",
"token_type": "bearer",
"expires_in": 28800
}
第二步:带着 token 去访问业务接口
curl http://localhost:8000/api/customers \
-H "Authorization: Bearer xxxx.yyyy"
第三步:如果不带 token,就直接拦截
{
"detail": "Admin authentication required"
}
状态码:
401 Unauthorized
4. 这次选择了什么实现方案
为了让新人更容易看懂,这次没有一上来就用复杂的 OAuth2 或完整 JWT 体系,而是用了一个轻量方案。
方案核心
- 管理员账号密码存在
.env - 登录成功后,后端生成一个 token
- 这个 token 本质上是“签名后的身份信息”
- 前端以后请求接口时,把 token 放到请求头里
- 后端收到请求后,统一校验 token
为什么这样做
因为它适合教学,容易理解:
- 代码少
- 依赖少
- 可以快速明白“登录鉴权”的主流程
等以后你完全理解了,再升级成标准 JWT 也不迟。
5. 第一步:把管理员账号密码放到 .env
先不要把账号密码写死在代码里。
这样做有两个问题:
- 改密码要改代码
- 容易把敏感信息提交到仓库
正确做法是放到 .env:
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123456
ADMIN_TOKEN_SECRET=change-this-in-production
ADMIN_TOKEN_EXPIRE_SECONDS=28800
这里每个配置的意思如下:
ADMIN_USERNAME:管理员账号ADMIN_PASSWORD:管理员密码ADMIN_TOKEN_SECRET:token 签名密钥ADMIN_TOKEN_EXPIRE_SECONDS:token 过期时间,单位秒
为什么还要一个 SECRET
因为 token 不能只靠“看起来像字符串”就相信它。
必须用一个只有服务端知道的密钥去签名,这样别人就没法随便伪造 token。
6. 第二步:写认证核心模块 app/auth.py
这个文件是整个登录系统的核心。
它主要做 3 件事:
- 读取配置
- 生成 token
- 校验 token
6.1 读取管理员配置
关键代码:
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123456")
ADMIN_TOKEN_SECRET = os.getenv("ADMIN_TOKEN_SECRET", "change-this-in-production")
ADMIN_TOKEN_EXPIRE_SECONDS = int(os.getenv("ADMIN_TOKEN_EXPIRE_SECONDS", "28800"))
这段代码的意思很直白:
- 优先从环境变量读取
- 如果环境变量没配,就使用默认值
6.2 生成 token
关键代码:
def create_access_token(username: str, expires_in: int = ADMIN_TOKEN_EXPIRE_SECONDS) -> str:
payload = {"sub": username, "exp": int(time.time()) + expires_in}
payload_b64 = _urlsafe_b64encode(
json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
)
signature = hmac.new(
ADMIN_TOKEN_SECRET.encode("utf-8"),
payload_b64.encode("utf-8"),
hashlib.sha256,
).digest()
signature_b64 = _urlsafe_b64encode(signature)
return f"{payload_b64}.{signature_b64}"
这段代码在做什么?
先拆成 4 步理解
- 准备一个 payload
payload = {"sub": username, "exp": int(time.time()) + expires_in}
这里:
sub可以理解成“当前是谁”exp表示“什么时候过期”
- 把 payload 转成字符串,再做 Base64 编码
payload_b64 = _urlsafe_b64encode(...)
- 使用
SECRET做签名
signature = hmac.new(..., hashlib.sha256).digest()
- 把“数据部分 + 签名部分”拼成 token
return f"{payload_b64}.{signature_b64}"
最终效果就是:
- token 里带着当前用户信息
- token 里带着过期时间
- token 被服务端密钥签过名
- 别人改了内容,签名就对不上
6.3 校验 token
关键代码:
def verify_access_token(token: str) -> dict | None:
try:
payload_b64, signature_b64 = token.split(".", 1)
except ValueError:
return None
expected_signature = hmac.new(
ADMIN_TOKEN_SECRET.encode("utf-8"),
payload_b64.encode("utf-8"),
hashlib.sha256,
).digest()
expected_signature_b64 = _urlsafe_b64encode(expected_signature)
if not hmac.compare_digest(signature_b64, expected_signature_b64):
return None
payload = json.loads(_urlsafe_b64decode(payload_b64).decode("utf-8"))
if payload.get("exp") < int(time.time()):
return None
return payload
这段代码的作用是:
- 先把 token 拆成两段
- 自己重新算一遍签名
- 看签名是否一致
- 再检查有没有过期
只要有一个条件不满足,就认为 token 无效。
6.4 提供统一依赖 require_admin
关键代码:
bearer_scheme = HTTPBearer(auto_error=False)
def require_admin(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
) -> str:
if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(status_code=401, detail="Admin authentication required")
payload = verify_access_token(credentials.credentials)
if not payload or payload.get("sub") != ADMIN_USERNAME:
raise HTTPException(status_code=401, detail="Invalid or expired admin token")
return payload["sub"]
这个依赖函数很重要。
你可以把它理解成“门卫”:
- 没带 token,不让进
- token 不合法,不让进
- token 过期,不让进
- 只有合法管理员 token,才放行
后面所有业务接口,都靠它来保护。
7. 第三步:增加管理员登录接口 app/routers/auth.py
现在有了生成 token 的能力,就可以做登录接口了。
关键代码:
@router.post("/login", response_model=schemas.AdminLoginResponse, summary="管理员登录")
def admin_login(payload: schemas.AdminLoginRequest):
if payload.username != ADMIN_USERNAME or payload.password != ADMIN_PASSWORD:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid admin username or password",
)
access_token = create_access_token(payload.username)
return {
"access_token": access_token,
"token_type": "bearer",
"expires_in": ADMIN_TOKEN_EXPIRE_SECONDS,
}
这个接口逻辑非常简单:
- 收到用户名和密码
- 和
.env里的配置对比 - 正确就生成 token
- 返回 token 给前端
登录请求 demo
{
"username": "admin",
"password": "admin123456"
}
登录成功响应 demo
{
"access_token": "xxxx.yyyy",
"token_type": "bearer",
"expires_in": 28800
}
登录失败响应 demo
{
"detail": "Invalid admin username or password"
}
8. 第四步:给登录接口定义请求和响应结构
这一步很多新人容易忽略,但其实很有必要。
在 app/schemas.py 中增加:
class AdminLoginRequest(BaseModel):
username: str = Field(..., min_length=1, description="管理员账号")
password: str = Field(..., min_length=1, description="管理员密码")
class AdminLoginResponse(BaseModel):
access_token: str = Field(..., description="管理员访问令牌")
token_type: str = Field("bearer", description="令牌类型")
expires_in: int = Field(..., description="令牌有效期(秒)")
这样做的好处:
- 请求格式更清晰
- 自动生成接口文档
- 前后端联调更省事
9. 第五步:最关键的一步,保护所有业务接口
很多人做登录时,只做了 /login,但没有保护其他路由。
这样等于白做。
真正的关键是:
要让客户、商品、订单、还款、看板这些路由都依赖 require_admin。
在 app/main.py 中这样接:
from fastapi import Depends, FastAPI
from .auth import require_admin
from .routers import auth, customers, products, orders, payments, dashboard
app.include_router(auth.router)
app.include_router(dashboard.router, dependencies=[Depends(require_admin)])
app.include_router(customers.router, dependencies=[Depends(require_admin)])
app.include_router(products.router, dependencies=[Depends(require_admin)])
app.include_router(orders.router, dependencies=[Depends(require_admin)])
app.include_router(payments.router, dependencies=[Depends(require_admin)])
这段代码的意思是:
auth.router不做保护,因为登录本身必须公开- 其他路由都要求先通过
require_admin
为什么在 main.py 统一加,而不是每个接口单独加?
因为统一加更省事、更不容易漏。
如果每个接口都自己写:
@router.get("", dependencies=[Depends(require_admin)])
那项目一大,很容易漏掉某个接口。
统一挂载时加依赖,维护成本更低。
10. 实际访问流程 demo
下面用真实访问流程帮助新人建立完整理解。
场景 1:未登录直接访问客户列表
请求:
curl http://localhost:8000/api/customers
返回:
{
"detail": "Admin authentication required"
}
说明:
- 因为没有
Authorization请求头 - 所以在
require_admin里被直接拦截
场景 2:先登录,再访问客户列表
先登录:
curl -X POST http://localhost:8000/api/admin/login \
-H "Content-Type: application/json" \
-d "{\"username\":\"admin\",\"password\":\"admin123456\"}"
得到 token 后,再请求:
curl http://localhost:8000/api/customers \
-H "Authorization: Bearer 你的token"
这次就可以正常返回客户数据了。
场景 3:token 伪造或过期
请求:
curl http://localhost:8000/api/customers \
-H "Authorization: Bearer abc.def"
返回:
{
"detail": "Invalid or expired admin token"
}
说明:
- token 签名不对
- 或者 token 已过期
- 都会被拒绝访问
11. 测试为什么也要一起改
登录改造之后,原来的测试不能再直接访问业务接口了。
比如以前测试里可能直接这样写:
response = client.get("/api/customers")
现在这样写一定会变成 401。
所以测试也要改成两步:
- 先登录
- 带 token 请求
测试辅助函数 demo
def get_admin_headers():
login_response = client.post(
"/api/admin/login",
json={"username": "admin", "password": "admin123456"},
)
assert login_response.status_code == 200
token = login_response.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
有了这个函数后,后续测试就简单了:
headers = get_admin_headers()
response = client.get("/api/customers", headers=headers)
assert response.status_code == 200
增加一个“未登录必须失败”的测试
这个测试很关键。
因为它能保证以后别人改代码时,不会把登录保护弄丢。
demo:
def test_login_required_for_protected_routes():
response = client.get("/api/customers")
assert response.status_code == 401
这类测试属于“防回归测试”。
12. 这次改造的完整链路
如果你还是觉得有点绕,可以只记住下面这条链路:
1)配置管理员信息
.env
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123456
2)登录接口校验账号密码
if payload.username != ADMIN_USERNAME or payload.password != ADMIN_PASSWORD:
raise HTTPException(status_code=401, detail="Invalid admin username or password")
3)登录成功后生成 token
access_token = create_access_token(payload.username)
4)业务接口请求时校验 token
payload = verify_access_token(credentials.credentials)
5)路由统一挂载依赖
app.include_router(customers.router, dependencies=[Depends(require_admin)])
这 5 步串起来,就形成了完整的登录鉴权闭环。
13. 新人最容易踩的坑
坑 1:只做登录,不做接口保护
这类问题最常见。
你以为自己做了登录,实际上别人根本不登录也能访问业务接口。
所以一定要检查:
- 登录接口是否公开
- 其他业务接口是否都加了
require_admin
坑 2:账号密码写死在代码里
这样做短期省事,长期很麻烦。
正确做法是放进 .env,这样以后改密码不用改代码。
坑 3:测试全部挂掉
这不是代码坏了,而是测试还没适配“必须登录”的新规则。
记住:
接口加鉴权后,测试也要跟着加登录。
坑 4:上线还在用默认密钥
ADMIN_TOKEN_SECRET=change-this-in-production
这只是开发环境默认值。
如果正式环境也用这个,就等于没上锁。
生产环境必须换成高强度随机字符串。
14. 这套方案适合什么场景
这次实现适合:
- 内部管理后台
- 小型项目
- 只有一个管理员或少量管理员
- 以“先跑通权限流程”为主要目标
如果后续需求升级,比如:
- 多角色权限
- 多端登录
- 刷新 token
- 强制下线
- 登录日志
那就建议继续升级成更标准的认证体系。
15. 一句话总结
这次改造,本质上做了两件事:
- 做了一个能发 token 的管理员登录接口
- 让所有业务接口在执行前先检查 token
所以整个流程可以记成:
账号密码在 .env -> 登录接口发 token -> 请求头带 token -> require_admin 校验 token -> 业务接口放行
如果你能把这条线讲清楚,说明你已经真正理解“管理员登录 + 接口鉴权”是怎么落地的了。