Go 基础体系 · 第 82/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。

Go 认证与授权:密码哈希、JWT、OAuth2、Casbin 与会话撤销

本文以 Go 1.26.4golang.org/x/crypto v0.55.0github.com/golang-jwt/jwt/v5 v5.2.2github.com/casbin/casbin/v2 v2.109.0 为基线。认证回答“当前主体是谁”,授权回答“该主体能否对这个对象执行该动作”;会话负责登录状态生命周期,审计记录安全决策。把四者混在一个 middleware 中,通常会产生无法撤销的令牌、过期权限缓存和难以诊断的拒绝。

依赖版本本身不等于安全。上线还需要威胁建模、TLS、密钥管理、限速、浏览器策略、数据库约束、日志脱敏和漏洞响应。参数要按目标硬件与流量测试。

1. 先画清信任边界与主体

主体可能是用户、服务账号或设备,凭证可以是密码、Cookie、Bearer token、mTLS 证书或 OAuth2 授权码。入口明确 TLS 终止点与可信转发头,不能因请求来自“内网”就跳过认证。

browser -> TLS gateway -> Go API -> policy/data store
            |                |
            | verified user  | resource-level authorization
            + trusted headers only from known proxy

认证结果形成不可变 Principal,只含 subject ID、tenant、认证方式和会话 ID。完整用户、token 或数据库连接不放 context;可变的用户名和邮箱不作为 subject 主键。

授权在后端每个动作执行;批量、导出、后台任务和 WebSocket 消息也不能只依赖前端隐藏按钮。

2. 固定依赖与升级纪律

安装时明确版本,升级时审查 release notes、算法默认值和漏洞公告:

go get golang.org/x/crypto@v0.55.0
go get github.com/golang-jwt/jwt/v5@v5.2.2
go get github.com/casbin/casbin/v2@v2.109.0
go mod tidy
go list -m golang.org/x/crypto github.com/golang-jwt/jwt/v5 github.com/casbin/casbin/v2
govulncheck ./...

安全补丁小步升级,先跑认证、时钟、撤销与策略回归,再灰度发布。构建记录 Go 版本、模块、提交;镜像不含开发密钥。

go.sum 只证明内容校验,不证明依赖无漏洞;还要生成 SBOM、签名镜像并最小化运行权限。

3. 密码永远只保存慢哈希

密码不能可逆保存,也不能只用 SHA-256。Argon2id 的参数包含内存、迭代、并行度、盐和输出长度;bcrypt 受 72 字节输入限制。新系统优先 Argon2id,并在生产硬件测定参数。

type ArgonParams struct {
	Memory      uint32
	Iterations  uint32
	Parallelism uint8
	SaltLength  uint32
	KeyLength   uint32
}

每个密码使用 crypto/rand 独立生成盐,编码串带算法和参数,例如 $argon2id$v=19$m=65536,t=3,p=2$...。盐不需要保密;可选 pepper 存在 KMS/secret manager,不与数据库同处。pepper 轮换需要版本和迁移设计。

登录前限制密码字节,防止超长输入放大 CPU/内存。错误统一返回“凭证无效”。未知账号也执行等价 dummy hash,并实施限速。

4. Argon2id 编码和常量时间比较

哈希解析限制参数上限,防止损坏数据触发巨大内存;错误返回 error,不 panic。

func derive(password, salt []byte, params ArgonParams) []byte {
	return argon2.IDKey(
		password,
		salt,
		params.Iterations,
		params.Memory,
		params.Parallelism,
		params.KeyLength,
	)
}

func equalHash(got, want []byte) bool {
	return len(got) == len(want) && subtle.ConstantTimeCompare(got, want) == 1
}

长度检查会泄露存储哈希长度,但哈希参数本来包含在编码串中;关键是相同长度的秘密比较使用常量时间函数。不要比较 base64 字符串,也不要把 password 转成 string 后长期保留额外副本。Go 无法保证内存立即擦除,进程和 dump 权限仍要最小化。

当用户成功登录且旧参数弱于当前策略时,在本次已验证密码上重新哈希并原子更新。这种按登录迁移避免一次性获得明文。失败登录绝不迁移。

6. 服务端 Session 与 Cookie

浏览器应用常用高熵随机 session ID 放 Cookie,服务端保存其哈希、用户、创建/最后活动/绝对过期时间、认证强度和撤销状态。数据库泄露后,攻击者不能直接使用已哈希 session ID。

