系统架构 · 安全与访问控制

认证回答"你是谁", 授权回答"你能干啥" — 密钥不该出现在代码和镜像里: 要么锁进 KMS, 要么短命到泄漏也无害

安全与访问控制 · 请求安全流水线 / mTLS 互认 / 密钥治理 认证答"你是谁", 授权答"你能干啥" — 每一站都可能说 No https Bearer user 调用 验签失败 401 策略拒绝 403 异步追加 ① 请求安全流水线 — 每一站都可能把你拦下 顺序不可换: 先认证后授权, 都过了才配得上"放行" 客户端 App · curl · 服务A 出示凭证, 等待放行 无状态, 可水平扩 TLS 终结 证书卸载 · 网关/LB 明文到此为止 内网还有下一段 mTLS 认证 AuthN JWT 验签 · exp/iss/aud 只答「你是谁」 失败 → 401 授权 AuthZ RBAC 角色 · ABAC 属性 答「你能干啥」 失败 → 403 业务服务 身份从 ctx 取 user_id 来自 token 不信参数传的身份 401 Unauthorized 无效 / 过期 / 被篡改 403 Forbidden 已登录但权限不够 审计日志 append-only · 防抵赖 ① ClientHello + 客户端证书 ② 服务端证书 + 验客户端证书 ③ 双向都过验, 会话密钥协商完成 ② mTLS 双向握手 — 机器的"认证": 互相出证, 谁也别想冒充 事故现场: 证书轮换脚本漏了一台 node2 的 sidecar, 第二天凌晨整批调用集体失败 → x509: certificate has expired or is not yet valid: current time 2026-09-26T03:14:07Z is after 2026-09-25T23:59:59Z 调用方 Pod 持 CA 签的客户端证书 服务端 Sidecar 证书由 Istio 统一管 JWT = header . payload . signature (Base64URL 三段) eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMDAxIiwiZXhwIjoxNzcwIn0.rtlX9fQ... ① header {"alg":"RS256"} — 声明算法, 验签端必须白名单校验 ② payload {"sub":"1001","exp":1770} — 只编码不加密, 别放敏感数据 ③ signature 服务端私钥签 — payload 改一个字节都过不了验签 经典攻击: 把 alg 改成 none + 删签名, 骗过不校验算法的库 → 全线失守 ③ 两条路 — 静态密钥的宿命 vs 动态密钥的解法 密钥要么不出保险柜(KMS), 要么短命(Vault 动态凭据) ① 硬编码 + 提交 AK/SK 写在 config.go 里 git push 进入历史 ② 永久留痕 revert 删不掉历史 fork / clone 全都有 ③ 被人拿到 gitleaks 扫描 / 内鬼 / 撞库 别赌没人看公开仓库 ④ 资损与外泄 陌生 IP 拉满对象存储 一夜 4TB 外发 + 账单爆炸 事故现场: gitleaks report — finding: aws-access-token · file: config/prod.go · commit: 3f9c2ab · 距提交已 89 天 ① 应用启动 AppRole 登录 Vault 只有 role_id + secret_id ② 动态凭据签发 vault read database/creds 1 小时 TTL 临时账号 ③ 短命账号连库 v-app-a1b2 只写 1 小时 泄漏半径 ≤ 1h ④ 到期自动销毁 Vault 自动 REVOKE 账号 审计里查无此人 破局效果: root 密码从 200 台机器的配置里消失; 拿到的账号 1 小时后连库都登不进 — 最安全的密码, 是不存在的密码 ● rose = 事故路径 · emerald = 正确路径 · cyan = 入口 · amber = 运行时边界 · violet = 数据/审计 · slate = 中性 读法: 上半张 = 一条请求的五道安检(401/403 拒在半路); 中间 = 机器对机器的互认; 下半张 = 同一个密钥的两种命运

