All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 4s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m16s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 5s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s
223 lines
11 KiB
TypeScript
223 lines
11 KiB
TypeScript
// PLAN-DOCVER.md §auth — access_token ของกลุ่ม /api/documents (document versioning)
|
|
// - POST /api/documents/token ออก JWT HS256 ให้ผู้เรียกที่ยืนยัน header "x-api-key"
|
|
// กับ env DOCVER_API_KEY (fail closed เมื่อ env ไม่ตั้ง/ว่าง — pattern เดียวกับ
|
|
// lib/external-api-auth.ts ของ N10: hash SHA-256 ทั้งสองค่า + timingSafeEqual,
|
|
// ข้อความ 401 ของ "ไม่ใส่ key" กับ "key ผิด" เป็นอันเดียวกัน, ห้าม log ค่าดิบ)
|
|
// - token เซ็นด้วย env DOCVER_TOKEN_SECRET (มือเขียน HS256 ผ่าน node:crypto ล้วน —
|
|
// ไม่เพิ่ม dependency) แล้วทุก endpoint อื่นของกลุ่มบังคับ Authorization: Bearer —
|
|
// identity (userid/username) กับ templateId แกะจาก claims ไม่รับจาก payload
|
|
// - claims "fid" = fileId ของ e-Court (string) — เป็น docId ของทุก operation ตรง ๆ
|
|
// — บังคับตั้งแต่ขอ token และ verify ไม่รับ token ที่ไม่มี fid
|
|
//
|
|
// ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test (tests/lib/) ต้องรันได้
|
|
// โดยไม่ต้องตั้ง DATABASE_URL/MINIO_* (pattern เดียวกับ lib/external-api-auth.ts)
|
|
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
|
|
import { docverError, type DocverError } from "@/lib/document-versioning";
|
|
|
|
/** claims ของ access_token docver — โครงสร้างตามผู้เรียกกำหนด (e-Court) */
|
|
export type DocverTokenClaims = {
|
|
userid: string;
|
|
username: string;
|
|
fid: string;
|
|
template: string | null;
|
|
};
|
|
|
|
function base64url(input: string): string {
|
|
return Buffer.from(input, "utf8").toString("base64url");
|
|
}
|
|
|
|
function decodeJsonSegment(segment: string): Record<string, unknown> | null {
|
|
let text: string;
|
|
try {
|
|
text = Buffer.from(segment, "base64url").toString("utf8");
|
|
} catch {
|
|
return null;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(text) as unknown;
|
|
if (
|
|
parsed === null ||
|
|
typeof parsed !== "object" ||
|
|
Array.isArray(parsed)
|
|
) {
|
|
return null;
|
|
}
|
|
return parsed as Record<string, unknown>;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** ออก JWT HS256 (base64url 3 segment) — payload = claims + iat/exp (วินาที) */
|
|
export function issueDocverToken(
|
|
claims: DocverTokenClaims,
|
|
secret: string,
|
|
ttlSeconds: number,
|
|
): string {
|
|
const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
|
const iat = Math.floor(Date.now() / 1000);
|
|
const payload = base64url(
|
|
JSON.stringify({ ...claims, iat, exp: iat + ttlSeconds }),
|
|
);
|
|
const signature = createHmac("sha256", secret)
|
|
.update(`${header}.${payload}`)
|
|
.digest("base64url");
|
|
return `${header}.${payload}.${signature}`;
|
|
}
|
|
|
|
/** claims ที่ถอดได้จาก payload ที่ตรวจ signature ผ่านแล้ว — รูปร่างผิด = invalid
|
|
* (กัน token ที่เซ็นถูกแต่ claims ไม่ครบ/ผิด type หลุดไปใช้ต่อ) */
|
|
function claimsFromPayload(
|
|
payload: Record<string, unknown>,
|
|
): DocverTokenClaims | null {
|
|
const { userid, username, fid, template } = payload;
|
|
if (typeof userid !== "string" || userid === "") return null;
|
|
if (typeof username !== "string" || username === "") return null;
|
|
if (typeof fid !== "string" || fid === "") return null;
|
|
if (template !== null && typeof template !== "string") return null;
|
|
return { userid, username, fid, template };
|
|
}
|
|
|
|
/** ตรวจ token: 3 segment → alg บังคับ HS256 (กัน alg-confusion เช่น "none") →
|
|
* เทียบ signature แบบ timing-safe (hash ทั้งสองฝั่งก่อน — digest ยาวเท่ากันเสมอ) →
|
|
* exp ต้องยังไม่ผ่านมาแล้ว → claims ต้องครบรูปร่าง */
|
|
export function verifyDocverToken(
|
|
token: string,
|
|
secret: string,
|
|
): { ok: true; claims: DocverTokenClaims } | { ok: false } {
|
|
const segments = token.split(".");
|
|
if (segments.length !== 3) return { ok: false };
|
|
const [headerSegment, payloadSegment, signatureSegment] = segments;
|
|
|
|
const header = decodeJsonSegment(headerSegment);
|
|
if (header === null || header.alg !== "HS256") return { ok: false };
|
|
|
|
const expected = createHmac("sha256", secret)
|
|
.update(`${headerSegment}.${payloadSegment}`)
|
|
.digest();
|
|
// ถอด base64url กลับเป็น byte ดิบของ signature ก่อน — แล้ว hash ทั้งสองฝั่ง
|
|
// (SHA-256 = 32 byte เท่ากันเสมอ แม้ segment จะเป็นขยะ decode ได้สั้น/ว่าง —
|
|
// timingSafeEqual จึงไม่ throw เรื่องความยาวต่างกัน)
|
|
const provided = createHash("sha256")
|
|
.update(Buffer.from(signatureSegment, "base64url"))
|
|
.digest();
|
|
const expectedHash = createHash("sha256").update(expected).digest();
|
|
if (!timingSafeEqual(provided, expectedHash)) return { ok: false };
|
|
|
|
const payload = decodeJsonSegment(payloadSegment);
|
|
if (payload === null) return { ok: false };
|
|
const exp = payload.exp;
|
|
if (typeof exp !== "number" || !Number.isFinite(exp)) return { ok: false };
|
|
if (exp <= Math.floor(Date.now() / 1000)) return { ok: false };
|
|
|
|
const claims = claimsFromPayload(payload);
|
|
if (claims === null) return { ok: false };
|
|
return { ok: true, claims };
|
|
}
|
|
|
|
/** auth ของ POST /api/documents/token — mirror requireExternalApiKey (N10) แต่
|
|
* env เป็น DOCVER_API_KEY และตอบผ่าน docverError ของกลุ่มนี้
|
|
* คืน null เมื่อผ่าน / Response 401 เมื่อไม่ผ่าน */
|
|
export function requireDocverApiKey(request: Request): Response | null {
|
|
// เทียบกับค่า trim แล้ว — กัน trailing newline จาก K8s secret / docker env_file
|
|
const envKey = process.env.DOCVER_API_KEY?.trim();
|
|
// const envKey =
|
|
// "2d9b76d49a4f5311f6a0dacf759ecec53f34b6d102f24c696890a4052c99cf54";
|
|
if (envKey === undefined || envKey === "") {
|
|
return docverError("ระบบยังไม่ได้ตั้งค่า API key", 401);
|
|
}
|
|
const provided = request.headers.get("x-api-key");
|
|
if (provided === null || provided === "") {
|
|
return docverError("API key ไม่ถูกต้อง", 401);
|
|
}
|
|
const providedHash = createHash("sha256").update(provided).digest();
|
|
const envHash = createHash("sha256").update(envKey).digest();
|
|
return timingSafeEqual(providedHash, envHash)
|
|
? null
|
|
: docverError("API key ไม่ถูกต้อง", 401);
|
|
}
|
|
|
|
/** 401 เดียวสำหรับทุกกรณี — ไม่แยกสาเหตุ (ไม่มี header / รูปผิด / signature ผิด /
|
|
* หมดอายุ / secret ไม่ตั้ง) กันใช้ endpoint เป็น oracle สำรวจ */
|
|
const BEARER_DENIED = "โทเคนไม่ถูกต้องหรือหมดอายุ";
|
|
|
|
/** บังคับ Authorization: Bearer <token> ของกลุ่ม docver — เรียกเป็นอย่างแรกของ
|
|
* ทุก endpoint ก่อน parse path param · env DOCVER_TOKEN_SECRET ไม่ตั้ง = ไม่มี
|
|
* token ใดผ่าน (fail closed — ไม่ใช่ 500 เพราะไม่ใช่ความผิดของผู้เรียก) */
|
|
export function requireDocverBearer(
|
|
request: Request,
|
|
): { ok: true; claims: DocverTokenClaims } | { ok: false; response: Response } {
|
|
const denied = () =>
|
|
({ ok: false, response: docverError(BEARER_DENIED, 401) }) as const;
|
|
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
|
|
// const secret =
|
|
// "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e";
|
|
if (secret === undefined || secret === "") return denied();
|
|
const header = request.headers.get("authorization");
|
|
if (header === null) return denied();
|
|
const match = /^Bearer\s+(\S+)$/i.exec(header);
|
|
if (match === null) return denied();
|
|
const verified = verifyDocverToken(match[1], secret);
|
|
if (!verified.ok) return denied();
|
|
return { ok: true, claims: verified.claims };
|
|
}
|
|
|
|
/** body ของ POST /api/documents/token — strict allowlist กัน typo เงียบ ๆ ตาม
|
|
* convention ของกลุ่ม (key อื่น = 400) · trim-ว่าง = ถือว่าไม่ส่ง */
|
|
const TOKEN_BODY_FIELDS = ["userid", "username", "fid", "template"] as const;
|
|
|
|
export function parseTokenRequestBody(
|
|
raw: unknown,
|
|
): { ok: true; claims: DocverTokenClaims } | DocverError {
|
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
return { ok: false, error: "body ต้องเป็น JSON object" };
|
|
}
|
|
const record = raw as Record<string, unknown>;
|
|
for (const key of Object.keys(record)) {
|
|
if (!(TOKEN_BODY_FIELDS as readonly string[]).includes(key)) {
|
|
return {
|
|
ok: false,
|
|
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${TOKEN_BODY_FIELDS.join(", ")})`,
|
|
};
|
|
}
|
|
}
|
|
|
|
const rawUserid = record.userid;
|
|
if (typeof rawUserid !== "string" || rawUserid.trim() === "") {
|
|
return { ok: false, error: "ต้องระบุ userid (string)" };
|
|
}
|
|
const userid = rawUserid.trim();
|
|
if (userid.length > 100) {
|
|
return { ok: false, error: "userid ยาวเกิน 100 ตัวอักษร" };
|
|
}
|
|
|
|
const rawUsername = record.username;
|
|
if (typeof rawUsername !== "string" || rawUsername.trim() === "") {
|
|
return { ok: false, error: "ต้องระบุ username (string)" };
|
|
}
|
|
const username = rawUsername.trim();
|
|
if (username.length > 200) {
|
|
return { ok: false, error: "username ยาวเกิน 200 ตัวอักษร" };
|
|
}
|
|
|
|
const rawFid = record.fid;
|
|
if (typeof rawFid !== "string" || rawFid.trim() === "") {
|
|
return { ok: false, error: "ต้องระบุ fid (string)" };
|
|
}
|
|
const fid = rawFid.trim();
|
|
if (fid.length > 200) {
|
|
return { ok: false, error: "fid ยาวเกิน 200 ตัวอักษร" };
|
|
}
|
|
|
|
const rawTemplate = record.template ?? null;
|
|
if (rawTemplate !== null && typeof rawTemplate !== "string") {
|
|
return { ok: false, error: "template ต้องเป็น string" };
|
|
}
|
|
const template = rawTemplate?.trim() || null;
|
|
if (template !== null && template.length > 100) {
|
|
return { ok: false, error: "template ยาวเกิน 100 ตัวอักษร" };
|
|
}
|
|
|
|
return { ok: true, claims: { userid, username, fid, template } };
|
|
}
|