切换主题
身份与安全边界
问题:知道资源 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 凭据应独立于管理员会话。登录和签名不能替代这些检查。