Better Admin LogoBetter Admin
架构

认证与权限

双 Token 会话、token_version 强制下线,以及 RBAC 位掩码的完整校验链路。

认证与吊销流程:登录签发双 Token、请求校验、token_version 主动吊销1登录 · 校验凭据密码走 bcrypt 校验,通过后签发 accessToken 与 refreshTokenPOST /api/auth/login2双 Token · 分级时效accessToken 用于业务请求,refreshToken 用于续期;rememberMe 决定持久化档位POST /api/auth/refresh3请求鉴权 · 服务端校验校验签名、有效期与 ver 声明,再交给权限守卫做位掩码匹配Authorization: Bearer4权限门控 · RBACPermissionsGuard 读取 @Permissions() 元数据,按位与匹配后方可进入处理器GET /api/users5主动吊销 · token_version登出 / 改密使 users.token_version 自增,JWT 的 ver 声明随之失配,旧 Token 全端失效POST /api/auth/logout

认证:自己实现,不借外部服务

项目不使用 Supabase Auth,认证体系由应用自身实现。四端的登录 / 登出 / Session / 用户身份 / 角色 / 权限行为必须保持一致。

双 Token 分工

Token用途特点
accessToken业务请求鉴权短期有效,无状态,服务端不存
refreshToken换取新的 accessToken长期有效,服务端托管 hash,可撤销

登录(POST /api/auth/login)返回两者:

{
  "data": {
    "accessToken": "eyJhbGciOi…",
    "refreshToken": "eyJhbGciOi…",
    "user": { "id": "V1StGXR8_Z5j", "username": "admin", "roles": [], "permissions": 9223372036854775807 }
  }
}

「记住我」决定 refreshToken 是否持久化到本地存储,因而不影响契约形状。

refreshToken 会轮换

POST /api/auth/refresh 不只是"续期",而是换发一对新 Token,旧 refreshToken 立即作废。这样即使旧 token 泄漏,攻击窗口也被限制在一次刷新之内。

token_version:一个字段实现全端强制下线

签发 JWT 时会把用户的 token_version 写进 payload 的 ver 声明。校验时若 ver 与数据库当前值不匹配,Token 直接失效。

于是"强制下线"变得极其简单:users.token_version 加一。改密码、封禁账号都这么做,该用户所有设备上已签发的 accessToken 与 refreshToken 同时失效。

accessToken 无状态带来的残留窗口

登出撤销的是服务端托管的 refreshToken。已签发的 accessToken 在自身有效期内仍然可用,这是无状态 JWT 的固有取舍,属预期行为。用户下一次刷新失败时会被引导重新登录。

权限:位掩码,不是权限行

10 个权限点:每个占一个二进制位,十进制值即 2 的幂bigint 位掩码 · 每位一个权限点,值 = 2 的幂SEARCH搜索1ADD新增2EDIT编辑4DELETE删除8BATCH_DELETE批量删除16ADD_CHILD新增子级32RESET重置64RESET_PASSWORD重置密码128GRANT授权256EXPORT导出512

权限点不是"数据库行 + 关联表",而是编译期常量枚举 + 整型位掩码。每个操作占一个 bit,值就是 2 的幂。

好处很直接:判断权限退化成一次按位与,不需要查表;新增权限点也不用动种子数据。

// apps/nest/src/db/schema/permissions.enum.ts(节选)
export const Permissions = {
  SEARCH: { value: 'SEARCH', label: 'search', bits: 1n, icon: 'search' },
  ADD: { value: 'ADD', label: 'add', bits: 2n, icon: 'plus' },
  EDIT: { value: 'EDIT', label: 'edit', bits: 4n, icon: 'pencil-line' },
  DELETE: { value: 'DELETE', label: 'delete', bits: 8n, icon: 'trash-2' },
  // … BATCH_DELETE / ADD_CHILD / RESET / RESET_PASSWORD / GRANT / EXPORT
} as const satisfies Record<string, PermissionMeta>;

权限怎么聚合出来

  1. 用户的每个角色,在 role_menus 里对每个菜单持有一个权限位;
  2. 把该用户所有角色的位按位或叠加,得到聚合权限位;
  3. 前端用它决定按钮显隐,服务端用它做最终裁决。

菜单可见性的规则是:只要存在 role_menus 记录就可见 —— 即使该记录上的权限位是 0,菜单也应当出现在侧边栏里,只是里面的操作按钮会被权限位门控。

超级管理员的全量位

super_admin 用"全 1 掩码"表示拥有全部权限:

  • 内部存储用有符号 -1n
  • 对外 JSON 归一化为正数 9223372036854775807,避免前端 BigInt 与位运算出现歧义。
// 对外输出时归一化:仅负数映射为 2^63-1
export function normalizePermissionBits(bits: bigint): bigint {
  return bits < 0n ? bits & SUPER_ADMIN_BITS_POSITIVE : bits;
}

// 判定:全量位直接放行,否则按位与
export function hasPermission(userBits: bigint, requiredBit: bigint): boolean {
  if (userBits === -1n || userBits === SUPER_ADMIN_BITS_POSITIVE) return true;
  return (userBits & requiredBit) !== 0n;
}

这个设计让新增权限点时无需修改种子数据 —— 全 1 掩码天然覆盖未来所有位。

服务端强制校验

前端路由守卫只是体验层。真正的裁决发生在服务端,链路是「装饰器声明 → 守卫读取 → 按位校验」。

// 1) 在 Controller / Handler 上声明所需权限位
@Get()
@Permissions('SEARCH')
findAll() { /* … */ }
// 2) PermissionsGuard 读取元数据并与用户聚合位做位掩码校验
const requiredBits: string[] =
  this.reflector.getAllAndOverride<string[]>(PERMISSIONS_KEY, [
    context.getHandler(),
    context.getClass(),
  ]) ?? [];

if (requiredBits.length === 0) return true;          // 无声明即放行

const userBits = BigInt(user.permissions);
if (userBits === -1n || userBits === SUPER_ADMIN_BITS_POSITIVE) return true;

校验失败抛 403

{ "code": "FORBIDDEN", "message": "无权限" }

装饰器可以叠加多个位

@Permissions('EDIT', 'DELETE') 表示同时要求两个位。契约里的 x-permission 扩展与这里的声明是同一套语义,两端必须对齐。

超级管理员的保护不变量

super_admin 角色受到额外保护,避免管理员把自己锁在系统外:

  • 不可删除
  • 不可停用
  • 其菜单授权不可修改
  • 受「最后活跃超管」不变量保护 —— 不允许出现系统里没有任何可用超管的状态

本页目录