@weifuwujs/auth
v1.3.0
Published
weifuwu 用户系统:scrypt、会话轮换、防枚举、限流、无 JS 表单页(memory/sqlite/postgres store + 契约套件)
Readme
用户系统(@weifuwujs/auth)
零配置、安全默认的用户系统:scrypt、服务端不透明 session、cookie 属性、登录轮换、防枚举、 限流、Origin 校验、单次短 TTL 重置 token。不进主入口,不 import 不背依赖。
五分钟闭环
npx @weifuwujs/weifuwu init my-app
cd my-app
cp -r "$(npm root)/@weifuwujs/auth/templates/react-router-auth/." .
npx @weifuwujs/auth migrate # 生成 data/auth.db
npm run dev # http://localhost:3000/login页面为 React Router 无 JS 表单(渐进增强):注册 / 登录 / 忘记密码 / 账户(改密、登出)。
叠加层(templates/react-router-auth)内容:
| 文件 | 作用 |
| --- | --- |
| app/auth.ts | createAuth() 单例(默认 sqliteAuthStore('data/auth.db')) |
| app/server.ts | use(auth.middleware).mount(auth) + use('/account', auth.require()) + 注入 authUser |
| app/routes/{login,register,reset-password,account}.tsx | 无 JS 表单页(PRG:失败 303 回页带 ?error=) |
端点
挂载:app.mount(auth)(JSON);同一端点同时接受 application/x-www-form-urlencoded
(表单 → 303 + Location,页面回显错误)。
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| POST | /auth/register | 注册(201;表单 303) |
| POST | /auth/login | 登录,轮换旧会话(防 session fixation) |
| POST | /auth/logout | 登出并清 cookie |
| GET | /auth/me | 当前用户(ctx.auth.user 每请求现查) |
| PATCH | /auth/me | 改邮箱(需当前密码) |
| POST | /auth/password/change | 改密(保留当前会话,踢其他) |
| POST | /auth/password/forgot | 恒 200(防枚举);未配置 hook 时 dev 打印链接、生产静默 |
| POST | /auth/password/reset | 单次 token;过期/复用一律无效 |
配置
// app/auth.ts
import { createAuth } from '@weifuwujs/auth'
import { sqliteAuthStore } from '@weifuwujs/auth/sqlite'
export const auth = createAuth({
store: sqliteAuthStore('data/auth.db'),
session: { cookieName: 'ww_session', maxAge: 30 * 24 * 3600 }, // secure 默认随生产环境开启
rateLimit: { max: 10, windowMs: 60_000 }, // 默认每 IP 每分钟 10 次
origin: false, // 默认同源校验;反代跨域时显式列白名单
})
// app/server.ts
const app = createApp().use(auth.middleware).mount(auth)
app.get('/orders', auth.require(), listOrders)
app.get('/billing', auth.require((user) => user.role === 'admin'), billing)最小可跑示例(零依赖,node 直接运行;test/batteries/readme-examples.test.ts 会真跑它):
import assert from 'node:assert/strict'
import { createApp } from '@weifuwujs/weifuwu'
import { createAuth, memoryAuthStore } from '@weifuwujs/auth'
const auth = createAuth({ store: memoryAuthStore(), rateLimit: { max: 1000 } })
const app = createApp().use(auth.middleware).mount(auth)
const post = (path: string, body: unknown) =>
app.fetch(new Request(`http://readme${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
}))
assert.equal((await post('/auth/register', { email: '[email protected]', password: 'secret123' })).status, 201)
assert.equal((await post('/auth/login', { email: '[email protected]', password: 'secret123' })).status, 200)
assert.equal((await post('/auth/login', { email: '[email protected]', password: 'wrong' })).status, 401)示例标注:
ts exec= 由测试真跑;ts fragment= 片段(依赖前后文,不独立运行);自足示例带包标签(如ts tasks),由测试编译。
ctx.auth = { user, login(user), logout(), verifyPassword(plain) };auth.require():HTTP 未登录 → 页面 302/login?next=…、API 401;角色/谓词不过 → 403; WS 升级未登录 → 401 拒绝;- 只有一个中间件实例:HTTP 用
app.use(auth.middleware),WS 同时用auth.install(app)(use+wsUse)。
换 store
import { postgresAuthStore } from '@weifuwujs/auth/postgres'
const auth = createAuth({ store: postgresAuthStore(process.env.DATABASE_URL!) })自定义 store 用契约套件自证(memory / sqlite / postgres 跑的是同一套):
import { authStoreContract } from '@weifuwujs/auth/testing'
authStoreContract('my-store', () => myAuthStore())工厂会被套件多次调用:每次须返回独立的 store(sqlite 驱动用 ':memory:' 或唯一路径)——
复用同一文件会状态泄漏并假红。
AuthStore 可拆(UserStore / SessionStore / ResetTokenStore),listUsers?() 为可选管理能力。
换 hasher(存量 bcrypt 惰性迁移)
import { scryptHasher } from '@weifuwujs/auth'
import { createAuth } from '@weifuwujs/auth'
const bcryptHasher = {
async hash(plain: string) {
return doBcryptHash(plain) // 你的实现
},
verify(plain: string, stored: string) {
if (stored.startsWith('$2')) return doBcryptCompare(plain, stored)
return scryptHasher.verify(plain, stored)
},
needsRehash: (stored: string) => stored.startsWith('$2'),
}
createAuth({ store, passwordHasher: bcryptHasher })登录成功后 needsRehash 为真时自动用 hash() 重算并落库;hash/verify 允许同步或异步。
hooks
createAuth({
store,
hooks: {
onRegister: (user) => analytics.track('signup', user.id),
onLogin: (user) => audit.log('login', user.id),
onPasswordChange: (user) => mailer.notify(user.email),
},
})hooks 被 awaited;抛错只记日志,不改变请求成败(外发邮件、统计不得拖垮登录)。
sendResetToken(user, url) 单独配置:未配置时 dev 打印链接、生产静默不发(防枚举)。
侧表 profile(业务字段不进 users)
// 业务字段放自己的表/服务,用示例中间件按需加载
const loadProfile: Middleware<Context, Context & { profile?: Profile }> = async (
req, ctx, next,
) => next(req, { ...ctx, profile: await profiles.byUserId(ctx.auth?.user?.id) })
const app = createApp()
.use(auth.middleware)
.use(loadProfile)
.mount(auth)users 只存身份字段;ctx.auth.user(PublicUser)永不携带业务字段与 passwordHash。
CLI(不加载用户代码)
weifuwu-auth migrate [--store sqlite:data/auth.db|postgres://…]
weifuwu-auth user:list
weifuwu-auth user:disable [email protected]
weifuwu-auth user:reset-password [email protected] [--password <plain>]user:reset-password 未指定明文时生成 24 字节随机密码并只打印一次。
CLI 惯例(1.3 起五包统一,见 docs/alignment.md):无命令 / 未知命令 → USAGE exit 2
且不写 stdout(此前静默 exit 0);--help → stdout + exit 0;错误走 stderr 且前缀 CODE: 只出现一次;
退出码 0 成功 / 1 运行时错误 / 2 用法错误。
测试辅助(/testing)
自定义 AuthStore 驱动与内置驱动(memory / sqlite / postgres)共跑同一套契约:
import { authStoreContract } from '@weifuwujs/auth/testing'
authStoreContract('my-store', () => myAuthStore())
// 第三个参数可选:{ skip: true } 或 { skip: '未实现清理' }契约覆盖:user CRUD 与唯一 email、session 生命周期与过期清理、reset token 单次消费与过期。
安全默认清单
| 项 | 默认 |
| --- | --- |
| 密码 | scrypt(scrypt$N$r$p$salt$hash),可插拔 + 惰性重算 |
| 会话 | 只存 token 哈希;登录轮换;改密踢其他;disable/过期即时失效 |
| cookie | HttpOnly + SameSite=Lax + Path=/ + Max-Age;Secure 按配置 |
| 防枚举 | 错误凭据统一文案;forgot 恒 200;时序均衡(dummy hash) |
| 限流 | 登录/注册/重置默认每 IP 每分钟 10 次(429 + retry-after) |
| Origin | 默认同源;表单 POST 与 JSON 同校验 |
| 重置 | token 单次、短 TTL(默认 15 分钟)、只存哈希 |
边界:单租户;不做 OAuth / 2FA / 邮箱验证 / 审计事件(P2),不做支付与后台模板。