机制视角 — 五道闸一条链

  • • 请求流水线: TLS 终结 → 认证(401) → 授权(403) → 业务 → 审计, 顺序不可换
  • • 认证与授权解耦: 前者答身份, 后者答权限, 失败码语义不同
  • • mTLS 是"机器的认证", JWT 是"用户的认证", 服务间与用户态两层互不替代
  • • 密钥是数据, KMS 是锁匠, Vault 是按小时发钥匙的前台

行为视角 — 凭证的生命周期

  • • 静态密钥必然泄漏, 问题只是时间与代价, 别赌运气
  • • 动态凭据 TTL 到点销毁, 泄漏半径从"永久"压到"1 小时"
  • • JWT 短有效期 + refresh 可撤销, 失窃损失封顶 15 分钟
  • • 证书一定会过期: 没有监控的证书是定时炸弹

生产价值 — 纵深防御

  • • 没有单点"过审": 每层独立校验, 一层失守不至全灭
  • • 最小权限限制爆炸半径: 拿下一个 Pod ≠ 拿下整个集群
  • • 审计日志让抵赖不可能: 谁、何时、对什么、结果如何
  • • CI 密钥扫描门禁把事故拦在 git push 之前

💡 一句话理解

把安全流程想成机场: 认证是安检口验护照(你是谁), 授权是登机口查登机牌(你上哪班机), TLS 是廊桥(半路没人能碰你行李), Secret 管理是地勤配钥匙的流程(钥匙绝不能贴在墙上)。任何一环偷懒, 前面全白做——安全是一条流水线, 不是一扇门。

工程上三条铁律藏在里面: 先认证后授权(401 与 403 语义不同); 网络位置不是信任凭证(零信任: 内网也要 mTLS + 验签); 密钥的宿命是泄漏, 所以要么让密钥永不出保险柜(KMS 信封加密), 要么让它短命到泄漏也无害(Vault 动态凭据, TTL 1 小时)。纵深防御的意义: 任何单层失守, 都不至全灭。

🧠 必知必会 必考 & 必会

Authentication 认证
回答"你是谁": 校验凭证的真实性(JWT 签名/密码/证书), 失败返回 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 密钥混淆)
Authorization 授权
回答"你能干啥": 基于角色/属性/关系对已认证身份做决策, 失败返回 403 Forbidden。401 是"没认出你", 403 是"认出你了但你不行"——对外接口常统一回 404, 免得泄露资源是否存在。
o, _ := repo.ByID(r.Context(), id)
if o.UserID != uid(r) {
    http.Error(w, "forbidden", 403)  // → Forbidden: 不是你的对象
}  // 关键: 对象级授权要落到每一行数据, "已登录"≠"都能看"
RBAC 角色授权
权限挂在角色上, 人领角色: 用户→角色→权限三层解耦, 授权审计变成"谁有什么角色"。代价是角色爆炸(role explosion)——一千个人有一千种细分需求时, 角色矩阵会膨胀到没人敢动。
# K8s Role: 只读 Pod 的最小规则
rules:
- apiGroups: [""]               # 核心组
  resources: ["pods"]
  verbs: ["get", "list", "watch"]  # 关键: verbs 无 delete, 能看不能删
