2931 字
15 分钟

后端分析:Fastify、Prisma 与 OAuth/OIDC 的实现

后端分析:Fastify、Prisma 与 OAuth/OIDC 的实现#

后端是这个 SSO 系统的核心。它既要处理管理后台的登录和 CRUD,又要对外提供 OAuth/OIDC 协议端点,还要负责 token、会话、权限和审计日志的一致性。

后端目录结构:

backend/
├── prisma/
│ ├── schema.prisma
│ └── seed.js
└── src/
├── server.js
├── db.js
├── config.js
├── middleware/
├── routes/
├── services/
├── authorization.js
├── accessScope.js
├── cors.js
├── http.js
├── utils.js
└── validation.js

整体分层比较清楚:

  • routes/:定义 HTTP 路由,处理鉴权装饰和参数传递。
  • services/:承载业务逻辑,例如登录、OAuth、用户、角色、应用、安全策略。
  • authorization.js:计算有效权限和权限继承。
  • accessScope.js:根据管理员权限限制管理范围。
  • middleware/auth.js:校验 Bearer token、会话、黑名单和权限版本。
  • db.js:Prisma 实例和 SQLite 参数。

服务启动流程#

入口在 backend/src/server.js。启动时主要做几件事:

  1. 生产环境检查 COOKIE_SECRET
  2. 调用 configureDb() 配置数据库。
  3. 调用 initKeys() 初始化 JWT 签名密钥。
  4. 创建 Fastify 实例。
  5. 注册 Helmet、CORS、Cookie、FormBody、RateLimit。
  6. 注册统一错误处理器。
  7. 注册健康检查和业务路由。
  8. 启动后台任务,包括验证码清理、过期数据清理、SQLite 自动备份。

Fastify 插件配置体现了几个安全细节:

  • 日志 redact 会隐藏 authorization、cookie、password、client_secret、refresh_token、code、code_verifier 等敏感字段。
  • API 侧启用 Helmet,但关闭 CSP,因为前端是独立 SPA,API 主要返回 JSON。
  • CORS 支持凭证,并通过 buildCorsOriginOption() 统一控制允许来源。
  • 全局 rate limit 默认每分钟 100 次。

统一错误格式也比较适合前端消费:

{
"error": {
"code": "invalid_request",
"message": "请求参数错误",
"requestId": "..."
}
}

这种格式让前端可以根据 code 做本地化映射或特殊处理,例如验证码错误、登录过期、权限不足。

数据库设计#

项目使用 Prisma + SQLite。db.js 在 SQLite 下启用:

PRAGMA busy_timeout = 5000;
PRAGMA journal_mode = WAL;

这两个设置是面向个人生产部署的实用增强:

  • busy_timeout 可以缓解短时间写锁冲突。
  • WAL 模式让读写并发体验更好。

Prisma schema 里有几个关键分组。

身份与权限:

  • User
  • Group
  • Role
  • Permission
  • UserRole
  • UserPermission
  • GroupMember
  • GroupRole
  • GroupPermission

OAuth/OIDC:

  • OAuthClient
  • Session
  • RefreshToken
  • AuthorizationCode
  • TokenBlacklist
  • UserConsent
  • KeyPair

审计与安全:

  • AuditLog
  • LoginLog
  • SecurityPolicy

值得注意的是,RolePermission 都有 clientId。这让系统能区分平台级资源和应用级资源。平台权限例如 sso.client.manage,应用权限例如 demo-app.user

权限继承规则#

权限判断核心在 authorization.js

export function permissionImplies(granted, required) {
if (!granted || !required) return false
if (granted === required) return true
const grantedParts = String(granted).split('.').filter(Boolean)
const requiredParts = String(required).split('.').filter(Boolean)
const adminIndex = grantedParts.indexOf('admin')
if (adminIndex === -1) return false
if (adminIndex === 0) return true
const scope = grantedParts.slice(0, adminIndex)
return scope.every((part, index) => requiredParts[index] === part)
}

