added server, db, auth system
This commit is contained in:
parent
4d3395fa1c
commit
199dab69f9
28 changed files with 989 additions and 192 deletions
280
server/auth.ts
Normal file
280
server/auth.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
import {
|
||||
generateRegistrationOptions,
|
||||
verifyRegistrationResponse,
|
||||
generateAuthenticationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
} from "@simplewebauthn/server"
|
||||
import { isoBase64URL } from "@simplewebauthn/server/helpers"
|
||||
import type {
|
||||
RegistrationResponseJSON,
|
||||
AuthenticationResponseJSON,
|
||||
} from "@simplewebauthn/server"
|
||||
import {
|
||||
createUser,
|
||||
getUserByUsername,
|
||||
getUserById,
|
||||
storeCredential,
|
||||
getCredentialById,
|
||||
updateCredentialCounter,
|
||||
createSession,
|
||||
getSession,
|
||||
deleteSession,
|
||||
} from "./db"
|
||||
|
||||
const RP_NAME = "Leo's Typing Tutor"
|
||||
|
||||
// Temporary challenge store (in-memory, keyed by random ID → challenge + metadata)
|
||||
const challenges = new Map<string, { challenge: string; userId?: number; expires: number }>()
|
||||
|
||||
// Cleanup expired challenges periodically
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [key, val] of challenges) {
|
||||
if (val.expires < now) challenges.delete(key)
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
function getRpId(req: Request): string {
|
||||
const url = new URL(req.url)
|
||||
return url.hostname
|
||||
}
|
||||
|
||||
function getOrigin(req: Request): string {
|
||||
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()
|
||||
}
|
||||
|
||||
function sessionCookie(token: string, maxAge = 7 * 24 * 60 * 60): string {
|
||||
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`
|
||||
}
|
||||
|
||||
function clearChallengeCookie(): string {
|
||||
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 }
|
||||
if (!username || username.length < 1 || username.length > 32) {
|
||||
return Response.json({ error: "Username must be 1-32 characters" }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check if username already taken
|
||||
const existing = await getUserByUsername(username)
|
||||
if (existing) {
|
||||
return Response.json({ error: "Username already taken" }, { status: 409 })
|
||||
}
|
||||
|
||||
// Create user
|
||||
const user = await createUser(username)
|
||||
|
||||
const rpID = getRpId(req)
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: RP_NAME,
|
||||
rpID,
|
||||
userName: username,
|
||||
userID: isoBase64URL.toBuffer(isoBase64URL.fromUTF8String(user.id.toString())),
|
||||
attestationType: "none",
|
||||
authenticatorSelection: {
|
||||
residentKey: "required",
|
||||
userVerification: "preferred",
|
||||
},
|
||||
})
|
||||
|
||||
// Store challenge
|
||||
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")
|
||||
if (!challengeKey) {
|
||||
return Response.json({ error: "No challenge found" }, { status: 400 })
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
const rpID = getRpId(req)
|
||||
const origin = getOrigin(req)
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
return Response.json({ error: "Verification failed" }, { status: 400 })
|
||||
}
|
||||
|
||||
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!)
|
||||
|
||||
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 options = await generateAuthenticationOptions({
|
||||
rpID,
|
||||
userVerification: "preferred",
|
||||
// Empty allowCredentials = discoverable credentials (passkey prompt)
|
||||
})
|
||||
|
||||
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")
|
||||
if (!challengeKey) {
|
||||
return Response.json({ error: "No challenge found" }, { status: 400 })
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Look up credential
|
||||
const credentialId = body.id
|
||||
const credential = await getCredentialById(credentialId)
|
||||
if (!credential) {
|
||||
return Response.json({ error: "Unknown credential" }, { status: 400 })
|
||||
}
|
||||
|
||||
const rpID = getRpId(req)
|
||||
const origin = getOrigin(req)
|
||||
|
||||
let verification
|
||||
try {
|
||||
verification = await verifyAuthenticationResponse({
|
||||
response: body,
|
||||
expectedChallenge: stored.challenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
credential: {
|
||||
id: credential.id,
|
||||
publicKey: isoBase64URL.toBuffer(credential.public_key),
|
||||
counter: credential.counter,
|
||||
transports: credential.transports ? JSON.parse(credential.transports) : undefined,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
return Response.json({ error: `Verification failed: ${err}` }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!verification.verified) {
|
||||
return Response.json({ error: "Verification failed" }, { status: 400 })
|
||||
}
|
||||
|
||||
// Update counter
|
||||
await updateCredentialCounter(credentialId, verification.authenticationInfo.newCounter)
|
||||
|
||||
// Create session
|
||||
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(", "),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function me(req: Request): Promise<Response> {
|
||||
const token = getCookie(req, "session")
|
||||
if (!token) {
|
||||
return Response.json({ user: null })
|
||||
}
|
||||
|
||||
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 } })
|
||||
}
|
||||
|
||||
export async function logout(req: Request): Promise<Response> {
|
||||
const token = getCookie(req, "session")
|
||||
if (token) {
|
||||
await deleteSession(token)
|
||||
}
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Set-Cookie": sessionCookie("", 0),
|
||||
},
|
||||
})
|
||||
}
|
||||
122
server/db.ts
Normal file
122
server/db.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { SQL } from "bun"
|
||||
|
||||
const db = new SQL({
|
||||
adapter: "sqlite",
|
||||
filename: "./leo-typing.db",
|
||||
create: true,
|
||||
strict: true,
|
||||
})
|
||||
|
||||
export async function initDb() {
|
||||
await db`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)
|
||||
`
|
||||
await db`
|
||||
CREATE TABLE IF NOT EXISTS credentials (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
public_key TEXT NOT NULL,
|
||||
counter INTEGER NOT NULL DEFAULT 0,
|
||||
transports TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)
|
||||
`
|
||||
await db`
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)
|
||||
`
|
||||
}
|
||||
|
||||
// ---- User queries ----
|
||||
|
||||
export async function createUser(username: string): Promise<{ id: number; username: string }> {
|
||||
const [user] = await db`
|
||||
INSERT INTO users (username) VALUES (${username})
|
||||
RETURNING id, username
|
||||
`
|
||||
return user as { id: number; username: string }
|
||||
}
|
||||
|
||||
export async function getUserByUsername(username: string) {
|
||||
const [user] = await db`SELECT id, username FROM users WHERE username = ${username}`
|
||||
return user as { id: number; username: string } | undefined
|
||||
}
|
||||
|
||||
export async function getUserById(id: number) {
|
||||
const [user] = await db`SELECT id, username FROM users WHERE id = ${id}`
|
||||
return user as { id: number; username: string } | undefined
|
||||
}
|
||||
|
||||
// ---- Credential queries ----
|
||||
|
||||
export async function storeCredential(
|
||||
credentialId: string,
|
||||
userId: number,
|
||||
publicKey: string,
|
||||
counter: number,
|
||||
transports?: string[],
|
||||
) {
|
||||
await db`
|
||||
INSERT INTO credentials (id, user_id, public_key, counter, transports)
|
||||
VALUES (${credentialId}, ${userId}, ${publicKey}, ${counter}, ${transports ? JSON.stringify(transports) : null})
|
||||
`
|
||||
}
|
||||
|
||||
export async function getCredentialById(credentialId: string) {
|
||||
const [cred] = await db`SELECT * FROM credentials WHERE id = ${credentialId}`
|
||||
return cred as {
|
||||
id: string
|
||||
user_id: number
|
||||
public_key: string
|
||||
counter: number
|
||||
transports: string | null
|
||||
} | undefined
|
||||
}
|
||||
|
||||
export async function getCredentialsByUserId(userId: number) {
|
||||
const creds = await db`SELECT * FROM credentials WHERE user_id = ${userId}`
|
||||
return creds as Array<{
|
||||
id: string
|
||||
user_id: number
|
||||
public_key: string
|
||||
counter: number
|
||||
transports: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
export async function updateCredentialCounter(credentialId: string, counter: number) {
|
||||
await db`UPDATE credentials SET counter = ${counter} WHERE id = ${credentialId}`
|
||||
}
|
||||
|
||||
// ---- Session queries ----
|
||||
|
||||
export async function createSession(userId: number): Promise<string> {
|
||||
const token = crypto.randomUUID()
|
||||
const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString()
|
||||
await db`
|
||||
INSERT INTO sessions (token, user_id, expires_at)
|
||||
VALUES (${token}, ${userId}, ${expires})
|
||||
`
|
||||
return token
|
||||
}
|
||||
|
||||
export async function getSession(token: string) {
|
||||
const [session] = await db`
|
||||
SELECT s.token, s.user_id, s.expires_at, u.username
|
||||
FROM sessions s JOIN users u ON s.user_id = u.id
|
||||
WHERE s.token = ${token} AND s.expires_at > datetime('now')
|
||||
`
|
||||
return session as { token: string; user_id: number; username: string; expires_at: string } | undefined
|
||||
}
|
||||
|
||||
export async function deleteSession(token: string) {
|
||||
await db`DELETE FROM sessions WHERE token = ${token}`
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue