checkpoint

This commit is contained in:
polwex 2026-04-18 22:04:32 +07:00
parent 199dab69f9
commit 9439681df9
4 changed files with 296 additions and 227 deletions

View file

@ -3,12 +3,12 @@ import {
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from "@simplewebauthn/server"
import { isoBase64URL } from "@simplewebauthn/server/helpers"
} from "@simplewebauthn/server";
import { isoBase64URL } from "@simplewebauthn/server/helpers";
import type {
RegistrationResponseJSON,
AuthenticationResponseJSON,
} from "@simplewebauthn/server"
} from "@simplewebauthn/server";
import {
createUser,
getUserByUsername,
@ -19,198 +19,212 @@ import {
createSession,
getSession,
deleteSession,
} from "./db"
} from "./db";
const RP_NAME = "Leo's Typing Tutor"
const RP_NAME = "shukudai";
// Temporary challenge store (in-memory, keyed by random ID → challenge + metadata)
const challenges = new Map<string, { challenge: string; userId?: number; expires: number }>()
const challenges = new Map<
string,
{ challenge: string; userId?: number; expires: number }
>();
// Cleanup expired challenges periodically
setInterval(() => {
const now = Date.now()
const now = Date.now();
for (const [key, val] of challenges) {
if (val.expires < now) challenges.delete(key)
if (val.expires < now) challenges.delete(key);
}
}, 60_000)
}, 60_000);
function getRpId(req: Request): string {
const url = new URL(req.url)
return url.hostname
const url = new URL(req.url);
return url.hostname;
}
function getOrigin(req: Request): string {
const url = new URL(req.url)
return url.origin
const url = new URL(req.url);
return url.origin;
}
function getCookie(req: Request, name: string): string | undefined {
const cookies = req.headers.get("cookie")
if (!cookies) return undefined
const match = cookies.split(";").find(c => c.trim().startsWith(`${name}=`))
return match?.split("=").slice(1).join("=").trim()
const cookies = req.headers.get("cookie");
if (!cookies) return undefined;
const match = cookies.split(";").find((c) => c.trim().startsWith(`${name}=`));
return match?.split("=").slice(1).join("=").trim();
}
function sessionCookie(token: string, maxAge = 7 * 24 * 60 * 60): string {
return `session=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}`
return `session=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}`;
}
function challengeCookie(key: string): string {
return `challenge_key=${key}; HttpOnly; SameSite=Strict; Path=/; Max-Age=120`
return `challenge_key=${key}; HttpOnly; SameSite=Strict; Path=/; Max-Age=120`;
}
function clearChallengeCookie(): string {
return `challenge_key=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`
return `challenge_key=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`;
}
// ---- Route handlers ----
export async function registerOptions(req: Request): Promise<Response> {
const { username } = await req.json() as { username: string }
const { username } = (await req.json()) as { username: string };
if (!username || username.length < 1 || username.length > 32) {
return Response.json({ error: "Username must be 1-32 characters" }, { status: 400 })
return Response.json(
{ error: "Username must be 1-32 characters" },
{ status: 400 },
);
}
// Check if username already taken
const existing = await getUserByUsername(username)
const existing = await getUserByUsername(username);
if (existing) {
return Response.json({ error: "Username already taken" }, { status: 409 })
return Response.json({ error: "Username already taken" }, { status: 409 });
}
// Create user
const user = await createUser(username)
const user = await createUser(username);
const rpID = getRpId(req)
const rpID = getRpId(req);
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID,
userName: username,
userID: isoBase64URL.toBuffer(isoBase64URL.fromUTF8String(user.id.toString())),
userID: isoBase64URL.toBuffer(
isoBase64URL.fromUTF8String(user.id.toString()),
),
attestationType: "none",
authenticatorSelection: {
residentKey: "required",
userVerification: "preferred",
},
})
});
// Store challenge
const challengeKey = crypto.randomUUID()
const challengeKey = crypto.randomUUID();
challenges.set(challengeKey, {
challenge: options.challenge,
userId: user.id,
expires: Date.now() + 120_000,
})
});
return new Response(JSON.stringify(options), {
headers: {
"Content-Type": "application/json",
"Set-Cookie": challengeCookie(challengeKey),
},
})
});
}
export async function registerVerify(req: Request): Promise<Response> {
const body = (await req.json()) as RegistrationResponseJSON
const challengeKey = getCookie(req, "challenge_key")
const body = (await req.json()) as RegistrationResponseJSON;
const challengeKey = getCookie(req, "challenge_key");
if (!challengeKey) {
return Response.json({ error: "No challenge found" }, { status: 400 })
return Response.json({ error: "No challenge found" }, { status: 400 });
}
const stored = challenges.get(challengeKey)
const stored = challenges.get(challengeKey);
if (!stored || stored.expires < Date.now()) {
challenges.delete(challengeKey!)
return Response.json({ error: "Challenge expired" }, { status: 400 })
challenges.delete(challengeKey!);
return Response.json({ error: "Challenge expired" }, { status: 400 });
}
challenges.delete(challengeKey)
challenges.delete(challengeKey);
const rpID = getRpId(req)
const origin = getOrigin(req)
const rpID = getRpId(req);
const origin = getOrigin(req);
let verification
let verification;
try {
verification = await verifyRegistrationResponse({
response: body,
expectedChallenge: stored.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
})
});
} catch (err) {
return Response.json({ error: `Verification failed: ${err}` }, { status: 400 })
return Response.json(
{ error: `Verification failed: ${err}` },
{ status: 400 },
);
}
if (!verification.verified || !verification.registrationInfo) {
return Response.json({ error: "Verification failed" }, { status: 400 })
return Response.json({ error: "Verification failed" }, { status: 400 });
}
const { credential } = verification.registrationInfo
const { credential } = verification.registrationInfo;
await storeCredential(
credential.id,
stored.userId!,
isoBase64URL.fromBuffer(credential.publicKey),
credential.counter,
credential.transports,
)
);
// Create session
const token = await createSession(stored.userId!)
const user = await getUserById(stored.userId!)
const token = await createSession(stored.userId!);
const user = await getUserById(stored.userId!);
return new Response(JSON.stringify({ verified: true, username: user?.username }), {
headers: {
"Content-Type": "application/json",
"Set-Cookie": [sessionCookie(token), clearChallengeCookie()].join(", "),
return new Response(
JSON.stringify({ verified: true, username: user?.username }),
{
headers: {
"Content-Type": "application/json",
"Set-Cookie": [sessionCookie(token), clearChallengeCookie()].join(", "),
},
},
})
);
}
export async function loginOptions(req: Request): Promise<Response> {
const rpID = getRpId(req)
const rpID = getRpId(req);
const options = await generateAuthenticationOptions({
rpID,
userVerification: "preferred",
// Empty allowCredentials = discoverable credentials (passkey prompt)
})
});
const challengeKey = crypto.randomUUID()
const challengeKey = crypto.randomUUID();
challenges.set(challengeKey, {
challenge: options.challenge,
expires: Date.now() + 120_000,
})
});
return new Response(JSON.stringify(options), {
headers: {
"Content-Type": "application/json",
"Set-Cookie": challengeCookie(challengeKey),
},
})
});
}
export async function loginVerify(req: Request): Promise<Response> {
const body = (await req.json()) as AuthenticationResponseJSON
const challengeKey = getCookie(req, "challenge_key")
const body = (await req.json()) as AuthenticationResponseJSON;
const challengeKey = getCookie(req, "challenge_key");
if (!challengeKey) {
return Response.json({ error: "No challenge found" }, { status: 400 })
return Response.json({ error: "No challenge found" }, { status: 400 });
}
const stored = challenges.get(challengeKey)
const stored = challenges.get(challengeKey);
if (!stored || stored.expires < Date.now()) {
challenges.delete(challengeKey!)
return Response.json({ error: "Challenge expired" }, { status: 400 })
challenges.delete(challengeKey!);
return Response.json({ error: "Challenge expired" }, { status: 400 });
}
challenges.delete(challengeKey)
challenges.delete(challengeKey);
// Look up credential
const credentialId = body.id
const credential = await getCredentialById(credentialId)
const credentialId = body.id;
const credential = await getCredentialById(credentialId);
if (!credential) {
return Response.json({ error: "Unknown credential" }, { status: 400 })
return Response.json({ error: "Unknown credential" }, { status: 400 });
}
const rpID = getRpId(req)
const origin = getOrigin(req)
const rpID = getRpId(req);
const origin = getOrigin(req);
let verification
let verification;
try {
verification = await verifyAuthenticationResponse({
response: body,
@ -221,60 +235,73 @@ export async function loginVerify(req: Request): Promise<Response> {
id: credential.id,
publicKey: isoBase64URL.toBuffer(credential.public_key),
counter: credential.counter,
transports: credential.transports ? JSON.parse(credential.transports) : undefined,
transports: credential.transports
? JSON.parse(credential.transports)
: undefined,
},
})
});
} catch (err) {
return Response.json({ error: `Verification failed: ${err}` }, { status: 400 })
return Response.json(
{ error: `Verification failed: ${err}` },
{ status: 400 },
);
}
if (!verification.verified) {
return Response.json({ error: "Verification failed" }, { status: 400 })
return Response.json({ error: "Verification failed" }, { status: 400 });
}
// Update counter
await updateCredentialCounter(credentialId, verification.authenticationInfo.newCounter)
await updateCredentialCounter(
credentialId,
verification.authenticationInfo.newCounter,
);
// Create session
const token = await createSession(credential.user_id)
const user = await getUserById(credential.user_id)
const token = await createSession(credential.user_id);
const user = await getUserById(credential.user_id);
return new Response(JSON.stringify({ verified: true, username: user?.username }), {
headers: {
"Content-Type": "application/json",
"Set-Cookie": [sessionCookie(token), clearChallengeCookie()].join(", "),
return new Response(
JSON.stringify({ verified: true, username: user?.username }),
{
headers: {
"Content-Type": "application/json",
"Set-Cookie": [sessionCookie(token), clearChallengeCookie()].join(", "),
},
},
})
);
}
export async function me(req: Request): Promise<Response> {
const token = getCookie(req, "session")
const token = getCookie(req, "session");
if (!token) {
return Response.json({ user: null })
return Response.json({ user: null });
}
const session = await getSession(token)
const session = await getSession(token);
if (!session) {
return new Response(JSON.stringify({ user: null }), {
headers: {
"Content-Type": "application/json",
"Set-Cookie": sessionCookie("", 0), // clear expired cookie
},
})
});
}
return Response.json({ user: { id: session.user_id, username: session.username } })
return Response.json({
user: { id: session.user_id, username: session.username },
});
}
export async function logout(req: Request): Promise<Response> {
const token = getCookie(req, "session")
const token = getCookie(req, "session");
if (token) {
await deleteSession(token)
await deleteSession(token);
}
return new Response(JSON.stringify({ ok: true }), {
headers: {
"Content-Type": "application/json",
"Set-Cookie": sessionCookie("", 0),
},
})
});
}