这个函数表达了项目的权限哲学:权限是点号路径,admin 是通配节点。

示例:

已拥有权限要求权限结果
admindemo-app.order.read允许
sso.adminsso.user.write允许
demo-app.admindemo-app.user允许
demo-app.userdemo-app.admin拒绝
sso.user.adminsso.role.read拒绝

用户有效权限由 getEffectiveAccess() 计算。它会合并:

  • 用户直接角色。
  • 用户直接权限。
  • 所属用户组角色。
  • 所属用户组权限。

并且支持 clientId 过滤。当 clientId* 时返回全量权限;当指定某个应用 client 时,只返回该应用范围内的角色和权限。

管理范围控制#

很多系统容易犯的错误是前端隐藏菜单,但后端不限制数据范围。这个项目把范围限制放在 accessScope.js

核心逻辑:

  • 拥有 sso.admin 的用户是平台管理员,可以管理全局。
  • 拥有 {app}.admin 的用户是应用管理员,只能管理该应用。
  • 如果请求里传入了无权管理的 app,后端直接返回 403。
  • 如果没有传 app,应用管理员默认使用自己可管理应用列表里的第一个。

requireScopedManagement(platformPermission, handler) 把平台权限和应用范围权限统一包起来:

  • 平台管理员或拥有指定平台权限的人直接进入 handler。
  • 否则根据 {app}.admin 解析管理范围,再进入 handler。

这样用户、用户组、角色、权限等接口都可以复用同一套范围约束。

管理后台登录#

管理后台登录逻辑在 authService.js

登录过程:

  1. 读取用户名、密码、验证码。
  2. 查询用户。
  3. 根据失败次数决定是否要求验证码。
  4. 判断账号锁定、禁用状态。
  5. 使用 bcrypt 校验密码。
  6. 密码错误时增加失败次数,达到阈值后锁定账号。
  7. 密码正确时重置失败计数,记录最后登录时间。
  8. 创建管理后台 session。
  9. 签发 access token、refresh token、id token。
  10. 写入登录日志。

安全策略来自 SecurityPolicy

  • 密码最小长度。
  • 是否要求数字。
  • 是否要求字母。
  • 登录失败多少次后要求验证码。
  • 登录失败多少次后锁定账号。
  • 锁定分钟数。
  • access token TTL。
  • refresh token TTL。
  • authorization code TTL。
  • CORS origin 白名单。

验证码使用 svg-captcha 生成,暂存在内存 captchaStore 中,并由后台定时任务清理过期数据。对单实例部署来说这个实现足够简单;如果以后改成多实例,需要把验证码状态迁移到 Redis 或数据库。

Token 签发#

token 相关逻辑集中在 tokenService.js

系统启动时调用 initKeys()

  • 如果数据库已有 KeyPair,则导入已有 RSA JWK。
  • 如果没有,则生成 RS256 密钥对并持久化。

JWKS 端点返回公钥:

GET /.well-known/jwks.json

业务系统可以使用这个端点远程校验 access token 签名。

access token claims 包含:

  • sub:用户 ID。
  • aud:资源服务 audience。
  • azp / client_id:发起授权的 client。
  • sid:SSO session ID。
  • nameusernameemail:用户信息。
  • scope:授权 scope。
  • rolesgroupspermissions:有效访问关系。
  • permission_version:权限版本。
  • jti:token ID,用于撤销黑名单。

permission_version 是一个重要设计。每次用户权限关系变化时,后端可以递增用户的 permissionVersion。Bearer 鉴权时如果 token 中版本和数据库不一致,就要求用户重新登录或刷新。

Bearer 鉴权#

middleware/auth.jsverifyBearer() 会做完整校验:

  1. 读取 Authorization: Bearer <token>
  2. 使用本地公钥校验 JWT 签名和 issuer。
  3. 如果接口要求 audience,则校验 aud
  4. 检查 jti 是否在 TokenBlacklist
  5. 查询用户,并要求用户状态为 active。
  6. 查询 session,并要求 session active 且未过期。
  7. 校验 permission_version
  8. 把用户、session、roles、permissions 写入 request.auth
  9. 异步节流更新 session 的 lastSeenAt