ABAC 属性授权
决策依据属性而非角色: 用户部门、资源标签、时间、IP 都参与计算, 适合"同部门才能看同部门单据"这类 RBAC 表达不了的规则。代价: 策略分散难审计, 通常收敛到 OPA 这类策略引擎统一管理。
# OPA Rego 0.x 语法; OPA 1.0+ 需写 allow if {...}
allow {
    input.user.dept == input.resource.dept
}
# 关键: 规则是数据驱动的, 换部门不用改一行业务代码
JWT 结构与验签
三段 Base64URL 用点相连: header(算法) . payload(claims) . signature。payload 只是编码不是加密, 任何持有者都能解出内容; 安全性全在第三段签名。生态: 校验常用 golang-jwt, 需要签名/加密一体时用 jose。
parts := strings.Split(token, ".")
// parts[1] base64 解开 → {"sub":"1001","role":"user"}  谁都读得到
// 关键: JWT 是"签名"不是"加密", 手机号/身份证放进去=裸奔
过期与刷新 Token
access token 短命(15 分钟)限制作恶窗口, refresh token 长命(7~14 天)但只在专门端点使用且可撤销。泄漏一枚 access token 最多横行 15 分钟, 而永不过期的 token 是发给攻击者的终身会员卡。
claims["exp"] = time.Now().Add(15 * time.Minute).Unix()
// 关键: access 15m + refresh 7d 可撤销, 失窃损失上限 15 分钟
// → 过期后校验返回: token has invalid claims: token is expired
TLS 与 mTLS
TLS 只验证服务端(浏览器模式), mTLS 双向出证书——服务端也验客户端, 适合服务间互认。微服务里证书常由 Sidecar 统一持有并自动轮换, 业务进程完全无感知。
# mTLS: 客户端也要出示自己的证书和私钥
curl --cert client.crt --key client.key --cacert ca.crt \
     https://orders.prod.svc:8443/ping
# 关键: 服务端证书 + 客户端证书两把都过验, 握手才成立
Secret Management
密钥的集中保管与按需分发: 应用不落盘、不进镜像, 启动时从 Vault(KV v2) 或 K8s Secret 拉取、只在内存持有。注意 K8s Secret 本质只是 base64 的 etcd 数据——要配 RBAC 最小权限 + etcd 静态加密才算"密"。
vault kv put secret/app/db password=S3cret   # KV v2: 版本化可回滚
vault kv get -field=password secret/app/db   # → S3cret
# 关键: base64 不是加密, 有 get 权限就等于明文
动态密钥 Dynamic Secrets
凭证按需生成、到点销毁: 每个实例拿到的是"只属于它的临时账号", TTL 一到 Vault 自动 REVOKE。泄漏半径从"永久"压缩到"TTL", 数据库里查无此人是动态密钥的浪漫。
vault read database/creds/app-rw
# → username: v-root-app-rw-BxZq-1695700000
#   password: A1a-xxxx-xxxx   lease_duration: 1h
# 关键: 这账号 1 小时后不存在, 泄了也白泄
KMS 密钥管理服务
云上的"保险柜+锁匠": 主密钥永不出 KMS, 应用只把数据送进去加密/解密; 配合信封加密(数据密钥加密数据, 主密钥加密数据密钥)可低成本加密海量数据。每一条 Decrypt 都有审计记录, 谁解了什么一查便知。
aws kms encrypt --key-id alias/app-master \
    --plaintext fileb://card.bin --output text --query CiphertextBlob
# 关键: 主密钥不出 KMS, 解密调用全部留痕可审计
最小权限 PoLP
Principle of Least Privilege: 只授予完成任务所必需的最小权限集合, 它是对抗"横向移动"的核心——攻进一个 Pod 不等于拿下整个集群。实践节奏是"先给最小, 报权限错误再加", 而不是"先给 * 再收敛"。
# K8s Role verbs: 一把梭 vs 最小
verbs: ["*"]                      # 错: 万能钥匙, 审计报告永远先点它
verbs: ["get", "list", "watch"]   # 对: 能看不能改, 够用就好
# 关键: 报权限错误再加, 好过出了事再收
Zero Trust 零信任
"默认不信任任何人": 内外网一视同仁, 每次访问都要认证+授权+加密, 不存在"进来就安全"的内网。口号是 never trust, always verify——内网裸奔是它反面教材的常驻第一名。
// 内网调用也要带 token + mTLS, 不因为是"内网"就免检
if !mtlsVerified || !tokenValid {
    return 401  // → Unauthorized: 内网不是免检通道
}  // 关键: 信任不取决于网络位置, 取决于每一次验证
Audit Log 审计日志
记录"谁在何时对什么做了什么、结果如何"的 append-only 流水, 是事后追责与合规的底牌。三要素: 不可篡改(独立存储 + hash 链)、含身份(取自认证的 user_id 而非请求参数)、含结果(allow/deny 都记)。
INSERT INTO audit_log(actor, action, object, result, prev_hash)
VALUES ('u:1001', 'order.refund', 'o:8842', 'deny', 'a3f9...');
-- 关键: prev_hash 逐条链起, 删改中间一条立刻断链可查

