@ activescott/ auth (英语)
框架不可知的直接认证,刻意小:单用魔法链接和一次性代码通过电子邮件和短信,以及密码(WebAuthn). 没有第三方身份提供者。 在节点和边缘运行时间(如Cloudflare Workers)上运行.
这个包是核心: Auth 类,JWT-cookie会话管理,以及提供者/存储界面. 它不单独处理任何具体的认证方法——将其与一个或多个提供者软件包和(可选的)框架适配器配对:
@activescott/auth-provider-email——电子邮件魔法链接+一次性代码@activescott/auth-provider-sms——短信一次性代码(@activescott/auth-sms-twilio提供Twilio运输: 通讯和验证)@activescott/auth-provider-passkey——密码(WebAuthn)@activescott/auth-adapter-react-router——反应路由器 v8 适配器
用于生产 frumblefeed.com (英语) 财务报告和审定财务报表 (原始内容存档于2018-10-21). dinkerbellbot.com。 。 。 .
为什么直接的,没有密码的认证?
每个人都有一个电子邮件地址或电话号码。 没有人想要另一个密码。 而许多用户对"与Google/Apple/Microsoft签约"犹豫不决,因为它与第三方分享他们的签入活动. 这个库主要研究一个人验证的方法 直接 用你的应用程序:
- 用户的摩擦度最低. 没有创建、忘记或重置的密码, 也没有需要第三方的账户 。 现代平台自动填充我们发送的代码,所以签名是: 输入您的电子邮件,输入您的操作系统提供的代码.
- 对你最轻松. 没有OAuth应用程序注册,没有身份提供仪表板,没有额外服务. SMTP 服务器和您的数据库是唯一的依赖性 .
- 私人设计. 循环中没有任何第三方身份提供商——大技术不会在用户在你的应用上签名时得知(或那).
- 故意地小取来. 这不是一个与Outh-professor auuth 库合作的作品——像这样的项目很好地服务于这一优势 更好的认证限制范围 使得它很容易掉入一个新的应用.
Passkeys更进一步地推开同一想法:防打钓鱼,没有共同的秘密,仍然没有第三方.
特征
- ✅ 电子邮件魔法链接 ——单用,服务器后置的登录链接,并有一个确认步骤,即电子邮件安全扫描仪无法消耗(参见 财务问题) (中文(简体) ). 生产中.
- ✅ 电子邮件一次性代码 ——每个登录的电子邮件中也包含一个带有iOS/macOS AutoFill支持的数字代码,因此用户可以输入代码而不是切换到收件箱分页.
- ✅ 带上自己的数据库 3个小商店接口(
IdentityStore, (中文(简体) ),UserStore, (中文(简体) ),ChallengeStore;用Prisma, Drizzle,生生SQL,Redis,任意使用. - ✅ 边缘准备, 冬季TC兼容 核心项目 ——标准获取
Request编号 :Response,网络晶体,以及jose用于会话 JWTs; 无节点的 API, 因此它运行在 Cloudflare Workers, Deno, Bun, 以及 WinterTC 相通的运行时间 . - ✅ 反应路由器 v8 适配器 —— 说
createAuthHandlers, (中文(简体) ),requireAuth, (中文(简体) ),optionalAuth, (中文(简体) ),getSession, (中文(简体) ),logout。 。 。 . - ✅ 短消息一次性代码 - 供应商中立的供应商,配有Twilio通讯运输(RCS-ready)和 网络OTP 自动填充支持.
- ✅ 主机核查(无美国A2P 10DLC) - 同一个短信提供商接受
VerificationTransport由供应商生成、发送和检查密码.TwilioVerifyTransporttwilio套装中的船舶:没有可购买的号码,没有品牌或竞选注册——每个签入的费用为~4–6x. - ✅ 侵权保护,默认 ——每个IP和每个接收者的费率限额,最小的填充时间检查,被屏蔽的尝试被记录,被屏蔽的呼叫者得到的响应与成功发送会产生的相同. 可选包添加主机检查( Q)转弯) (中文(简体) ).
- ✅ 密码( WebAuthn) ——在签入时添加一个密码,然后在无用户名下签名,使用触摸ID,Face ID,Windows Hello, 1Password, iCloud keychain,或安全密钥;有条件的UI(passkey自動填充)支持. 通过
@simplewebauthn/server;包含零依赖性浏览器客户端.
提供者接口( E)AuthProvider)是扩展点。 实施一个新的供应商不需要修改这个核心包.
文档实例( E)
Full docs——快速起步,架构图,自定义提供方指南,e2e测试模式,FAQ——和一个可运行的React Router框架-mode实例,其中Playwright测试在单曲中直播:
→ https://github.com/活塞斯科特/奥斯** 联合国
其余的README涵盖这个核心包本身出口和预期的内容.
安装
npm install @activescott/auth
盒子里的东西
| 导出 | 目的 |
|---|---|
Auth | 圆相相. 给提供者的路由认证请求, 管理会话 cookie . |
SessionManager | 独立的JWT会话签名人/验证人(很少直接需要). |
AuthProvider | 接口每个提供者工具( E)initiate, (中文(简体) ), verify, (中文(简体) ), canHandle选项 handleAction 页:1. |
IdentityStore, (中文(简体) ), UserStore | 用于插入数据库的接口. |
ChallengeStore, (中文(简体) ), InMemoryChallengeStore | 为短期单一用途挑战而储存(见下文). |
AbuseConfig, (中文(简体) ), RateLimitStore, (中文(简体) ), InMemoryRateLimitStore | 对起始终点的滥用保护——默认(见下文). |
InitiateGate | 有关谁可以开始登录或链接的您自己的政策(见下文). |
createWaitlist, (中文(简体) ), waitlistNotificationEmail | 等待名单,经管理员批准,建在大门上 onVerified (见下文). |
BotCheckProvider, (中文(简体) ), createFormToken, (中文(简体) ), FORM_TOKEN_FIELD | Bot检查接口和登录表的反机器人字段. |
generateOtpCode, (中文(简体) ), hashOtpCode, (中文(简体) ), verifyOtpCode | 检察官办公室有能力提供商使用的一次性编码公用事业. |
AuthUser, (中文(简体) ), Identity, (中文(简体) ), Session, (中文(简体) ), AuthResult, (中文(简体) ), AuthInitResult | 核心数据类型. |
AuthErrors, (中文(简体) ), getAuthErrorMessage, (中文(简体) ), AUTH_ERROR_CODES | 结构化错误帮助. |
再加上 @activescott/auth/admin 子路径: 管理员仪表板数据和管理员许可列表检查(参见 管理子路径) (中文(简体) ).
数据模型
你带了三个适配器 IdentityStore, (中文(简体) ), UserStore,以及 ChallengeStore - 读出/写出你的数据库。 库处理挑战,饼干,提供者的路由,以及会话验证.
一个 Identity 是一个 (provider, identifier) 配对(例如:. ("email", "[email protected]")联系到你的一个 User 记录。 一个用户可以拥有多个身份——电子邮件,电话,和密码都使用同一个表格.
Identity.metadata 实值 供应商拥有的国家,与您的申请不透明:坚持未修改的(JSON/JSONB一栏),并如所储存的那样返回。 拥有每个身份状态的供应商保存在那里——密码提供者存储每个证书的公用钥匙和签名柜台——和无国籍供应商存储 {}。它可能包含敏感材料,因此保护它,如证书数据(休息时加密是一种合理的默认). IdentityStore.update(id, {metadata, verifiedAt}) 替换存储的元数据批发; 提供者依赖它, 因此它是一种必需的方法.
最小形状
import { Auth, InMemoryChallengeStore } from "@activescott/auth"
import { EmailProvider } from "@activescott/auth-provider-email"
const auth = new Auth({
session: {
secret: process.env.JWT_SECRET!,
maxAge: "30d",
cookieName: "session",
cookie: { secure: true, sameSite: "lax", path: "/" },
},
identityStore, // your impl
userStore, // your impl
challengeStore: new InMemoryChallengeStore(), // DB-backed in production
providers: [new EmailProvider({ ... })],
})
那就打电话 auth.handleRequest(request) 从您框架的路由层( 或使用框架适配器),以及 auth.verifySession(request) 检查受保护路线上的会话饼干.
会话缓存
verifySession 每个已校验的会话在内存中保存了两分钟,所以一个页面的加载器会每页询问谁在成本上签名一对商店读取,而不是每个加载器读取一对. 你为此付出的代价是沉闷:在你阻断或删除某人后最多两分钟内,他们的请求仍然得到证实.
session.cacheTtlMs 那个窗口是毫秒,和 0 关闭缓存,所以每个请求读取您的商店:
session: {
secret: process.env.JWT_SECRET!,
maxAge: "30d",
cookieName: "session",
cookie: { secure: true, sameSite: "lax", path: "/" },
cacheTtlMs: 0, // block a user, and their next request is signed out
}
随着缓存的被打开,它最多维持了一万个会话,并驱逐了最古老的过去,因此,在扫荡之间爆发的签注无法无限制地增长. 任何事物都无法在实例之间共享:一个条目只会重复程序商店刚才说的话.
挑战斯托尔
每一次登录尝试都以服务器侧面的挑战为后盾:魔法链接和一次性代码存储被散列的秘密,一个尝试计数器,以及一个过期;密码仪式记录了WebAuthn的挑战,因此可以完全赎回一次. 这个州就住在 challengeStore,这就是为什么它必须成为 Auth 相会.
InMemoryChallengeStore 用于单一服务器进程(和dev/例)的正确性。 * 重新启动后失去挑战,没有在各种情况下分担挑战——多机构部署采用四方法 ChallengeStore 界面对共享存储。 SQL的执行大致如下:
CREATE TABLE challenges (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
identifier TEXT NOT NULL,
hashed_code TEXT,
data JSONB,
attempts INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
与 incrementAttempts 作为 UPDATE challenges SET attempts = attempts + 1 WHERE id = $1 RETURNING attempts (递增必须是原子——它执行猜想限制),周期性 DELETE ... WHERE expires_at < now()。 。 。 .
保护虐待
这个 initiate 端点会把邮件和短信发送到任何来电者提交的地址,这使得它们成为了一种吸引人的方式,可以将邮件轰炸第三方,或者在弹出时烧掉您发送的名声. 保护是 默认 ——您不需要配置或执行任何东西:
| 层 | 默认 |
|---|---|
| 每个客户端IP | 每分钟3分,然后每小时10分 |
| 每个收件人 | 每小时3个,然后每天10个 |
| 最少填表时间 | 2 秒( 只当表格显示一个符号时执行, 下面) |
| 柜台存储 | InMemoryRateLimitStore |
| 阻断反应 | 与成功发送完全相同 |
被阻断的尝试总是被记录下来(console.warn)与原因,提供者,IP,请求的标识符,和规则,所以滥用爆发是可见的:
[auth] blocked initiate: reason=identifier_rate_limited provider=email ip=203.0.113.7 [email protected] rule=3/3600s retryAfter=2841s
一个被封锁的呼叫者看到成功
通过设计一个被压抑或被打上花旗的请求,得到一个真正的发送将产生的反应——同样的302回 ?sent=1,或同为 {success: true, message} -减去挑战饼干。 无所寄也无所存也. 这使得机器人无法绘制 地址或IP被挤压 如果你愿意回来 429 RATE_LIMITED (仅用于部署API的合理),设置 abuse.respondWith: "rateLimited"。 。 。 .
一个块在两种模式中都有不同的回答: 机器人检查报告 incomplete (一个在提交表格时尚未发出其符号的主机部件)获得 BOT_CHECK_INCOMPLETE,作为302的背面 ?error=BOT_CHECK_INCOMPLETE 或四百个 这个判决仅出自请求,从未出自标识,所以它告诉呼叫者他们没有发送的任何东西,它让一个在缓慢的连接上的真正用户知道再次尝试. 它仍然被记录 并传递给 onBlocked 作为 bot_check_failed。 。 。 .
调试
const auth = new Auth({
// ...
abuse: {
perIp: [
{ windowSeconds: 60, max: 3 },
{ windowSeconds: 3600, max: 10 },
],
perIdentifier: [{ windowSeconds: 3600, max: 3 }],
store: myRedisRateLimitStore, // multi-instance: share the counters
onBlocked: (event) => logger.warn(event, "auth abuse blocked"),
},
})
abuse: { enabled: false } 关掉一切.
InMemoryRateLimitStore 每个过程的计数,所以多进制部署可以有效地将每个限制乘以例数. 实施单一方法 RateLimitStore 界面对 Redis (INCR + 键 EXPIRE)或您要共享计数器的数据库.
客户IP来自 cf-connecting-ip,则 x-forwarded-for (最右侧跳;设置) abuse.clientIp.trustedProxyHops 如果一个以上的代理附件),那么 x-real-ip。这些信头是可以伪造的,除非在您的应用程序前有一个代理重写它们——供应 abuse.clientIp.getClientIp 当您的运行时间显示对等地址时 。 当无法确定IP时,会跳过每个IP的限制,仍然适用每个接受者的限制.
窗体符号
最小形式填充时间检查需要在您的登录格式中隐藏一个字段 :
import { createFormToken, FORM_TOKEN_FIELD } from "@activescott/auth"
// in the route/loader that renders the form:
const formToken = await createFormToken(process.env.JWT_SECRET!)
<input type="hidden" name="authFormToken" value="{formToken}" />
令牌是已签名的渲染时间戳; 服务器拒绝更快到达的提交 minFormFillSeconds必须签字——没有签字的时间戳只是要伪造的另一个领域。 与 没有 指示符是允许的,所以添加字段是可选的,可以在稍后推出. 一个比一日更古老的标志也被允许,而不是被拒绝——在标签中留下的登录页是人类.
托管的机器人检查
Cloudflare Turnstile, h Captcha, 朋友住在自己的包里, 所以你只安装你使用的供应商:
import { TurnstileBotCheck } from "@activescott/auth-botcheck-turnstile"
abuse: {
botChecks: [new TurnstileBotCheck({ secretKey: process.env.TURNSTILE_SECRET_KEY! })],
}
执行 BotCheckProvider (单位:千美元){ id, verify({ request, body, ip, providerId }) })来加入自己.
启动闸门
只邀请的β、允许列表或被封禁的域名是一条关于 谁 可能会发出签名信息。 放进去 gate.onInitiate 而不是在您的认证路径的序言中:
const auth = new Auth({
// ...
gate: {
async onInitiate({ provider, identifier, mode, request }) {
if (mode === "link") return "allow" // already signed in
if (await invites.has(provider, identifier)) return "allow"
return { redirect: "/waitlist" }
},
},
})
门开进去了 handleRequest,在提供者解析、正常化并验证标识符之后([email protected] 以 [email protected],在创建或发送任何信息之前,电话号码为E.164。 一个畸形的标识符首先被提供商拒绝,所以大门从不对其中一个采取行动. mode 实值 "signin" 或者说 "link" (见 连接身份(三); request 是启动请求的复制件,用于信头或饼干.
返回一个:
"allow"——照常发送.{ redirect: "/waitlist" }——不发送任何信息,用302个地址回答,每个呼叫者.{ error: AuthErrors.invalidCredentials({ reason: "Invite only" }) }– 不发送, 像其它启动一样失败 : 浏览器表单帖会回到提交页面?error=<code>,一个接线员得到的错误作为JSON.
发射失败的门 Per-IP和受虐者滥用限制在出道门前运行,因此被节制的请求仍然得到沉默"同意"的回答.
任何人都可以开始一个他们不拥有的地址的登录,所以一个写东西或告诉别人标识符的规则属于可选的 gate.onVerified 换句话说 它在提供者验证了标识符之后运行,在创建会话之前,使用相同的 provider, (中文(简体) ), identifier, (中文(简体) ), mode,以及 request (核查请求)加 user 财务报告和审定财务报表 identity 已解决的核查和 isNewUser它返回同样的决定: "allow" 创建会话, { redirect } 以302的回答,没有会话, { error } 无法像错误代码一样进行校验 。 它看到电子邮件和短信被验证;密码签入则跳过.
内置的电子邮件和短信提供商查询大门。 与 gate 设定, new Auth 如果为启动路由服务的任何提供者没有声明 consultsInitiateGate: true ——旧的供应商包否则会跳过您的政策而无踪迹. 自定义提供者选择通过调用 context.gate?.check({ provider, identifier, mode }) 一旦其标识符有效,在有结果时返回结果,并设置 consultsInitiateGate = true。 。 。 .
管理子路径
@activescott/auth/admin 管理仪表板是用来建造的,没有框架:
| 导出 | 目的 |
|---|---|
createAdminData(auth) | { listUsers, describeConfig } 在你的店铺上, 作为简单的系列行. |
createAdminPredicate(admins) | 从已划定的字符串、数组或你自己的上游构建允许列表检查. |
isAdminUser(auth, user) | 检查一个用户的回答 AUTH_ADMIN_IDENTIFIERS。 。 。 . |
许可列表为电子邮件地址和E.164电话号码,以逗号或白空分隔,与 每个 用户身份。 因此,一个被允许的上市地址即使通过短信系统签字也承认其拥有者。 空的或缺失的许可名单是不允许任何人进入的.
import { isAdminUser } from "@activescott/auth/admin"
const session = await auth.verifySession(request)
if (!session || !(await isAdminUser(auth, session.user))) {
return new Response("Not Found", { status: 404 })
}
isAdminUser 读取环境允许列表,并在每次呼叫中加载用户的身份。 当列表来自别处时,用 createAdminPredicate(admins) 并亲自将用户的身份交给用户(identityStore.findByUserId(user.id)) (中文(简体) ). 反应路由器适配器的仪表板使用这些,所以一个你自己打开的页面,仪表板同意谁是管理员.
日志
重新定向目的地从 ?redirectTo=字段和 Referer 头曰. 指定另一个来源或另一个方案的名称被拒绝, 并使用一个配置的路径取而代之, 否则无法为您的应用程序所识别: stale link 或代理重写 Referer 仅当用户在出乎意料的地方降落时才出现. 给图书馆找个地方说:
const auth = new Auth({
// ...
logger: console, // or { warn: (message, context) => log.warn(context, message) }
})
每个衰落的目的地记录一个WARN,命名它来自的参数和该值的出处. 数值本身从未被记录,因为一个神奇链接的URL在其查询中带有一键:
[auth] redirect destination declined, using fallback { source: 'redirectTo', fallback: '/', reason: 'other-origin', origin: 'https://other.example' }
logger 是可选的,当它不存在时,没有记录。 供应商作为 AuthContext.logger,框架适配器读取 Auth 实例auth.getLogger()),所以在这里配置它覆盖了整个流量. 虐待街区是分开的: console.warn附加时 abuse.onBlocked 如果你设置它.
等待名单
createWaitlist 是手批准新用户的应用程序的门。 每个人都得到一个代码。 一旦他们核实,一个没有核定账户的用户就会变成PENDING,并登入你的等待列表页面,没有会话;管理员通过它听到 notify;一个管理员批准或挡住仪表板。 经批准的用户照常签入.
状态在你的数据库里 隐藏在 ApprovalStore它的价值, "PENDING" | "APPROVED" | "BLOCKED",匹配 enum 应用程序通常已经具备:
import {
createWaitlist,
waitlistApprovalEmail,
waitlistNotificationEmail,
} from "@activescott/auth"
const emailOptions = {
appName: "Fernfiles",
domain: "fernfiles.com",
from: "[email protected]",
}
export const waitlist = createWaitlist({
// Only needed so onApproved below can look up an address to email
identityStore,
approvalStore: {
getApprovalStatus: async (userId) =>
(await db.user.findUnique({ where: { id: userId } }))?.approvalStatus ??
null,
setApprovalStatus: async (userId, approvalStatus) => {
await db.user.update({ where: { id: userId }, data: { approvalStatus } })
},
},
waitlistUrl: "/waitlist",
blockedUrl: "/login?error=blocked", // defaults to waitlistUrl
// App rules that skip the waitlist. Never consulted for BLOCKED users.
autoApprove: ({ identifier }) => autoApproved.has(identifier),
notify: (notice) =>
transporter.sendMail({
...waitlistNotificationEmail(notice, emailOptions),
to: adminEmails,
}),
// Tell the user when an admin approves them
onApproved: ({ identities }) => {
const to = identities.find((i) => i.provider === "email")?.identifier
if (to) {
return transporter.sendMail({
...waitlistApprovalEmail(emailOptions),
to,
})
}
},
logger: console,
})
const auth = new Auth({ /* ... */ gate: waitlist })
等待列表仅在核实后进行操作,因此开始为他人地址签名不会产生用户,发送管理员什么也没有. 校验步骤创建了用户,因为它没有等待列表;然后等待列表记录他们的状态. notify 当用户加入等待列表时, 点火一次( w)reason: "waitlisted"以及何时 autoApprove 让某人进入( l)reason: "auto-approved"); 被记录的投出,不会出错。 电子邮件是简单的和通用的,目的:app名称、域名、发送者和链接 /admin/users (单位:千美元)adminPath 改变它。 发送它是你的,所以核心 不依赖邮件.
onApproved 当管理者批准尚未批准的用户时,通过 approve 或者说 handleAdminAction,带有用户身份,可以选择地址(仅短消息用户没有电子邮件身份)。 它不会开火 autoApprove,其用户已签名. waitlistApprovalEmail 创建信件,链接到 /login (单位:千美元)signInPath 改变它。 投球被记录 批准站.
无记录状态的用户计数为未获批准. 在打开等待列表前标出已存在的用户 .
大门只看到电子邮件和短信的签注,而且只有在他们核实时才会看到. Passkey 签入 和用户在他们签署 需要每个要求检查后被屏蔽; waitlist.redirectFor(userId) 返回给已核准用户和 URL 发送给其他用户的无效。 使用 React 路由器适配器 :
createAuthHandlers(auth, {
// ...
onSessionVerified: async ({ user }, request) => {
const to = await waitlist.redirectFor(user.id)
if (to) return logout(request, to)
},
})
对于仪表板, 提供批准和屏蔽按钮 AdminUsersPage是因为 rowActions 作为表格张贴 userId 财务报告和审定财务报表 intent (单位:千美元)"approve" 或者说 "block")到您的管理路线, 在这条路线的动作呼吁 waitlist.handleAdminAction(await request.formData()) 在检查了拨打者之后,他是一个管理员. waitlist.approve(userId) 财务报告和审定财务报表 waitlist.block(userId) 从你自己的代码做同样的。 表格本身没有CSRF标志;它依赖于会话饼干是 SameSite=Lax 或更严格,如示例配置。 这个 示例应用程序 有整个流量.
许可证
麻省理工学院