-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.ts
More file actions
45 lines (44 loc) · 15.7 KB
/
Copy pathserver.ts
File metadata and controls
45 lines (44 loc) · 15.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from "jsr:@simplewebauthn/server@14.0.0";
const PORT = Number(Deno.env.get("PORT") || "8000");
const RP_ID = Deno.env.get("RP_ID") || "localhost";
const ORIGIN = Deno.env.get("ORIGIN") || `http://localhost:${PORT}`;
const RP_NAME = Deno.env.get("RP_NAME") || "稼働状況ダッシュボード";
const ADMIN_NAMES = (Deno.env.get("ADMIN_NAME") || "").split(",").map((name) => name.trim()).filter(Boolean);
if (!ADMIN_NAMES.length) throw new Error("ADMIN_NAME を .env に設定してください");
const DATA_DIR = new URL("./data/", import.meta.url), DATA_FILE = new URL("./data/auth.json", import.meta.url);
const SESSION_TTL = 3 * 60 * 60 * 1000, CHALLENGE_TTL = 5 * 60 * 1000, encoder = new TextEncoder();
type Credential = { id: string; publicKey: string; counter: number; transports?: string[] };
type User = { id: string; name: string; normalizedName: string; role: "admin" | "member"; status: "pending" | "active" | "disabled"; passphrase: { salt: string; hash: string }; passphraseText?: string; credentials: Credential[]; createdAt: string };
type Store = { users: User[] }; type Flow = { userId: string; challenge: string; expiresAt: number };
const sessions = new Map<string, { userId: string; expiresAt: number }>(), registrationFlows = new Map<string, Flow>(), authenticationFlows = new Map<string, Flow>();
const b64 = (bytes: Uint8Array) => { let binary = ""; for (const byte of bytes) binary += String.fromCharCode(byte); return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); };
const bytes = (value: string) => { const padded = value.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((value.length + 3) % 4); const binary = atob(padded); return Uint8Array.from(binary, (char) => char.charCodeAt(0)); };
const token = () => b64(crypto.getRandomValues(new Uint8Array(32))), normalize = (value: string) => value.normalize("NFKC").replace(/\s+/g, "").trim();
const json = (data: unknown, status = 200, headers: HeadersInit = {}) => new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json; charset=utf-8", ...headers } });
const bad = (message: string, status = 400) => json({ error: message }, status);
const getCookie = (request: Request, name: string) => (request.headers.get("cookie") || "").split(";").map((part) => part.trim()).find((part) => part.startsWith(`${name}=`))?.slice(name.length + 1);
const setCookie = (name: string, value: string, maxAge?: number) => `${name}=${value}; Path=/; HttpOnly; SameSite=Lax;${maxAge === undefined ? "" : ` Max-Age=${maxAge};`}${ORIGIN.startsWith("https://") ? " Secure;" : ""}`;
const clearCookie = (name: string) => `${name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0;`;
async function loadStore(): Promise<Store> { try { return JSON.parse(await Deno.readTextFile(DATA_FILE)); } catch (error) { if (!(error instanceof Deno.errors.NotFound)) throw error; return { users: [] }; } }
let store = await loadStore();
async function saveStore() { await Deno.mkdir(DATA_DIR, { recursive: true }); const temp = new URL("./data/auth.json.tmp", import.meta.url); await Deno.writeTextFile(temp, JSON.stringify(store, null, 2)); await Deno.rename(temp, DATA_FILE); }
async function hashPassphrase(passphrase: string, salt = crypto.getRandomValues(new Uint8Array(16))) { const key = await crypto.subtle.importKey("raw", encoder.encode(passphrase), "PBKDF2", false, ["deriveBits"]); const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", salt, iterations: 310000, hash: "SHA-256" }, key, 256); return { salt: b64(salt), hash: b64(new Uint8Array(bits)) }; }
function userByName(name: string) { return store.users.find((user) => user.normalizedName === normalize(name)); }
function currentUser(request: Request) { const tokenValue = getCookie(request, "session"), session = tokenValue && sessions.get(tokenValue); if (!session || session.expiresAt <= Date.now()) { if (tokenValue) sessions.delete(tokenValue); return undefined; } return store.users.find((user) => user.id === session.userId && user.status === "active"); }
async function readBody(request: Request) { try { return await request.json(); } catch { throw new Error("JSON形式が不正です"); } }
function originOK(request: Request) { const origin = request.headers.get("origin"); return !origin || origin === ORIGIN; }
function cleanFlows() { for (const [id, flow] of registrationFlows) if (flow.expiresAt <= Date.now()) registrationFlows.delete(id); for (const [id, flow] of authenticationFlows) if (flow.expiresAt <= Date.now()) authenticationFlows.delete(id); }
async function registrationOptions(user: User) { return generateRegistrationOptions({ rpName: RP_NAME, rpID: RP_ID, userName: user.name, userID: encoder.encode(user.id), attestationType: "none", excludeCredentials: user.credentials.map((credential) => ({ id: credential.id, transports: credential.transports as any })), supportedAlgorithmIDs: [-7, -257], authenticatorSelection: { residentKey: "preferred", userVerification: "required" } }); }
async function registerStart(request: Request) { const input = await readBody(request), name = String(input.name || "").trim(), passphrase = String(input.passphrase || ""); if (!name || name.length > 80) return bad("名前を入力してください"); if (passphrase.length > 200) return bad("合言葉は200文字以内で入力してください"); if (userByName(name)) return bad("この名前はすでに登録されています", 409); const id = crypto.randomUUID(), isAdmin = ADMIN_NAMES.some((adminName) => normalize(adminName) === normalize(name)); const user: User = { id, name, normalizedName: normalize(name), role: isAdmin ? "admin" : "member", status: isAdmin ? "active" : "pending", passphrase: await hashPassphrase(passphrase), passphraseText: passphrase, credentials: [], createdAt: new Date().toISOString() }; store.users.push(user); const options = await registrationOptions(user); const flowId = token(); registrationFlows.set(flowId, { userId: id, challenge: options.challenge, expiresAt: Date.now() + CHALLENGE_TTL }); return json(options, 200, { "set-cookie": setCookie("reg_flow", flowId, 300) }); }
async function registerFinish(request: Request) { const flowId = getCookie(request, "reg_flow"), flow = flowId && registrationFlows.get(flowId); if (!flow || flow.expiresAt <= Date.now()) return bad("登録の有効期限が切れました。最初からやり直してください"); const user = store.users.find((item) => item.id === flow.userId); if (!user) return bad("登録者が見つかりません"); try { const verification = await verifyRegistrationResponse({ response: await readBody(request), expectedChallenge: flow.challenge, expectedOrigin: ORIGIN, expectedRPID: RP_ID, requireUserVerification: true }); if (!verification.verified || !verification.registrationInfo) return bad("パスキーの登録を確認できませんでした"); const info = verification.registrationInfo; user.credentials.push({ id: info.credential.id, publicKey: b64(info.credential.publicKey), counter: info.credential.counter, transports: info.credential.transports }); await saveStore(); registrationFlows.delete(flowId); return json({ ok: true, message: user.status === "pending" ? "登録しました。管理者の承認をお待ちください" : "パスキーを登録しました" }, 200, { "set-cookie": clearCookie("reg_flow") }); } catch (error) { console.error(error); return bad("パスキーの登録に失敗しました"); } }
async function passkeyRegisterStart(request: Request) { const user = currentUser(request); if (!user) return bad("ログインが必要です", 401); const options = await registrationOptions(user), flowId = token(); registrationFlows.set(flowId, { userId: user.id, challenge: options.challenge, expiresAt: Date.now() + CHALLENGE_TTL }); return json(options, 200, { "set-cookie": setCookie("reg_flow", flowId, 300) }); }
async function loginStart(request: Request) { const input = await readBody(request), user = userByName(String(input.name || "")); if (!user || user.status !== "active" || !user.credentials.length) return bad("ログインできる登録者が見つかりません", 401); const options = await generateAuthenticationOptions({ rpID: RP_ID, userVerification: "required", allowCredentials: user.credentials.map((credential) => ({ id: credential.id, transports: credential.transports as any })) }); const flowId = token(); authenticationFlows.set(flowId, { userId: user.id, challenge: options.challenge, expiresAt: Date.now() + CHALLENGE_TTL }); return json(options, 200, { "set-cookie": setCookie("login_flow", flowId, 300) }); }
async function loginFinish(request: Request) { const flowId = getCookie(request, "login_flow"), flow = flowId && authenticationFlows.get(flowId); if (!flow || flow.expiresAt <= Date.now()) return bad("ログインの有効期限が切れました。最初からやり直してください"); const user = store.users.find((item) => item.id === flow.userId); if (!user || user.status !== "active") return bad("ログインできません", 401); const response = await readBody(request), credential = user.credentials.find((item) => item.id === response.id); if (!credential) return bad("登録済みのパスキーが見つかりません", 401); try { const verification = await verifyAuthenticationResponse({ response, expectedChallenge: flow.challenge, expectedOrigin: ORIGIN, expectedRPID: RP_ID, requireUserVerification: true, credential: { id: credential.id, publicKey: bytes(credential.publicKey), counter: credential.counter, transports: credential.transports as any } }); if (!verification.verified) return bad("ログインを確認できませんでした", 401); credential.counter = verification.authenticationInfo.newCounter; await saveStore(); authenticationFlows.delete(flowId); const sessionToken = token(); sessions.set(sessionToken, { userId: user.id, expiresAt: Date.now() + SESSION_TTL }); return json({ ok: true }, 200, { "set-cookie": setCookie("session", sessionToken) }); } catch (error) { console.error(error); return bad("パスキーでのログインに失敗しました", 401); } }
const publicUser = (user: User) => ({ id: user.id, name: user.name, role: user.role, status: user.status, passphrase: user.passphraseText ?? "", credentialCount: user.credentials.length, createdAt: user.createdAt });
async function api(request: Request, url: URL) { cleanFlows(); const method = request.method, path = url.pathname; if (["POST", "DELETE", "PATCH"].includes(method) && !originOK(request)) return bad("Originが不正です", 403); if (method === "POST" && path === "/api/register/start") return registerStart(request); if (method === "POST" && path === "/api/register/finish") return registerFinish(request); if (method === "POST" && path === "/api/login/start") return loginStart(request); if (method === "POST" && path === "/api/login/finish") return loginFinish(request); if (method === "POST" && path === "/api/logout") return json({ ok: true }, 200, { "set-cookie": clearCookie("session") }); if (method === "GET" && path === "/api/me") { const user = currentUser(request); return user ? json({ ...publicUser(user), admin: user.role === "admin" }) : bad("未ログインです", 401); } const user = currentUser(request); if (!user) return bad("ログインが必要です", 401); if (method === "POST" && path === "/api/passkey/register/start") return passkeyRegisterStart(request); if (method === "GET" && path === "/api/me/passkeys") return json(user.credentials.map((credential, index) => ({ id: credential.id, label: `パスキー ${index + 1}`, transports: credential.transports || [] }))); const passkey = path.match(/^\/api\/me\/passkeys\/([^/]+)$/); if (method === "DELETE" && passkey) { if (user.credentials.length <= 1) return bad("最後のパスキーは削除できません"); const index = user.credentials.findIndex((credential) => credential.id === passkey[1]); if (index < 0) return bad("パスキーが見つかりません", 404); user.credentials.splice(index, 1); await saveStore(); return json({ ok: true }); } if (method === "GET" && path === "/api/admin/users" && user.role === "admin") return json(store.users.map(publicUser)); const status = path.match(/^\/api\/admin\/users\/([^/]+)\/status$/); if (method === "POST" && status && user.role === "admin") { const target = store.users.find((item) => item.id === status[1]), input = await readBody(request); if (!target || !["active", "disabled", "pending"].includes(input.status)) return bad("対象または状態が不正です"); if (target.id === user.id && input.status !== "active") return bad("自分自身を無効化できません"); target.status = input.status; await saveStore(); return json(publicUser(target)); } const deletion = path.match(/^\/api\/admin\/users\/([^/]+)$/); if (method === "DELETE" && deletion && user.role === "admin") { const index = store.users.findIndex((item) => item.id === deletion[1]); if (index < 0) return bad("対象が見つかりません", 404); if (store.users[index].id === user.id) return bad("自分自身は削除できません"); store.users.splice(index, 1); await saveStore(); return json({ ok: true }); } return bad("Not found", 404); }
const publicFiles: Record<string, string> = { "/auth.html": "public/auth.html", "/auth.js": "public/auth.js", "/auth.css": "public/auth.css" };
const staticAliases: Record<string, string> = { "/index.html": "static/index.html", "/app.js": "static/app.js", "/style.css": "static/style.css", "/admin.html": "static/admin.html", "/admin.js": "static/admin.js", "/admin.css": "static/admin.css", "/mypage.html": "static/mypage.html", "/mypage.js": "static/mypage.js", "/mypage.css": "static/mypage.css", "/timecard.csv": "static/data/timecard.csv", "/member.csv": "static/data/member.csv", "/fee.csv": "static/data/fee.csv" };
const mime: Record<string, string> = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".csv": "text/csv; charset=utf-8" };
async function staticResponse(request: Request, url: URL) { const user = currentUser(request), pathname = url.pathname === "/" ? "/index.html" : url.pathname; let file: string | undefined; if (pathname.startsWith("/public/")) { const relative = decodeURIComponent(pathname.slice("/public/".length)); if (["auth.html", "auth.js", "auth.css"].includes(relative)) file = `public/${relative}`; } else if (user && pathname.startsWith("/static/")) { const relative = decodeURIComponent(pathname.slice("/static/".length)); if (relative && !relative.includes("..") && !relative.startsWith("/")) file = `static/${relative}`; } else if (user) { file = staticAliases[pathname] || publicFiles[pathname]; } else { file = publicFiles[pathname]; }
if (pathname === "/index.html" && !user) return Response.redirect(new URL("/auth.html", url), 302); if (pathname === "/admin.html" && (!user || user.role !== "admin")) return Response.redirect(new URL(user ? "/index.html" : "/auth.html", url), 302); if (!file) return user ? new Response("Not found", { status: 404 }) : Response.redirect(new URL("/auth.html", url), 302); try { const content = await Deno.readFile(new URL(`./${file}`, import.meta.url)), extension = file.slice(file.lastIndexOf(".")); return new Response(content, { headers: { "content-type": mime[extension] || "application/octet-stream", "cache-control": "no-store", "x-content-type-options": "nosniff", "content-security-policy": "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'" } }); } catch { return new Response("Not found", { status: 404 }); } }
console.log(`Payroll server: ${ORIGIN}`); Deno.serve({ port: PORT }, async (request) => { const url = new URL(request.url); try { return url.pathname.startsWith("/api/") ? await api(request, url) : await staticResponse(request, url); } catch (error) { console.error(error); return json({ error: "サーバー内部エラー" }, 500); } });