From cf46450f5f9e3eb4a12c1e0ecda2948a67eab563 Mon Sep 17 00:00:00 2001 From: Andy Burke Date: Fri, 4 Jul 2025 14:51:49 -0700 Subject: [PATCH] fix: login sessions --- deno.json | 1 + deno.lock | 5 ++ public/api/auth/index.ts | 13 ++-- public/api/users/index.ts | 7 +-- public/index.html | 1 + public/js/smartforms.js | 22 +++++-- public/signup_login_wall.html | 4 +- public/tabs/talk.html | 17 ++--- public/tabs/user.html | 19 ++++++ utils/prechecks.ts | 3 + utils/totp.ts | 114 ++++++++++++++++++++++++++++++++++ 11 files changed, 179 insertions(+), 27 deletions(-) create mode 100644 utils/totp.ts diff --git a/deno.json b/deno.json index 2650347..d22dcda 100644 --- a/deno.json +++ b/deno.json @@ -35,6 +35,7 @@ "@andyburke/fsdb": "jsr:@andyburke/fsdb@^0.9.0", "@andyburke/lurid": "jsr:@andyburke/lurid@^0.2.0", "@andyburke/serverus": "jsr:@andyburke/serverus@^0.7.1", + "@da/bcrypt": "jsr:@da/bcrypt@^1.0.1", "@std/assert": "jsr:@std/assert@^1.0.13", "@std/encoding": "jsr:@std/encoding@^1.0.10", "@std/http": "jsr:@std/http@^1.0.19", diff --git a/deno.lock b/deno.lock index 26b9edc..0b5849b 100644 --- a/deno.lock +++ b/deno.lock @@ -7,6 +7,7 @@ "jsr:@andyburke/lurid@0.2": "0.2.0", "jsr:@andyburke/serverus@*": "0.7.1", "jsr:@andyburke/serverus@~0.7.1": "0.7.1", + "jsr:@da/bcrypt@^1.0.1": "1.0.1", "jsr:@std/assert@*": "1.0.13", "jsr:@std/assert@^1.0.13": "1.0.13", "jsr:@std/async@^1.0.13": "1.0.13", @@ -64,6 +65,9 @@ "jsr:@std/path@^1.0.8" ] }, + "@da/bcrypt@1.0.1": { + "integrity": "d2172d3acbcff52e0465557a1a48b1ff1c92df08c90712dae5372255a8c45eb3" + }, "@std/assert@1.0.13": { "integrity": "ae0d31e41919b12c656c742b22522c32fb26ed0cba32975cb0de2a273cb68b29", "dependencies": [ @@ -205,6 +209,7 @@ "jsr:@andyburke/fsdb@0.9", "jsr:@andyburke/lurid@0.2", "jsr:@andyburke/serverus@~0.7.1", + "jsr:@da/bcrypt@^1.0.1", "jsr:@std/assert@^1.0.13", "jsr:@std/encoding@^1.0.10", "jsr:@std/http@^1.0.19", diff --git a/public/api/auth/index.ts b/public/api/auth/index.ts index 583ef6d..810c542 100644 --- a/public/api/auth/index.ts +++ b/public/api/auth/index.ts @@ -1,15 +1,14 @@ import { PASSWORD_ENTRIES } from '../../../models/password_entry.ts'; import { USER, USERS } from '../../../models/user.ts'; -import { generateSecret } from 'jsr:@stdext/crypto/utils'; import { encodeBase32 } from 'jsr:@std/encoding'; -import { verify } from 'jsr:@stdext/crypto/hash'; import lurid from 'jsr:@andyburke/lurid'; import { SESSION, SESSIONS } from '../../../models/session.ts'; import { TOTP_ENTRIES } from '../../../models/totp_entry.ts'; -import { verifyTotp } from 'jsr:@stdext/crypto/totp'; import { encodeBase64 } from 'jsr:@std/encoding/base64'; import parse_body from '../../../utils/bodyparser.ts'; import { SESSION_ID_TOKEN, SESSION_SECRET_TOKEN } from '../../../utils/prechecks.ts'; +import * as bcrypt from 'jsr:@da/bcrypt'; +import { verifyTotp } from '../../../utils/totp.ts'; const DEFAULT_SESSION_TIME: number = 60 * 60 * 1_000; // 1 Hour @@ -78,7 +77,8 @@ export async function POST(req: Request, meta: Record): Promise { const now = new Date().toISOString(); const expires: string = session_settings.expires ?? new Date(new Date(now).valueOf() + DEFAULT_SESSION_TIME).toISOString(); + crypto.getRandomValues(session_secret_buffer); + const session: SESSION = { id: lurid(), user_id: session_settings.user.id, - secret: encodeBase32(generateSecret()), + secret: encodeBase32(session_secret_buffer), timestamps: { created: now, expires, diff --git a/public/api/users/index.ts b/public/api/users/index.ts index 6fe5cee..a10197a 100644 --- a/public/api/users/index.ts +++ b/public/api/users/index.ts @@ -1,13 +1,12 @@ import { PASSWORD_ENTRIES, PASSWORD_ENTRY } from '../../../models/password_entry.ts'; import { USER, USERS } from '../../../models/user.ts'; -import { generateSecret } from 'jsr:@stdext/crypto/utils'; -import { hash } from 'jsr:@stdext/crypto/hash'; import lurid from 'jsr:@andyburke/lurid'; import { encodeBase64 } from 'jsr:@std/encoding'; import parse_body from '../../../utils/bodyparser.ts'; import { create_new_session, SESSION_RESULT } from '../auth/index.ts'; import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../utils/prechecks.ts'; import * as CANNED_RESPONSES from '../../../utils/canned_responses.ts'; +import * as bcrypt from 'jsr:@da/bcrypt'; // TODO: figure out a better solution for doling out permissions const DEFAULT_USER_PERMISSIONS: string[] = [ @@ -107,8 +106,8 @@ export async function POST(req: Request, meta: Record): Promise { try { + console.log( 'hi'); const session_response = await api.fetch("/users/me"); if (!session_response.ok) { diff --git a/public/js/smartforms.js b/public/js/smartforms.js index a170f3c..506f24e 100644 --- a/public/js/smartforms.js +++ b/public/js/smartforms.js @@ -1,6 +1,6 @@ document.addEventListener("DOMContentLoaded", () => { /* make all forms semi-smart */ - const forms = document.querySelectorAll("form"); + const forms = document.querySelectorAll("form[data-smart]"); for (const form of forms) { const script = form.querySelector("script"); @@ -21,17 +21,27 @@ document.addEventListener("DOMContentLoaded", () => { } const url = form.action; + const method = form.dataset.method ?? "POST"; + console.dir({ + method, + form, + }); try { // TODO: send session header - const response = await fetch(url, { - method: "POST", + const options = { + method, headers: { Accept: "application/json", - "Content-Type": "application/json", }, - body: JSON.stringify(body), - }); + }; + + if (["POST", "PUT", "PATCH"].includes(method)) { + options.headers["Content-Type"] = "application/json"; + options.body = JSON.stringify(body); + } + + const response = await fetch(url, options); if (!response.ok) { const error_body = await response.json(); diff --git a/public/signup_login_wall.html b/public/signup_login_wall.html index ee05a1a..3267114 100644 --- a/public/signup_login_wall.html +++ b/public/signup_login_wall.html @@ -102,6 +102,8 @@
Sign Up
- + + +
diff --git a/utils/prechecks.ts b/utils/prechecks.ts index f3d0333..375a226 100644 --- a/utils/prechecks.ts +++ b/utils/prechecks.ts @@ -22,6 +22,9 @@ export async function get_session(request: Request, meta: Record): meta.valid_totp = meta.valid_session && meta.session && meta.request_totp ? await verifyTotp(meta.request_totp, meta.session.secret) : false; + console.dir({ + meta + }); } export async function get_user(request: Request, meta: Record): Promise { diff --git a/utils/totp.ts b/utils/totp.ts new file mode 100644 index 0000000..d2bd404 --- /dev/null +++ b/utils/totp.ts @@ -0,0 +1,114 @@ +// https://jsr.io/@stdext/crypto/0.1.0/hotp.ts +// https://jsr.io/@stdext/crypto/0.1.0/totp.ts + +import { decodeBase32 } from 'jsr:@std/encoding@^1'; + +/** Converts a counter value to a DataView. + * + * @ignore + */ +export function counterToBuffer(counter: number): DataView { + const buffer = new DataView(new ArrayBuffer(8)); + buffer.setBigUint64(0, BigInt(counter), false); + return buffer; +} + +/** Generates a HMAC-SHA1 hash of the specified key and counter. + * + * @ignore + */ +export async function generateHmacSha1( + key: Uint8Array, + data: BufferSource +): Promise { + const importedKey = await crypto.subtle.importKey( + 'raw', + key, + { name: 'HMAC', hash: 'SHA-1' }, + false, + ['sign'] + ); + + const signedData = await crypto.subtle.sign( + 'HMAC', + importedKey, + data + ); + + return new Uint8Array(signedData); +} + +/** Truncates the HMAC value to a 6-digit HOTP value. + * + * @ignore + */ +export function truncate(value: Uint8Array, length: number): string { + const offset = value[19] & 0xf; + const code = (value[offset] & 0x7f) << 24 | + (value[offset + 1] & 0xff) << 16 | + (value[offset + 2] & 0xff) << 8 | + (value[offset + 3] & 0xff); + const digits = code % Math.pow(10, length); + return digits.toString().padStart(length, '0'); +} + +/** Generates a HMAC-based one-time password (HOTP) using the specified key and counter. + * + * @param key a secret key used to generate the HOTP. Can be a string in base32 encoding or a Uint8Array. + * @param counter a counter value used to generate the HOTP. + * @returns a 6-digit HOTP value. + */ +export async function generateHotp( + key: string | Uint8Array, + counter: number +): Promise { + const parsedKey = typeof key === 'string' ? decodeBase32(key) : key; + const buffer = counterToBuffer(counter); + + const hmac = await generateHmacSha1(parsedKey, buffer); + return truncate(hmac, 6); +} + +/** Verifies a HMAC-based one-time password (HOTP) using the specified key and counter. + * + * @param otp the one-time password to verify. + * @param key a secret key used to generate the HOTP. Can be a string in base32 encoding or a Uint8Array. + * @param counter a counter value used to generate the HOTP. + */ +export async function verifyHotp( + otp: string, + key: string | Uint8Array, + counter: number +): Promise { + return otp === await generateHotp(key, counter); +} + +/** Generate a TOTP value from a key and a time. + * + * @param key a secret key used to generate the HOTP. Can be a string in base32 encoding or a Uint8Array. + * @param t0 the initial time to use for the counter. + * @returns the TOTP value. + */ +export function generateTotp( + key: string | Uint8Array, + t0: number = 0, + t: number = Date.now() +): Promise { + const counter = Math.floor((t - t0) / 30000); + return generateHotp(key, counter); +} + +/** Verifies a TOTP value from a key and a time. + * + * @param otp the one-time password to verify. + * @param key a secret key used to generate the HOTP. Can be a string in base32 encoding or a Uint8Array. + * @param t0 the initial time to use for the counter. + */ +export async function verifyTotp( + otp: string, + key: string | Uint8Array, + t0: number = 0, + t: number = Date.now() +): Promise { + return otp === await generateTotp(key, t0, t); +}