后端分析: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。启动时主要做几件事:
- 生产环境检查
COOKIE_SECRET。 - 调用
configureDb()配置数据库。 - 调用
initKeys()初始化 JWT 签名密钥。 - 创建 Fastify 实例。
- 注册 Helmet、CORS、Cookie、FormBody、RateLimit。
- 注册统一错误处理器。
- 注册健康检查和业务路由。
- 启动后台任务,包括验证码清理、过期数据清理、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 里有几个关键分组。
身份与权限:
UserGroupRolePermissionUserRoleUserPermissionGroupMemberGroupRoleGroupPermission
OAuth/OIDC:
OAuthClientSessionRefreshTokenAuthorizationCodeTokenBlacklistUserConsentKeyPair
审计与安全:
AuditLogLoginLogSecurityPolicy
值得注意的是,Role 和 Permission 都有 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 是通配节点。
示例:
| 已拥有权限 | 要求权限 | 结果 |
|---|---|---|
admin | demo-app.order.read | 允许 |
sso.admin | sso.user.write | 允许 |
demo-app.admin | demo-app.user | 允许 |
demo-app.user | demo-app.admin | 拒绝 |
sso.user.admin | sso.role.read | 拒绝 |
用户有效权限由 getEffectiveAccess() 计算。它会合并:
- 用户直接角色。
- 用户直接权限。
- 所属用户组角色。
- 所属用户组权限。
并且支持 clientId 过滤。当 clientId 是 * 时返回全量权限;当指定某个应用 client 时,只返回该应用范围内的角色和权限。
管理范围控制
很多系统容易犯的错误是前端隐藏菜单,但后端不限制数据范围。这个项目把范围限制放在 accessScope.js。
核心逻辑:
- 拥有
sso.admin的用户是平台管理员,可以管理全局。 - 拥有
{app}.admin的用户是应用管理员,只能管理该应用。 - 如果请求里传入了无权管理的
app,后端直接返回 403。 - 如果没有传
app,应用管理员默认使用自己可管理应用列表里的第一个。
requireScopedManagement(platformPermission, handler) 把平台权限和应用范围权限统一包起来:
- 平台管理员或拥有指定平台权限的人直接进入 handler。
- 否则根据
{app}.admin解析管理范围,再进入 handler。
这样用户、用户组、角色、权限等接口都可以复用同一套范围约束。
管理后台登录
管理后台登录逻辑在 authService.js。
登录过程:
- 读取用户名、密码、验证码。
- 查询用户。
- 根据失败次数决定是否要求验证码。
- 判断账号锁定、禁用状态。
- 使用 bcrypt 校验密码。
- 密码错误时增加失败次数,达到阈值后锁定账号。
- 密码正确时重置失败计数,记录最后登录时间。
- 创建管理后台 session。
- 签发 access token、refresh token、id token。
- 写入登录日志。
安全策略来自 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。name、username、email:用户信息。scope:授权 scope。roles、groups、permissions:有效访问关系。permission_version:权限版本。jti:token ID,用于撤销黑名单。
permission_version 是一个重要设计。每次用户权限关系变化时,后端可以递增用户的 permissionVersion。Bearer 鉴权时如果 token 中版本和数据库不一致,就要求用户重新登录或刷新。
Bearer 鉴权
middleware/auth.js 的 verifyBearer() 会做完整校验:
- 读取
Authorization: Bearer <token>。 - 使用本地公钥校验 JWT 签名和 issuer。
- 如果接口要求 audience,则校验
aud。 - 检查
jti是否在TokenBlacklist。 - 查询用户,并要求用户状态为 active。
- 查询 session,并要求 session active 且未过期。
- 校验
permission_version。 - 把用户、session、roles、permissions 写入
request.auth。 - 异步节流更新 session 的
lastSeenAt。
管理后台 API 使用 requireAdminAuth(),它在 requireAuth() 基础上额外要求 audience 为 sso-admin。这能避免业务系统 token 被拿来调用 SSO 管理接口。
OAuth/OIDC 端点
OAuth 路由在 routes/oauth.js:
| 路径 | 作用 |
|---|---|
GET /.well-known/openid-configuration | OIDC Discovery。 |
GET /.well-known/jwks.json | JWKS 公钥。 |
GET /oauth/authorize | 授权码入口。 |
POST /oauth/consent | 用户授权确认。 |
POST /oauth/token | 换 token。 |
POST /oauth/revoke | 撤销 token。 |
POST /oauth/introspect | token 内省。 |
GET /oauth/userinfo | OIDC UserInfo。 |
GET /oauth/logout | OIDC logout。 |
Discovery 返回的元数据包括授权端点、token 端点、userinfo、jwks、revoke、introspection、logout、支持的 grant type 和 PKCE 方法。
授权码入口
handleAuthorize() 处理 /oauth/authorize:
- 要求
response_type=code。 - 根据
client_id查询 client。 - 校验
redirect_uri、scope、audience。 - public client 必须使用 PKCE。
- 根据
sso_sessioncookie 查找当前 SSO session。 - 支持
prompt=none、prompt=login、prompt=consent。 - 未登录则跳到管理前端
/login?returnTo=...。 - 如果非 first-party client 且需要授权确认,则跳到
/consent。 - 生成 authorization code 并跳回业务系统 redirect URI。
这里有个细节:consent 提交后会再次校验 client 请求,避免用户在授权页期间篡改参数后继续签发 token。
Token 端点
handleToken() 支持三种 grant type:
authorization_coderefresh_tokenclient_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 哈希,不保存明文。
每次刷新时:
- 根据 refresh token 哈希查询记录。
- 要求 token active 且未过期。
- 如果 token 已不是 active,说明可能被复用,撤销整个 token family。
- 将当前 refresh token 标记为 used。
- 校验用户和 session。
- 签发新的 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 时,系统要求:
clientKeynameclientTypeaudience
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.jsgroupService.jsroleService.jspermissionService.js
它们的共同点:
- 支持按应用前缀过滤。
- 创建或更新访问关系时会校验角色、权限是否属于当前应用。
- 访问关系变更后会影响用户的
permissionVersion。 - 管理操作会写审计日志。
用户创建时,如果传入 app,系统会自动分配该应用的默认用户角色。这适合业务系统注册场景:新注册用户天然获得 {app}.user 权限。
安全策略与密钥轮换
securityService.js 负责读取和更新默认安全策略。管理后台可以修改:
- 密码策略。
- 登录验证码阈值。
- 账号锁定策略。
- token TTL。
- authorization code TTL。
- CORS origin 和 suffix。
签名密钥轮换由 tokenService.rotateKeys() 完成,会生成新的 RS256 JWK 并覆盖数据库中的 KeyPair。
当前实现只有一个固定 kid:sso-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 内置测试框架:
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 兼容性测试。
下一篇继续看前端如何把这些后端能力组织成一个管理后台。
个人 SSO 权限系统
记录我为个人多项目搭建统一登录和权限管理系统的完整过程,覆盖架构、OAuth、安全闭环、权限模型、接入和部署。
March7th