sumor
v3.3.4
Published
Sumor OAuth framework
Readme
Sumor - OAuth 认证框架
Sumor 是一个面向 Express.js 应用的完整 OAuth 2.0 认证框架,内置基于角色的访问控制(RBAC)。它将 OAuth 集成、令牌管理和权限路由保护简化为开箱即用的中间件与工具函数,适用于多服务架构。
核心特性
- 完整 OAuth 2.0 流程:完整的授权码流程,自动令牌交换
- 会话与令牌管理:基于 HTTP-only Cookie 的安全令牌刷新机制
- JWT 验证:通过 JWKS(JSON Web Key Set)进行内置 JWT 签名校验
- 基于角色的访问控制(RBAC):应用启动时同步权限定义,运行时进行权限/角色校验
- TypeScript 优先:完整的 TypeScript 支持与类型定义
- Express 集成:即插即用的中间件与预配置路由
- Mock 模式:无需真实 OAuth 服务即可在本地进行开发调试
- Web 客户端 SDK:浏览器端令牌刷新、用户状态管理与权限工具函数
安装
npm install sumor架构概览
Sumor 分为两个入口:
| 入口 | 用途 |
| ----------- | --------------------------- |
| sumor | 服务端(Node.js / Express) |
| sumor/web | 客户端(浏览器) |
浏览器应用
│ (1) 应用初始化时调用 refreshToken()
│ (2) login() → 跳转至 OAuth 授权页
│ (3) OAuth 回调 → /api/oauth/callback
│ (4) 后续请求携带 HttpOnly Cookie
▼
你的 Express 应用
├── oauthRoutes ← /api/oauth/*(令牌刷新、回调、登出)
├── loadJwtUserMiddleware ← 校验 JWT,注入 req.jwtUser
└── 你的业务路由 ← 访问 req.jwtUser,调用 OAuthService 方法
▼
OAuth 服务提供商
└── 签发 JWT,管理用户与权限,提供 JWKS 公钥服务端用法
引入
import { OAuthService, loadJwtUserMiddleware, oauthRoutes } from 'sumor'导出说明
| 导出 | 类型 | 说明 |
| ----------------------- | -------------- | ----------------------------- |
| OAuthService | 类 | 与 OAuth 提供商 API 交互 |
| loadJwtUserMiddleware | 中间件 | 校验 JWT 并注入 req.jwtUser |
| oauthRoutes | Express Router | 预配置的 OAuth 路由 |
基础配置
在 Express 应用中注册 OAuth 路由和中间件:
import express from 'express'
import { OAuthService, loadJwtUserMiddleware, oauthRoutes } from 'sumor'
const app = express()
// 注册预配置的 OAuth 路由(回调、令牌刷新、登出)
app.use('/api/oauth', oauthRoutes)
// JWT 中间件:校验令牌并为后续路由注入 req.jwtUser
app.use(loadJwtUserMiddleware)
// 你的受保护路由
app.use('/api/user', userRoutes)注意: 请在
loadJwtUserMiddleware之前注册oauthRoutes,确保 OAuth 回调端点(/api/oauth/callback)无需认证即可访问。
启动时同步权限
在应用启动时,将权限定义注册到 OAuth 提供商:
const oauthService = new OAuthService()
await oauthService.updatePermissions({
permissions: ['posts:view', 'posts:create', 'posts:edit', 'posts:delete'],
permissionLabels: [{ module: 'posts', zh: '文章管理', en: 'Posts Management' }]
})权限字符串格式为 <模块>:<操作>。
在路由中访问用户信息
loadJwtUserMiddleware 执行后,req.jwtUser 包含解码后的 JWT 载荷:
app.get('/api/profile', (req, res) => {
const { userId, roles, permissions, isVerified } = req.jwtUser
res.json({
userId,
roles: roles?.split(',') ?? [],
permissions: permissions?.split(',') ?? [],
isVerified: isVerified === 1
})
})req.jwtUser 属性说明:
| 属性 | 类型 | 说明 |
| ------------- | -------- | ------------------------------ |
| userId | string | 用户唯一标识 |
| roles | string | 逗号分隔的角色 ID |
| permissions | string | 逗号分隔的权限字符串 |
| isVerified | number | 1 表示已认证,0 表示未认证 |
| tenantId | string | 多租户标识 |
| jti | string | JWT ID(会话标识符) |
| exp | number | 令牌过期时间戳 |
| iat | number | 令牌签发时间戳 |
OAuthService 方法
在服务端任意位置创建实例,自动读取环境变量配置:
const oauthService = new OAuthService()updatePermissions(config)
将应用的权限定义同步到 OAuth 提供商。
await oauthService.updatePermissions({
permissions: ['resource:view', 'resource:edit'],
permissionLabels: [{ module: 'resource', zh: '资源管理', en: 'Resource Management' }]
})getUserInfo(userId)
获取单个用户的详细信息。
const userInfo = await oauthService.getUserInfo('user-123')
// 返回:{ userId, username, nickname, email, avatar, ... }getUsersInfo(userIds)
批量获取多个用户的信息。
const users = await oauthService.getUsersInfo(['user-1', 'user-2', 'user-3'])
// 返回:[{ userId, username, ... }, ...]searchUsers(searchTerm, limit)
按名称或邮箱搜索用户。
const results = await oauthService.searchUsers('alice', 20)
// 返回:[{ userId, username, email, ... }, ...]revokeSession(sessionId)
登出时撤销(黑名单化)会话。
await oauthService.revokeSession(req.jwtUser.jti)checkBlacklist(sessionId)
检查会话是否已被撤销。
const isRevoked = await oauthService.checkBlacklist(sessionId)预配置 OAuth 路由
oauthRoutes 自动注册以下端点:
| 方法 | 路径 | 需要认证 | 说明 |
| ------ | --------------------- | -------- | --------------------------------------- |
| GET | /api/oauth/callback | 否 | 使用授权码换取令牌 |
| PUT | /api/oauth/token | 否 | 刷新访问令牌,返回用户信息和 OAuth 地址 |
| POST | /api/oauth/logout | 是 | 撤销会话并清除 Cookie |
客户端用法(sumor/web)
引入
import {
refreshToken,
login,
logout,
hasPermission,
hasRole,
oauthUrl,
oauthStore,
axios
} from 'sumor/web'
import type { ApiResponse } from 'sumor/web'导出说明
| 导出 | 类型 | 说明 |
| --------------- | ---------- | ------------------------------------------ |
| refreshToken | 函数 | 刷新令牌并同步用户状态(应用初始化时调用) |
| login | 函数 | 跳转到 OAuth 登录页 |
| logout | 函数 | 调用登出端点并清除用户状态 |
| hasPermission | 函数 | 检查当前用户是否有某个权限 |
| hasRole | 函数 | 检查当前用户是否有某个角色 |
| oauthStore | 单例 | 内存中的用户和 OAuth 状态存储 |
| oauthUrl | 对象 | 生成 OAuth 提供商导航地址的工具函数 |
| axios | Axios 实例 | 预配置 Axios,自动处理 401 令牌刷新重试 |
| ApiResponse | 类型 | 标准 API 响应封装类型 |
应用初始化
在应用初始化时调用一次 refreshToken(),从已存储的 refresh token Cookie 中恢复用户状态:
// main.ts 或 App.vue 的 onMounted
import { refreshToken } from 'sumor/web'
await refreshToken()
// 此后可通过 oauthStore 访问用户状态在 SSR 环境中,
refreshToken()会直接返回,不执行任何操作。
登录与登出
import { login, logout } from 'sumor/web'
// 跳转到 OAuth 授权页
login()
// 登出:撤销会话并清除用户状态
await logout()订阅用户状态变化
通过 oauthStore 读取当前用户并监听状态变化:
import { oauthStore } from 'sumor/web'
// 读取当前用户
const user = oauthStore.getUser()
// { id, isVerified, roles, permissions } 或 null
// 订阅登录/登出事件
oauthStore.onUserChange(user => {
if (user) {
console.log('已登录:', user.id)
} else {
console.log('已登出')
}
})UserInfo 属性说明:
| 属性 | 类型 | 说明 |
| ------------- | -------- | -------------------- |
| id | string | 用户 ID |
| isVerified | number | 1 表示已认证 |
| roles | string | 逗号分隔的角色 ID |
| permissions | string | 逗号分隔的权限字符串 |
权限与角色检查
import { hasPermission, hasRole } from 'sumor/web'
// 检查特定权限(模块 + 操作)
if (hasPermission('posts', 'edit')) {
// 用户可以编辑文章
}
// 检查模块下的任意权限(通配符)
if (hasPermission('posts', '*')) {
// 用户有 posts 模块的任意权限
}
// 检查角色
if (hasRole('admin')) {
// 用户是管理员
}OAuth 地址工具函数
import { oauthUrl } from 'sumor/web'
oauthUrl.avatar(userId) // 用户头像图片地址
oauthUrl.user() // 用户个人中心地址
oauthUrl.home() // OAuth 提供商首页地址
oauthUrl.site() // 站点管理页面地址
oauthUrl.feedback() // 意见反馈页面地址在 Mock 模式 下,这些地址会指向本地占位页面。
发送带认证的 HTTP 请求
导出的 axios 实例会在 401 响应时自动刷新令牌并重试请求:
import { axios } from 'sumor/web'
import type { ApiResponse } from 'sumor/web'
// GET
const { data } = await axios.get<ApiResponse<UserInfo>>('/api/user/info')
// POST
const { data } = await axios.post<ApiResponse<Item>>('/api/items', { name: '我的项目' })
// PUT
const { data } = await axios.put<ApiResponse<Item>>(`/api/items/${id}`, updates)
// DELETE
const { data } = await axios.delete<ApiResponse<null>>(`/api/items/${id}`)
// 带进度的文件上传
await axios.post<ApiResponse<UploadResult>>('/api/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: e => {
const percent = Math.round((e.loaded * 100) / (e.total ?? e.loaded))
console.log(`上传进度:${percent}%`)
}
})ApiResponse<T> 类型:
interface ApiResponse<T> {
code: string // 成功时为 'OK'
message: string
data: T
}环境变量配置
Sumor 完全通过环境变量读取配置,无需向构造函数传参。
必填环境变量
OAUTH_ENDPOINT=https://auth.example.com
OAUTH_CLIENT_KEY=your-app-client-id
OAUTH_CLIENT_SECRET=your-app-client-secret
OAUTH_REDIRECT_URI=http://localhost:3000/api/oauth/callback| 变量 | 说明 |
| --------------------- | ------------------------------------- |
| OAUTH_ENDPOINT | OAuth 提供商的根地址 |
| OAUTH_CLIENT_KEY | OAuth 应用客户端 ID |
| OAUTH_CLIENT_SECRET | OAuth 应用客户端密钥 |
| OAUTH_REDIRECT_URI | 回调地址(须与 OAuth 提供商配置一致) |
JWT 签名校验使用 OAuth 提供商的 JWKS 公钥(从 {OAUTH_ENDPOINT}/api/oauth/jwks 获取),无需本地密钥。
Mock 模式
Mock 模式允许在没有真实 OAuth 服务的情况下进行本地开发。通过设置 OAUTH_MOCK=true 启用。此模式下,Sumor 使用 HS256 签发本地 JWT 令牌,并注册额外的 Mock 专用端点。
工作原理
当 OAUTH_MOCK=true 时:
oauthRoutes在/api/oauth/mock/下注册额外的子路由PUT /api/oauth/token路由在本地校验 Mock 令牌,不调用 OAuth 提供商- Web 客户端的
login()直接调用POST /api/oauth/mock/login,不跳转到 OAuth 提供商 logout()调用POST /api/oauth/mock/logout在本地清除 CookieoauthUrl.*工具函数返回本地占位页面地址
Mock 服务端环境变量
# 启用 Mock 模式
OAUTH_MOCK=true
# Mock 用户配置(均为可选,括号内为默认值)
OAUTH_MOCK_USER_ID=mock-user-001
OAUTH_MOCK_USER_ROLES=admin
OAUTH_MOCK_USER_PERMISSIONS=
OAUTH_MOCK_USER_IS_VERIFIED=1| 变量 | 默认值 | 说明 |
| ----------------------------- | --------------- | ------------------------------------------- |
| OAUTH_MOCK | false | 设为 true 启用 Mock 模式 |
| OAUTH_MOCK_USER_ID | mock-user-001 | Mock 用户 ID |
| OAUTH_MOCK_USER_ROLES | admin | 逗号分隔的 Mock 角色 |
| OAUTH_MOCK_USER_PERMISSIONS | (空) | 逗号分隔的 Mock 权限 |
| OAUTH_MOCK_USER_IS_VERIFIED | 1 | Mock 认证状态(0 或 1) |
| OAUTH_MOCK_USERS | (空) | JSON 数组格式的 Mock 用户列表用于搜索和查询 |
Mock 专用路由(仅在 OAUTH_MOCK=true 时注册)
| 方法 | 路径 | 说明 |
| ------ | ----------------------------- | -------------------------------------------- |
| POST | /api/oauth/mock/login | 签发 Mock 令牌并返回用户信息(无需页面跳转) |
| POST | /api/oauth/mock/logout | 清除 Mock 令牌 Cookie |
| GET | /api/oauth/mock/avatar/:id | 返回 404(Mock 模式无头像服务) |
| GET | /api/oauth/mock/nav/:target | OAuth 提供商导航链接的本地占位页面 |
Mock 模式示例配置
Mock 用户列表
通过配置 OAUTH_MOCK_USERS 环境变量为 getUserInfo()、getUsersInfo() 和 searchUsers() 方法提供测试数据,无需真实 OAuth 服务。
用户对象属性:
interface MockUser {
userId: string // 必需:用户唯一标识
username?: string // 可选:默认为 `user_{userId}`
email?: string // 可选:默认为 `{userId}@example.com`
name?: string // 可选:默认为 `User {userId}`
avatar?: string // 可选:头像 URL
phone?: string // 可选:电话号码
createdAt?: string // 可选:ISO 8601 时间戳
updatedAt?: string // 可选:ISO 8601 时间戳
isActive?: boolean // 可选:默认为 `true`
}行为说明:
getUserInfo('user001')返回配置的用户对象,不存在则生成默认用户getUsersInfo(['user001', 'unknown'])返回已配置用户及动态生成的未知用户searchUsers('alice')在配置用户中按 userId、username、name、email 模糊搜索并返回结果
配置示例:
export OAUTH_MOCK_USERS='[
{"userId": "user001", "username": "alice", "email": "[email protected]", "name": "Alice Wang"},
{"userId": "user002", "username": "bob", "email": "[email protected]", "name": "Bob Smith"}
]'更详细的配置示例和使用模式参考 Mock 用户列表使用指南。
.env.development:
OAUTH_MOCK=true
OAUTH_MOCK_USER_ID=dev-user-1
OAUTH_MOCK_USER_ROLES=admin
OAUTH_MOCK_USER_PERMISSIONS=posts:view,posts:edit,posts:delete
OAUTH_MOCK_USER_IS_VERIFIED=1
OAUTH_MOCK_USERS='[
{
"userId": "user001",
"username": "alice",
"email": "[email protected]",
"name": "Alice Wang",
"avatar": "https://api.example.com/avatars/alice.jpg",
"isActive": true
},
{
"userId": "user002",
"username": "bob",
"email": "[email protected]",
"name": "Bob Smith"
}
]'应用代码无需任何修改 —— 相同的 oauthRoutes、loadJwtUserMiddleware、login() 和 logout() 调用在 Mock 模式和生产模式下均正常工作。
安全提示
Mock 令牌使用固定的 HS256 密钥签名,可通过载荷中的 iss: 'mock-oauth' 字段识别。请勿在生产环境中使用 OAUTH_MOCK=true。
使用示例
服务端 — 路由权限校验
app.post('/api/posts', (req, res) => {
const permissions = req.jwtUser.permissions?.split(',') ?? []
if (!permissions.includes('posts:create')) {
return res.status(403).json({ code: 'FORBIDDEN', message: '权限不足' })
}
// 创建文章...
res.json({ code: 'OK', data: { id: 'new-post-id' } })
})服务端 — 从 OAuth 提供商获取用户信息
app.get('/api/posts/:id/author', async (req, res) => {
const post = await db.findPost(req.params.id)
const author = await oauthService.getUserInfo(post.authorId)
res.json({ code: 'OK', data: author })
})客户端 — Vue 组件登录/登出
import { login, logout, oauthStore, hasPermission } from 'sumor/web'
import { ref, onMounted } from 'vue'
const user = ref(oauthStore.getUser())
onMounted(() => {
oauthStore.onUserChange(u => {
user.value = u
})
})
const canEdit = () => hasPermission('posts', 'edit')<template>
<button v-if="!user" @click="login">登录</button>
<button v-else @click="logout">登出</button>
<button v-if="canEdit()" @click="editPost">编辑</button>
</template>客户端 — Vue Router 路由守卫
import { refreshToken, hasPermission } from 'sumor/web'
// 挂载路由前恢复用户状态
await refreshToken()
router.beforeEach(to => {
if (to.meta.requiresPermission) {
const [module, operation] = (to.meta.requiresPermission as string).split(':')
if (!hasPermission(module, operation)) {
return '/403'
}
}
})安全注意事项
- 令牌存储在 HTTP-only Cookie 中,JavaScript 无法访问(XSS 防护)
- 生产环境请务必使用 HTTPS
- JWT 签名通过 OAuth 提供商的 JWKS 公钥进行校验
- 登出操作会立即在 OAuth 提供商侧将会话加入黑名单
- 请勿在非本地开发环境中开启
OAUTH_MOCK=true
常见问题排查
req.jwtUser 为 undefined
请确保 loadJwtUserMiddleware 在访问 req.jwtUser 的路由之前注册。
令牌校验失败
请确认 OAUTH_ENDPOINT 指向正确的 OAuth 提供商地址。Sumor 从 {OAUTH_ENDPOINT}/api/oauth/jwks 获取 JWKS 公钥。
refreshToken() 执行后用户未设置
可能是 refresh token Cookie 不存在或已过期,需要用户重新通过 login() 登录。
Mock 模式下受保护路由返回 401
请确认 OAUTH_MOCK=true 在服务器启动时已正确设置。修改该变量后需重启服务。
许可证
MIT License — 详见 LICENSE。
