前端手摸手跑路之 AI 应用开发(八)

FastAPI 与 Vue 登录认证:密码哈希、JWT 和当前用户

用户 CRUD 完成后,还需要增加鉴权功能。这一篇给注册流程加入密码存储,再实现登录签发令牌、验证令牌和查询当前用户,最后接入 Vue 页面

最终提供两个认证接口:POST /auth/login 接收用户名密码,GET /auth/me 根据 Bearer Token 返回当前用户

一、保存密码哈希

数据库不保存明文密码,而是保存密码哈希。登录时用输入密码验证已有哈希,不需要也不能通过解密哈希还原密码

安装依赖并创建模块:

bash 复制代码
cd /d/code/ai-workspace-rebuild/backend  # cd 切换到后端目录
uv add 'pwdlib[argon2]' pyjwt  # add 添加依赖;[argon2] 安装密码哈希算法支持
touch app/security.py  # touch 创建模块,已有文件不会被清空

单引号防止 Bash 将方括号解释为文件匹配模式。PyJWT 的安装名是 pyjwt,代码中使用 import jwt

app/security.py 先定义密码处理:

python 复制代码
from pwdlib import PasswordHash

password_hasher = PasswordHash.recommended()
DUMMY_PASSWORD_HASH = password_hasher.hash("dummy-password")


def hash_password(plain_password: str) -> str:
    return password_hasher.hash(plain_password)


def verify_password(plain_password: str, hashed_password: str) -> bool:
    return password_hasher.verify(plain_password, hashed_password)

当前推荐配置使用 Argon2。哈希字符串包含算法参数和随机盐,同一密码多次生成的哈希通常不同,因此不能重新 hash 后直接比较字符串,而要使用 verify

可以用演示密码验证:

bash 复制代码
# python -c 执行引号内的代码,只使用演示密码
uv run python -c '
from app.security import hash_password, verify_password

hashed = hash_password("demo-password-123")
print(verify_password("demo-password-123", hashed))
print(verify_password("wrong-password", hashed))
'

预期依次输出 True 和 False。DUMMY_PASSWORD_HASH 用于不存在用户的认证流程,后面会用到,它不是任何账号的默认密码

二、给已有用户增加密码字段

在 app/models/users.py 的 User 中增加:

python 复制代码
hashed_password: Mapped[str] = mapped_column(String(255))

255 是哈希字符串列的长度,不是明文密码长度限制。生成迁移:

bash 复制代码
uv run alembic revision --autogenerate -m "add user password hash"  # revision 创建版本,--autogenerate 比较结构,-m 指定说明

已有用户没有密码哈希,直接添加非空列会遇到旧数据无法满足约束的问题。这里为旧记录填入一个随机密码的哈希,并且不保留随机密码

bash 复制代码
# secrets 生成随机密码,只输出它的哈希,不打印或保存明文
uv run python -c '
import secrets
from app.security import hash_password

print(hash_password(secrets.token_urlsafe(32)))
'

token_urlsafe(32) 使用 32 个随机字节生成字符串。将输出的完整哈希放进新迁移文件,保留自动生成的导入和版本标识:

python 复制代码
LEGACY_UNUSABLE_PASSWORD_HASH = "替换为上一步输出的完整哈希"


def upgrade() -> None:
    op.add_column(
        "users",
        sa.Column(
            "hashed_password",
            sa.String(length=255),
            nullable=False,
            server_default=LEGACY_UNUSABLE_PASSWORD_HASH,
        ),
    )
    op.alter_column("users", "hashed_password", server_default=None)


def downgrade() -> None:
    op.drop_column("users", "hashed_password")

server_default 是数据库端默认值。先用它补齐旧记录,再移除默认值,已经填入的哈希仍保留,之后新用户必须明确写入哈希

本次迁移还需移除自动生成的 persistence_test 删表及反向建表操作,保持这份迁移只处理密码字段

bash 复制代码
uv run alembic upgrade head  # 应用迁移到最新版本
uv run alembic current  # 查看数据库记录的版本

本项目版本为 be68204d14e2,其他项目生成的版本号会不同。升级后要立即接好注册写入逻辑,原来的创建代码没有提供哈希,会违反新约束