Set-Cookie: __Host-session=base64url-random; Path=/; Secure; HttpOnly; SameSite=Lax
Cache-Control: no-store

__Host- 前缀要求 Secure、Path=/ 且无 Domain,可缩小 Cookie 注入范围。HttpOnly 防 JavaScript 读取但不防 XSS 代用户发请求;Secure 只经 HTTPS;SameSite 是 CSRF 的一道防线,不替代敏感操作 token/origin 检查。

Session 创建时轮换 ID,登录、权限提升和 MFA 后再次轮换,防 fixation。空闲过期和绝对过期都在服务端执行。退出、改密、账号禁用、MFA 重置和风险事件可撤销单会话或全部会话。

8. JWT 是签名声明,不是会话数据库

JWT 由 header、payload、signature 三段组成,base64url 不是加密。任何拿到 token 的人都能读取 claims,因此不放密码、隐私资料或内部密钥。签名证明由相应密钥签发且内容未改,不证明客户端值得信任,也不自动撤销。

最小 claims 包括 isssubaudexpiat、可选 nbfjti 和会话/租户 ID。Access token 短期有效,只承载授权所需最小声明。角色若会频繁变化,长时间复制到 token 会产生权限陈旧。

{
  "iss":"https://identity.example.com",
  "sub":"user_123",
  "aud":["article-api"],
  "exp":1788206700,
  "iat":1788206400,
  "jti":"token_456",
  "sid":"session_789"
}

token 放 URL query 会进入浏览历史、Referer、代理和日志,必须避免。使用 Authorization: Bearer,错误响应不回显 token。

9. JWT 验证必须固定算法与 claims

解析前就指定允许算法,不能信任 header 自称的 alg。验证 issuer、audience、expiration、not-before,并限制可接受时钟偏差。kid 只用于从受控 key set 选择密钥,不能拼文件路径、SQL 或任意 URL。

type Claims struct {
	TenantID string `json:"tenant_id"`
	SessionID string `json:"sid"`
	jwt.RegisteredClaims
}

func parseToken(raw string, key *rsa.PublicKey) (*Claims, error) {
	claims := &Claims{}
	token, err := jwt.ParseWithClaims(
		raw,
		claims,
		func(token *jwt.Token) (any, error) {
			if token.Method.Alg() != jwt.SigningMethodRS256.Alg() {
				return nil, errors.New("unexpected signing algorithm")
			}
			return key, nil
		},
		jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()}),
		jwt.WithIssuer("https://identity.example.com"),
		jwt.WithAudience("article-api"),
		jwt.WithExpirationRequired(),
		jwt.WithLeeway(30*time.Second),
	)
	if err != nil || !token.Valid {
		return nil, ErrInvalidToken
	}
	return claims, nil
}

对外统一无效 token,内部按过期、签名、issuer/audience、key 缺失分类指标。不要把解析 error 和 raw token 记录日志。Claims 还要验证 subject/session/tenant 格式和长度。

11. Refresh Token 旋转和重放检测

Refresh token 是高价值长期凭证,应是不可预测随机值,服务端只存哈希与 token family 状态。每次刷新在事务中消费旧 token、签发新 access+refresh token,并标记旧 token 已使用。并发重复使用同一旧 token 表明重放或客户端竞态。

BEGIN;
SELECT status, family_id FROM refresh_tokens
WHERE token_hash = ? FOR UPDATE;
-- active: 标记 used,插入下一枚 token
-- used: 撤销整个 family,记录 replay 事件
COMMIT;

网络超时可能发生在服务端提交后、客户端收到新 token 前。协议需有短暂 grace/幂等请求 ID,或让客户端重新登录,不能简单复用旧 token 而把真正攻击当正常。多个设备用不同 family,用户可查看并撤销单设备。

Refresh endpoint 也要限速、验证 client/session 绑定和风险信号。Access token 短期减少撤销检查压力,但管理员禁用、改密和高风险场景可能要求每请求检查 session generation。

12. OAuth2 与 OIDC 不可混为一谈

OAuth2 是委托授权框架,Access Token 让客户端访问 Resource Server;OIDC 在其上增加 ID Token、UserInfo 和身份语义。登录应使用 Authorization Code + PKCE,不使用隐式流。服务端生成并保存高熵 statenonce 和 PKCE verifier,并把它们绑定到发起登录的浏览器会话。

