Skip to content

身份与安全边界 ​

问题:知道资源 ID 就能修改吗 ​

认证回答调用者是谁,授权回答他是否能执行这个动作。即使 UUID 很难猜,只按 ID 查任务也可能跨用户泄露。必须在拥有者范围内读取和修改,并确认动作条件。

浏览器用户、业务应用和执行 Worker 身份不同,凭据寿命、协议和信任边界也不同。把一把超级 API Key 复制给所有角色会放大泄露影响。

原理:每个入口建立 Principal ​

边界验证凭据后生成明确 Principal,再把受信归属传给应用用例。业务应用只操作自己的任务;Worker 只上报属于其有效 Attempt 与租约的事件;管理员会话只从管理入口使用。

Cookie 会被浏览器自动携带,因此写操作需要 CSRF 防护等策略。Bearer/API Key 通常显式附加,但仍需保护传输、保存与日志。CORS 不是完整认证或 CSRF 方案。

最小例子:在范围内查,而后决策 ​

sql
-- 教学查询:应用身份来自已验证的 Principal,不取请求体中的任意值。
SELECT id, status, version FROM tasks
WHERE id = $1 AND application_id = $2;

本例只说明归属过滤,还需要请求认证和业务动作规则。将查不到返回 404 可以减少存在性暴露,具体错误语义按接口约定。

方案比较:来源可信与内容可靠 ​

机制用途不能代替
会话 / API Key验证调用身份对具体资源授权
CSRF token防自动携带 Cookie 的跨站写入身份与业务权限
HMAC 签名+时间窗验证回调来源、限制重放时间Inbox 去重和序列规则
短期资源授权限定对象与操作资源完整性验证

签名应覆盖原始消息字节及必要时间信息,接收端用常量时间比较。重新序列化 JSON 后再验签可能改变空白与字段顺序。

真实案例:三个 Listener 与回调签名 ​

Server 分离 Public、Worker、Management 入口。管理前端在非只读请求附加 CSRF token,401 清理会话并跳转登录;接口写入还附带幂等键。这两类 token 各有责任。

App Demo 的回调签名格式含时间戳与摘要,允许正负五分钟窗口,以 HMAC-SHA256 验证时间戳与原始 body。随后再检查事件结构与应用归属。

已核对的实现 · 本地代码快照

CSRF、错误与幂等请求 frontend · b0308f40

src/api/client.ts · 第 1–60 行
符号:http / write · 核对日期 2026-10-02
来源与提交版本一致

import axios from "axios";
import { user } from "@/auth";
import type { Resource, ResourcePage } from "./types";
export const http = axios.create({ baseURL: "/api/v1", timeout: 20000 });
http.interceptors.request.use((c) => {
  if (user.value && !["get", "head", "options"].includes(c.method || "get"))
    c.headers["X-CSRF-Token"] = user.value.csrf_token;
  return c;
});
export function errorText(e: unknown): string {
  if (axios.isAxiosError(e)) {
    const p = e.response?.data;
    return (
      (p?.detail || p?.title || e.message) +
      (p?.request_id ? `(请求 ${p.request_id})` : "")
    );
  }
  return e instanceof Error ? e.message : String(e);
}
http.interceptors.response.use(
  (r) => r,
  async (e) => {
    if (e.response?.status === 401) {
      user.value = null;
      if (!location.pathname.endsWith("/login")) location.assign("/login");
    }
    return Promise.reject(e);
  },
);
export async function get<T>(
  path: string,
  params?: object,
  signal?: AbortSignal,
) {
  return (await http.get<T>(path, { params, signal })).data;
}
export async function write<T = Resource>(
  path: string,
  body: unknown,
  key: string,
  method: "post" | "put" | "patch" | "delete" = "post",
) {
  return (
    await http.request<T>({
      url: path,
      method,
      data: body,
      headers: { "Idempotency-Key": key },
    })
  ).data;
}
export async function choices(path: string) {
  const items: Resource[] = [];
  for (let page = 1; ; page++) {
    const p = await get<ResourcePage>(path, { page, page_size: 200 });
    items.push(...p.items);
    if (items.length >= p.total || p.items.length === 0) return items;
  }
}

片段展示核对时的源码;完整文件指纹用于检测后续变化。这里的路径用于定位,不要求手机访问源码仓库。

会话与登录状态 frontend · b0308f40

src/auth.ts · 第 2–46 行
符号:authReady · 核对日期 2026-10-02
来源与提交版本一致

export interface AdminSession {
  principal_id: string;
  email: string;
  csrf_token: string;
  expires_at: string;
}
export const user = ref<AdminSession | null>(null);
export const authError = ref("");
export const isAuthenticated = computed(
  () => !!user.value && Date.parse(user.value.expires_at) > Date.now(),
);
export const authReady = fetch("/api/v1/auth/session", {
  credentials: "same-origin",
  cache: "no-store",
})
  .then(async (r) => {
    if (r.status === 401) {
      user.value = null;
      return;
    }
    if (!r.ok) throw new Error("无法读取登录会话,请检查服务状态");
    user.value = await r.json();
  })
  .catch((e) => {
    authError.value = e instanceof Error ? e.message : String(e);
    user.value = null;
  });
export function login(returnTo = "/overview") {
  const target = new URL("/api/v1/auth/login", location.origin);
  target.searchParams.set("return_to", returnTo);
  location.assign(target.pathname + target.search);
}
export async function logout() {
  if (user.value) {
    const r = await fetch("/api/v1/auth/logout", {
      method: "POST",
      credentials: "same-origin",
      headers: { "X-CSRF-Token": user.value.csrf_token },
    });
    if (!r.ok) throw new Error("退出失败,请重试");
  }
  user.value = null;
  location.assign("/login");
}

