名称: bknd-会话处理 描述: 用于管理Bknd应用程序中的用户会话。涵盖JWT令牌生命周期、会话持久性、自动续订、检查身份验证状态、使会话无效和处理过期。
会话处理
管理Bknd中的用户会话:令牌持久性、会话检查、自动续订和无效化。
先决条件
- Bknd项目已启用身份验证(
bknd-setup-auth) - 已配置并运行的身份验证策略(
bknd-login-flow) - 对于SDK:已安装
bknd包 - 对于React:已安装
@bknd/react包
何时使用UI模式
- 在管理面板中查看JWT配置
- 检查Cookie设置
- 测试会话过期
UI步骤: 管理面板 > 身份验证 > 配置 > JWT/Cookie设置
何时使用代码模式
- 在前端实现会话持久性
- 在页面加载时检查身份验证状态
- 优雅处理令牌过期
- 实现自动刷新模式
- 服务器端会话验证
Bknd中的会话工作原理
Bknd使用无状态JWT基础会话:
- 登录 - 服务器创建带有用户数据的签名JWT,返回令牌
- 存储 - 令牌存储在Cookie(自动)或localStorage/header(手动)
- 请求 - 每个请求发送令牌进行身份验证
- 验证 - 服务器验证签名和过期
- 续订 - Cookie可自动续订;header令牌需要手动刷新
关键概念: 无服务器端会话存储。令牌本身就是会话。
会话配置
JWT设置
import { defineConfig } from "bknd";
export default defineConfig({
auth: {
enabled: true,
jwt: {
secret: process.env.JWT_SECRET!, // 生产环境必需
alg: "HS256", // 算法: HS256 | HS384 | HS512
expires: 604800, // 7天,以秒为单位
issuer: "my-app", // 令牌发行者声明
fields: ["id", "email", "role"], // 令牌载荷中的用户字段
},
},
});
JWT选项:
| 选项 | 类型 | 默认值 | 描述 |
|---|---|---|---|
secret |
string | "" |
签名密钥(生产环境至少256位) |
alg |
string | "HS256" |
HMAC算法 |
expires |
number | - | 令牌生命周期(秒) |
issuer |
string | - | 发行者声明(iss) |
fields |
string[] | ["id","email","role"] |
编码在令牌中的用户字段 |
Cookie设置
{
auth: {
cookie: {
secure: process.env.NODE_ENV === "production", // 仅HTTPS
httpOnly: true, // 无JavaScript访问
sameSite: "lax", // CSRF保护
expires: 604800, // 匹配JWT过期
renew: true, // 活动时自动扩展
path: "/", // Cookie范围
pathSuccess: "/dashboard", // 登录后重定向
pathLoggedOut: "/login", // 登出后重定向
},
},
}
Cookie选项:
| 选项 | 类型 | 默认值 | 描述 |
|---|---|---|---|
secure |
boolean | true |
要求HTTPS |
httpOnly |
boolean | true |
阻止JavaScript访问 |
sameSite |
string | "lax" |
"strict" | "lax" | "none" |
expires |
number | 604800 |
Cookie生命周期(秒) |
renew |
boolean | true |
请求时自动续订 |
pathSuccess |
string | "/" |
登录后重定向 |
pathLoggedOut |
string | "/" |
登出后重定向 |
SDK方法
使用存储的会话持久性
import { Api } from "bknd";
// 持久性会话(在页面刷新/浏览器重启后存活)
const api = new Api({
host: "http://localhost:7654",
storage: localStorage, // 令牌持久化
});
// 仅会话(标签页关闭时清除)
const api = new Api({
host: "http://localhost:7654",
storage: sessionStorage, // 标签页关闭时清除令牌
});
// 无持久性(令牌仅存内存)
const api = new Api({
host: "http://localhost:7654",
// 无存储 = 页面刷新时丢失令牌
});
应用启动时检查会话
async function initializeAuth() {
const api = new Api({
host: "http://localhost:7654",
storage: localStorage,
});
// 检查现有令牌是否仍有效
const { ok, data } = await api.auth.me();
if (ok && data?.user) {
console.log("会话有效:", data.user.email);
return { api, user: data.user };
}
console.log("无有效会话");
return { api, user: null };
}
// 应用挂载时
const { api, user } = await initializeAuth();
会话状态管理
import { Api } from "bknd";
class SessionManager {
private api: Api;
private user: User | null = null;
private listeners: Set<(user: User | null) => void> = new Set();
constructor(host: string) {
this.api = new Api({ host, storage: localStorage });
}
// 初始化 - 应用启动时调用
async init() {
const { ok, data } = await this.api.auth.me();
this.user = ok ? data?.user ?? null : null;
this.notifyListeners();
return this.user;
}
// 获取当前会话
getUser() {
return this.user;
}
isAuthenticated() {
return this.user !== null;
}
// 登录 - 创建新会话
async login(email: string, password: string) {
const { ok, data, error } = await this.api.auth.login("password", {
email,
password,
});
if (!ok) throw new Error(error?.message || "登录失败");
this.user = data!.user;
this.notifyListeners();
return this.user;
}
// 登出 - 销毁会话
async logout() {
await this.api.auth.logout();
this.user = null;
this.notifyListeners();
}
// 刷新会话(重新验证令牌)
async refresh() {
const { ok, data } = await this.api.auth.me();
this.user = ok ? data?.user ?? null : null;
this.notifyListeners();
return this.user;
}
// 订阅会话变更
subscribe(callback: (user: User | null) => void) {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
}
private notifyListeners() {
this.listeners.forEach((cb) => cb(this.user));
}
}
type User = { id: number; email: string; role?: string };
// 用法
const session = new SessionManager("http://localhost:7654");
await session.init();
session.subscribe((user) => {
console.log("会话变更:", user?.email || "已登出");
});
基于Cookie的会话(自动)
const api = new Api({
host: "http://localhost:7654",
tokenTransport: "cookie", // 使用httpOnly cookies
});
// 登录自动设置Cookie
await api.auth.login("password", { email, password });
// 所有请求自动包含Cookie
await api.data.readMany("posts");
// 登出清除Cookie
await api.auth.logout();
Cookie模式优势:
- HttpOnly = XSS保护(JavaScript无法访问令牌)
- 每个请求自动续订(如果
cookie.renew: true) - 无需手动令牌管理
- 使用
sameSite自动CSRF保护
基于Header的会话(手动)
const api = new Api({
host: "http://localhost:7654",
storage: localStorage,
tokenTransport: "header", // 默认
});
// 令牌存储在localStorage中,通过Authorization头发送
await api.auth.login("password", { email, password });
// 令牌自动包含:
// Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
处理会话过期
检测过期令牌
async function makeAuthenticatedRequest<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (error) {
// 检查错误是否因会话过期
if (isAuthError(error)) {
// 会话过期 - 重定向到登录或刷新
await handleExpiredSession();
}
throw error;
}
}
function isAuthError(error: unknown): boolean {
if (error instanceof Error) {
return error.message.includes("401") || error.message.includes("Unauthorized");
}
return false;
}
async function handleExpiredSession() {
// 选项1: 重定向到登录
window.location.href = "/login?expired=true";
// 选项2: 显示重新身份验证模态框
// showReauthModal();
// 选项3: 尝试刷新(如果使用刷新令牌)
// await refreshToken();
}
自动刷新模式
由于Bknd使用无状态JWT,无内置刷新令牌。相反,使用api.auth.me()重新验证并扩展基于Cookie的会话:
class SessionWithAutoRefresh {
private api: Api;
private refreshInterval: number | null = null;
constructor(host: string) {
this.api = new Api({
host,
tokenTransport: "cookie", // Cookie在请求时自动续订
});
}
// 启动定期会话检查
startAutoRefresh(intervalMs = 5 * 60 * 1000) {
// 每5分钟
this.refreshInterval = window.setInterval(async () => {
const { ok } = await this.api.auth.me();
if (!ok) {
this.stopAutoRefresh();
this.onSessionExpired();
}
}, intervalMs);
}
stopAutoRefresh() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
this.refreshInterval = null;
}
}
private onSessionExpired() {
// 处理过期会话
window.location.href = "/login?session=expired";
}
}
主动令牌刷新
对于基于header的身份验证,在令牌过期前重新登录:
import { jwtDecode } from "jwt-decode"; // npm install jwt-decode
class TokenManager {
private api: Api;
private refreshTimer: number | null = null;
constructor(host: string) {
this.api = new Api({ host, storage: localStorage });
}
// 在过期前安排刷新
scheduleRefresh(token: string) {
const decoded = jwtDecode<{ exp: number }>(token);
const expiresAt = decoded.exp * 1000; // 转换为毫秒
const refreshAt = expiresAt - 5 * 60 * 1000; // 过期前5分钟
const delay = refreshAt - Date.now();
if (delay > 0) {
this.refreshTimer = window.setTimeout(() => {
this.promptRelogin();
}, delay);
}
}
private promptRelogin() {
// 显示模态框要求用户重新身份验证
// 或重定向到登录页面,带返回URL
}
cleanup() {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
}
}
}
React集成
会话提供器
import { createContext, useContext, useEffect, useState, ReactNode } from "react";
import { Api } from "bknd";
type User = { id: number; email: string; role?: string };
type SessionContextType = {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
checkSession: () => Promise<User | null>;
clearSession: () => void;
};
const SessionContext = createContext<SessionContextType | null>(null);
const api = new Api({
host: "http://localhost:7654",
storage: localStorage,
});
export function SessionProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
// 挂载时检查会话
useEffect(() => {
checkSession().finally(() => setIsLoading(false));
}, []);
async function checkSession() {
const { ok, data } = await api.auth.me();
const user = ok ? data?.user ?? null : null;
setUser(user);
return user;
}
function clearSession() {
setUser(null);
api.auth.logout();
}
return (
<SessionContext.Provider
value={{
user,
isLoading,
isAuthenticated: user !== null,
checkSession,
clearSession,
}}
>
{children}
</SessionContext.Provider>
);
}
export function useSession() {
const context = useContext(SessionContext);
if (!context) throw new Error("useSession必须用在SessionProvider内");
return context;
}
会话感知组件
import { useSession } from "./SessionProvider";
function Header() {
const { user, isAuthenticated, clearSession } = useSession();
if (!isAuthenticated) {
return <a href="/login">登录</a>;
}
return (
<div>
<span>欢迎, {user!.email}</span>
<button onClick={clearSession}>登出</button>
</div>
);
}
function ProtectedPage() {
const { isLoading, isAuthenticated } = useSession();
if (isLoading) return <div>检查会话中...</div>;
if (!isAuthenticated) return <Navigate to="/login" />;
return <div>受保护内容</div>;
}
会话过期处理程序
import { useEffect } from "react";
import { useSession } from "./SessionProvider";
function SessionExpirationHandler() {
const { checkSession, clearSession } = useSession();
useEffect(() => {
// 定期检查会话
const interval = setInterval(async () => {
const user = await checkSession();
if (!user) {
// 会话过期
alert("您的会话已过期。请重新登录。");
clearSession();
window.location.href = "/login";
}
}, 5 * 60 * 1000); // 每5分钟
// 窗口获得焦点时检查(用户返回标签页)
const handleFocus = () => checkSession();
window.addEventListener("focus", handleFocus);
return () => {
clearInterval(interval);
window.removeEventListener("focus", handleFocus);
};
}, [checkSession, clearSession]);
return null; // 不可见组件
}
// 添加到应用根
function App() {
return (
<SessionProvider>
<SessionExpirationHandler />
<Routes />
</SessionProvider>
);
}
服务器端会话验证
在API路由中验证会话
import { getApi } from "bknd";
export async function GET(request: Request, app: BkndApp) {
const api = getApi(app);
const user = await api.auth.resolveAuthFromRequest(request);
if (!user) {
return new Response("未授权", { status: 401 });
}
// 会话有效 - 用户数据可用
console.log("用户ID:", user.id);
console.log("邮箱:", user.email);
console.log("角色:", user.role);
return new Response(JSON.stringify({ user }));
}
服务器端会话检查(Next.js)
// app/api/me/route.ts
import { getApp, getApi } from "bknd/adapter/nextjs";
export async function GET(request: Request) {
const app = await getApp();
const api = getApi(app);
const user = await api.auth.resolveAuthFromRequest(request);
if (!user) {
return Response.json({ user: null }, { status: 401 });
}
return Response.json({ user });
}
常见模式
记住最后活动
// 跟踪用户活动以进行会话超时警告
let lastActivity = Date.now();
// 用户交互时更新
document.addEventListener("click", () => (lastActivity = Date.now()));
document.addEventListener("keypress", () => (lastActivity = Date.now()));
// 检查不活动
setInterval(() => {
const inactiveMinutes = (Date.now() - lastActivity) / 1000 / 60;
if (inactiveMinutes > 25) {
// 警告用户会话即将过期
showSessionWarning();
}
if (inactiveMinutes > 30) {
// 强制登出
api.auth.logout();
window.location.href = "/login?reason=inactive";
}
}, 60000); // 每分钟检查
多标签会话同步
// 同步跨浏览器标签的会话状态
window.addEventListener("storage", async (event) => {
if (event.key === "auth") {
if (event.newValue === null) {
// 在另一个标签页登出
window.location.href = "/login";
} else {
// 在另一个标签页登录 - 刷新会话
await api.auth.me();
window.location.reload();
}
}
});
安全会话存储
// 对于敏感应用,使用sessionStorage + 标签页关闭时警告
const api = new Api({
host: "http://localhost:7654",
storage: sessionStorage,
});
window.addEventListener("beforeunload", (e) => {
if (api.auth.me()) {
e.preventDefault();
e.returnValue = "离开将导致登出。";
}
});
常见陷阱
刷新时丢失会话
问题: 用户页面刷新后登出
修复: 提供存储适配器:
// 错误 - 无持久性
const api = new Api({ host: "http://localhost:7654" });
// 正确
const api = new Api({
host: "http://localhost:7654",
storage: localStorage,
});
本地Cookie不工作
问题: 开发环境中Cookie未设置
修复: 为localhost禁用secure标志:
{
auth: {
cookie: {
secure: process.env.NODE_ENV === "production", // 开发环境false
},
},
}
会话检查阻塞UI
问题: 检查会话时应用显示空白
修复: 显示加载状态:
function App() {
const { isLoading } = useSession();
if (isLoading) {
return <LoadingSpinner />; // 不要留空白
}
return <Routes />;
}
过期令牌仍在存储中
问题: 旧令牌导致持续401错误
修复: 身份验证失败时清除存储:
async function checkSession() {
const { ok } = await api.auth.me();
if (!ok) {
// 清除陈旧令牌
localStorage.removeItem("auth");
return null;
}
return user;
}
验证
测试会话处理:
1. 会话跨刷新持久化:
// 登录
await api.auth.login("password", { email: "test@example.com", password: "pass" });
// 刷新页面,然后:
const { ok, data } = await api.auth.me();
console.log("会话持久化:", ok && data?.user); // 应为true
2. 会话正确过期:
// 在配置中设置短过期时间(用于测试)
jwt: { expires: 10 } // 10秒
// 登录,等待15秒
await api.auth.login("password", { email, password });
await new Promise(r => setTimeout(r, 15000));
const { ok } = await api.auth.me();
console.log("会话过期:", !ok); // 应为true
3. 登出清除会话:
await api.auth.logout();
const { ok } = await api.auth.me();
console.log("会话清除:", !ok); // 应为true
做与不做
做:
- 根据用例配置适当的JWT过期时间
- 可能时使用httpOnly cookies(XSS保护)
- 应用初始化时检查会话有效性
- 优雅处理会话过期并提供UI反馈
- 匹配Cookie过期时间与JWT过期时间
- 生产环境使用
secure: true
不做:
- 仅将令牌存储在内存中(刷新时丢失)
- 无续订机制使用长过期时间
- 忽略会话过期错误
- 无明确原因混合cookie和header身份验证
- 非绝对必要禁用httpOnly
- 忘记在登出时清除存储
相关技能
- bknd-setup-auth - 配置身份验证系统
- bknd-login-flow - 登录/登出功能
- bknd-oauth-setup - OAuth/社交登录提供者
- bknd-protect-endpoint - 保护特定端点
- bknd-public-vs-auth - 配置公共与身份验证访问