authorize request: state + nonce + code_challenge(S256)
callback: exact redirect URI + state
token exchange: code + code_verifier
ID token: signature + iss + aud + exp + nonce (+ azp when required)

redirect URI 采用精确白名单,不允许开放重定向。授权码只能使用一次且短期有效。ID Token 的 audience 是客户端,不是拿来调用任意 API 的 Access Token。Resource Server 依据 token 类型和自己的 audience 验证。

不要从前端传来的 email 直接建立账号。OIDC 用户主键应是 (issuer, subject);邮箱需依据 provider 的 verified 声明和本地关联策略处理,账号合并属于高风险操作并要求二次验证。

13. 认证 middleware 与 context

Middleware 从 header/Cookie 提取凭证、验证后把最小 Principal 放入请求 context。Context key 使用私有类型,访问函数返回 (Principal, bool);Handler 缺少主体时返回稳定 401。

type principalKey struct{}

type Principal struct {
	SubjectID string
	TenantID  string
	SessionID string
	Methods   []string
}

func WithPrincipal(ctx context.Context, principal Principal) context.Context {
	principal.Methods = slices.Clone(principal.Methods)
	return context.WithValue(ctx, principalKey{}, principal)
}

func PrincipalFrom(ctx context.Context) (Principal, bool) {
	principal, ok := ctx.Value(principalKey{}).(Principal)
	return principal, ok
}

Context 值不可作为可变权限缓存;slice/map 在边界复制。认证解析不应启动 goroutine。请求取消会终止远端 session 查询,但不能把取消误记为无效凭证。401 表示未认证/凭证无效,403 表示已认证但无权;为防资源枚举,某些对象级拒绝可统一映射 404,但内部审计保留真实原因。

14. 授权模型:RBAC、ABAC 与对象关系

RBAC 把权限赋给角色,适合后台岗位;ABAC 根据主体、资源和环境属性决策;对象关系授权表达 owner/editor/member 等关系。真实系统常组合:先验证租户与全局角色,再检查对象归属和具体动作。

权限命名用资源+动作,例如 article:publish,不要把 URL 或菜单名当权限。角色继承要有界,避免隐式获得高权限。默认拒绝,显式允许;安全依赖不可用时敏感操作 fail closed。

type Authorizer interface {
	Authorize(ctx context.Context, principal Principal, resource Article, action string) error
}

接口由调用方定义,返回 error 以区分拒绝、策略不可用和 context 超时。授权发生在加载必要资源后、产生副作用前;数据库查询还应带 tenant 条件,避免先越界加载再靠应用拒绝。

15. Casbin 的模型、策略与执行路径

Casbin 用 model 描述 request、policy、role relation、effect 和 matcher,用 policy 数据描述谁能对什么做什么。Enforce 把请求代入 matcher,角色管理器解析继承,effect 合并命中的 allow/deny。

[request_definition]
r = sub, dom, obj, act

[policy_definition]
p = sub, dom, obj, act

[role_definition]
g = _, _, _

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = g(r.sub, p.sub, r.dom) && r.dom == p.dom && r.obj == p.obj && r.act == p.act
p, editor, tenant-1, article, read
p, publisher, tenant-1, article, publish
g, user-123, publisher, tenant-1

路径匹配函数、正则和自定义 ABAC 函数扩大表达力,也扩大审计和性能难度。策略输入必须是规范化内部 ID,不能把未经处理的路径/租户字符串塞入 matcher。模型和策略作为版本化配置评审。

16. Casbin 并发、缓存和策略一致性

Enforcer 的并发能力取决于具体类型、adapter 和运行时策略修改方式。读多场景使用库提供的同步/缓存 Enforcer,并在当前版本跑 race;不要在普通 Enforce 并发时随意 LoadPolicy 或增删规则。

多实例策略更新需要持久 adapter、watcher 通知和版本号。Watcher 通知可能丢失或乱序,因此实例定期核对策略版本;应用新快照成功后再原子发布。权限撤销要求很高时,长 TTL cache 不可接受。

缓存 key 至少包含 subject、tenant、resource/action 和会影响结果的策略版本。不能只按角色缓存,也不能把任意资源 ID永久存入无界 map。deny 缓存同样需要失效。策略存储不可用时,旧缓存允许多久由风险策略明确,而不是默认永久可用。

菜单权限可以复用策略用于展示,但真正 API Enforce 仍不可省略。批量操作逐对象授权或用数据库范围约束,不能只检查第一项。

