feat: 负责人下钻视图+项目负责人编辑管理+企微授权自助绑定
- 任务/项目负责人 union scope:任务中心分组视图、工作台项目进度卡、收录验证列 - 项目负责人可管理自己项目:编辑/删除项目、编辑任务、分配负责人、推送、项目内建任务;堵死转移归属/越权新建/任务迁移 - 只读依赖放开给已登录:inspect_feishu、wecom_list_internal_contacts - 企微 OAuth 自助绑定:/api/wecom-bind(state 签名校验+登录态一致性+归属冲突检查),未绑定 banner 增加入口 - 工作台项目进度卡与未绑定 banner 排版修复 - 修 user-auth 判空分支被 Turbopack dev 折叠删除导致匿名请求 500
This commit is contained in:
@@ -156,6 +156,71 @@ export function wecomClaimantPartnerId(externalUserId: string): string {
|
||||
return `partner-ext-${hashText(`claimant:wecom:${externalUserId}`)}`;
|
||||
}
|
||||
|
||||
export const WECOM_BIND_STATE_TTL_MS = 10 * 60 * 1_000;
|
||||
|
||||
type WecomBindStatePayload = {
|
||||
v: 2;
|
||||
uid: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
jti: string;
|
||||
};
|
||||
|
||||
export async function signWecomBindState(
|
||||
userId: string,
|
||||
options: { secret?: string; nowMs?: number; ttlMs?: number } = {},
|
||||
): Promise<string> {
|
||||
const secret = options.secret ?? getWecomOAuthSecret();
|
||||
const issuedAt = options.nowMs ?? Date.now();
|
||||
const ttl = options.ttlMs ?? WECOM_BIND_STATE_TTL_MS;
|
||||
const uid = String(userId ?? "").trim().slice(0, 64);
|
||||
if (!uid) throw new Error("缺少后台账号 ID,无法发起企微绑定");
|
||||
const payload: WecomBindStatePayload = {
|
||||
v: 2,
|
||||
uid,
|
||||
iat: issuedAt,
|
||||
exp: issuedAt + ttl,
|
||||
jti: toBase64Url(crypto.getRandomValues(new Uint8Array(12))),
|
||||
};
|
||||
const payloadSegment = toBase64Url(
|
||||
new TextEncoder().encode(JSON.stringify(payload)),
|
||||
);
|
||||
const signature = await hmacSha256(secret, payloadSegment);
|
||||
return `${payloadSegment}.${toBase64Url(signature)}`;
|
||||
}
|
||||
|
||||
export async function verifyWecomBindState(
|
||||
state: string,
|
||||
options: { secret?: string; nowMs?: number } = {},
|
||||
): Promise<{ userId: string } | null> {
|
||||
const raw = String(state ?? "").trim();
|
||||
const dotIndex = raw.indexOf(".");
|
||||
if (dotIndex <= 0 || dotIndex === raw.length - 1) return null;
|
||||
const payloadSegment = raw.slice(0, dotIndex);
|
||||
const signatureSegment = raw.slice(dotIndex + 1);
|
||||
if (/[^A-Za-z0-9_-]/.test(payloadSegment) || /[^A-Za-z0-9_-]/.test(signatureSegment)) {
|
||||
return null;
|
||||
}
|
||||
let payload: WecomBindStatePayload;
|
||||
try {
|
||||
payload = JSON.parse(
|
||||
new TextDecoder().decode(fromBase64Url(payloadSegment)),
|
||||
) as WecomBindStatePayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (payload?.v !== 2 || typeof payload.uid !== "string") return null;
|
||||
const uid = payload.uid.trim().slice(0, 64);
|
||||
if (!uid) return null;
|
||||
const secret = options.secret ?? getWecomOAuthSecret();
|
||||
const expected = await hmacSha256(secret, payloadSegment);
|
||||
const provided = fromBase64Url(signatureSegment);
|
||||
if (!constantTimeEquals(expected, provided)) return null;
|
||||
const now = options.nowMs ?? Date.now();
|
||||
if (now > payload.exp + WECOM_IDENTITY_GRACE_MS) return null;
|
||||
return { userId: uid };
|
||||
}
|
||||
|
||||
export async function resolveWecomClaimant(
|
||||
db: DatabaseClient,
|
||||
externalUserId: string,
|
||||
|
||||
Reference in New Issue
Block a user