Bknd会话处理Skill bknd-session-handling

这个技能用于管理Bknd应用程序中的用户会话,包括JWT令牌的创建、存储、验证、自动续订和过期处理,确保安全高效的身份验证机制。关键词:Bknd, 会话管理, JWT, 身份验证, 后端开发, 安全性, 令牌生命周期, 自动刷新

后端开发 0 次安装 0 次浏览 更新于 3/8/2026

名称: 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基础会话

  1. 登录 - 服务器创建带有用户数据的签名JWT,返回令牌
  2. 存储 - 令牌存储在Cookie(自动)或localStorage/header(手动)
  3. 请求 - 每个请求发送令牌进行身份验证
  4. 验证 - 服务器验证签名和过期
  5. 续订 - 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 - 配置公共与身份验证访问