19. 测试攻击面而不是只测成功登录

密码测试覆盖错误编码、参数上限、迁移和常量时间函数的正确输入;JWT 表驱动覆盖错误 alg、签名、issuer、audience、exp/nbf、leeway、空 sub、未知 kid 和超长 token;Cookie 测 Secure/HttpOnly/SameSite/Path;授权测跨租户、对象越权和策略撤销。

使用固定时钟或 parser time hook,避免测试依赖真实 sleep。并发测试 refresh token 只能一个成功,另一个触发定义好的重放策略;Casbin 策略更新跑 go test -race。Fuzz 严格解析 Argon2 编码、Bearer header 和 claims,保证畸形输入不 panic、不大分配。

21. 部署与密钥管理

私钥、pepper、OAuth client secret 放 secret manager/KMS,应用按最小权限读取,禁止写镜像、Git、命令行参数和日志。公私钥带 kid/version;预发布、生产完全隔离 issuer、audience、Cookie domain 和回调 URI。

所有外部入口强制 TLS,代理到应用的信任链和 X-Forwarded-* 来源白名单化。NTP/时钟监控很重要,JWT leeway 只是容忍小偏差,不是掩盖时钟故障。数据库备份同样包含 session/refresh hash 和策略,应加密、限权和测试恢复。

配置在启动时校验,生产禁止开发密钥。健康检查不泄露 key、策略或用户数据;ready 只反映必需密钥和策略是否已加载。

22. 如何选择会话、JWT、OIDC 与 Casbin

要求即时撤销的同域 Web 优先服务端 session;多资源服务需要本地验证时用短 access JWT 加可撤销 refresh session;第三方登录用 OIDC。权限紧邻数据时用领域方法和 SQL tenant/owner 条件;策略跨服务且需独立管理时选 Casbin,并承担 watcher、缓存失效和审计成本。

23. 可运行的密码哈希核心

下面代码生成独立盐并验证 Argon2id 结果。存储层把参数、盐和 key 一起编码;解码时还必须按前文限制参数上限。外围继续限制并发、输入字节和登录速率。

package password

import (
	"crypto/rand"
	"crypto/subtle"
	"errors"
	"fmt"

	"golang.org/x/crypto/argon2"
)

type Params struct {
	Memory      uint32
	Iterations  uint32
	Parallelism uint8
	SaltLength  uint32
	KeyLength   uint32
}

type HashValue struct {
	Params Params
	Salt   []byte
	Key    []byte
}

func Hash(password []byte, params Params) (HashValue, error) {
	if err := validateParams(params); err != nil {
		return HashValue{}, err
	}
	salt := make([]byte, params.SaltLength)
	if _, err := rand.Read(salt); err != nil {
		return HashValue{}, fmt.Errorf("generate password salt: %w", err)
	}
	key := argon2.IDKey(password, salt, params.Iterations, params.Memory,
		params.Parallelism, params.KeyLength)
	return HashValue{Params: params, Salt: salt, Key: key}, nil
}

func Verify(password []byte, stored HashValue) (bool, error) {
	if err := validateParams(stored.Params); err != nil {
		return false, fmt.Errorf("validate password parameters: %w", err)
	}
	got := argon2.IDKey(password, stored.Salt, stored.Params.Iterations,
		stored.Params.Memory, stored.Params.Parallelism, stored.Params.KeyLength)
	return subtle.ConstantTimeCompare(got, stored.Key) == 1, nil
}

func validateParams(params Params) error {
	if params.Memory < 8*1024 || params.Memory > 256*1024 {
		return errors.New("argon2 memory is outside allowed range")
	}
	if params.Iterations < 1 || params.Iterations > 10 {
		return errors.New("argon2 iterations are outside allowed range")
	}
	if params.Parallelism < 1 || params.Parallelism > 8 {
		return errors.New("argon2 parallelism is outside allowed range")
	}
	if params.SaltLength < 16 || params.SaltLength > 64 ||
		params.KeyLength < 16 || params.KeyLength > 64 {
		return errors.New("argon2 salt or key length is outside allowed range")
	}
	return nil
}

完整生命周期才是目标:密码验证创建可撤销会话,短 token 固定 audience,refresh 旋转检测重放,每次对资源授权,策略变更可失效,安全事件可审计。任何单个算法或库都不能替代这条链路。


系列导航与关联阅读

官方资料

本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。