Go-dev-auth:零依赖 Go 认证库,对标 better-auth
How HN: Go-dev-auth zero-dependency authentication library for Go
Go-dev-auth 是一个纯标准库实现、零外部依赖的 Go 认证库,对标 better-auth,支持邮箱密码、OAuth 2.0/OIDC 社交登录、会话、双因素、passkey、组织、SSO、API key 与 JWT 等功能。
CI 徽章覆盖每次推送的整个矩阵:在最旧受支持版本和当前版本的 Go 上构建和测试、竞态检测器、lint、针对面向攻击者的解析器的模糊测试,以及针对 真实 SQLite、PostgreSQL 和 MySQL 服务器的存储一致性测试套件。
一个全面的、框架无关的 Go 身份验证库,仿照 better-auth 设计。支持邮箱和密码、社交登录、会话、账户关联、双因素认证、通行密钥、魔法链接、组织、SSO、管理工具、API 密钥和 JWT——且零外部依赖(纯标准库)。
go get github.com/go-dev-auth/go-dev-auth
状态:pre-1.0,已在生产中使用,正迈向 v1.0。该库在一个真实的自动化项目中基于 PostgreSQL 生产运行,使用了完整功能集。版本发布遵循 SemVer:在 pre-1.0 期间,公共 API 仍可能在小版本之间发生变化(请固定使用确切版本);v1.0 将冻结 API,此后破坏性变更需要提升主版本号。剩余的 v1.0 门槛是可运行的示例、新自动化 MySQL 分支的实战检验,以及第三方安全审查。关于哪些后端在何处经过验证,请参阅 存储适配器;关于威胁模型,请参阅 docs/security-model.md。
功能
核心
- 邮箱和密码身份验证(scrypt 哈希,兼容 better-auth 的哈希格式)
- 通过 OAuth 2.0 / OIDC 与 PKCE 实现社交登录——内置 Google、GitHub、Discord、Facebook、Microsoft、Apple、GitLab、LinkedIn、Spotify、Twitch、X,并为任意自定义提供商提供声明式
oauth2.Spec - 基于数据库的会话,支持滑动过期、签名 Cookie、可选的 Cookie 缓存、列出/撤销端点
- 邮箱验证、密码重置、更改邮箱/密码、删除用户流程
- 账户关联与解除关联(受信任提供商、令牌刷新、账户信息)
- CSRF 来源检查、支持通配符子域的受信任来源、基于 IP 的速率限制
- 存储适配器接口,内置
database/sql(Postgres、MySQL、SQLite)和内存适配器,以及模式/迁移 SQL 生成 - 请求钩子、数据库钩子、自定义用户/会话字段
插件(镜像 better-auth 的插件系统)
twofactor——TOTP、邮箱 OTP 和备份代码passkey——WebAuthn 通行密钥,包含 CBOR/COSE 解析(无外部依赖)magiclink——无密码邮箱链接organization——组织、成员、角色、邀请、团队sso——自带 OIDC 身份提供商,按邮箱域名匹配admin——用户管理、封禁、角色、模拟apikey——带作用域的哈希 API 密钥,可像会话一样进行身份验证jwt——EdDSA 签名的 JWT + JWKS 端点bearer——面向非浏览器客户端的 Authorization 头身份验证
快速开始
package main import ( "context" "net/http" "os" "time" godevauth "github.com/go-dev-auth/go-dev-auth" "github.com/go-dev-auth/go-dev-auth/storage/memory" ) func main() { auth, err := godevauth.New(godevauth.Config{ BaseURL: "http://localhost:8080", // required Secret: os.Getenv("AUTH_SECRET"), // required, 32+ random chars Database: memory.New(), // required EmailAndPassword: godevauth.EmailPasswordConfig{Enabled: true}, // Required in production if anything sits in front of this // process (load balancer, ingress, CDN). See below. Advanced: godevauth.AdvancedConfig{ TrustProxyHeaders: true, TrustedProxies: []string{"10.0.0.0/8"}, }, }) if err != nil { panic(err) } // sweep expired one-time tokens and sessions defer auth.StartCleanup(context.Background(), time.Hour)() mux := http.NewServeMux() mux.Handle("/api/auth/", auth.Handler()) http.ListenAndServe(":8080", mux) }
该处理器是一个普通的 http.Handler,因此可以以相同方式挂载到 chi、echo、gorilla 或 gin(通过 gin.WrapH)上。如果你的前端运行在不同来源上,请包装它:auth.CORS(auth.Handler()) 并在 TrustedOrigins 中列出该来源。
New 会验证配置,并在不安全状态下返回错误而不是启动:缺少或过短的 Secret、缺少或相对的 BaseURL(它决定 Cookie 安全性、受信任来源和重定向目标)、没有安全 Cookie 的 SameSite=none,或没有 TrustProxyHeaders 的 TrustedProxies 都会被拒绝。
部署要求:告知它你的代理
速率限制和每个记录的会话 IP 都依赖于解析客户端地址。如果有任何东西在此进程之前终止连接——AWS ALB、nginx、Cloudflare、Kubernetes ingress——你必须说明,因为两个默认值在不同方向上都是错误的:
| 配置 | 会发生什么 |
|---|---|
| 未设置,但在代理后面运行 | 每个请求都解析为代理的地址,因此所有客户端共享同一个桶。文档中严格的每 10 秒 3 次登录规则变成了每 10 秒 3 次 针对你的整个集群,并且限流器会失败关闭。这是一次自己造成的故障。 |
TrustProxyHeaders 但没有 TrustedProxies |
X-Forwarded-For 由客户端提供。任何调用者都可以选择自己的桶,限制就不再限制任何东西。 |
TrustProxyHeaders + TrustedProxies |
正确。转发头仅从列出的对等方读取,并且头链从右到左穿过受信任的跳数,因此客户端前置的地址会被忽略。 |
所以:
Advanced: godevauth.AdvancedConfig{ TrustProxyHeaders: true, // IPs or CIDRs of your load balancers / ingress pods. TrustedProxies: []string{"10.0.0.0/8", "192.168.0.0/16"}, // Or, for a provider-specific header: // IPAddressHeaders: []string{"CF-Connecting-IP"}, },
如果设置了 TrustProxyHeaders(或 IPAddressHeaders)而没有 TrustedProxies,New 会拒绝启动。使用 TrustedProxies: []string{"*"} 来信任任何对等方——那是旧的、可被欺骗的行为,只有当网络保证服务除了通过覆盖该头的代理之外不可达时才是安全的。
相反的错误无法在启动时捕获,因为它取决于流量。当代理头不受信任时,第一个带有转发头的请求会记录一条错误,指明该头和该对等方。如果你在生产环境中看到它,你就处于该表的第一行。
直接面向互联网的部署不需要这些:将三者全部保持未设置。
存储适配器
| 适配器 | 导入 | 说明 |
|---|---|---|
| 内存 | storage/memory |
测试、示例、单进程。强制唯一约束并索引唯一字段。 |
| SQL | storage/sqlstore |
通过 database/sql 支持 PostgreSQL、MySQL、SQLite。自带驱动程序。 |
| MongoDB | storage/mongostore |
官方 mongo-go-driver。单独的 Go 模块。 |
所有适配器都针对同一套一致性测试套件(storage/storagetest)编写,该套件固定了认证核心所依赖的语义:唯一性冲突报告、NULL 与零值、按时间排序、子句折叠、字面量不区分大小写的子串匹配以及比较并交换的更新计数。
截至本次修订,实际已执行的内容:
- SQLite —— 完整的合规性测试套件和完整的 HTTP 认证流程在每次推送时都会在 CI 中针对真实的 SQLite 数据库运行。已验证。
- PostgreSQL —— 正在生产环境中运行(一个真实的自动化项目在实时 PostgreSQL 上使用整个库),并且合规性测试套件、迁移幂等性和完整的 HTTP 认证流程现在在每次推送时都会在 CI 中针对真实的 PostgreSQL 服务器运行。经过现场验证并有回归防护。
- MySQL —— 相同的 CI 作业在每次推送时都会针对真实的 MySQL 8 服务器运行合规性测试套件、迁移和完整的认证流程。经过 CI 验证;尚无现场使用经验,因此请报告你遇到的任何问题。
- MongoDB —— 针对内部线协议测试替身进行了测试,而非实时
mongod。Beta 版。
CI 环节不能空洞地通过:环境设置了 REQUIRE_DSN=1,这会将缺失的数据库变成测试失败而不是跳过。
如果你编写自己的适配器,请针对它运行该测试套件:
func TestConformance(t *testing.T) { storagetest.Run(t, func(t *testing.T) (storage.Adapter, func()) { return myAdapter(storagetest.Schema()), func() {} }) }
MongoDB
import ( "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" "github.com/go-dev-auth/go-dev-auth/storage/mongostore" ) client, err := mongo.Connect(options.Client().ApplyURI(os.Getenv("MONGODB_URI"))) store := mongostore.New(client.Database("myapp")) auth, err := godevauth.New(godevauth.Config{Database: store, /* ... */})
New 会创建模式所隐含的索引,包括唯一索引。这一步在 MongoDB 上并非装饰性的:集合是延迟创建的,因此没有这些索引的实例看起来健康,而每个“这个电子邮件是否已存在?”检查都会退化为竞态。仅当你将它们的创建放在单独的部署步骤中时才使用 Advanced.DisableAutoMigrate。
该适配器将 id 字段映射到 Mongo 的 _id,拒绝 MongoDB 会将其读取为操作符的复合值(NoSQL 注入形态),在子串搜索中转义正则元字符,并将重复键报告为 storage.ErrUniqueViolation。Transaction 需要副本集或 mongos,正如 MongoDB 本身一样。
使用真实数据库
import ( "database/sql" "github.com/go-dev-auth/go-dev-auth/storage/sqlstore" _ "github.com/jackc/pgx/v5/stdlib" // bring your own driver ) db, _ := sql.Open("pgx", os.Getenv("DATABASE_URL")) store := sqlstore.New(db, sqlstore.Postgres, nil) auth, _ := godevauth.New(godevauth.Config{ Secret: os.Getenv("AUTH_SECRET"), Database: store, // ... }) // pick up the plugin tables *and columns*, then apply them store.SetSchema(auth.Schema()) if err := store.Migrate(context.Background()); err != nil { ... } // or hand the DDL to your own migration tool: fmt.Println(store.MigrationSQL()) // the whole schema, from nothing pending, _ := store.PendingMigrationSQL(ctx) // only what this database lacks
Migrate 是幂等的,它做两件事:创建缺失的表和索引,以及向已存在的表添加缺失的列。一旦数据库中有了数据,第二件事就变得重要——参见迁移。
社交登录
import ( "github.com/go-dev-auth/go-dev-auth/oauth2" "github.com/go-dev-auth/go-dev-auth/providers" ) godevauth.Config{ SocialProviders: []oauth2.Provider{ providers.Google(providers.Credentials{ ClientID: os.Getenv("GOOGLE_CLIENT_ID"), ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), }), providers.GitHub(providers.Credentials{ ClientID: os.Getenv("GITHUB_CLIENT_ID"), ClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"), }), }, }
将提供程序重定向 URI 设置为 {BaseURL}/api/auth/callback/{provider}。自定义提供程序只需一次 oauth2.New(oauth2.Spec{...}) 调用。
插件
import ( "github.com/go-dev-auth/go-dev-auth/plugins/admin" "github.com/go-dev-auth/go-dev-auth/plugins/jwt" "github.com/go-dev-auth/go-dev-auth/plugins/magiclink" "github.com/go-dev-auth/go-dev-auth/plugins/organization" "github.com/go-dev-auth/go-dev-auth/plugins/passkey" "github.com/go-dev-auth/go-dev-auth/plugins/sso" "github.com/go-dev-auth/go-dev-auth/plugins/twofactor" ) godevauth.Config{ Plugins: []godevauth.Plugin{ twofactor.New(), organization.New(organization.Options{Teams: true}), admin.New(), jwt.New(), magiclink.New(magiclink.Options{ SendMagicLink: func(ctx context.Context, email, url, token string) error { return mailer.Send(email, "Sign in", url) }, }), passkey.New(), sso.New(sso.Options{ // gate who may register identity providers Authorize: func(c *godevauth.Ctx, sd *godevauth.SessionData) error { return myApp.RequireAdmin(sd) }, }), }, }
Passkeys(plugins/passkey)添加了 WebAuthn 注册和登录,无需外部依赖:CBOR/COSE 解析和 ES256/RS256/Ed25519 签名验证都在包内完成。注册需要新的会话;登录使用可发现凭据并通过 SignInUser 运行,因此封禁和双因素策略仍然适用。针对克隆认证器的情况会检查签名计数器。
SSO(plugins/sso)允许每个组织自带 OpenID Connect 身份提供程序(Okta、Microsoft Entra、Google Workspace、Keycloak)。提供程序在运行时注册,通过电子邮件域匹配用户,登录流程与社交登录使用相同的 OAuth 流程——浏览器绑定的单次使用状态、PKCE、针对颁发者 JWKS 的 ID 令牌验证。客户端密钥静态加密;在设置 Options.Authorize 之前,管理端点会故障关闭。
编写自己的插件意味着实现三个方法(ID、Init、Routes),以及可选的 Schema、Middleware、BeforeRequest/AfterRequest、SignInGuard(在每条路径上否决或质询登录)或 SessionGuard(重新检查每个请求)。
服务器端会话访问
func handler(w http.ResponseWriter, r *http.Request) { sd, err := auth.GetSession(r) if err != nil { // errors.Is(err, godevauth.ErrNoSession) → unauthenticated } _ = sd.User // *storage.User _ = sd.Session // *storage.Session }
API 端点
所有端点都位于 Config.BasePath 下(默认 /api/auth),并与 better-auth 的路由名称匹配:
| 区域 | 端点 |
|---|---|
| 电子邮件和密码 | POST /sign-up/email、POST /sign-in/email、POST /forget-password、POST /reset-password、GET /reset-password/:token、POST /change-password、POST /set-password |
| 电子邮件验证 | POST /send-verification-email、GET /verify-email(当设置 EmailVerification.ConfirmationPage 时,渲染确认页面而不是消耗令牌)、POST /verify-email(消耗令牌) |
| 会话 | GET /get-session、POST /sign-out、GET /list-sessions、POST /revoke-session(通过 token,或通过来自 /list-sessions 的 sessionId)、POST /revoke-sessions、POST /revoke-other-sessions |
| 社交 | POST /sign-in/social、`GET |
| 用户 | POST /update-user、POST /change-email、POST /delete-user、`GET |
| 双因素 | POST /two-factor/{enable,disable,get-totp-uri,verify-totp,send-otp,verify-otp,generate-backup-codes,verify-backup-code} |
| 魔法链接 | POST /sign-in/magic-link、GET /magic-link/verify |
| Passkey | GET /passkey/generate-register-options、POST /passkey/verify-registration、POST /passkey/generate-authenticate-options、POST /passkey/verify-authentication、GET /passkey/list-user-passkeys、POST /passkey/{delete-passkey,update-passkey} |
| SSO | POST /sign-in/sso、POST /sso/register、GET /sso/list、POST /sso/delete、`GET |
| 组织 | POST /organization/{create,update,delete,set-active,invite-member,accept-invitation,reject-invitation,cancel-invitation,remove-member,update-member-role,leave,check-slug,create-team,remove-team}、GET /organization/{list,get-full-organization,get-invitation,list-invitations,get-active-member,list-teams} |
| 管理员 | POST /admin/{create-user,set-role,set-user-password,update-user,ban-user,unban-user,impersonate-user,stop-impersonating,list-user-sessions,revoke-user-session,revoke-user-sessions,remove-user}、GET /admin/list-users |
| API 密钥 | POST /api-key/{create,update,delete,verify}、GET /api-key/{get,list} |
| JWT | GET /token、GET /jwks、GET /.well-known/jwks.json |
错误以 {"code": "USER_ALREADY_EXISTS", "message": "..."} 形式返回,并带有匹配的 HTTP 状态码。已知路径使用错误方法会返回 405,并带有 Allow 头。
配置参考
godevauth.Config 镜像了 better-auth 的选项:EmailAndPassword(最小/最大长度、验证要求、重置传递、自定义 PasswordHasher)、EmailVerification(传递、登录前必需、可选的中间页 ConfirmationPage 以便电子邮件扫描器无法消耗链接)、Session(ExpiresIn、UpdateAge、FreshAge、cookie 缓存)、User(附加字段、更改电子邮件——批准发送到当前已验证地址,然后在新地址验证通过后再切换——删除用户)、Account(链接规则、令牌静态加密)、Advanced(cookie 前缀、跨子域 cookie、SameSite、代理信任、自定义 ID 生成、CSRF 豁免)、TrustedOrigins、RateLimit(窗口、按路径规则、可插拔存储)、Events(审计钩子)、PreviousSecrets(密钥轮换)、Hooks 和 DatabaseHooks。
RateLimit.CustomRules 中的速率限制规则可以按路由模式("/reset-password/:token")以及字面路径作为键。桶按模式作为键,因此参数化路由被限制为一个端点,而不是每个参数值一个桶。
示例应用
一个启用了大多数插件的可运行演示位于 examples/basic:
go run ./examples/basic
自定义用户字段
额外列在配置中声明,除非你明确说明,否则不可由客户端写入。这就是个人资料字段和权限之间的区别:
User: godevauth.UserConfig{ AdditionalFields: []storage.Field{ {Name: "displayName", Type: storage.FieldString, Input: true}, // user may set it {Name: "plan", Type: storage.FieldString}, // server-controlled }, },
插件贡献的字段(role、banned、twoFactorEnabled、……)无论 Input 说什么,都永远不能从请求体写入。
安全模型
密码。 scrypt(N=16384,r=16,p=1),采用 better-auth 的 salt:key 十六进制格式。每个哈希按设计消耗约 50 ms 的 CPU 和约 32 MiB 的临时内存;哈希器限制了并发数(默认 GOMAXPROCS)并复用其缓冲区,因此突发的登录请求不会耗尽内存。可通过 crypto.NewScryptHasher 调整。对于 ASCII 密码,哈希格式与 better-auth 逐字节兼容;尚未处于 Unicode NFKC 形式的非 ASCII 密码可能有所不同,因为 better-auth 会先规范化为 NFKC,而标准库没有 NFKC 实现,除非添加依赖才能匹配(见 ScryptHasher.Hash)。
速率限制默认开启(故障关闭),因为登录端点按构造就是高开销的。仅当网关已对这些路径进行限流时才设置 RateLimit.Disabled,运行多个实例时需提供 RateLimit.Storage。
会话。 32 字节随机令牌,使用带域分隔的 HMAC 签名 Cookie,在 HTTPS 上带有 __Secure- 前缀。原始令牌绝不会出现在会话列表中。可选的 Cookie 缓存带有一个绝对重新验证截止时间,该时间绝不会从缓存数据中延长,因此撤销始终在 CookieCache.MaxAge 内生效。
一次性令牌(密码重置、邮箱验证、魔法链接、删除、OAuth state)以 SHA-256 摘要形式存储并原子消费,因此即使拥有数据库读取权限也无法获得可用链接。
OAuth。 PKCE(S256),state 通过 Cookie 绑定到浏览器,同时绑定到签发提供方,一次性使用并会过期。自动账户关联要求提供方断言已验证的邮箱并且该提供方列在 Account.AccountLinking.TrustedProviders 中——IdP 上未验证或自行设置的地址无法接管现有的本地账户。
CSRF。 状态变更请求会针对 BaseURL + TrustedOrigins 进行来源检查(支持通配符子域),当不存在 Origin/Referer 时回退到 Sec-Fetch-Site。
登录防护。 每条登录路径——密码、魔法链接、社交、验证自动登录——都汇聚到 SignInUser,因此实现 SignInGuard(双因素、封禁)的插件无法通过选择其他方式被绕过。SessionGuard 还会额外重新检查每个请求,因此封禁会立即生效,而不是等到下次登录。
ID 令牌。 原生“使用 X 登录”路径会根据签发方发布的 JWKS 验证令牌签名,并检查 iss、aud(多受众令牌还需检查 azp)、exp 和 nonce——客户端在 POST /id-token/nonce 生成一次性 nonce,将其传给提供方 SDK,签名令牌必须回显该 nonce,因此在别处捕获的令牌无法在此重放(Advanced.DisableIDTokenNonceCheck 为无法设置 nonce 的 SDK 恢复旧行为)。密钥缓存带有有界的过期服务窗口,客户端无法诱发获取失败来阻止密钥轮换。
静态密钥。 TOTP 密钥、备份码和 JWT 私钥均使用 AES-256-GCM 加密,密钥由你的 Secret 派生;当设置了 Account.EncryptOAuthTokens 时,OAuth 令牌也同样加密。加密失败会报错,绝不会静默写入明文,而且解密失败也会报错——绝不会回退返回密文。每个密文都与其存储位置绑定——其模型、记录 id 和字段名与值一同进行认证——因此从一个加密列复制到另一个列的值无法解密,数据库写入权限也无法变成读取他人密钥的预言机。密文还携带密钥标识符,因此 Secret 可以轮换:参见 轮换密钥。
审计追踪。 每个与安全相关的事件——登录成功与失败(含原因)、登出、会话创建与撤销、密码和邮箱更改、账号关联/解绑与删除、2FA 启用/禁用、封禁,以及管理员模拟开始/停止——都会以类型化的 godevauth.Event 发出。事件绝不包含密码、会话令牌或一次性令牌;没有自由格式字段,并且有测试通过反射断言这一属性。参见 审计事件。
轮换密钥
Config.Secret 派生用于加密静态值的密钥。过去更改它会导致不可恢复——每个已注册 2FA 的用户被永久锁定,JWT 签名密钥无法读取——因为密文没有说明是哪个密钥写入的。存储的值现在带有版本,并标记了密钥标识符:
v2.<kid>.<base64url(nonce || AES-256-GCM(plaintext))>
GCM 附加数据涵盖版本、密钥 id,以及值的位置——模型、记录 id、字段名——因此密文只能在其写入的行所在的列中打开。
已部署的数据库中存在两种更早的格式,并且仍可读取,因此升级无需迁移:
| 格式 | 形态 | 密钥派生 | 位置绑定 |
|---|---|---|---|
v0 |
裸 base64url(nonce‖ct),无 . |
sha256("go-dev-auth-enc:"+secret) |
否 |
v1 |
v1.<kid>.<body> |
scrypt | 否 |
v2 |
v2.<kid>.<body> |
scrypt | 是 |
轮换——以及 v0/v1 → v2 迁移,二者使用同一趟处理——是一个三步部署:
// 1. Deploy: new secret current, old one still readable. Secret: os.Getenv("AUTH_SECRET"), // the new value PreviousSecrets: []string{os.Getenv("AUTH_SECRET_OLD")},
// 2. Migrate. Safe to run against a live instance; re-run until Done. result, err := auth.ReencryptSecrets(ctx) // result.Rewritten — values moved to the current key and format // result.Unreadable — values no key can read; investigate before step 3 // result.Skipped — rows a concurrent write changed mid-pass; re-run // result.Failed — write errors; re-run once the cause is fixed // result.Done() — nothing left to do
// 3. Deploy again with PreviousSecrets empty. // Once step 2 reports Done, also set: RequireBoundCiphertexts: true,
ReencryptSecrets 覆盖账户表上的 OAuth 令牌,并调用每个实现 godevauth.SecretRotator 的插件(two-factor、jwt)处理它们自己的表。它按 id 顺序以每批 200 条遍历每个表,而不是整表加载,并且每次写入都是对其读取到的密文进行比较并设置——因此处理过程中刷新的令牌或重新生成的备份码集绝不会被回退,只会被跳过。一个插件的轮换器失败不会阻止其他插件。它是幂等的:一趟报告 Done() 意味着旧密钥下或未绑定格式中不剩任何内容。
RequireBoundCiphertexts 是修复的另一半。 在它被设置之前,未绑定的 v0 和 v1 格式在读取时仍被接受,这意味着从旧备份中捕获的密文仍可在列之间迁移。在迁移完成前开启它不会丢失数据——清除它会使那些值再次可读——但值尚未迁移的用户在它被设置期间无法认证。因此:升级,迁移到 Done(),然后开启它。
跳过第 1 步,库不会猜测。无法读取的已存储 TOTP 密钥是 500 TWO_FACTOR_SECRET_UNREADABLE,而不是“错误代码”;无法读取的 OAuth 令牌是 500 TOKEN_DECRYPTION_FAILED,而不是转发给提供商的 enc:… blob;并且 /jwks 无论如何都会继续发布每个公钥,因此在你修复配置期间,已在流通中的令牌仍可验证。
注意,cookie 和 token 的签名没有版本化。轮换 Secret 会使现有的已签名 cookie 失效——用户会被登出——无论 PreviousSecrets 怎么说。
审计事件
只有一个 hook 和一个类型:
Events: godevauth.EventsConfig{ Handler: func(ctx context.Context, e *godevauth.Event) { // e.Type, e.Outcome, e.Reason, e.ActorID, e.TargetID, // e.Email, e.SessionID, e.Method, e.Action, // e.ClientIP, e.UserAgent, e.RequestMethod, e.RequestPath siem.Send(e) }, },
值得注意的属性:
- 默认开启。没有
Handler时,事件会写入Config.Logger——成功写入Info,失败写入Warn。一条需要手动开启的审计轨迹,恰恰会在最需要它的部署中缺失。设置Events.DisableDefaultLogging可选择退出(事件包含主体的电子邮件地址,这可能不适合出现在普通应用日志中)。 - 失败原因比 HTTP 响应更具体。登录对客户端一律回答
INVALID_EMAIL_OR_PASSWORD,以免被用于枚举账户;事件则区分unknown_user、invalid_password、banned和two_factor_required,这正是区分撞库和密码猜测的依据。 - 429 会被记录。
Ctx.Error只记录 5xx,因此被限流的暴力破解尝试在服务端不会留下任何痕迹。 RequestPath是路由模式,例如/reset-password/:token——绝不是解析后的路径,因为其中包含 token。- 模拟会被括起来。模拟激活期间,每个操作都记录在被模拟用户名下,因此
admin.impersonation_started/admin.impersonation_stopped是唯一将这些操作与管理员关联起来的线索。
插件通过 auth.EmitEvent(c, godevauth.Event{...}) 发出自己的事件。
运维
- 清理:
auth.CleanupExpired(ctx)或auth.StartCleanup(ctx, time.Hour)。否则过期的验证行和会话永远不会被回收。 - 位于代理之后:设置
Advanced.TrustProxyHeaders和Advanced.TrustedProxies。参见快速开始——默认配置在负载均衡器后面就是一场迟早要发生的故障。 - 轮换
Secret:使用Config.PreviousSecrets和auth.ReencryptSecrets进行三步部署。参见轮换密钥。 - 升级到绑定密文:运行
auth.ReencryptSecrets直到它报告Done(),然后用Config.RequireBoundCiphertexts: true部署。同一节。 - 审计轨迹:默认开启并写入
Config.Logger;用Events.Handler路由它。参见审计事件。 - 多实例:提供一个共享的
RateLimit.Storage;其他一切都是无状态的。 - 会话 cookie 缓存:当插件注册
SessionGuard时,它会自动被绕过(admin 插件的封禁检查就会这样做)。如果从用户的缓存副本提供授权决策,封禁就会在缓存窗口期内被忽视。 - 迁移:参见下方的迁移。
- 内省:
auth.Routes()列出每个已注册的端点;auth.Schema()列出完整 schema。
迁移
插件不只是添加表(passkey 和 sso 各带来一个)。它们还会向你已有的表添加列:admin 向 user 添加 role、banned、banReason 和 banExpires,并向 session 添加 impersonatedBy;twofactor 向 user 添加 twoFactorEnabled;organization 向 session 添加 activeOrganizationId;Config.User.AdditionalFields 也是如此。库升级也可能以同样的方式添加核心列。
CREATE TABLE IF NOT EXISTS 对已经存在的表不做任何事,因此在有数据的数据库上,列级别的步骤不是可选项。store.Migrate(ctx) 两者都做:
store.Migrate(ctx) |
创建缺失的表和索引,并且向现有表添加缺失的列。幂等——每次启动时调用即可。godevauth.New 中的自动迁移运行的正是这个。 |
store.MigrationSQL() |
完整的从零创建 DDL,用于手动配置新数据库。绝不包含 ALTER;它完全不知道你的数据库已有什么。 |
store.PendingMigrationSQL(ctx) |
内省实时数据库,仅返回它缺失的语句——缺失表返回 CREATE TABLE,缺失列返回 ALTER TABLE ... ADD COLUMN。为空表示已是最新。 |
store.CheckSchema(ctx) |
以错误形式报告漂移,并指出表和列名,不做任何更改。 |
关于添加的列,有两点需要了解:
- 它们始终可为空,无论
Field.Required怎么说。表中已有的行对于不存在的列没有值,而且没有引擎会接受在已填充数据的表上添加没有默认值的NOT NULL。因此,由迁移添加的必填列在回填数据并自行收紧约束之前,是由应用程序而非数据库强制执行的。 - 迁移需要 DDL 权限。如果你的应用程序的数据库角色没有这些权限,请从部署步骤运行迁移,设置
Advanced.DisableAutoMigrate,并设置Advanced.VerifySchema,这样遗漏的迁移会在启动时失败,并给出指明缺失列的消息——而不是在首次登录时以驱动错误的形式失败。
auth, err := godevauth.New(godevauth.Config{ Database: store, Advanced: godevauth.AdvancedConfig{ DisableAutoMigrate: true, // migrations are a deploy step VerifySchema: true, // ...so refuse to start if one was missed }, // ... }) // go-dev-auth: verifying the database schema: sqlstore: the database schema is // out of date: table "twoFactor" is missing; table "user" is missing column(s) // "role", "banned", "banReason", "banExpires"
性能与成本
在 4 核 arm64 容器(go test -bench .)上针对内存适配器测量。这是对库自身请求路径的诚实度量,但并不能很好地代表你的生产延迟,因为在生产环境中,数据库往返将主导下面除密码哈希之外的每一行。请将这些理解为“库增加了什么”,而不是“一次登录花费多少”。
| 操作 | 成本 |
|---|---|
| 会话验证(cookie -> 用户) | 约 0.6 µs 并行,21 次分配 |
| 通过路由器的完整 HTTP 请求 | 约 4 µs,47 次分配 |
| 会话查找,1 万行(内存适配器) | 约 0.5 µs(有索引) |
| 密码哈希 / 验证 | 约 51 ms,21 KB |
除密码哈希外,一切都是微秒级。开销实际花在哪里,按顺序如下:
1. 密码哈希主导 CPU。每次登录约 51 ms,意味着大约 sign-ins/sec ÷ 20 个 CPU 核心。在每秒 100 次登录时,就是约 5 个核心除了 scrypt 什么都不做。这是有意为之——正是它让被盗的哈希破解成本高昂——所以杠杆不是让它更便宜,而是减少使用频率:会话默认持续 7 天,因此已登录的用户不会再触碰它。
2. 数据库往返主导其他一切。一个已认证请求需要两次查询(先会话,后用户),在托管数据库上每次约 1 ms——是请求本身 CPU 成本的一千倍。开启会话 cookie 缓存可将其降为零:
Session: godevauth.SessionConfig{ CookieCache: godevauth.CookieCacheConfig{Enabled: true, MaxAge: 5 * time.Minute}, },
代价是陈旧性:被撤销的会话在缓存副本过期前仍然有效。注册了 SessionGuard 插件(管理插件的封禁检查就是其中之一)时,默认会绕过缓存,而不是用陈旧的用户来做出授权决定;设置 CookieCache.AcceptStaleAuthorization 可以仍然享受这一节省,并接受封禁在 MaxAge 内生效。
3. 登录突发时的内存是有界的,而非与流量成正比。每个进行中的哈希需要 128·N·r = 32 MiB 的暂存空间,因此并发上限为 GOMAXPROCS,并且缓冲区被池化:无论到达多少请求,峰值瞬时内存都是 GOMAXPROCS × 32 MiB。在 MaxWait(3 秒)内无法获得槽位的请求会以 503 SERVICE_BUSY 被丢弃,而不是排队形成客户端会体验为挂起的数秒积压。
调整哈希成本是可能的,但会改变安全/成本权衡,并使现有哈希无法验证:
// Fewer cores per sign-in, less resistance to offline cracking. // Fresh deployments only. crypto.NewScryptHasher(crypto.ScryptParams{N: 16384, R: 8, P: 1, MaxConcurrent: 8})
项目布局
. the godevauth package: config, routing, handlers
.github/ CI, templates, CONTRIBUTING, SECURITY
storage/ persistence contract, models, schema
storage/memory in-memory adapter
storage/sqlstore PostgreSQL / MySQL / SQLite
storage/mongostore MongoDB (separate module)
storage/storagetest adapter conformance suite
crypto/ password hashing, tokens, TOTP, JWT
oauth2/ OAuth 2.0 / OIDC client
providers/ ready-made provider configurations
plugins/<name>/ optional features, one package each
plugins/plugintest harness for testing a plugin
ratelimit/ rate limiter and store interface
examples/basic a runnable server
docs/ architecture map, plugin guide, reviews
根目录是扁平的,因为 Go 要求一个包的所有文件
共享一个目录:这些文件就是 godevauth 包,
将它们嵌套意味着要么更改导入路径,要么拆分
包。所有能成为独立包的东西都已经独立了。文件按
其内容命名——session.go、router.go、handler_email.go
——因此列表读起来就像目录。
docs/architecture.md 是完整的地图:每个 文件拥有什么,以及新的更改应放在哪里。
开发中
go work init . ./storage/mongostore ./storage/sqlstore/integration
make check # fmt, vet, lint, tests, race
make help 列出了每个目标。go.work 是开发者本地的,不会提交。SQLite 集成测试需要 CGO_ENABLED=1;Postgres、MySQL 和 MongoDB 测试套件除非设置了匹配的 DSN 环境变量,否则会跳过。
关于变更的期望请参见 CONTRIBUTING.md,安全模型和报告流程请参见 SECURITY.md,架构图和审计记录请参见 docs/。
在使用 AI 编码助手?llms.txt 是一份关于 API 以及保持生成代码正确的规则的单文件简报;AGENTS.md 涵盖了为本仓库做贡献的智能体。
License
MIT
来源:Hacker News · github.com