片段展示核对时的源码;完整文件指纹用于检测后续变化。这里的路径用于定位,不要求手机访问源码仓库。

Callback 校验、去重与缺口 app-demo · c383860e

internal/core/task/application/callback.go · 第 26–109 行
符号:ReceiveCallback / VerifyCallbackSignature · 核对日期 2026-10-02
来源与提交版本一致

func (s *Service) ReceiveCallback(ctx context.Context, input taskdomain.CallbackInput, applicationID string, discard bool, now time.Time) error {
	if input.SpecVersion != "1.0" || input.Sequence < 1 || input.EventID == "" || input.Type == "" {
		return ErrInvalidCallback
	}
	if parsed, err := uuid.Parse(input.EventID); err != nil || parsed == uuid.Nil {
		return ErrInvalidCallback
	}
	if input.ApplicationID != "" && input.ApplicationID != applicationID {
		return ErrInvalidCallback
	}
	if parsed, err := uuid.Parse(input.ExecutionID); err != nil || parsed == uuid.Nil {
		return ErrInvalidCallback
	}
	if input.TaskID != "" {
		if parsed, err := uuid.Parse(input.TaskID); err != nil || parsed == uuid.Nil {
			return ErrInvalidCallback
		}
	}
	if input.ExternalRef == "" {
		order, err := s.repository.GetOrderByExecution(ctx, input.ExecutionID)
		if err != nil {
			return ErrInvalidCallback
		}
		input.ExternalRef = order.ID
	}
	if parsed, err := uuid.Parse(input.ExternalRef); err != nil || parsed == uuid.Nil {
		return ErrInvalidCallback
	}
	if input.Status != "" && !map[string]bool{"CREATED": true, "QUEUED": true, "RUNNING": true, "SUCCEEDED": true, "FAILED": true, "TIMED_OUT": true, "CANCELING": true, "CANCELED": true}[input.Status] {
		return ErrInvalidCallback
	}
	if discard {
		s.logger.Warn("demo discarded callback after validation", "event_id", input.EventID, "execution_id", input.ExecutionID, "sequence", input.Sequence)
		return nil
	}
	progress := 0
	if input.Progress != nil {
		progress = *input.Progress
	}
	_, gap, err := s.repository.AcceptCallback(ctx, taskports.CallbackEvent{
		ID:          input.EventID,
		TaskID:      input.TaskID,
		ExecutionID: input.ExecutionID,
		Type:        input.Type,
		Sequence:    input.Sequence,
		Body:        append([]byte(nil), input.RawBody...),
		ReceivedAt:  now,
	}, input.ExternalRef, input.Status, int32(progress), input.Data, now)
	if gap {
		s.logger.Warn("callback sequence gap", "order_id", input.ExternalRef, "execution_id", input.ExecutionID, "sequence", input.Sequence)
	}
	return err
}

func VerifyCallbackSignature(header string, body, secret []byte, now time.Time) error {
	timestampPart, signaturePart, ok := strings.Cut(header, ",")
	if !ok {
		return ErrInvalidCallbackSignature
	}
	rawTimestamp, okTimestamp := strings.CutPrefix(timestampPart, "t=")
	rawSignature, okSignature := strings.CutPrefix(signaturePart, "v1=")
	if !okTimestamp || !okSignature {
		return ErrInvalidCallbackSignature
	}
	timestamp, err := strconv.ParseInt(rawTimestamp, 10, 64)
	if err != nil {
		return ErrInvalidCallbackSignature
	}
	if delta := now.Sub(time.Unix(timestamp, 0)); delta < -5*time.Minute || delta > 5*time.Minute {
		return ErrExpiredCallback
	}
	received, err := hex.DecodeString(rawSignature)
	if err != nil {
		return ErrInvalidCallbackSignature
	}
	mac := hmac.New(sha256.New, secret)
	_, _ = fmt.Fprintf(mac, "%d.", timestamp)
	_, _ = mac.Write(body)
	if !hmac.Equal(received, mac.Sum(nil)) {
		return ErrInvalidCallbackSignature
	}
	return nil
}

片段展示核对时的源码;完整文件指纹用于检测后续变化。这里的路径用于定位,不要求手机访问源码仓库。

当前项目的身份体系不要泛化成完整角色权限系统。课程讲通用授权原则,案例只描述代码实际提供的边界。

失败与边界:安全失败不能当网络抖动 ​

认证或授权拒绝需要修复身份/权限,不能无限重试。签名时间窗依赖时钟;时间偏差应有监测。已签名 URL、密钥、Cookie 和外部响应体不能进入正常日志。

回调地址由外部输入决定时,还需 SSRF 防护、协议与目标限制;签名不解决服务器主动访问恶意目标的问题。压缩包路径与受控命令模板是文件、操作系统层面的另一类边界。

迁移练习与参考答案 ​

练习:多租户导出服务用管理员 Cookie 登录,导出回调带签名。是否已经足够安全?

参考答案:还要按租户范围检查资源与动作,写接口验证 CSRF,回调验证原始字节、时间窗、事件归属与去重;文件下载最小权限短期授权。后台 Worker 凭据应独立于管理员会话。登录和签名不能替代这些检查。

继续阅读:资源授权、API 契约。

理解原理 · 分析取舍 · 用真实代码检验