FastAPI 从 0 到 1 增加管理员登录(通俗易懂)

4 阅读11分钟

FastAPI 从 0 到 1 增加管理员登录

这篇文档专门写给新人看。

目标很简单:

  • 增加一个管理员登录接口
  • 登录成功后返回 token
  • 以后访问客户、商品、订单、还款、看板这些接口时,必须先带上 token
  • 没登录的人,一律返回 401

你可以把这次改造理解成一句话:

先发“门禁卡”,再让所有业务接口检查“你有没有门禁卡”。


1. 先理解这个需求到底在做什么

很多新人一看到“管理员登录”,第一反应就是:

  • 写一个 /login
  • 校验账号密码
  • 返回“登录成功”

其实这还不够。

因为真正的需求不是“做一个登录页面”,而是:

  1. 让管理员能证明“我是管理员”
  2. 后端能识别这个身份
  3. 所有业务接口都能统一拦住未登录请求

所以这次改造,必须包含下面 4 个部分:

  1. 管理员账号密码放哪儿
  2. 登录成功后返回什么
  3. 后端怎么校验这个“登录凭证”
  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

先不要把账号密码写死在代码里。

这样做有两个问题:

  1. 改密码要改代码
  2. 容易把敏感信息提交到仓库

正确做法是放到 .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 件事:

  1. 读取配置
  2. 生成 token
  3. 校验 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 步理解

  1. 准备一个 payload
payload = {"sub": username, "exp": int(time.time()) + expires_in}

这里:

  • sub 可以理解成“当前是谁”
  • exp 表示“什么时候过期”
  1. 把 payload 转成字符串,再做 Base64 编码
payload_b64 = _urlsafe_b64encode(...)
  1. 使用 SECRET 做签名
signature = hmac.new(..., hashlib.sha256).digest()
  1. 把“数据部分 + 签名部分”拼成 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

这段代码的作用是:

  1. 先把 token 拆成两段
  2. 自己重新算一遍签名
  3. 看签名是否一致
  4. 再检查有没有过期

只要有一个条件不满足,就认为 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,
    }

这个接口逻辑非常简单:

  1. 收到用户名和密码
  2. .env 里的配置对比
  3. 正确就生成 token
  4. 返回 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

所以测试也要改成两步:

  1. 先登录
  2. 带 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. 一句话总结

这次改造,本质上做了两件事:

  1. 做了一个能发 token 的管理员登录接口
  2. 让所有业务接口在执行前先检查 token

所以整个流程可以记成:

账号密码在 .env -> 登录接口发 token -> 请求头带 token -> require_admin 校验 token -> 业务接口放行

如果你能把这条线讲清楚,说明你已经真正理解“管理员登录 + 接口鉴权”是怎么落地的了。