Elixir 认证授权实战:JWT、Guardian 与 Phoenix.Token

系统讲解 Elixir 生态的认证授权方案:JWT 的三段结构与签名算法、Guardian 的配置与 Pipeline 插件链、Token 签发验证与刷新、Phoenix.Token 的适用边界、令牌吊销与密钥轮换,以及 alg=none、算法混淆、重放等常见攻击面与防御。

认证(Authentication,你是谁)与授权(Authorization,你能做什么)是两件常被混为一谈的事。在传统的服务端会话模型里,两者由一个 Session ID 承载:浏览器存 Cookie,服务端查 Session 表,从表里读出用户身份与角色。这套机制简单可靠,但有两个硬伤——水平扩展需要共享 Session 存储,移动端与跨域场景下 Cookie 又很不方便。

JWT(JSON Web Token)的流行正是为了解决这些问题:把身份与权限信息签名后放在令牌里,服务端无需查表即可验证,天然适合无状态的 API 与多服务架构。但「无需查表」也意味着「无法立即撤销」,这个根本性的取舍衍生出了一整套工程实践——什么时候该用 JWT,什么时候该用不透明令牌,吊销如何设计,密钥如何轮换。

一、认证与授权的边界

1.1 两种模型对比

维度服务端会话无状态 JWT
状态存储服务端(Redis/DB)客户端(令牌本身)
水平扩展需共享存储天然支持
撤销删 Session 即生效需额外机制(黑名单/版本号)
跨域/移动端Cookie 受限Authorization 头通用
令牌大小几十字节几百字节到数 KB
信息泄露风险低(只存 ID)高(payload 是明文 Base64)
适用单体 Web 应用微服务、API、移动端

1.2 常见误区

  • 把 JWT 当加密:JWT 的 payload 只是 Base64URL 编码,任何人都能解开。绝不放密码、身份证号等敏感信息;
  • 把 JWT 当会话的等价替代:JWT 的强项是无状态校验,弱项是撤销。需要「立刻踢下线」的场景要用混合方案;
  • 过期时间设得过长:很多系统把 exp 设成 30 天,等于把长期凭证交给了客户端。正确做法是短寿命 access token + 长寿命 refresh token。

二、JWT 结构与签名算法

2.1 三段式结构

一个 JWT 由三部分用 . 连接:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiIxMjMiLCJleHAiOjE3Njg4fQ . dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
        Header (Base64URL)                  Payload (Base64URL)                Signature
部分内容是否可读
Header{"alg": "HS256", "typ": "JWT"}明文可读
Payload标准声明 + 自定义声明明文可读
Signature对前两段的签名不可伪造(但可重放)

2.2 标准声明

声明名称说明
issIssuer签发者,用于校验来源
subSubject主体,通常是用户 ID
audAudience接收方,防止令牌被用于其他服务
expExpiration Time过期时间(Unix 秒)
nbfNot Before生效时间
iatIssued At签发时间
jtiJWT ID令牌唯一标识,用于吊销与防重放

sub、exp、iss 是必须校验的三个声明。省略 aud 校验会让 A 服务的令牌在 B 服务上畅通无阻。

2.3 签名算法选型

算法类型密钥适用场景
HS256HMAC-SHA256对称共享密钥单一服务自签自验
RS256RSA-SHA256私钥签 / 公钥验多服务验证、需公开公钥
ES256ECDSA-P256私钥签 / 公钥验同上,签名更短
EdDSAEd25519私钥签 / 公钥验现代选择,性能与安全兼顾
none无签名无绝不允许

算法选择的判据是「谁需要验证」:如果只有签发者自己验证(单体应用或网关统一验证),HS256 足够且更快;如果多个服务需要独立验证令牌,必须用非对称算法,否则共享密钥的扩散面等于把签发权交给了每一个验证方。

2.4 签名与验证的本质

signature = HMAC-SHA256(base64url(header) + "." + base64url(payload), secret)

验证方用同样的密钥重算签名并比对。这里的三个关键点:

  1. 算法由 header 声明,但验证方必须白名单校验——否则攻击者可以把 alg 改成 none 或把 RS256 改成 HS256(用公钥当 HMAC 密钥),这就是著名的「算法混淆攻击」;
  2. 签名保证完整性,不保证机密性——payload 是明文;
  3. 签名不防重放——同一个令牌在过期前可以被无限次使用,防重放需要 jti + 服务端记录。

三、Guardian 配置与 Pipeline

3.1 安装与基础配置

# mix.exs
{:guardian, "~> 2.3"}

# config/config.exs
config :my_app, MyApp.Guardian,
  issuer: "my_app",
  secret_key: System.get_env("GUARDIAN_SECRET_KEY"),
  ttl: {1, :hour}

密钥必须来自环境变量或密钥管理服务,绝不能提交进仓库。mix guardian.gen.secret 可以生成一个合适的随机密钥。

3.2 定义 Guardian 模块

defmodule MyApp.Guardian do
  use Guardian, otp_app: :my_app

  alias MyApp.Accounts

  @impl true
  def subject_for_token(%{id: id}, _claims), do: {:ok, to_string(id)}
  def subject_for_token(_, _), do: {:error, :invalid_resource}

  @impl true
  def resource_from_claims(%{"sub" => id}) do
    case Accounts.get_user(id) do
      nil -> {:error, :resource_not_found}
      user -> {:ok, user}
    end
  end

  def resource_from_claims(_), do: {:error, :invalid_claims}
end

这两个回调定义了「资源 ↔ 令牌」的双向映射。subject_for_token/2 在签发时把用户转成 sub,resource_from_claims/1 在验证时把 sub 还原成用户。注意 resource_from_claims/1 每次都查数据库——这正是「JWT 无状态」在实践中被打破的地方,也是吊销能生效的原因。

3.3 Phoenix Pipeline

# lib/my_app_web/router.ex
pipeline :api_auth do
  plug Guardian.Plug.Pipeline,
    module: MyApp.Guardian,
    error_handler: MyAppWeb.AuthErrorHandler

  plug Guardian.Plug.VerifyHeader, scheme: "Bearer", realm: "Bearer"
  plug Guardian.Plug.EnsureAuthenticated
  plug Guardian.Plug.LoadResource, allow_nil: false
end

scope "/api/v1", MyAppWeb.API do
  pipe_through [:api, :api_auth]
  resources "/orders", OrderController
end
插件作用
Pipeline指定使用的 Guardian 模块与错误处理器
VerifyHeader从 Authorization: Bearer xxx 中提取令牌并验证签名
VerifySession从 Session 中提取令牌(浏览器场景)
EnsureAuthenticated无有效令牌时中断请求,返回 401
LoadResource调用 resource_from_claims/1,把用户放进 conn.assigns
EnsureAuthenticated + claims检查特定声明(如 role)以做授权

3.4 错误处理

defmodule MyAppWeb.AuthErrorHandler do
  import Plug.Conn

  @behaviour Guardian.Plug.ErrorHandler

  @impl true
  def auth_error(conn, {type, _reason}, _opts) do
    body = Jason.encode!(%{error: to_string(type)})

    conn
    |> put_resp_content_type("application/json")
    |> send_resp(401, body)
  end
end

错误信息要保持模糊——返回「token expired」还是「invalid signature」对合法客户端无差别,却给攻击者提供了免费的探测反馈。

四、Token 签发、验证与刷新

4.1 签发

# 基础签发
{:ok, token, claims} = MyApp.Guardian.encode_and_sign(user)

# 带自定义声明与过期时间
{:ok, token, _claims} =
  MyApp.Guardian.encode_and_sign(user, %{"role" => user.role, "org" => user.org_id},
    ttl: {15, :minute},
    token_type: "access")

# 刷新令牌:长寿命,只用于换取新的 access token
{:ok, refresh, _} = MyApp.Guardian.encode_and_sign(user, %{}, token_type: "refresh", ttl: {7, :day})

token_type 会在 claims 中写入 typ 字段。access 与 refresh 必须用不同的 type,否则攻击者可以用 refresh token 直接访问业务接口。

4.2 验证

# 只验签与声明,不查库
{:ok, claims} = MyApp.Guardian.decode_and_verify(token)

# 验签 + 还原资源
{:ok, user, claims} = MyApp.Guardian.resource_from_token(token)

# 在 Plug 之外手动验证(如 WebSocket 握手)
case MyApp.Guardian.resource_from_token(token) do
  {:ok, user, _} -> {:ok, user}
  {:error, reason} -> {:error, reason}
end

WebSocket 场景下无法使用 Plug Pipeline,需要在 Phoenix.Socket.connect/3 中手动验证,见 https://plumephp.com/elixir-phoenix-channels-realtime/ 的 Socket 认证章节。