旧用户没有可供登录的已知密码,后续需要密码设置或重置流程。正式系统也可以分阶段增加可空字段、部署兼容代码、处理旧数据,再加非空约束,不能把这份迁移直接当作通用上线方案

三、注册请求接收密码,响应不带密码

app/schemas/users.py 的创建模型增加 password:

python 复制代码
class UserCreate(BaseModel):
    username: str = Field(min_length=3, max_length=20)
    display_name: str = Field(min_length=1, max_length=50)
    password: str = Field(min_length=8, max_length=128)

UserResponse 继续只包含 id、username、display_name;UserUpdate 也不增加密码,密码修改需要独立设计

在 Service 中导入 hash_password,并替换创建时的调用:

python 复制代码
from app.security import hash_password

# 放在用户名重复检查之后
hashed_password = hash_password(user_data.password)
created_user = repository.create(user_data, hashed_password)

Repository 的 create 改为:

python 复制代码
def create(self, user_data: UserCreate, hashed_password: str) -> User:
    new_user = User(
        username=user_data.username,
        display_name=user_data.display_name,
        hashed_password=hashed_password,
    )

    self._session.add(new_user)
    self._session.commit()
    self._session.refresh(new_user)
    return new_user

这里只向 ORM 显式传入哈希。当前 Repository 仍接收完整 UserCreate,能够访问明文 password,但不会保存它;不要把请求模型整体展开给 ORM,也不要记录包含密码的请求日志

前端 UserCreate 同步增加 password: string,表单用 password 状态提交:

ts 复制代码
const password = ref("");

// 放在已有 submit 的 try 中
const data = await createUser({
  username: username.value,
  display_name: displayName.value,
  password: password.value,
});
result.value = JSON.stringify(data);
password.value = "";

注册输入框:

vue 复制代码
<input
  v-model="password"
  type="password"
  autocomplete="new-password"
  minlength="8"
  maxlength="128"
  required
/>

type=password 只遮住显示内容,不会加密网络请求,正式部署仍需 HTTPS。浏览器约束改善输入体验,后端校验不能省略

四、根据用户名验证身份

Repository 增加:

python 复制代码
def get_by_username(self, username: str) -> User | None:
    statement = select(User).where(User.username == username)
    return self._session.scalar(statement)

登录需要读取 hashed_password,所以查询完整 User,而不是像 exists_by_username 一样只取 ID。Session.get 按主键查找,不能用它按当前非主键的 username 查询

创建 app/services/auth.py,先实现身份验证:

python 复制代码
from app.models.users import User
from app.repositories.users import UserRepository
from app.security import DUMMY_PASSWORD_HASH, verify_password


class InvalidCredentialsError(Exception):
    pass


def authenticate_user(
    username: str,
    password: str,
    repository: UserRepository,
) -> User:
    user = repository.get_by_username(username)

    stored_hash = user.hashed_password if user is not None else DUMMY_PASSWORD_HASH
    password_is_valid = verify_password(password, stored_hash)

    if user is None or not password_is_valid:
        raise InvalidCredentialsError("Invalid username or password")

    return user

条件表达式在用户存在时选真实哈希,否则选占位哈希。两种情况都执行一次密码验证,以减少直接返回和哈希计算之间的耗时差异

用户名不存在和密码错误使用相同提示,避免登录接口直接暴露用户名是否存在。即使输入匹配占位哈希,只要 user 为 None 仍会失败

五、JWT 保存什么

JWT 是 JSON Web Token。本项目使用 HS256 签名,服务端用同一密钥签发和验证令牌

签名用于识别篡改,不加密载荷。令牌内容可被读取,不能放入密码或密码哈希

当前载荷包含三个字段:

字段 含义
sub 用户 ID,以字符串保存
iat 签发时间
exp 过期时间

在 security.py 增加导入和配置:

python 复制代码
import os
from datetime import UTC, datetime, timedelta

import jwt
from jwt.exceptions import InvalidTokenError

JWT_SECRET_KEY = os.getenv(
    "JWT_SECRET_KEY",
    "dev-only-change-this-secret-before-deployment",
)
JWT_ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

这里的默认密钥是公开的本地开发占位值,不能用于真实部署。部署时必须通过环境变量提供独立随机密钥;os.getenv 不会自动读取 .env

签发函数:

