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 尚未接入认证和权限控制,认证成功也不等于拥有任意操作权限
下一篇将进入重头戏:正式接入大模型!在认证基础上实现普通对话、流式输出和多轮上下文