认证回答"你是谁", 授权回答"你能干啥" — 密钥不该出现在代码和镜像里: 要么锁进 KMS, 要么短命到泄漏也无害
把安全流程想成机场: 认证是安检口验护照(你是谁), 授权是登机口查登机牌(你上哪班机), TLS 是廊桥(半路没人能碰你行李), Secret 管理是地勤配钥匙的流程(钥匙绝不能贴在墙上)。任何一环偷懒, 前面全白做——安全是一条流水线, 不是一扇门。
工程上三条铁律藏在里面: 先认证后授权(401 与 403 语义不同); 网络位置不是信任凭证(零信任: 内网也要 mTLS + 验签); 密钥的宿命是泄漏, 所以要么让密钥永不出保险柜(KMS 信封加密), 要么让它短命到泄漏也无害(Vault 动态凭据, TTL 1 小时)。纵深防御的意义: 任何单层失守, 都不至全灭。
401 Unauthorized。认证通过≠放行, 它只建立身份, 能不能干是下一步授权的事。 // golang-jwt v5: Parse 自动验签, 但算法必须白名单 tok, err := jwt.Parse(raw, keyFunc, jwt.WithValidMethods([]string{"RS256"})) if err != nil || !tok.Valid { return 401 // → token has invalid claims: token is expired } // 关键: 白名单防"换算法攻击"(RS256→HS256 密钥混淆)
403 Forbidden。401 是"没认出你", 403 是"认出你了但你不行"——对外接口常统一回 404, 免得泄露资源是否存在。 o, _ := repo.ByID(r.Context(), id) if o.UserID != uid(r) { http.Error(w, "forbidden", 403) // → Forbidden: 不是你的对象 } // 关键: 对象级授权要落到每一行数据, "已登录"≠"都能看"
# K8s Role: 只读 Pod 的最小规则 rules: - apiGroups: [""] # 核心组 resources: ["pods"] verbs: ["get", "list", "watch"] # 关键: verbs 无 delete, 能看不能删
# OPA Rego 0.x 语法; OPA 1.0+ 需写 allow if {...} allow { input.user.dept == input.resource.dept } # 关键: 规则是数据驱动的, 换部门不用改一行业务代码
golang-jwt, 需要签名/加密一体时用 jose。 parts := strings.Split(token, ".") // parts[1] base64 解开 → {"sub":"1001","role":"user"} 谁都读得到 // 关键: JWT 是"签名"不是"加密", 手机号/身份证放进去=裸奔
claims["exp"] = time.Now().Add(15 * time.Minute).Unix() // 关键: access 15m + refresh 7d 可撤销, 失窃损失上限 15 分钟 // → 过期后校验返回: token has invalid claims: token is expired
# mTLS: 客户端也要出示自己的证书和私钥 curl --cert client.crt --key client.key --cacert ca.crt \ https://orders.prod.svc:8443/ping # 关键: 服务端证书 + 客户端证书两把都过验, 握手才成立
vault kv put secret/app/db password=S3cret # KV v2: 版本化可回滚 vault kv get -field=password secret/app/db # → S3cret # 关键: base64 不是加密, 有 get 权限就等于明文
vault read database/creds/app-rw # → username: v-root-app-rw-BxZq-1695700000 # password: A1a-xxxx-xxxx lease_duration: 1h # 关键: 这账号 1 小时后不存在, 泄了也白泄
aws kms encrypt --key-id alias/app-master \
--plaintext fileb://card.bin --output text --query CiphertextBlob
# 关键: 主密钥不出 KMS, 解密调用全部留痕可审计# K8s Role verbs: 一把梭 vs 最小 verbs: ["*"] # 错: 万能钥匙, 审计报告永远先点它 verbs: ["get", "list", "watch"] # 对: 能看不能改, 够用就好 # 关键: 报权限错误再加, 好过出了事再收
// 内网调用也要带 token + mTLS, 不因为是"内网"就免检 if !mtlsVerified || !tokenValid { return 401 // → Unauthorized: 内网不是免检通道 } // 关键: 信任不取决于网络位置, 取决于每一次验证
INSERT INTO audit_log(actor, action, object, result, prev_hash) VALUES ('u:1001', 'order.refund', 'o:8842', 'deny', 'a3f9...'); -- 关键: prev_hash 逐条链起, 删改中间一条立刻断链可查
老服务当年图省事直接绑 cluster-admin——拿下一个能 exec 的 Pod 就等于拿下整个集群。给业务建专属 ServiceAccount, 只授本 namespace 的最小读权限。
# sa.yaml — 业务 Pod 专属最小权限 apiVersion: v1 kind: ServiceAccount metadata: {name: orders-sa, namespace: prod} --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: {name: orders-config-reader, namespace: prod} rules: - apiGroups: [""] resources: ["configmaps"] verbs: ["get", "list", "watch"] # 只读配置, 不碰 secrets --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: {name: orders-bind, namespace: prod} subjects: - kind: ServiceAccount name: orders-sa roleRef: {kind: Role, name: orders-config-reader, apiGroup: rbac.authorization.k8s.io}
上线后验证: kubectl auth can-i delete pods --as=system:serviceaccount:prod:orders-sa -n prod 返回 no——爆炸半径从"全集群"缩到"几个 configmap"。
直接全网 STRICT 会把没注入 sidecar 的老服务当场打死。走 PERMISSIVE(明文/mTLS 都收) → 逐 namespace 收紧 STRICT 的两步迁移, 随时可回退。
# 第一步: mesh 级 PERMISSIVE — 明文与 mTLS 都能通, 老服务不炸 apiVersion: security.istio.io/v1beta1 kind: PeerAuthentication metadata: {name: default, namespace: istio-system} # root ns = 全网格生效 spec: mtls: {mode: PERMISSIVE} # 第二步: 逐 namespace 改 STRICT; 未注入 sidecar 的调用立刻被拒 # 验证: kubectl get peerauthentication -A # 回退: 改回 PERMISSIVE 秒级生效 — STRICT 上线必须留这条后路
收紧后扫描器再扫内网, 明文连接全部被 sidecar reset, 渗透报告该项清零。
静态 root 密钥人人都有, 等于没人负责。Vault database 引擎按应用实例签发 1 小时生命周期的临时账号, 到点自动 DROP。
# 1) 启用 database 引擎并注册 MySQL 连接 (Vault 1.x) vault secrets enable database vault write database/config/shop-mysql \ plugin_name=mysql-database-plugin \ connection_url='{{username}}:{{password}}@tcp(10.0.8.11:3306)/' \ allowed_roles=app-rw username=vaultadmin password=$VAULT_DB_PASS # 2) 角色: 动态账号的 CREATE 语句 + 生命周期 vault write database/roles/app-rw db_name=shop-mysql \ creation_statements="CREATE USER '{{name}}'@'%' IDENTIFIED BY '{{password}}';\ GRANT SELECT,INSERT,UPDATE ON shop.* TO '{{name}}'@'%';" \ default_ttl=1h max_ttl=24h # 3) 应用每次启动: vault read database/creds/app-rw → 临时账号, 1h 后自动销毁
改完后: 实例泄漏最多损失 1 小时数据窗口权限, 且每个账号的每条 SQL 在 MySQL 侧都能对到具体实例。
以前 token 泄漏等于账号裸奔一个月。服务端校验换成标准模板: 验签 + 算法白名单 + 强制过期。
// golang-jwt/jwt/v5 服务端校验模板: 验签 + 白名单 + 强制 exp tok, err := jwt.Parse(raw, func(t *jwt.Token) (any, error) { if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok { return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) } return rsaPub, nil }, jwt.WithValidMethods([]string{"RS256"}), jwt.WithExpirationRequired()) if err != nil { // → token has invalid claims: token is expired (过期时) http.Error(w, "unauthorized", http.StatusUnauthorized) return } uid := tok.Claims.(jwt.MapClaims)["uid"] // 关键: 身份从 token 取, 不信请求参数
配套签发侧: access 15m + refresh 7d(可撤销)。盗号者拿到的 access token 半小时后就是废纸。
每个服务各写一份 JWT 校验 = 30 个 bug 温床, 还曾有服务忘了挂中间件。鉴权与限流下沉到 APISIX, 业务仓库只信任网关注入的身份。
# APISIX 路由: 认证与限流在网关一层做完 routes: - uri: /api/orders/* upstream_id: orders-svc plugins: jwt-auth: {} # 验签失败 → 401, 流量不进业务 limit-req: rate: 200 # 200 r/s 平滑速率 burst: 100 # 允许 100 瞬时突发 key_type: var key: remote_addr rejected_code: 429 # 业务只信任网关注入的身份头; 服务间另有 mTLS 防伪造头
业务代码净删约 2400 行, 安全基线从"各团队自觉"变成"入口统一配置"。
接口只查了登录态没查归属, 水平越权一改一个准。修法: 查询条件强制绑定 owner, 让数据层天然只吐自己的行。
// 错(老代码): 只验登录, 不验归属 → /api/orders/8843 换个 id 就是别人的单 // SELECT id, amount FROM orders WHERE id = 8843 // // 对: 查询强制绑定 uid, 数据层天然只吐自己的行 func (r *OrderRepo) ByID(ctx context.Context, id, uid int64) (*Order, error) { var o Order err := r.db.GetContext(ctx, &o, `SELECT id, amount, status FROM orders WHERE id = ? AND user_id = ?`, id, uid) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound // 不区分"不存在/不是你的", 不泄露存在性 } return &o, err }
回归测试加了一组"拿别人的 id 调接口必须 404"的用例, IDOR 类漏洞再没复发。
审计表和业务库同权限, 出事时关键记录总会"恰好缺失"。审计账号只给 INSERT, 每条记录带前一条 hash, 篡改即断链。
-- 审计账号: 只许写入, 不许改历史 (DBA 之外无人能动) CREATE USER 'audit_writer'@'%' IDENTIFIED BY '****'; GRANT INSERT ON audit.* TO 'audit_writer'@'%'; CREATE TABLE audit.operations ( id BIGINT PRIMARY KEY AUTO_INCREMENT, actor VARCHAR(64), action VARCHAR(64), object VARCHAR(64), result CHAR(8), prev_hash CHAR(64), created_at DATETIME(6) ); -- 防抵赖核心: hash = SHA256(prev_hash + actor + action + object) -- 删改任何一条, 之后所有 hash 对不上, 断链告警立刻响
过等保测评时, "运维能否删除审计记录"这一项直接给了满分证据。
直接换密钥 = 切换瞬间旧签名全部验败, 对账当场炸锅。轮换走三步: 新钥签发 → 双钥验签共存 → 旧钥下线。
// 验签侧: 当前钥 + 上一把共存, 按 key id 选择 keys := map[string][]byte{ "k2026a": newKey, // 签发侧已切到新钥 "k2025z": oldKey, // 24h 窗口内的历史签名仍要能验 } kid := req.Header.Get("X-Sign-Key") got, _ := hex.DecodeString(req.Header.Get("X-Sign")) if !hmac.Equal(got, sign(keys[kid], req.Body)) { return http.StatusUnauthorized // → invalid signature (kid 未知/不匹配) } // 窗口结束删 oldKey — 轮换完成, 全程无一单验败
轮换窗口 24 小时, 期间成功率曲线平稳如常——密钥轮换从"心理负担"变成"例行动作"。
跳板机被钓到办公网凭证, 攻击者在"可信内网"横向两小时拖走三张表。内网服务全部补上 mTLS 与短 token, 本服务的安全基线配置如下。
# application-security.properties — 本服务的内网安全基线 security.mtls.enabled=true security.mtls.ca=/etc/certs/internal-ca.crt security.token.required=true security.token.max-ttl=PT15M # 15 分钟, 内网调用也一样 security.audit.log-deny=true # 拒绝也记录, 攻击路径可回放 # 效果: 偷来的凭证没有客户端证书, 第一次 TLS 握手就被拒
复测时攻击队拿着偷来的密码原地打转——没有证书, 连握手都过不去。
上一次靠人眼在 code review 里发现 AK, 命好; 这次把扫描做成合并门禁, 带密钥的 commit 在 PR 阶段就挂红。
# .github/workflows/security.yml — CI 密钥扫描门禁 jobs: gitleaks: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: {fetch-depth: 0} # 全量历史, 浅克隆漏检旧提交 - uses: gitleaks/gitleaks-action@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # 发现泄漏 exit 1 → PR 挂红; 泄漏后第一动作是"作废轮换", 删历史是第二
上线首月拦下 4 次真实泄漏(测试密钥居多), code review 里再没人需要瞪眼看 40 位十六进制串。
git log -p 和所有 fork 里密钥都还在. 正解: 第一动作轮换作废, 再用 git filter-repo 清历史 + CI 扫描门禁兜底。 # 错: dbPass = "Pr0d-P@ss" 写进 config.go 并 push # → 89 天后: gitleaks finding: generic-api-key (commit 3f9c2ab) # 对: 代码里只有 VAULT_ADDR; 已泄漏先轮换作废, 再 filter-repo 清历史
WithValidMethods 算法白名单。 // 错: strings.Split(token,".")[1] 解 payload 直接用 → 改 role 即管理员 // 对: jwt.Parse(raw, kf, jwt.WithValidMethods([]string{"RS256"})) // → 伪造签名返回: signature is invalid
// 错: {user.role==='admin' && <button>删除</button>} — 接口毫无校验 // 对: DELETE /api/users/42 服务端再验一次 role // → 无权限返回 403 Forbidden
# 错: curl http://orders.internal/api/orders → token 在交换机上裸奔 # 对: PeerAuthentication mtls STRICT → 明文连接被 sidecar 直接 reset # (curl 报: curl: (56) Recv failure: Connection reset by peer)
# 错: LTAI5t... 2019 年创建, 从未轮换, 权限还是 Administrator # 对: STS AssumeRole 签临时凭证, 有效期 1h # → 到期后拿它连云控制台/API 都是 AccessDenied
// 错: SELECT * FROM orders WHERE id=8843 → 别人的单也返回 // 对: SELECT * FROM orders WHERE id=8843 AND user_id=1001 // → 空结果 + 404, 连"这单存在"都不告诉攻击者
automountServiceAccountToken: false。 # 错: default SA 绑 cluster-admin → Pod 里 token 就是万能钥匙 # 对: automountServiceAccountToken: false + 按需只读 Role # 验证: kubectl auth can-i delete pods --as=system:serviceaccount:prod:default -n prod → no
// 错: claims["exp"] = 4102444800 // 2100-01-01, 一发终身 // → 离职员工的 token 三年后仍然有效 // 对: exp = now + 15m → 过期返回: token has invalid claims: token is expired
// 错: log.Printf("login req: %+v", req) → 手机号/密码全量落盘 // 对: log.Printf("login req: phone=%s", mask(phone)) // → 输出 138****5678, 合规与排障两不误
# 错: 证书静默过期 → curl: (60) SSL certificate problem: certificate has expired # 对: cert-manager 自动续签; 告警规则基于 blackbox_exporter 指标 # probe_ssl_earliest_cert_expiry < 30*86400 (剩余秒数) 即触发
HttpOnly + Secure + SameSite Cookie, JS 读不到。 // 错: localStorage.setItem('jwt', token) → XSS 一行代码打包带走 // 对: Set-Cookie: jwt=...; HttpOnly; Secure; SameSite=Strict // → document.cookie 读不到, XSS 拿不走
// 错: md5.Sum([]byte(pwd)) → 彩虹表 0.3 秒反查 // 对: bcrypt.GenerateFromPassword(pwd, 12) → "$2a$12$..." // 校验: bcrypt.CompareHashAndPassword(hash, pwd) == nil
* 与 credentials 共存, 这样配等于对全网开放. 正解: Origin 白名单回显 + Vary: Origin。 # 错: Allow-Origin: * + Allow-Credentials: true → 浏览器拒绝 # 报错: The value of 'Access-Control-Allow-Origin' header must not # be the wildcard '*' when the request's credentials mode is 'include'. # 对: 白名单命中才回显 Origin, 并加 Vary: Origin
// 错: 逐路由组手动 Use(auth) → 新增 /internal/* 组忘挂 // → 扫描器直接触发内部重算接口 // 对: 默认全局 auth + 显式白名单(/healthz) → 未挂组一律 401
--build-arg/ENV 随构建命令进层元数据, 删了文件层还在. 正解: 运行时注入(Vault Agent / secret 挂载), 镜像零密钥。 # 错: docker build --build-arg DB_PASS=Pr0d! → history --no-trunc 原样可见 # 层里删了文件也没用, 元数据仍带密码 # 对: 运行时 Vault Agent 渲染 /vault/secrets/config, 镜像内零密钥
-- 错: 审计表复用业务账号 → 有写权限即可抹掉记录 -- 对: GRANT INSERT ON audit.* TO 'audit_writer'; 无 UPDATE/DELETE -- prev_hash 链: 删中间一条, 断链告警立刻响
// 错: 登出只清 localStorage → 泄漏 token 继续有效到 exp // 对: SET blacklist:<jti> 1 EX 812 # TTL=剩余寿命秒数 // 中间件验签后查黑名单, 命中即 401
# 错: test 的 application.properties 与 prod 共用同一个 DB 密码 # → 测试机被入侵 = 生产库门户洞开 # 对: 环境强隔离: prod 用 database/creds/prod-rw, test 独立凭据
// 错: /oauth/callback 只拿 code 换 token → 攻击者可注入他的 code // 受害者"登录成功", 实际绑的是攻击者的第三方账号 // 对: state 随机入 session, 回调不一致直接 403
# 错: IAM 里 kms:* 授给所有人 → 加密形同虚设 # 对: key policy 只允许 orders-role 调 Decrypt, EnableKeyRotation=true # CloudTrail 里每一条 Decrypt 都能对到具体角色