python 复制代码
def create_access_token(user_id: int) -> str:
    now = datetime.now(UTC)
    payload = {
        "sub": str(user_id),
        "iat": now,
        "exp": now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
    }
    return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)

timedelta 表示时间间隔,当前时间加 30 分钟就是过期时间。PyJWT 会把日期时间转换为相应的时间戳

六、验证签名、有效期和必要字段

在 security.py 增加:

python 复制代码
class InvalidAccessTokenError(Exception):
    pass


def decode_access_token(token: str) -> int:
    try:
        payload = jwt.decode(
            token,
            JWT_SECRET_KEY,
            algorithms=[JWT_ALGORITHM],
            options={"require": ["sub", "iat", "exp"]},
        )
    except InvalidTokenError as exc:
        raise InvalidAccessTokenError("Invalid or expired token") from exc

    subject = payload.get("sub")
    if not isinstance(subject, str):
        raise InvalidAccessTokenError("Invalid or expired token")

    try:
        return int(subject)
    except ValueError as exc:
        raise InvalidAccessTokenError("Invalid or expired token") from exc

algorithms 固定服务端允许的算法,不根据收到的令牌动态决定

require 要求字段必须存在,这与字段值是否有效是两项检查。例如 exp 存在时需要检查是否过期,而 require 又补上"不能缺少 exp"的约束

最后确认 sub 是字符串且能转成整数,得到查询用户所需的 ID。令牌验证成功不等于对应用户仍存在,还需要数据库查询

七、登录请求与响应模型

创建认证 Schema 和 Router 文件:

bash 复制代码
touch app/schemas/auth.py app/routers/auth.py  # 创建认证数据模型和路由模块

app/schemas/auth.py:

python 复制代码
from pydantic import BaseModel, Field


class LoginRequest(BaseModel):
    username: str = Field(min_length=3, max_length=20)
    password: str = Field(min_length=1, max_length=128)


class TokenResponse(BaseModel):
    access_token: str
    token_type: str = "bearer"

注册决定新密码的长度要求,登录则校验已有凭据。当前登录允许非空密码进入验证,返回统一的认证结果

在认证 Service 中补充导入和 login:

python 复制代码
from app.schemas.auth import LoginRequest, TokenResponse
from app.security import create_access_token


def login(
    credentials: LoginRequest,
    repository: UserRepository,
) -> TokenResponse:
    user = authenticate_user(
        credentials.username,
        credentials.password,
        repository,
    )
    return TokenResponse(access_token=create_access_token(user.id))

只有 authenticate_user 成功返回后才会生成令牌

八、登录接口返回令牌

app/routers/auth.py:

python 复制代码
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session

from app.database import get_session
from app.repositories.users import UserRepository
from app.schemas.auth import LoginRequest, TokenResponse
from app.services.auth import InvalidCredentialsError
from app.services.auth import login as login_service

router = APIRouter(prefix="/auth", tags=["auth"])


@router.post("/login", response_model=TokenResponse)
def login(
    credentials: LoginRequest,
    session: Annotated[Session, Depends(get_session)],
) -> TokenResponse:
    repository = UserRepository(session)
    try:
        return login_service(credentials, repository)
    except InvalidCredentialsError as exc:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail=str(exc),
            headers={"WWW-Authenticate": "Bearer"},
        ) from exc

在 main.py 导入并注册 auth_router:

python 复制代码
from app.routers.auth import router as auth_router

# 放在应用创建之后,与 users_router 一起注册
app.include_router(auth_router)

成功返回 access_token 和 token_type;凭据错误返回 401,WWW-Authenticate 声明 Bearer 认证方案。请求本身不符合 Schema 时仍返回 422

九、认证依赖从请求中获取当前用户

客户端后续发送:

http 复制代码
Authorization: Bearer <access_token>

创建依赖模块:

bash 复制代码
mkdir -p app/dependencies  # mkdir 创建目录,-p 补齐父目录并允许目录已存在
touch app/dependencies/__init__.py app/dependencies/auth.py  # 创建包标记和认证依赖

app/dependencies/auth.py:

python 复制代码
from typing import Annotated

from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session

from app.database import get_session
from app.repositories.users import UserRepository
from app.schemas.users import UserResponse
from app.security import InvalidAccessTokenError, decode_access_token
from app.services.users import UserNotFoundError
from app.services.users import get_user_by_id as get_user_by_id_service

