Clojure 认证授权与安全实践:Ring 安全链、JWT、密码哈希与审计

深入 Clojure Web 应用的认证授权与安全实践:Ring 中间件安全链、Session 与会话管理、JWT 与令牌设计、密码哈希与密钥管理、CSRF/XSS/SQL 注入防护、权限模型与审计日志,帮你把安全从「散落的检查」变成「可复用、可测试、可审计的中间件链」。

安全不是「加一个登录页」,而是一整条从请求入口到数据落库的防线。在 Clojure 里,这条防线最自然的组织方式是 Ring 中间件——每个中间件负责一个关注点,组合成一条可复用、可测试的安全链。本文从威胁模型讲到审计日志,覆盖 Session 与 JWT、密码哈希、CSRF/XSS/SQL 注入防护、权限模型与审计,帮你把安全决策沉淀成代码与配置,而不是散落在每个 handler 里。

1. 威胁模型与安全清单

1.1 先想清楚在防谁

威胁建模三问:
  资产是什么?   —— 用户数据、支付、管理权限
  谁能接触?     —— 匿名、登录用户、管理员、内部服务
  怎么被攻破?   —— 注入、越权、会话劫持、CSRF

1.2 OWASP 核心风险对照

风险Clojure 侧对策
注入参数化查询、拒绝字符串拼 SQL
认证失效强密码哈希、会话过期、限流
越权集中式权限检查中间件
XSS输出编码、CSP
CSRF令牌校验中间件

1.3 安全清单

上线前必查:
  密码是否强哈希(bcrypt/argon2)
  会话是否 HttpOnly + Secure + SameSite
  写操作是否校验 CSRF
  SQL 是否全部参数化
  敏感字段是否脱敏后入日志
  密钥是否来自环境变量而非硬编码

心智:安全清单是「底线」,不是「全部」——它挡住绝大多数常见漏洞,剩下的靠威胁建模逐项排查。

2. Ring 中间件安全链

2.1 中间件即安全层

(def app
  (-> handler
      (wrap-anti-forgery)          ;; CSRF
      (wrap-session)               ;; 会话
      (wrap-authentication)        ;; 认证
      (wrap-authorization)         ;; 授权
      (wrap-rate-limit)            ;; 限流
      (wrap-security-headers)      ;; 安全响应头
      (wrap-request-logging)))     ;; 审计

2.2 认证中间件

(defn wrap-authentication [handler]
  (fn [req]
    (let [token (get-in req [:headers "authorization"])
          user  (when token (verify-token (extract-bearer token)))]
      (handler (assoc req :identity user)))))

(defn wrap-authorization [handler]
  (fn [req]
    (if (authorized? (:identity req) (:uri req) (:request-method req))
      (handler req)
      {:status 403 :body "禁止访问"})))

2.3 顺序很重要

中间件从外到内的顺序:
  安全头 -> 限流 -> 会话 -> 认证 -> 授权 -> 业务
错误顺序的后果:
  认证在会话之前 -> 拿不到 session
  授权在认证之前 -> 拿不到 identity

心法:中间件链是「洋葱」——顺序决定了谁能看到谁的结果。安全相关的中间件必须放在业务之前、依赖项之后,顺序错了等于没装。

3. Session 与会话管理

3.1 服务端 Session

(require '[ring.middleware.session :as session]
         '[ring.middleware.session.memory :as mem])

(def store (mem/memory-store))

(def app
  (session/wrap-session handler
    {:store store
     :cookie-attrs {:http-only true
                    :secure    true          ;; 仅 HTTPS
                    :same-site :lax}}))      ;; 防 CSRF
属性作用建议
HttpOnlyJS 无法读取必开
Secure仅 HTTPS 传输必开
SameSite限制跨站携带Lax 或 Strict
Max-Age过期时间按场景
Path作用路径最小化

3.3 会话固定与过期

;; 登录成功后必须「换新 session id」(防会话固定)
(defn login [req]
  (if (valid-credentials? req)
    (-> (redirect "/dashboard")
        (assoc :session {:user-id (:id user)
                         :issued-at (System/currentTimeMillis)}))
    (unauthorized)))

;; 过期:绝对超时 + 空闲超时

心法:登录成功要「换会话」——攻击者先诱导你用一个已知的 session id 登录,之后就复用这个 id 劫持你。换 id 是最便宜的防护。

4. JWT 与令牌

4.1 签发与校验

;; deps.edn
{:deps {buddy/buddy-sign {:mvn/version "3.5.2"}}}

(require '[buddy.sign.jwt :as jwt])

(def secret (System/getenv "JWT_SECRET"))

;; 签发
(def token
  (jwt/sign {:user-id 42 :role :admin :exp (+ (quot (System/currentTimeMillis) 1000) 3600)}
            secret {:alg :hs256}))

;; 校验(过期会抛异常)
(try
  (jwt/unsign token secret {:alg :hs256})
  (catch Exception _ nil))

4.2 HS256 与 RS256

算法密钥适用
HS256对称共享单服务、内部
RS256非对称公私钥多服务、第三方验签

4.3 JWT 的坑

常见错误:
  用 none 算法    -> 必须显式指定 alg
  把敏感信息放 payload -> payload 只是 Base64,不是加密
  无法主动失效    -> JWT 无状态,登出靠黑名单或短过期
  密钥硬编码      -> 从环境变量/密钥服务取

心法:JWT 的「无状态」是优点也是代价——它省去了查会话,却也让「立即吊销」变难。要么用短过期 + 刷新令牌,要么维护吊销列表;别把「无状态」当成「不用管失效」。

5. 密码哈希与密钥管理

5.1 用 bcrypt 或 argon2

;; deps.edn
{:deps {buddy/buddy-hashers {:mvn/version "2.0.167"}}}

(require '[buddy.hashers :as hashers])

;; 哈希
(def hashed (hashers/derive "user-password" {:alg :bcrypt+sha512}))

;; 校验(自动识别算法与参数)
(hashers/verify "user-password" hashed)   ;; => {:valid true :update false}

;; 参数升级后,旧哈希会提示 update
(hashers/verify "user-password" old-hashed) ;; => {:valid true :update true}

5.2 为什么不能自己加盐 SHA256

慢哈希 vs 快哈希:
  SHA256   -> 快,适合校验完整性,不适合存密码
  bcrypt   -> 慢,故意拖慢暴力破解
  argon2   -> 慢且抗 GPU,内存硬化
原则:密码哈希要「慢」,且每个用户独立盐

5.3 密钥管理

密钥来源优先级:
  密钥管理服务(Vault/KMS)  >  环境变量  >  配置文件(禁)
轮换:
  支持双密钥并行(新签旧验),平滑轮换

心法:密码哈希只有两个正确答案:bcrypt 或 argon2——其余「自己加盐 SHA」的方案都是在给自己挖坑。密钥永远不落代码库,从环境或密钥服务取。

6. CSRF 防护

6.1 为什么需要 CSRF

攻击场景:
  你登录了银行 -> 浏览器持有 session cookie
  访问恶意页面 -> 该页面向银行发 POST(浏览器自动带 cookie)
  银行以为是你本人操作 -> 转账成功
防御:让「表单」带上一个只有本站知道的令牌

6.2 Ring 反 CSRF

(require '[ring.middleware.anti-forgery :as af])

(def app
  (af/wrap-anti-forgery handler))

;; 模板里放令牌
;; <input type="hidden" name="__anti-forgery-token"
;;        value="{{anti-forgery-token}}">

;; 或读 ring.middleware.anti-forgery/*anti-forgery-token*

6.3 SameSite 与 API

策略选择:
  传统表单站  -> 反 CSRF 令牌 + SameSite=Lax
  纯 JSON API -> 用 Authorization 头(不依赖 cookie)天然免疫
  跨站需带凭证 -> SameSite=None + Secure + 令牌

心法:「基于 Cookie 的写操作」必须防 CSRF——换成 Authorization 头携带令牌后,浏览器不会自动带上,CSRF 就失效了。这是 API 相比表单更安全的天然优势。

7. XSS 与输出编码

7.1 转义一切输出

(require '[hiccup.util :refer [escape-html]])

;; 危险:直接把用户输入插进 HTML
[:div (str "你好 " user-input)]        ;; 若输入是 <script>... 就中招

;; 安全:转义
[:div (str "你好 " (escape-html user-input))]

7.2 CSP 响应头

(defn wrap-security-headers [handler]
  (fn [req]
    (let [resp (handler req)]
      (update resp :headers merge
              {"Content-Security-Policy"
               "default-src 'self'; script-src 'self'; object-src 'none'"
               "X-Content-Type-Options" "nosniff"
               "X-Frame-Options" "DENY"
               "Strict-Transport-Security" "max-age=31536000")))))

7.3 存储型与反射型

XSS 两种形态:
  反射型 —— 输入立即出现在响应里
  存储型 —— 输入存进库,之后被渲染
共性防御:输出编码 + CSP + 不用 innerHTML

心法:XSS 的根治办法是「输出编码 + CSP」双保险——编码挡住注入的标签,CSP 让即使注入成功也无法执行脚本。别指望「过滤输入」,编码输出才是正解。

8. SQL 注入与输入校验

8.1 永远参数化

(require '[next.jdbc :as jdbc])

;; 危险:字符串拼接
(jdbc/execute! ds [(str "select * from users where name = '" name "'")])

;; 安全:参数占位
(jdbc/execute! ds ["select * from users where name = ?" name])

;; 动态排序:白名单,绝不拼用户输入
(def sortable #{"name" "created_at" "email"})
(defn safe-order [col]
  (if (sortable col) col "created_at"))

8.2 输入校验

;; 用 Malli 在入口校验
(require '[malli.core :as m])

(def UserInput
  [:map
   [:email   [:string {:min 3 :max 254}]]
   [:age     [:int {:min 0 :max 150}]]
   [:name    [:string {:min 1 :max 100}]]])

(m/validate UserInput {:email "a@x.com" :age 30 :name "Alice"})
;; => true

8.3 白名单优于黑名单

原则:
  排序字段、重定向目标、文件类型 -> 用白名单
  黑名单永远漏(总有没想到的绕过方式)

心法:注入类漏洞的根因是「把数据当代码」——SQL 参数化让数据永远是数据;动态排序、重定向这类「结构化选择」必须用白名单。别做黑名单过滤,它只会给你虚假的安全感。

9. 权限模型与审计

9.1 从 RBAC 到细粒度

权限模型演进:
  RBAC   —— 角色 -> 权限(管理员、编辑、访客)
  ABAC   —— 属性(部门、时间、资源归属)
  资源级 —— 「只能改自己的订单」

9.2 集中式授权检查

(def permissions
  {:admin  #{:read :write :delete :manage-users}
   :editor #{:read :write}
   :viewer #{:read}})

(defn can? [user action]
  (contains? (permissions (:role user)) action))

;; 资源级:归属校验
(defn can-edit-order? [user order]
  (or (can? user :manage-users)
      (= (:user-id order) (:id user))))

9.3 审计日志

(defn audit! [ctx action target]
  (log/info :audit
            {:actor   (:user-id ctx)
             :action  action
             :target  target
             :ip      (:ip ctx)
             :ts      (System/currentTimeMillis)}))

;; 在敏感操作处调用
(audit! ctx :order-delete {:order-id 1234})

心法:授权检查要「集中」、审计要「完整」——权限散落在每个 handler 里必然漏;把 can? 收敛到中间件与领域函数,敏感操作一律留审计(谁、何时、对什么、做了什么)。

10. 速查表与一句话记忆

需求做法
中间件顺序安全头/限流/会话/认证/授权
会话 CookieHttpOnly + Secure + SameSite
登录换会话防会话固定
JWT 签发buddy-sign,显式 alg
密码哈希bcrypt 或 argon2
密钥来源环境变量或密钥服务
CSRF反伪造令牌 + SameSite
XSS输出编码 + CSP
SQL 注入参数化 + 排序白名单
输入校验Malli 入口校验
权限集中式 can? + 资源级归属
审计敏感操作留 actor/action/target

一句话记忆:Clojure 安全 = Ring 中间件链(安全头/限流/会话/认证/授权,顺序即防线)→ 会话用 HttpOnly+Secure+SameSite、登录换 id → JWT 显式 alg、短过期、可吊销 → 密码只认 bcrypt/argon2、密钥不落代码 → CSRF 靠令牌与 Authorization 头 → XSS 靠输出编码 + CSP → 注入靠参数化 + 白名单 → 授权集中式、审计全覆盖——把安全做成可复用、可测试、可审计的中间件链,而不是散落的检查。

延伸阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「clojure」更多文章

  1. Datalog 查询与 Datomic/Xtdb:数据即事实、pull、时间旅行与架构
  2. Clojure 静态检查与格式化工具链:clj-kondo、cljfmt、zprint 与 CI
  3. Clojure 日志、指标与可观测性:Timbre、OpenTelemetry 与采样