🏭 生产实战 real world

场景 1 · 审计报告点名 47 个 Pod 挂着 cluster-admin, 一周内全部收紧

老服务当年图省事直接绑 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"。

场景 2 · 200 个老服务从"内网明文"迁到 mTLS STRICT, 灰度两周零事故

直接全网 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, 渗透报告该项清零。

场景 3 · 把 root 数据库密码从 200 台机器的配置文件里撤下来

静态 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 侧都能对到具体实例。

场景 4 · 登录态 token 从"一发管 30 天"改到"15 分钟", 盗号损失封顶

以前 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 半小时后就是废纸。

场景 5 · 30 份复制粘贴的鉴权代码, 收敛到网关一次做完

每个服务各写一份 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 行, 安全基线从"各团队自觉"变成"入口统一配置"。

场景 6 · 客服反馈"改个 ID 就能看到别人的订单" — IDOR 专项整改

接口只查了登录态没查归属, 水平越权一改一个准。修法: 查询条件强制绑定 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 类漏洞再没复发。

场景 7 · 监管要求"操作可追溯不可抵赖": append-only 审计 + hash 链

审计表和业务库同权限, 出事时关键记录总会"恰好缺失"。审计账号只给 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 对不上, 断链告警立刻响

过等保测评时, "运维能否删除审计记录"这一项直接给了满分证据。

场景 8 · 支付回调验签密钥轮换: 双密钥共存窗口, 全程零停机

直接换密钥 = 切换瞬间旧签名全部验败, 对账当场炸锅。轮换走三步: 新钥签发 → 双钥验签共存 → 旧钥下线。

// 验签侧: 当前钥 + 上一把共存, 按 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 小时, 期间成功率曲线平稳如常——密钥轮换从"心理负担"变成"例行动作"。

场景 9 · "内网还要加密? " — 办公网被拖库后的零信任改造

跳板机被钓到办公网凭证, 攻击者在"可信内网"横向两小时拖走三张表。内网服务全部补上 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 握手就被拒

复测时攻击队拿着偷来的密码原地打转——没有证书, 连握手都过不去。

场景 10 · 又一次"密码进了 git", CI 上 gitleaks 门禁一次性卡死

