安全不是「加一个登录页」,而是一整条从请求入口到数据落库的防线。在 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
3.2 Cookie 属性
| 属性 | 作用 | 建议 |
|---|---|---|
| HttpOnly | JS 无法读取 | 必开 |
| 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. 速查表与一句话记忆
| 需求 | 做法 |
|---|---|
| 中间件顺序 | 安全头/限流/会话/认证/授权 |
| 会话 Cookie | HttpOnly + 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 REST API 设计实战 — 鉴权与错误模型
- Clojure 网络服务深入 — Ring 中间件机制
- Clojure 数据库访问 — 参数化查询与事务
- Clojure 现代 Web 栈 — 全栈 Web 工程
- Clojure 微服务架构实战 — 服务间认证与授权
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。