diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/auth.tsx | 93 | ||||
-rw-r--r-- | src/db/schema.ts | 34 | ||||
-rw-r--r-- | src/index.tsx | 21 |
3 files changed, 139 insertions, 9 deletions
diff --git a/src/auth.tsx b/src/auth.tsx new file mode 100644 index 0000000..ebda74e --- /dev/null +++ b/src/auth.tsx @@ -0,0 +1,93 @@ +import { Hono } from "hono"; +import * as swa from "@simplewebauthn/server"; +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { RP_ID, ORIGIN, db } from "./index.js"; +import { sessionTable, userTable, webauthnChallenges } from "./db/schema.js"; +import { stringify, parse } from "superjson"; +import { setCookie } from "hono/cookie"; + +let app = new Hono(); + +export const LoginForm = () => <section class="register-form"> + <button hx-post="/auth/register-begin" hx-target="closest .register-form" hx-swap="outerHTML">register</button> + <button hx-post="/auth/login-begin" hx-target="closest .register-form" hx-swap="outerHTML">login</button> +</section>; + +app.post("/register-begin", async c => { + const username = randomUUID(); + let options = await swa.generateRegistrationOptions({ + rpName: "uneven", + rpID: RP_ID, + userName: username, + authenticatorSelection: { + residentKey: "required", + userVerification: "preferred", + }, + }); + await db.insert(webauthnChallenges).values({ key: "register:" + username, challenge: options.challenge }); + return c.html( + <section + class="register-form" + _={` + on load + call SimpleWebAuthnBrowser.startRegistration(${JSON.stringify(options)}) + fetch /auth/register-finish with method:"POST", body:JSON.stringify({ resp: it, username: ${JSON.stringify(username)} }) + put the result into my.outerHTML + `} + /> + ); +}); + +app.post("/register-finish", async c => { + let { resp, username } = await c.req.json(); + let [{ chall }] = await db.delete(webauthnChallenges).where(eq(webauthnChallenges.key, "register:" + username)).returning({ chall: webauthnChallenges.challenge }); + let r = await swa.verifyRegistrationResponse({ response: resp, expectedChallenge: chall, expectedOrigin: ORIGIN }); + if (!r.verified || !("registrationInfo" in r)) return c.html(<p style="color: red">Could not verify registration response!</p>); + await db.insert(userTable).values({ name: username, passkey: stringify(r.registrationInfo), passkeyId: r.registrationInfo!.credential.id }); + return c.html( + <p>You now have an account!</p> + ); +}); + +app.post("/login-begin", async c => { + let options = await swa.generateAuthenticationOptions({ + rpID: RP_ID, + userVerification: "preferred", + }); + let key = randomUUID(); + await db.insert(webauthnChallenges).values({ challenge: options.challenge, key: "login:" + key }); + return c.html( + <section + class="register-form" + _={` + on load + call SimpleWebAuthnBrowser.startAuthentication(${JSON.stringify(options)}) + fetch /auth/login-finish with method:"POST", body:JSON.stringify({ resp: it, key: ${JSON.stringify(key)} }) + put the result into my.outerHTML + `} + /> + ); +}); + +app.post("/login-finish", async c => { + let { resp, key } = await c.req.json(); + let [{ chall }] = await db.delete(webauthnChallenges).where(eq(webauthnChallenges.key, "login:" + key)).returning({ chall: webauthnChallenges.challenge }); + let [{ id: userId, passkey }] = await db.select().from(userTable).where(user => eq(user.passkeyId, resp.id)); + if (!passkey) return c.html(<p style="color: red">Who are you?</p>); + let r = await swa.verifyAuthenticationResponse({ + response: resp, + expectedChallenge: chall, + expectedOrigin: ORIGIN, + expectedRPID: RP_ID, + requireUserVerification: false, + credential: parse<swa.VerifiedRegistrationResponse["registrationInfo"]>(passkey)!.credential, + }); + if (!r.verified) return c.html(<p style="color: red">Could not verify authentication response!</p>); + let uuid = randomUUID(); + await db.insert(sessionTable).values({ userId, uuid }); + setCookie(c, "session", uuid); + return c.html(<p>Logged in!</p>); +}); + +export default app; diff --git a/src/db/schema.ts b/src/db/schema.ts index 15da652..01b7228 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,5 +1,33 @@ -import { text, sqliteTable } from "drizzle-orm/sqlite-core"; +import { relations } from "drizzle-orm"; +import { text, sqliteTable, integer } from "drizzle-orm/sqlite-core"; -export const groupsTable = sqliteTable("groups", { - name: text("name").notNull(), +export const groupTable = sqliteTable("groups", { + id: integer().primaryKey(), + name: text().notNull(), +}); + +export const userTable = sqliteTable("users", { + id: integer().primaryKey(), + name: text().unique().notNull(), + passkey: text(), + passkeyId: text("passkey_id").notNull(), +}); + +export const sessionTable = sqliteTable("sessions", { + id: integer().primaryKey(), + uuid: text().unique().notNull(), + userId: integer("user_id").notNull(), +}); + +export const sessionRelations = relations(sessionTable, ({ one }) => ({ + user: one(userTable, { + fields: [sessionTable.userId], + references: [userTable.id], + }), +})); + +export const webauthnChallenges = sqliteTable("webauthn_challenges", { + id: integer().primaryKey(), + challenge: text().notNull(), + key: text().notNull(), }); diff --git a/src/index.tsx b/src/index.tsx index c72d766..e029577 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,13 +2,17 @@ import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { createClient } from "@libsql/client"; import { drizzle } from "drizzle-orm/libsql"; -import { groupsTable } from "./db/schema.js"; +import { groupTable } from "./db/schema.js"; +import authRouter, { LoginForm } from "./auth.js"; -const app = new Hono(); -const db = drizzle(createClient({ url: "file:data.db" })); +export const RP_ID = "localhost"; // "uneven.0m.nu"; +export const ORIGIN = `http://${RP_ID}`; + +let app = new Hono(); +export let db = drizzle(createClient({ url: "file:data.db" })); async function Groups() { - const result = await db.select().from(groupsTable).all(); + let result = await db.select().from(groupTable).all(); return <ul>{ result.map(group => <li>{group.name}</li>) @@ -20,10 +24,13 @@ app.get("/", c => c.html( <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.6/dist/htmx.min.js" integrity="sha384-Akqfrbj/HpNVo8k11SXBb6TlBWmXXlYQrCSqEWmyKJe+hDm3Z/B2WVG4smwBkRVm" crossorigin="anonymous"></script> + <script src="https://unpkg.com/htmx.org@2.0.6/dist/htmx.min.js" integrity="sha384-Akqfrbj/HpNVo8k11SXBb6TlBWmXXlYQrCSqEWmyKJe+hDm3Z/B2WVG4smwBkRVm" crossorigin="anonymous"></script> + <script src="https://unpkg.com/hyperscript.org@0.9.14" integrity="sha384-NzchC8z9HmP/Ed8cheGl9XuSrFSkDNHPiDl+ujbHE0F0I7tWC4rUnwPXP+7IvVZv" crossorigin="anonymous"></script> + <script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js" integrity="sha384-x+9k/LwnOU31Uw0BjGIuH0mJYPM4b5yBa/0GkqcR5tlgphBf9LtXYySTFNK/UtL3" crossorigin="anonymous"></script> <title>Uneven</title> </head> <body> + <LoginForm /> <Groups /> <button hx-get="/button" hx-swap="outerHTML">click me!</button> </body> @@ -39,7 +46,9 @@ app.get("/button", c => c.html( >disco button!</button> )); +app.route("/auth", authRouter); + serve({ fetch: app.fetch, - port: 3000, + port: 80, }, info => console.log(`Server is running on http://localhost:${info.port}`)); |