4.3 刷新与交换

# 用 refresh token 换一对新的 access + refresh
{:ok, _old, {new_access, _}} =
  MyApp.Guardian.exchange(refresh_token, "refresh", "access", ttl: {15, :minute})

刷新时必须做轮换(rotation):每次使用 refresh token 都签发新的 refresh 并作废旧的。这样一旦 refresh token 泄露,攻击者使用时会导致合法用户的令牌失效,从而被检测到。若不做轮换,一个泄露的 refresh token 可以安静地使用 7 天。

4.4 令牌传递方式

方式优点风险
Authorization: Bearer 头无 CSRF 风险,跨域友好需要客户端 JS 存储(XSS 可窃取)
HttpOnly CookieJS 无法读取(抗 XSS)需 CSRF 防护,跨域麻烦
URL 参数便于分享极不安全,会进日志与 Referer
localStorage简单XSS 可窃取

推荐组合:access token 放内存(JS 变量),refresh token 放 HttpOnly + Secure + SameSite=Strict Cookie。这样 XSS 拿不到长期凭证,CSRF 也被 SameSite 挡住。

五、Phoenix.Token、吊销与密钥轮换

5.1 Phoenix.Token 的适用边界

Phoenix.Token 不是 JWT,它是 Phoenix 内置的签名令牌工具,用于「一次性、单一用途」的场景:

# 邮箱验证 / 密码重置
token = Phoenix.Token.sign(MyAppWeb.Endpoint, "user confirmation", user.id, max_age: 86_400)

case Phoenix.Token.verify(MyAppWeb.Endpoint, "user confirmation", token, max_age: 86_400) do
  {:ok, user_id} -> confirm(user_id)
  {:error, :expired} -> {:error, :token_expired}
  {:error, :invalid} -> {:error, :invalid_token}
end
维度Guardian(JWT)Phoenix.Token
标准RFC 7519Phoenix 私有格式
跨服务验证支持(非对称算法)不支持(共享 secret_key_base)
自定义声明支持不支持
大小较大较小
用途API 认证邮箱验证、密码重置、临时签名 URL

判据很简单:需要跨服务或需要携带声明,用 JWT;只是给用户一个带签名的临时凭证,用 Phoenix.Token。密码重置链接用 JWT 是过度设计,而 API 认证用 Phoenix.Token 则无法与其他服务互通。

5.2 吊销的三种方案

JWT 天然不可撤销,实践中用三种方案弥补:

方案实现代价生效延迟
短寿命 + 刷新access token 15 分钟客户端需处理刷新≤ 令牌寿命
黑名单把吊销的 jti 存入 Redis,验证时查每次请求一次查询立即
版本号用户表加 token_version,令牌携带该值每次请求一次查询立即

黑名单实现(基于 Guardian.DB 或自建):

defmodule MyApp.TokenRegistry do
  def revoke(jti, exp) do
    # TTL 设为令牌剩余寿命,到期自动清理
    ttl = exp - System.system_time(:second)
    Redix.command!(:redis, ["SET", "revoked:#{jti}", "1", "EX", max(ttl, 1)])
  end

  def revoked?(jti), do: Redix.command!(:redis, ["EXISTS", "revoked:#{jti}"]) == 1
end

版本号方案更简洁:用户表加一列 token_version,签发时写入 claims,验证时比对。改密码或「登出所有设备」只需把版本号加一,所有旧令牌立即失效——一次数据库查询(或用缓存)解决全部吊销需求。

5.3 密钥轮换

密钥轮换的目标是「不中断服务地更换签名密钥」。对称算法(HS256)做不到无缝轮换——密钥换了,旧令牌全部失效。正确做法是改用非对称算法并发布 JWKS:

签名方:用私钥签发,header 中带 kid(密钥 ID)
验证方:按 kid 从 JWKS 端点取对应公钥验证
轮换:新令牌用新 kid 签发,旧公钥保留一个令牌寿命周期后再移除
# 用 JOSE 手动验证(Guardian 底层也是 JOSE)
def verify_with_jwks(token) do
  {_, %{"kid" => kid}} = JOSE.JWT.peek_protected(token)
  jwk = fetch_public_key(kid)                       # 从 JWKS 缓存获取
  {true, %JOSE.JWT{fields: claims}, _} = JOSE.JWT.verify_strict(jwk, ["RS256"], token)
  {:ok, claims}
end

关键约束:验证方必须显式白名单允许的算法(verify_strict 的第二个参数),这是防算法混淆攻击的唯一可靠手段。

六、权限策略与常见攻击面

6.1 从认证到授权

认证解决「你是谁」,授权解决「你能不能做这件事」。最小可用方案是把角色放进 claims:

plug :ensure_role, ["admin"] when action in [:delete]

defp ensure_role(conn, roles) do
  claims = Guardian.Plug.current_claims(conn)
  if claims["role"] in roles do
    conn
  else
    conn |> put_status(403) |> halt()
  end
end

但角色很快会不够用——「只能删除自己部门的订单」这类规则需要资源级判断。此时应引入策略层:

defmodule MyApp.Policy do
  def authorize(:delete, %Order{} = order, %User{} = user) do
    order.user_id == user.id or user.role == "admin"
  end
end

权限判断必须在服务端每次执行,绝不能只靠前端隐藏按钮。把 claims 里的角色当作缓存的提示可以,当作唯一依据不行。

6.2 攻击面清单

攻击原理防御
alg: none把算法改成 none,签名校验被跳过验证方白名单算法
算法混淆RS256 改 HS256,用公开的公钥当 HMAC 密钥白名单算法 + 区分密钥类型
重放截获令牌后重复使用短 exp + jti 黑名单 + TLS
令牌泄露XSS 窃取 localStorageHttpOnly Cookie + CSP
CSRFCookie 自动携带,跨站发起请求SameSite + CSRF Token
声明注入篡改 payload(若签名校验缺失)永远校验签名
敏感信息泄露payload 明文不放敏感数据
弱密钥密钥过短可被暴力破解至少 256 位随机密钥

6.3 与加密体系的配合

JWT 只解决「凭证的完整性」,不解决「传输的机密性」与「存储的机密性」。完整的安全体系还需要:

  • 传输层:全站 HTTPS,TLS 配置参考 https://plumephp.com/erlang-security-crypto/ 的 TLS 章节;
  • 密码存储:Argon2id 或 bcrypt,绝不用 SHA256 直接哈希;
  • 密钥管理:密钥进 KMS 或环境变量,定期轮换;
  • 审计日志:记录签发、刷新、吊销事件,异常模式(同一 sub 短时间多次刷新)应告警。

七、最佳实践与总结

  • access token 短寿命,refresh token 长寿命且轮换:15 分钟 + 7 天是常见起点,轮换是检测泄露的关键;
  • 验证方必须白名单算法:alg: none 与算法混淆是 JWT 历史上最严重的两类漏洞,用 verify_strict 一次性堵住;
  • payload 不放敏感信息:Base64 不是加密,任何能拿到令牌的人都能读到全部声明;
  • 区分 access 与 refresh 的 typ:用 token_type 隔离用途,防止 refresh token 被当作 access token 使用;
  • 优先用版本号做吊销:比黑名单更简洁,一次比对即可让全部旧令牌失效;
  • 需要跨服务就上非对称算法 + JWKS:对称密钥在多方之间共享会无限扩大信任面,且无法平滑轮换;
  • 单一用途的临时凭证用 Phoenix.Token:密码重置、邮箱验证不必套用 JWT 的复杂度;
  • 认证与授权分层实现:Guardian 负责「你是谁」,策略模块负责「你能不能做」,两者不要混在一个 Plug 里;
  • 令牌存储位置决定威胁模型:内存 + HttpOnly Cookie 的组合在 XSS 与 CSRF 之间取得了最好的平衡。

JWT 不是银弹,它的价值与代价同样鲜明:无状态校验带来了水平扩展与跨服务互认,也带来了撤销困难与信息暴露。真正成熟的方案从来不是「用不用 JWT」,而是根据业务对「撤销时效」和「跨服务验证」的要求,组合出短寿命令牌、刷新轮换、版本号吊销、非对称签名这几块积木。把这套组合与 Phoenix 的 Plug 管道、OTP 的监督模型结合起来,你就能得到一个既安全又易于演进的认证体系。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「erlang」更多文章

  1. Erlang/Elixir 容器化与集群部署:Release、Docker 与 libcluster
  2. Elixir HTTP 客户端与连接池:Mint、Finch 与 Req 实战
  3. Erlang/Elixir gRPC 与 Protobuf:从编码原理到 grpcbox 实战