管理后台 API 使用 requireAdminAuth(),它在 requireAuth() 基础上额外要求 audience 为 sso-admin。这能避免业务系统 token 被拿来调用 SSO 管理接口。

OAuth/OIDC 端点#

OAuth 路由在 routes/oauth.js

路径作用
GET /.well-known/openid-configurationOIDC Discovery。
GET /.well-known/jwks.jsonJWKS 公钥。
GET /oauth/authorize授权码入口。
POST /oauth/consent用户授权确认。
POST /oauth/token换 token。
POST /oauth/revoke撤销 token。
POST /oauth/introspecttoken 内省。
GET /oauth/userinfoOIDC UserInfo。
GET /oauth/logoutOIDC logout。

Discovery 返回的元数据包括授权端点、token 端点、userinfo、jwks、revoke、introspection、logout、支持的 grant type 和 PKCE 方法。

授权码入口#

handleAuthorize() 处理 /oauth/authorize

  1. 要求 response_type=code
  2. 根据 client_id 查询 client。
  3. 校验 redirect_uri、scope、audience。
  4. public client 必须使用 PKCE。
  5. 根据 sso_session cookie 查找当前 SSO session。
  6. 支持 prompt=noneprompt=loginprompt=consent
  7. 未登录则跳到管理前端 /login?returnTo=...
  8. 如果非 first-party client 且需要授权确认,则跳到 /consent
  9. 生成 authorization code 并跳回业务系统 redirect URI。

这里有个细节:consent 提交后会再次校验 client 请求,避免用户在授权页期间篡改参数后继续签发 token。

Token 端点#

handleToken() 支持三种 grant type:

  • authorization_code
  • refresh_token
  • client_credentials

授权码换 token 时会校验:

  • client 是否 active。
  • confidential client 的 secret。
  • authorization code 是否存在、属于当前 client、未过期、未使用。
  • redirect URI 是否一致。
  • public client 是否满足 PKCE。
  • code_verifier 的 S256 结果是否等于保存的 challenge。
  • 用户和 session 是否有效。

然后调用 issueTokenSet() 签发 access token、refresh token、id token。

Refresh Token Rotation#

refresh token 存库时只保存 sha256 哈希,不保存明文。

每次刷新时:

  1. 根据 refresh token 哈希查询记录。
  2. 要求 token active 且未过期。
  3. 如果 token 已不是 active,说明可能被复用,撤销整个 token family。
  4. 将当前 refresh token 标记为 used。
  5. 校验用户和 session。
  6. 签发新的 access token 和 refresh token,并沿用原 token family。

这是比较重要的安全机制。refresh token 泄漏后,如果攻击者和正常用户同时使用旧 token,系统能通过复用检测撤销整组 token。

Revoke 与 Introspection#

/oauth/revoke 会优先尝试把 refresh token 标记为 revoked。如果传入的是 access token,则校验后把它的 jti 写入 TokenBlacklist

/oauth/introspect 会校验 token 签名和 issuer,并继续检查:

  • jti 是否被拉黑。
  • client 是否仍然 active。
  • 用户是否 active。
  • session 是否 active 且未过期。
  • permission_version 是否仍然一致。

它返回 { active: true, ...payload }{ active: false }。适合无法本地校验 JWT 或需要实时状态的机密客户端。

应用接入管理#

应用管理逻辑在 clientService.js

创建 client 时,系统要求:

  • clientKey
  • name
  • clientType
  • audience

clientType 只能是:

  • confidential:后端应用,拥有 client secret。
  • public:前端或无法安全保存 secret 的应用,必须使用 PKCE。

创建应用时会在事务里自动创建:

  • OAuth client。
  • {clientKey}.admin{clientKey}.user 两个基础权限。
  • {clientKey}.admin{clientKey}.user 两个内置角色。
  • {clientKey}-admin 默认应用管理员用户。