上一次靠人眼在 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 位十六进制串。

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 密钥硬编码提交进 git, 历史永久留痕 — 症状: 泄漏告警在提交后 89 天才来. 原因: revert 只删 HEAD, 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 清历史
坑 2 · JWT 不校验签名, 伪造 token 畅通无阻 — 症状: 把 payload 里 role 改成 admin 也能登录. 原因: 手写 base64 解析直接用 payload, 或验签没限定算法. 正解: 库验签 + WithValidMethods 算法白名单。
// 错: strings.Split(token,".")[1] 解 payload 直接用 → 改 role 即管理员
// 对: jwt.Parse(raw, kf, jwt.WithValidMethods([]string{"RS256"}))
//    → 伪造签名返回: signature is invalid
坑 3 · 权限只藏前端按钮, 接口裸奔 — 症状: 页面上没有删除按钮, 用 curl 直调接口照样删成功. 原因: 前端隐藏只是 UX, 接口没做服务端校验. 正解: 每个接口服务端鉴权, 前端隐藏只是体验优化。
// 错: {user.role==='admin' && <button>删除</button>} — 接口毫无校验
// 对: DELETE /api/users/42 服务端再验一次 role
//    → 无权限返回 403 Forbidden
坑 4 · 内网裸奔明文 HTTP — 症状: 渗透报告"内网交换机上可嗅探完整 token". 原因: "内网=可信"的假设在跳板机被入侵后全线崩塌. 正解: 服务间 mTLS(STRICT) + 内网请求同样带凭证, 零信任。
# 错: curl http://orders.internal/api/orders → token 在交换机上裸奔
# 对: PeerAuthentication mtls STRICT → 明文连接被 sidecar 直接 reset
#    (curl 报: curl: (56) Recv failure: Connection reset by peer)
坑 5 · 长期静态 AK/SK 永不轮换 — 症状: 离职员工半年后还能拉生产数据. 原因: AK 无 TTL 无轮换, 权限随时间只增不减. 正解: STS 临时凭证/动态密钥, 加定期自动作废。
# 错: LTAI5t... 2019 年创建, 从未轮换, 权限还是 Administrator
# 对: STS AssumeRole 签临时凭证, 有效期 1h
#    → 到期后拿它连云控制台/API 都是 AccessDenied
坑 6 · 水平越权 IDOR: 改 id 就能看别人订单 — 症状: /api/orders/8842 换成 8843 读到别人的单. 原因: 只做了功能级"是否登录", 没做对象级"是不是你的". 正解: 查询条件强制绑定 owner, 或对象级 ACL 检查。
// 错: SELECT * FROM orders WHERE id=8843        → 别人的单也返回
// 对: SELECT * FROM orders WHERE id=8843 AND user_id=1001
//    → 空结果 + 404, 连"这单存在"都不告诉攻击者
坑 7 · serviceAccount 挂 cluster-admin — 症状: 拿下一个可 exec 的 Pod 就能删全集群资源. 原因: Pod 的 SA token 是 cluster-admin, 权限天花板给到了集群级. 正解: 每个 SA 最小 Role, 默认 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
坑 8 · token 永不过期 — 症状: 三年前离职员工的链接还能打通接口. 原因: 签发时没设 exp, 或 exp 写成 2099 年"图省事". 正解: access 短命 15m + refresh 可撤销, 中间件强制校验 exp。
// 错: claims["exp"] = 4102444800   // 2100-01-01, 一发终身
//     → 离职员工的 token 三年后仍然有效
// 对: exp = now + 15m → 过期返回: token has invalid claims: token is expired
坑 9 · 日志打印手机号/密码明文 — 症状: 日志平台按手机号能搜出用户记录, 等保测评直接不过. 原因: debug 打印整个 request body 没脱敏. 正解: 日志脱敏中间件, 序列化时掩码。
// 错: log.Printf("login req: %+v", req) → 手机号/密码全量落盘
// 对: log.Printf("login req: phone=%s", mask(phone))
//    → 输出 138****5678, 合规与排障两不误
坑 10 · 证书过期没人监控, 凌晨集体故障 — 症状: 半夜 mTLS 握手批量失败, 全链路 5xx. 原因: 证书没进续期清单, 无剩余有效期告警. 正解: cert-manager 自动续期 + 过期前 30 天告警。
# 错: 证书静默过期 → curl: (60) SSL certificate problem: certificate has expired
# 对: cert-manager 自动续签; 告警规则基于 blackbox_exporter 指标
#    probe_ssl_earliest_cert_expiry < 30*86400 (剩余秒数) 即触发
坑 11 · JWT 放 localStorage 被 XSS 一锅端 — 症状: 一个营销页的 XSS, 全站用户 token 被打包带走. 原因: localStorage 对页面 JS 完全透明, XSS 即 token 失守. 正解: HttpOnly + Secure + SameSite Cookie, JS 读不到。
// 错: localStorage.setItem('jwt', token) → XSS 一行代码打包带走
// 对: Set-Cookie: jwt=...; HttpOnly; Secure; SameSite=Strict
//    → document.cookie 读不到, XSS 拿不走
坑 12 · 密码用 MD5 存储 — 症状: 拖库后彩虹表秒破八成密码. 原因: MD5 是摘要不是 KDF, GPU 每秒可算百亿次. 正解: bcrypt/argon2id + 每用户随机 salt。
// 错: md5.Sum([]byte(pwd)) → 彩虹表 0.3 秒反查
// 对: bcrypt.GenerateFromPassword(pwd, 12) → "$2a$12$..."
//    校验: bcrypt.CompareHashAndPassword(hash, pwd) == nil
坑 13 · CORS 配 * 还带凭证 — 症状: 带登录态的跨域请求被浏览器直接拒绝, 前端联调报错. 原因: 规范禁止 * 与 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
坑 14 · 管理接口忘挂鉴权中间件 — 症状: /api/internal/recalc 被扫描器直接触发执行. 原因: 鉴权靠"每个路由组记得挂", 新接口忘了就裸奔. 正解: 默认拒绝——全局统一挂鉴权, 白名单显式放行健康检查。
// 错: 逐路由组手动 Use(auth) → 新增 /internal/* 组忘挂
//     → 扫描器直接触发内部重算接口
// 对: 默认全局 auth + 显式白名单(/healthz) → 未挂组一律 401
坑 15 · Secret 烧进镜像层 — 症状: 在镜像仓库的构建历史里翻出生产密码. 原因: --build-arg/ENV 随构建命令进层元数据, 删了文件层还在. 正解: 运行时注入(Vault Agent / secret 挂载), 镜像零密钥。
# 错: docker build --build-arg DB_PASS=Pr0d! → history --no-trunc 原样可见
#     层里删了文件也没用, 元数据仍带密码
# 对: 运行时 Vault Agent 渲染 /vault/secrets/config, 镜像内零密钥
坑 16 · 审计日志可被运维随手删 — 症状: 事故复盘时关键时段审计记录缺失. 原因: 审计表与业务库同账号同权限, 能写就能 UPDATE/DELETE. 正解: 独立账号只给 INSERT + 独立存储 + hash 链校验。
-- 错: 审计表复用业务账号 → 有写权限即可抹掉记录
-- 对: GRANT INSERT ON audit.* TO 'audit_writer';  无 UPDATE/DELETE
--     prev_hash 链: 删中间一条, 断链告警立刻响
坑 17 · 退出登录 token 不生效 — 症状: 用户改密/登出后, 旧 token 仍能调接口到自然过期. 原因: JWT 无状态, 服务端不知道"这枚已废". 正解: jti 黑名单(Redis, TTL=剩余寿命) + 改密全设备失效。
// 错: 登出只清 localStorage → 泄漏 token 继续有效到 exp
// 对: SET blacklist:<jti> 1 EX 812   # TTL=剩余寿命秒数
//     中间件验签后查黑名单, 命中即 401
坑 18 · 测试环境密钥与生产相同 — 症状: 测试服务的日志平台里搜出生产密码. 原因: "方便调试"共用了同一个 DB 密码, 测试环境防护弱等于给生产开后门. 正解: 环境间密钥强隔离, 测试环境也走 Vault。
# 错: test 的 application.properties 与 prod 共用同一个 DB 密码
#     → 测试机被入侵 = 生产库门户洞开
# 对: 环境强隔离: prod 用 database/creds/prod-rw, test 独立凭据
坑 19 · OAuth 回调不校验 state — 症状: 用户"被绑定"上攻击者的第三方账号(login CSRF). 原因: 授权码回调时没验 state, 攻击者把自己的 code 塞给受害者. 正解: state 随机生成入 session, 回调必验。
// 错: /oauth/callback 只拿 code 换 token → 攻击者可注入他的 code
//     受害者"登录成功", 实际绑的是攻击者的第三方账号
// 对: state 随机入 session, 回调不一致直接 403
坑 20 · KMS 只加密不控权限 — 症状: 数据加了密还是泄漏, 因为人人都有 Decrypt 权限. 原因: 把 KMS 当"加密按钮", 忘了它同时是权限系统——谁能解密才是关键. 正解: key policy 按服务最小授权 + 开启自动轮换 + Decrypt 全审计。
# 错: IAM 里 kms:* 授给所有人 → 加密形同虚设
# 对: key policy 只允许 orders-role 调 Decrypt, EnableKeyRotation=true
#    CloudTrail 里每一条 Decrypt 都能对到具体角色