bearer_scheme = HTTPBearer(auto_error=False)


def get_current_user(
    credentials: Annotated[
        HTTPAuthorizationCredentials | None,
        Depends(bearer_scheme),
    ],
    session: Annotated[Session, Depends(get_session)],
) -> UserResponse:
    if credentials is None:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Authentication required",
            headers={"WWW-Authenticate": "Bearer"},
        )

    try:
        user_id = decode_access_token(credentials.credentials)
        return get_user_by_id_service(user_id, UserRepository(session))
    except (InvalidAccessTokenError, UserNotFoundError) as exc:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid or expired token",
            headers={"WWW-Authenticate": "Bearer"},
        ) from exc

HTTPBearer 只解析请求头,不验证 JWT 签名。auto_error=False 让缺少可用 Bearer 凭据时返回 None,由应用统一生成 401

credentials.credentials 才是令牌字符串。验证后继续查询数据库,用户已被删除时,即使令牌未过期也不能通过认证

dependencies 不是框架强制的目录名,真正声明依赖的是 Depends。单独放这个模块是为了让多个接口复用认证过程:security 处理密码和令牌,Service 处理登录业务,dependency 处理 HTTP 凭据及认证失败响应

十、当前用户接口与 Swagger 授权

在认证 Router 补充:

python 复制代码
from app.dependencies.auth import get_current_user
from app.schemas.users import UserResponse


@router.get("/me", response_model=UserResponse)
def get_me(
    current_user: Annotated[UserResponse, Depends(get_current_user)],
) -> UserResponse:
    return current_user

访问 http://127.0.0.1:8000/docs,先不授权调用 /auth/me,应返回 401

使用新增密码字段后注册的账号调用 /auth/login,复制 access_token。点击接口列表上方靠右的 Authorize,或受保护接口的小锁,在 Value 中只粘贴令牌,不加 Bearer 前缀,再执行 /auth/me,应返回用户公开信息

只有引用认证依赖的接口才受保护,原来的用户 CRUD 不会因增加 /auth/me 自动获得权限控制

十一、前端封装登录和身份查询

在 frontend/src/api/users.ts 给已有的 isUser 和 readErrorMessage 增加 export,复用结构校验与错误解析

bash 复制代码
cd /d/code/ai-workspace-rebuild/frontend  # 切换到前端目录
touch src/api/auth.ts  # 创建认证请求模块

src/api/auth.ts:

ts 复制代码
import { isUser, readErrorMessage, type User } from "./users";

export type LoginInput = {
  username: string;
  password: string;
};

export type TokenResponse = {
  access_token: string;
  token_type: string;
};

function isTokenResponse(value: unknown): value is TokenResponse {
  return (
    typeof value === "object" &&
    value !== null &&
    "access_token" in value &&
    typeof value.access_token === "string" &&
    "token_type" in value &&
    typeof value.token_type === "string"
  );
}

export async function login(input: LoginInput): Promise<TokenResponse> {
  const response = await fetch("http://127.0.0.1:8000/auth/login", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(input),
  });

  if (!response.ok) {
    throw new Error(await readErrorMessage(response));
  }

  const body: unknown = await response.json();
  if (!isTokenResponse(body)) {
    throw new Error("Invalid login response");
  }
  return body;
}

export async function getCurrentUser(accessToken: string): Promise<User> {
  const response = await fetch("http://127.0.0.1:8000/auth/me", {
    headers: { Authorization: `Bearer ${accessToken}` },
  });

  if (!response.ok) {
    throw new Error(await readErrorMessage(response));
  }

  const body: unknown = await response.json();
  if (!isUser(body)) {
    throw new Error("Invalid current user response");
  }
  return body;
}

令牌不会因为登录成功自动附加到后续请求,客户端必须明确写入 Authorization 请求头。未指定 method 时 fetch 使用 GET

十二、Vue 登录状态与表单

在 App.vue 保留注册逻辑,补充导入:

ts 复制代码
import { getCurrentUser, login } from "./api/auth";
import { createUser, type User } from "./api/users";

在已有 ref 导入下增加状态和方法:

ts 复制代码
const loginUsername = ref("");
const loginPassword = ref("");
const accessToken = ref("");
const currentUser = ref<User | null>(null);
const loginError = ref("");
const isLoggingIn = ref(false);

async function submitLogin() {
  if (isLoggingIn.value) return;

  isLoggingIn.value = true;
  loginError.value = "";
  accessToken.value = "";
  currentUser.value = null;

  try {
    const token = await login({
      username: loginUsername.value,
      password: loginPassword.value,
    });

    const user = await getCurrentUser(token.access_token);
    accessToken.value = token.access_token;
    currentUser.value = user;
  } catch (err: unknown) {
    loginError.value = err instanceof Error ? err.message : "登录失败";
  } finally {
    loginPassword.value = "";
    isLoggingIn.value = false;
  }
}

两个请求顺序执行:先登录拿令牌,再持令牌查询用户。全部成功后才保存状态,失败时不会残留上次用户信息。finally 无论成功失败都会清空密码并恢复按钮

在注册表单之后增加独立登录表单,不能嵌套 form:

vue 复制代码
<form @submit.prevent="submitLogin">
  <div>
    <label for="login-username">用户名:</label>
    <input
      id="login-username"
      v-model="loginUsername"
      autocomplete="username"
      required
    />
  </div>
  <div>
    <label for="login-password">密码:</label>
    <input
      id="login-password"
      v-model="loginPassword"
      type="password"
      autocomplete="current-password"
      required
    />
  </div>
  <button type="submit" :disabled="isLoggingIn">
    {{ isLoggingIn ? "登录中..." : "登录" }}
  </button>
</form>

<p v-if="loginError">{{ loginError }}</p>
<p v-if="currentUser">
  当前用户:{{ currentUser.display_name }}({{ currentUser.username }})
</p>

注册使用 new-password,登录使用 current-password,帮助浏览器识别密码框用途。当前令牌只保存在组件内存中,刷新页面就会丢失登录状态

十三、验证完整链路

使用专门的测试账号检查:

操作 预期结果
提交带密码的注册请求 创建成功,响应不包含密码和哈希
正确用户名密码登录 返回令牌,页面显示当前用户
错误密码登录 401,提示 Invalid username or password
未携带令牌请求 /auth/me 401
合法令牌请求 /auth/me 返回公开用户信息
刷新前端页面 内存登录状态消失

数据库客户端在宿主机运行时,主机填写 127.0.0.1、端口 5432;postgres 是 Compose 网络内的服务名。遇到 getaddrinfo failed 应先检查主机名,而不是修改密码

阶段结果

到这里,用户注册已保存 Argon2 密码哈希,已有记录通过迁移保留。后端能够验证用户名密码、签发和校验 JWT,并通过认证依赖返回当前用户;前端完成登录、携带令牌查询身份和错误展示

但当前完成的是基础认证链路:

  • 令牌只保存在内存中,没有持久登录、刷新令牌和主动撤销机制
  • 旧用户只有占位哈希,尚未实现密码重置与修改流程
  • 用户 CRUD 尚未接入认证和权限控制,认证成功也不等于拥有任意操作权限

下一篇将进入重头戏:正式接入大模型!在认证基础上实现普通对话、流式输出和多轮上下文

相关推荐
风骏时光牛马44 分钟前
产品功能可用性测试报告
前端
骑着蜗牛撵大象32744 分钟前
Jev 使用完整指南:从申请 API Key 到置信度路由,把 TypeSafe 决策模型接进自己的代码
前端
二月龙44 分钟前
大模型 RAG 检索增强是什么?简单讲清原理和实用价值
后端
CopyCode44 分钟前
我把 Cursor 接到了蓝湖上,设计师再也不用追着我问"还原了吗"
前端
1360967572344 分钟前
Jev 决策模型原理:为什么 Agent 循环里 80% 的判断不该交给 LLM
后端
陈柒吖44 分钟前
安卓代码加固(1):加密DEX
android·前端
吃饱了得干活44 分钟前
数据放哪儿:从 HashMap 到一致性哈希
java·后端
旺仔不是程序员44 分钟前
pg_trgm GIN 索引:PostgreSQL 正则、模糊与近似度查询的三合一加速器
数据库·后端·sql
一粒麦仔44 分钟前
SafeTensors vs GGUF:大模型权重格式的硬核拆解
人工智能·后端·架构