对于 confidential client,client secret 只返回一次,数据库只保存 bcrypt 哈希。应用管理员密码也只返回一次。

这个设计让应用接入具有“开箱即管理”的能力。

用户、用户组、角色、权限#

用户、用户组、角色和权限分别由对应 service 处理:

  • userService.js
  • groupService.js
  • roleService.js
  • permissionService.js

它们的共同点:

  • 支持按应用前缀过滤。
  • 创建或更新访问关系时会校验角色、权限是否属于当前应用。
  • 访问关系变更后会影响用户的 permissionVersion
  • 管理操作会写审计日志。

用户创建时,如果传入 app,系统会自动分配该应用的默认用户角色。这适合业务系统注册场景:新注册用户天然获得 {app}.user 权限。

安全策略与密钥轮换#

securityService.js 负责读取和更新默认安全策略。管理后台可以修改:

  • 密码策略。
  • 登录验证码阈值。
  • 账号锁定策略。
  • token TTL。
  • authorization code TTL。
  • CORS origin 和 suffix。

签名密钥轮换由 tokenService.rotateKeys() 完成,会生成新的 RS256 JWK 并覆盖数据库中的 KeyPair

当前实现只有一个固定 kidsso-local-key。这对个人系统足够简单,但也意味着轮换后旧 token 无法继续通过 JWKS 验证。更成熟的做法是保留旧公钥到旧 token 全部过期,再移除旧 key。

后台任务#

startBackgroundJobs() 启动三类任务:

  • 每 5 分钟清理过期验证码。
  • 每天 03:00 清理过期 session、refresh token、authorization code 和 90 天前登录日志。
  • SQLITE_BACKUP_CRON 执行 SQLite 备份,默认每天 02:30。

SQLite 备份使用:

VACUUM INTO 'backup-file.db'

然后 gzip 压缩,并按保留天数删除旧备份。相比直接复制数据库文件,VACUUM INTO 对 SQLite 更稳妥。

测试覆盖#

后端使用 Node 内置测试框架:

Terminal window
npm test

现有测试覆盖了:

  • 参数校验工具。
  • CORS。
  • 应用管理范围。
  • 权限继承。
  • HTTP 集成行为。

对 SSO 系统来说,后续最值得补强的是 OAuth 流程测试,尤其是 authorization code 复用、PKCE 失败、refresh token 复用检测、revoke 后 introspection 状态变化。

后端小结#

这个后端的特点是实用而完整:

  • 用 Fastify 保持 HTTP 层简洁。
  • 用 Prisma 明确表达身份、权限和 OAuth 状态。
  • 用 OIDC Discovery 和 JWKS 降低业务系统接入成本。
  • 用 refresh token rotation、token blacklist、session 状态和 permission version 管理 token 生命周期。
  • accessScope 在后端限制应用管理员边界。
  • 用 SQLite WAL 和自动备份支撑个人生产部署。

它的主要演进方向也很明确:

  • 多实例部署时迁移 PostgreSQL,并把验证码、session 写入节流等状态迁出内存。
  • 密钥轮换支持多 kid 和旧 key 保留窗口。
  • 引入更完整的邮件能力,用于重置密码、邀请注册和异常登录提醒。
  • 完善 OAuth/OIDC 兼容性测试。

下一篇继续看前端如何把这些后端能力组织成一个管理后台。

后端分析:Fastify、Prisma 与 OAuth/OIDC 的实现
https://march7th.online/blog/posts/0036-后端分析fastifyprisma-与-oauth-oidc-的实现/
作者
Yiguo
发布于
2026-07-01
许可协议
CC BY-NC-SA 4.0
最后更新于 2026-07-01
所属合集

个人 SSO 权限系统

记录我为个人多项目搭建统一登录和权限管理系统的完整过程,覆盖架构、OAuth、安全闭环、权限模型、接入和部署。

查看完整合集

目录