├── .env.example ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── astro.config.mjs ├── package.json ├── pnpm-lock.yaml ├── setup.sql ├── src ├── env.d.ts ├── lib │ ├── client │ │ └── webauthn.ts │ └── server │ │ ├── 2fa.ts │ │ ├── db.ts │ │ ├── email-verification.ts │ │ ├── email.ts │ │ ├── encryption.ts │ │ ├── password-reset.ts │ │ ├── password.ts │ │ ├── rate-limit.ts │ │ ├── session.ts │ │ ├── totp.ts │ │ ├── user.ts │ │ ├── utils.ts │ │ └── webauthn.ts ├── middleware.ts └── pages │ ├── 2fa │ ├── index.ts │ ├── passkey │ │ ├── index.astro │ │ └── register.astro │ ├── reset.astro │ ├── security-key │ │ ├── index.astro │ │ └── register.astro │ ├── setup.astro │ └── totp │ │ ├── index.astro │ │ └── setup.astro │ ├── api │ ├── email-verification │ │ ├── index.ts │ │ ├── resend-code.ts │ │ └── verify.ts │ ├── login-passkey.ts │ ├── login.ts │ ├── password-reset │ │ ├── session.ts │ │ ├── update-password.ts │ │ ├── verify-2fa │ │ │ ├── passkey.ts │ │ │ ├── recovery-code.ts │ │ │ ├── security-key.ts │ │ │ └── totp.ts │ │ └── verify-email.ts │ ├── session.ts │ ├── user │ │ ├── index.ts │ │ ├── passkey │ │ │ ├── credential.ts │ │ │ ├── credentials │ │ │ │ └── [credential_id].ts │ │ │ └── verify.ts │ │ ├── password.ts │ │ ├── recovery-code │ │ │ ├── index.ts │ │ │ └── reset.ts │ │ ├── reset-2fa.ts │ │ ├── security-key │ │ │ ├── credential.ts │ │ │ ├── credentials │ │ │ │ └── [credential_id].ts │ │ │ └── verify.ts │ │ └── totp │ │ │ ├── index.ts │ │ │ └── verify.ts │ └── webauthn │ │ └── challenge.ts │ ├── forgot-password.astro │ ├── index.astro │ ├── login.astro │ ├── recovery-code.astro │ ├── reset-password │ ├── 2fa │ │ ├── index.ts │ │ ├── passkey.astro │ │ ├── recovery-code.astro │ │ ├── security-key.astro │ │ └── totp.astro │ ├── index.astro │ └── verify-email.astro │ ├── settings.astro │ ├── signup.astro │ └── verify-email.astro └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | ENCRYPTION_KEY="" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | # generated types 4 | .astro/ 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # logs 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | 23 | # jetbrains setting folder 24 | .idea/ 25 | 26 | sqlite.db -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "trailingComma": "none", 4 | "printWidth": 120, 5 | "plugins": ["prettier-plugin-astro"], 6 | "overrides": [ 7 | { 8 | "files": "*.astro", 9 | "options": { 10 | "parser": "astro" 11 | } 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 pilcrowOnPaper and contributors 2 | 3 | Permission to use, copy, modify, and/or distribute this software for 4 | any purpose with or without fee is hereby granted. 5 | 6 | THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL 7 | WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES 8 | OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE 9 | FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY 10 | DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN 11 | AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT 12 | OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Email and password example with 2FA and WebAuthn in Astro 2 | 3 | Built with SQLite. 4 | 5 | - Password checks with HaveIBeenPwned 6 | - Sign in with passkeys 7 | - Email verification 8 | - 2FA with TOTP 9 | - 2FA recovery codes 10 | - 2FA with passkeys and security keys 11 | - Password reset with 2FA 12 | - Login throttling and rate limiting 13 | 14 | Emails are just logged to the console. Rate limiting is implemented using JavaScript `Map`. 15 | 16 | ## Initialize project 17 | 18 | Create `sqlite.db` and run `setup.sql`. 19 | 20 | ``` 21 | sqlite3 sqlite.db 22 | ``` 23 | 24 | Create a .env file. Generate a 128 bit (16 byte) string, base64 encode it, and set it as `ENCRYPTION_KEY`. 25 | 26 | ```bash 27 | ENCRYPTION_KEY="L9pmqRJnO1ZJSQ2svbHuBA==" 28 | ``` 29 | 30 | > You can use OpenSSL to quickly generate a secure key. 31 | > 32 | > ```bash 33 | > openssl rand --base64 16 34 | > ``` 35 | 36 | Install dependencies and run the application: 37 | 38 | ``` 39 | pnpm i 40 | pnpm dev 41 | ``` 42 | 43 | ## Notes 44 | 45 | - We do not consider user enumeration to be a real vulnerability so please don't open issues on it. If you really need to prevent it, just don't use emails. 46 | - This example does not handle unexpected errors gracefully. 47 | - There are some major code duplications (specifically for 2FA) to keep the codebase simple. 48 | - Astro warns about unused functions (`get2FARedirect()`) but this is a bug with the language server. 49 | - TODO: Passkeys will only work when hosted on `localhost:4321`. Update the host and origin values before deploying. 50 | - TODO: You may need to rewrite some queries and use transactions to avoid race conditions when using MySQL, Postgres, etc. 51 | - TODO: Users are not shown their recovery code when they first register their second factor. 52 | - TODO: This project relies on the `X-Forwarded-For` header for getting the client's IP address. 53 | - TODO: Logging should be implemented. 54 | -------------------------------------------------------------------------------- /astro.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "astro/config"; 2 | 3 | import node from "@astrojs/node"; 4 | 5 | // https://astro.build/config 6 | export default defineConfig({ 7 | output: "server", 8 | adapter: node({ 9 | mode: "standalone" 10 | }), 11 | security: { 12 | checkOrigin: true 13 | } 14 | }); 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-astro-email-password-webauthn", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "scripts": { 6 | "dev": "astro dev", 7 | "start": "astro dev", 8 | "build": "astro check && astro build", 9 | "preview": "astro preview", 10 | "astro": "astro", 11 | "format": "prettier -w ." 12 | }, 13 | "dependencies": { 14 | "@astrojs/check": "^0.8.3", 15 | "@astrojs/node": "^8.3.3", 16 | "@node-rs/argon2": "^1.8.3", 17 | "@oslojs/binary": "^0.3.0", 18 | "@oslojs/crypto": "^0.6.2", 19 | "@oslojs/encoding": "1.1.0", 20 | "@oslojs/otp": "^0.2.1", 21 | "@oslojs/webauthn": "^0.6.4", 22 | "@pilcrowjs/db-query": "^0.0.2", 23 | "@pilcrowjs/object-parser": "^0.0.3", 24 | "astro": "^4.14.3", 25 | "better-sqlite3": "^11.2.0", 26 | "typescript": "^5.5.4", 27 | "uqr": "^0.1.2" 28 | }, 29 | "devDependencies": { 30 | "@types/better-sqlite3": "^7.6.11", 31 | "prettier": "^3.3.3", 32 | "prettier-plugin-astro": "^0.14.1" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /setup.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE user ( 2 | id INTEGER NOT NULL PRIMARY KEY, 3 | email TEXT NOT NULL UNIQUE, 4 | username TEXT NOT NULL, 5 | password_hash TEXT NOT NULL, 6 | email_verified INTEGER NOT NULL DEFAULT 0, 7 | recovery_code BLOB NOT NULL 8 | ); 9 | 10 | CREATE INDEX email_index ON user(email); 11 | 12 | CREATE TABLE session ( 13 | id TEXT NOT NULL PRIMARY KEY, 14 | user_id INTEGER NOT NULL REFERENCES user(id), 15 | expires_at INTEGER NOT NULL, 16 | two_factor_verified INTEGER NOT NULL DEFAULT 0 17 | ); 18 | 19 | CREATE TABLE email_verification_request ( 20 | id TEXT NOT NULL PRIMARY KEY, 21 | user_id INTEGER NOT NULL REFERENCES user(id), 22 | email TEXT NOT NULL, 23 | code TEXT NOT NULL, 24 | expires_at INTEGER NOT NULL 25 | ); 26 | 27 | CREATE TABLE password_reset_session ( 28 | id TEXT NOT NULL PRIMARY KEY, 29 | user_id INTEGER NOT NULL REFERENCES user(id), 30 | email TEXT NOT NULL, 31 | code TEXT NOT NULL, 32 | expires_at INTEGER NOT NULL, 33 | email_verified INTEGER NOT NULL NOT NULL DEFAULT 0, 34 | two_factor_verified INTEGER NOT NULL DEFAULT 0 35 | ); 36 | 37 | CREATE TABLE totp_credential ( 38 | id INTEGER NOT NULL PRIMARY KEY, 39 | user_id INTEGER NOT NULL UNIQUE REFERENCES user(id), 40 | key BLOB NOT NULL 41 | ); 42 | 43 | CREATE TABLE passkey_credential ( 44 | id BLOB NOT NULL PRIMARY KEY, 45 | user_id INTEGER NOT NULL REFERENCES user(id), 46 | name TEXT NOT NULL, 47 | algorithm INTEGER NOT NULL, 48 | public_key BLOB NOT NULL 49 | ); 50 | 51 | CREATE TABLE security_key_credential ( 52 | id BLOB NOT NULL PRIMARY KEY, 53 | user_id INTEGER NOT NULL REFERENCES user(id), 54 | name TEXT NOT NULL, 55 | algorithm INTEGER NOT NULL, 56 | public_key BLOB NOT NULL 57 | ); 58 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | declare namespace App { 4 | interface Locals { 5 | user: import("./lib/server/user").User | null; 6 | session: import("./lib/server/session").Session | null; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/lib/client/webauthn.ts: -------------------------------------------------------------------------------- 1 | import { decodeBase64 } from "@oslojs/encoding"; 2 | import { ObjectParser } from "@pilcrowjs/object-parser"; 3 | 4 | export async function createChallenge(): Promise { 5 | const response = await fetch("/api/webauthn/challenge", { 6 | method: "POST" 7 | }); 8 | if (!response.ok) { 9 | throw new Error("Failed to create challenge"); 10 | } 11 | const result = await response.json(); 12 | const parser = new ObjectParser(result); 13 | return decodeBase64(parser.getString("challenge")); 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/server/2fa.ts: -------------------------------------------------------------------------------- 1 | import { db } from "./db"; 2 | import { generateRandomRecoveryCode } from "./utils"; 3 | import { ExpiringTokenBucket } from "./rate-limit"; 4 | import { decryptToString, encryptString } from "./encryption"; 5 | 6 | import type { User } from "./user"; 7 | 8 | export const recoveryCodeBucket = new ExpiringTokenBucket(3, 60 * 60); 9 | 10 | export function resetUser2FAWithRecoveryCode(userId: number, recoveryCode: string): boolean { 11 | // Note: In Postgres and MySQL, these queries should be done in a transaction using SELECT FOR UPDATE 12 | const row = db.queryOne("SELECT recovery_code FROM user WHERE id = ?", [userId]); 13 | if (row === null) { 14 | return false; 15 | } 16 | const encryptedRecoveryCode = row.bytes(0); 17 | const userRecoveryCode = decryptToString(encryptedRecoveryCode); 18 | if (recoveryCode !== userRecoveryCode) { 19 | return false; 20 | } 21 | 22 | const newRecoveryCode = generateRandomRecoveryCode(); 23 | const encryptedNewRecoveryCode = encryptString(newRecoveryCode); 24 | 25 | try { 26 | db.execute("BEGIN TRANSACTION", []); 27 | // Compare old recovery code to ensure recovery code wasn't updated. 28 | const result = db.execute("UPDATE user SET recovery_code = ? WHERE id = ? AND recovery_code = ?", [ 29 | encryptedNewRecoveryCode, 30 | userId, 31 | encryptedRecoveryCode 32 | ]); 33 | if (result.changes < 1) { 34 | db.execute("ROLLBACK", []); 35 | return false; 36 | } 37 | db.execute("UPDATE session SET two_factor_verified = 0 WHERE user_id = ?", [userId]); 38 | db.execute("DELETE FROM totp_credential WHERE user_id = ?", [userId]); 39 | db.execute("DELETE FROM passkey_credential WHERE user_id = ?", [userId]); 40 | db.execute("DELETE FROM security_key_credential WHERE user_id = ?", [userId]); 41 | db.execute("COMMIT", []); 42 | } catch (e) { 43 | if (db.inTransaction()) { 44 | db.execute("ROLLBACK", []); 45 | } 46 | throw e; 47 | } 48 | return true; 49 | } 50 | 51 | export function get2FARedirect(user: User): string { 52 | if (user.registeredPasskey) { 53 | return "/2fa/passkey"; 54 | } 55 | if (user.registeredSecurityKey) { 56 | return "/2fa/security-key"; 57 | } 58 | if (user.registeredTOTP) { 59 | return "/2fa/totp"; 60 | } 61 | return "/2fa/setup"; 62 | } 63 | 64 | export function getPasswordReset2FARedirect(user: User): string { 65 | if (user.registeredPasskey) { 66 | return "/reset-password/2fa/passkey"; 67 | } 68 | if (user.registeredSecurityKey) { 69 | return "/reset-password/2fa/security-key"; 70 | } 71 | if (user.registeredTOTP) { 72 | return "/reset-password/2fa/totp"; 73 | } 74 | return "/2fa/setup"; 75 | } 76 | -------------------------------------------------------------------------------- /src/lib/server/db.ts: -------------------------------------------------------------------------------- 1 | import sqlite3 from "better-sqlite3"; 2 | import { SyncDatabase } from "@pilcrowjs/db-query"; 3 | 4 | import type { SyncAdapter } from "@pilcrowjs/db-query"; 5 | 6 | const sqlite = sqlite3("sqlite.db"); 7 | 8 | const adapter: SyncAdapter = { 9 | query: (statement: string, params: unknown[]): unknown[][] => { 10 | const result = sqlite 11 | .prepare(statement) 12 | .raw() 13 | .all(...params); 14 | return result as unknown[][]; 15 | }, 16 | execute: (statement: string, params: unknown[]): sqlite3.RunResult => { 17 | const result = sqlite.prepare(statement).run(...params); 18 | return result; 19 | } 20 | }; 21 | 22 | class Database extends SyncDatabase { 23 | public inTransaction(): boolean { 24 | return sqlite.inTransaction; 25 | } 26 | } 27 | 28 | export const db = new Database(adapter); 29 | -------------------------------------------------------------------------------- /src/lib/server/email-verification.ts: -------------------------------------------------------------------------------- 1 | import { generateRandomOTP } from "./utils"; 2 | import { db } from "./db"; 3 | import { ExpiringTokenBucket } from "./rate-limit"; 4 | import { encodeBase32LowerCaseNoPadding } from "@oslojs/encoding"; 5 | 6 | import type { APIContext } from "astro"; 7 | 8 | export function getUserEmailVerificationRequest(userId: number, id: string): EmailVerificationRequest | null { 9 | const row = db.queryOne( 10 | "SELECT id, user_id, code, email, expires_at FROM email_verification_request WHERE id = ? AND user_id = ?", 11 | [id, userId] 12 | ); 13 | if (row === null) { 14 | return row; 15 | } 16 | const request: EmailVerificationRequest = { 17 | id: row.string(0), 18 | userId: row.number(1), 19 | code: row.string(2), 20 | email: row.string(3), 21 | expiresAt: new Date(row.number(4) * 1000) 22 | }; 23 | return request; 24 | } 25 | 26 | export function createEmailVerificationRequest(userId: number, email: string): EmailVerificationRequest { 27 | deleteUserEmailVerificationRequest(userId); 28 | const idBytes = new Uint8Array(20); 29 | crypto.getRandomValues(idBytes); 30 | const id = encodeBase32LowerCaseNoPadding(idBytes); 31 | 32 | const code = generateRandomOTP(); 33 | const expiresAt = new Date(Date.now() + 1000 * 60 * 10); 34 | db.queryOne( 35 | "INSERT INTO email_verification_request (id, user_id, code, email, expires_at) VALUES (?, ?, ?, ?, ?) RETURNING id", 36 | [id, userId, code, email, Math.floor(expiresAt.getTime() / 1000)] 37 | ); 38 | 39 | const request: EmailVerificationRequest = { 40 | id, 41 | userId, 42 | code, 43 | email, 44 | expiresAt 45 | }; 46 | return request; 47 | } 48 | 49 | export function deleteUserEmailVerificationRequest(userId: number): void { 50 | db.execute("DELETE FROM email_verification_request WHERE user_id = ?", [userId]); 51 | } 52 | 53 | export function sendVerificationEmail(email: string, code: string): void { 54 | console.log(`To ${email}: Your verification code is ${code}`); 55 | } 56 | 57 | export function setEmailVerificationRequestCookie(context: APIContext, request: EmailVerificationRequest): void { 58 | context.cookies.set("email_verification", request.id, { 59 | httpOnly: true, 60 | path: "/", 61 | secure: import.meta.env.PROD, 62 | sameSite: "lax", 63 | expires: request.expiresAt 64 | }); 65 | } 66 | 67 | export function deleteEmailVerificationRequestCookie(context: APIContext): void { 68 | context.cookies.set("email_verification", "", { 69 | httpOnly: true, 70 | path: "/", 71 | secure: import.meta.env.PROD, 72 | sameSite: "lax", 73 | maxAge: 0 74 | }); 75 | } 76 | 77 | export function getUserEmailVerificationRequestFromRequest(context: APIContext): EmailVerificationRequest | null { 78 | if (context.locals.user === null) { 79 | return null; 80 | } 81 | const id = context.cookies.get("email_verification")?.value ?? null; 82 | if (id === null) { 83 | return null; 84 | } 85 | const request = getUserEmailVerificationRequest(context.locals.user.id, id); 86 | if (request === null) { 87 | deleteEmailVerificationRequestCookie(context); 88 | } 89 | return request; 90 | } 91 | 92 | export const sendVerificationEmailBucket = new ExpiringTokenBucket(3, 60 * 10); 93 | 94 | export interface EmailVerificationRequest { 95 | id: string; 96 | userId: number; 97 | code: string; 98 | email: string; 99 | expiresAt: Date; 100 | } 101 | -------------------------------------------------------------------------------- /src/lib/server/email.ts: -------------------------------------------------------------------------------- 1 | import { db } from "./db"; 2 | 3 | export function verifyEmailInput(email: string): boolean { 4 | return /^.+@.+\..+$/.test(email) && email.length < 256; 5 | } 6 | 7 | export function checkEmailAvailability(email: string): boolean { 8 | const row = db.queryOne("SELECT COUNT(*) FROM user WHERE email = ?", [email]); 9 | if (row === null) { 10 | throw new Error(); 11 | } 12 | return row.number(0) === 0; 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/server/encryption.ts: -------------------------------------------------------------------------------- 1 | import { decodeBase64 } from "@oslojs/encoding"; 2 | import { createCipheriv, createDecipheriv } from "crypto"; 3 | import { DynamicBuffer } from "@oslojs/binary"; 4 | 5 | const key = decodeBase64(import.meta.env.ENCRYPTION_KEY); 6 | 7 | export function encrypt(data: Uint8Array): Uint8Array { 8 | const iv = new Uint8Array(16); 9 | crypto.getRandomValues(iv); 10 | const cipher = createCipheriv("aes-128-gcm", key, iv); 11 | const encrypted = new DynamicBuffer(0); 12 | encrypted.write(iv); 13 | encrypted.write(cipher.update(data)); 14 | encrypted.write(cipher.final()); 15 | encrypted.write(cipher.getAuthTag()); 16 | return encrypted.bytes(); 17 | } 18 | 19 | export function encryptString(data: string): Uint8Array { 20 | return encrypt(new TextEncoder().encode(data)); 21 | } 22 | 23 | export function decrypt(encrypted: Uint8Array): Uint8Array { 24 | if (encrypted.byteLength < 33) { 25 | throw new Error("Invalid data"); 26 | } 27 | const decipher = createDecipheriv("aes-128-gcm", key, encrypted.slice(0, 16)); 28 | decipher.setAuthTag(encrypted.slice(encrypted.byteLength - 16)); 29 | const decrypted = new DynamicBuffer(0); 30 | decrypted.write(decipher.update(encrypted.slice(16, encrypted.byteLength - 16))); 31 | decrypted.write(decipher.final()); 32 | return decrypted.bytes(); 33 | } 34 | 35 | export function decryptToString(data: Uint8Array): string { 36 | return new TextDecoder().decode(decrypt(data)); 37 | } 38 | -------------------------------------------------------------------------------- /src/lib/server/password-reset.ts: -------------------------------------------------------------------------------- 1 | import { db } from "./db"; 2 | import { encodeHexLowerCase } from "@oslojs/encoding"; 3 | import { generateRandomOTP } from "./utils"; 4 | import { sha256 } from "@oslojs/crypto/sha2"; 5 | 6 | import type { APIContext } from "astro"; 7 | import type { User } from "./user"; 8 | 9 | export function createPasswordResetSession(token: string, userId: number, email: string): PasswordResetSession { 10 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 11 | const session: PasswordResetSession = { 12 | id: sessionId, 13 | userId, 14 | email, 15 | expiresAt: new Date(Date.now() + 1000 * 60 * 10), 16 | code: generateRandomOTP(), 17 | emailVerified: false, 18 | twoFactorVerified: false 19 | }; 20 | db.execute("INSERT INTO password_reset_session (id, user_id, email, code, expires_at) VALUES (?, ?, ?, ?, ?)", [ 21 | session.id, 22 | session.userId, 23 | session.email, 24 | session.code, 25 | Math.floor(session.expiresAt.getTime() / 1000) 26 | ]); 27 | return session; 28 | } 29 | 30 | export function validatePasswordResetSessionToken(token: string): PasswordResetSessionValidationResult { 31 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 32 | const row = db.queryOne( 33 | `SELECT password_reset_session.id, password_reset_session.user_id, password_reset_session.email, password_reset_session.code, password_reset_session.expires_at, password_reset_session.email_verified, password_reset_session.two_factor_verified, 34 | user.id, user.email, user.username, user.email_verified, IIF(totp_credential.id IS NOT NULL, 1, 0), IIF(passkey_credential.id IS NOT NULL, 1, 0), IIF(security_key_credential.id IS NOT NULL, 1, 0) FROM password_reset_session 35 | INNER JOIN user ON password_reset_session.user_id = user.id 36 | LEFT JOIN totp_credential ON user.id = totp_credential.user_id 37 | LEFT JOIN passkey_credential ON user.id = passkey_credential.user_id 38 | LEFT JOIN security_key_credential ON user.id = security_key_credential.user_id 39 | WHERE password_reset_session.id = ?`, 40 | [sessionId] 41 | ); 42 | if (row === null) { 43 | return { session: null, user: null }; 44 | } 45 | const session: PasswordResetSession = { 46 | id: row.string(0), 47 | userId: row.number(1), 48 | email: row.string(2), 49 | code: row.string(3), 50 | expiresAt: new Date(row.number(4) * 1000), 51 | emailVerified: Boolean(row.number(5)), 52 | twoFactorVerified: Boolean(row.number(6)) 53 | }; 54 | const user: User = { 55 | id: row.number(7), 56 | email: row.string(8), 57 | username: row.string(9), 58 | emailVerified: Boolean(row.number(10)), 59 | registeredTOTP: Boolean(row.number(11)), 60 | registeredPasskey: Boolean(row.number(12)), 61 | registeredSecurityKey: Boolean(row.number(13)), 62 | registered2FA: false 63 | }; 64 | if (user.registeredPasskey || user.registeredSecurityKey || user.registeredTOTP) { 65 | user.registered2FA = true; 66 | } 67 | if (Date.now() >= session.expiresAt.getTime()) { 68 | db.execute("DELETE FROM password_reset_session WHERE id = ?", [session.id]); 69 | return { session: null, user: null }; 70 | } 71 | return { session, user }; 72 | } 73 | 74 | export function setPasswordResetSessionAsEmailVerified(sessionId: string): void { 75 | db.execute("UPDATE password_reset_session SET email_verified = 1 WHERE id = ?", [sessionId]); 76 | } 77 | 78 | export function setPasswordResetSessionAs2FAVerified(sessionId: string): void { 79 | db.execute("UPDATE password_reset_session SET two_factor_verified = 1 WHERE id = ?", [sessionId]); 80 | } 81 | 82 | export function invalidateUserPasswordResetSessions(userId: number): void { 83 | db.execute("DELETE FROM password_reset_session WHERE user_id = ?", [userId]); 84 | } 85 | 86 | export function validatePasswordResetSessionRequest(context: APIContext): PasswordResetSessionValidationResult { 87 | const token = context.cookies.get("password_reset_session")?.value ?? null; 88 | if (token === null) { 89 | return { session: null, user: null }; 90 | } 91 | const result = validatePasswordResetSessionToken(token); 92 | if (result.session === null) { 93 | deletePasswordResetSessionTokenCookie(context); 94 | } 95 | return result; 96 | } 97 | 98 | export function setPasswordResetSessionTokenCookie(context: APIContext, token: string, expiresAt: Date): void { 99 | context.cookies.set("password_reset_session", token, { 100 | expires: expiresAt, 101 | sameSite: "lax", 102 | httpOnly: true, 103 | path: "/", 104 | secure: !import.meta.env.DEV 105 | }); 106 | } 107 | 108 | export function deletePasswordResetSessionTokenCookie(context: APIContext): void { 109 | context.cookies.set("password_reset_session", "", { 110 | maxAge: 0, 111 | sameSite: "lax", 112 | httpOnly: true, 113 | path: "/", 114 | secure: !import.meta.env.DEV 115 | }); 116 | } 117 | 118 | export function sendPasswordResetEmail(email: string, code: string): void { 119 | console.log(`To ${email}: Your reset code is ${code}`); 120 | } 121 | 122 | export interface PasswordResetSession { 123 | id: string; 124 | userId: number; 125 | email: string; 126 | expiresAt: Date; 127 | code: string; 128 | emailVerified: boolean; 129 | twoFactorVerified: boolean; 130 | } 131 | 132 | export type PasswordResetSessionValidationResult = 133 | | { session: PasswordResetSession; user: User } 134 | | { session: null; user: null }; 135 | -------------------------------------------------------------------------------- /src/lib/server/password.ts: -------------------------------------------------------------------------------- 1 | import { hash, verify } from "@node-rs/argon2"; 2 | import { sha1 } from "@oslojs/crypto/sha1"; 3 | import { encodeHexLowerCase } from "@oslojs/encoding"; 4 | 5 | export async function hashPassword(password: string): Promise { 6 | return await hash(password, { 7 | memoryCost: 19456, 8 | timeCost: 2, 9 | outputLen: 32, 10 | parallelism: 1 11 | }); 12 | } 13 | 14 | export async function verifyPasswordHash(hash: string, password: string): Promise { 15 | return await verify(hash, password); 16 | } 17 | 18 | export async function verifyPasswordStrength(password: string): Promise { 19 | if (password.length < 8 || password.length > 255) { 20 | return false; 21 | } 22 | const hash = encodeHexLowerCase(sha1(new TextEncoder().encode(password))); 23 | const hashPrefix = hash.slice(0, 5); 24 | const response = await fetch(`https://api.pwnedpasswords.com/range/${hashPrefix}`); 25 | const data = await response.text(); 26 | const items = data.split("\n"); 27 | for (const item of items) { 28 | const hashSuffix = item.slice(0, 35).toLowerCase(); 29 | if (hash === hashPrefix + hashSuffix) { 30 | return false; 31 | } 32 | } 33 | return true; 34 | } 35 | -------------------------------------------------------------------------------- /src/lib/server/rate-limit.ts: -------------------------------------------------------------------------------- 1 | export class RefillingTokenBucket<_Key> { 2 | public max: number; 3 | public refillIntervalSeconds: number; 4 | 5 | constructor(max: number, refillIntervalSeconds: number) { 6 | this.max = max; 7 | this.refillIntervalSeconds = refillIntervalSeconds; 8 | } 9 | 10 | private storage = new Map<_Key, RefillBucket>(); 11 | 12 | public check(key: _Key, cost: number): boolean { 13 | const bucket = this.storage.get(key) ?? null; 14 | if (bucket === null) { 15 | return true; 16 | } 17 | const now = Date.now(); 18 | const refill = Math.floor((now - bucket.refilledAt) / (this.refillIntervalSeconds * 1000)); 19 | if (refill > 0) { 20 | return Math.min(bucket.count + refill, this.max) >= cost; 21 | } 22 | return bucket.count >= cost; 23 | } 24 | 25 | public consume(key: _Key, cost: number): boolean { 26 | let bucket = this.storage.get(key) ?? null; 27 | const now = Date.now(); 28 | if (bucket === null) { 29 | bucket = { 30 | count: this.max - cost, 31 | refilledAt: now 32 | }; 33 | this.storage.set(key, bucket); 34 | return true; 35 | } 36 | const refill = Math.floor((now - bucket.refilledAt) / (this.refillIntervalSeconds * 1000)); 37 | bucket.count = Math.min(bucket.count + refill, this.max); 38 | bucket.refilledAt = now; 39 | if (bucket.count < cost) { 40 | return false; 41 | } 42 | bucket.count -= cost; 43 | this.storage.set(key, bucket); 44 | return true; 45 | } 46 | } 47 | 48 | export class Throttler<_Key> { 49 | public timeoutSeconds: number[]; 50 | 51 | private storage = new Map<_Key, ThrottlingCounter>(); 52 | 53 | constructor(timeoutSeconds: number[]) { 54 | this.timeoutSeconds = timeoutSeconds; 55 | } 56 | 57 | public consume(key: _Key): boolean { 58 | let counter = this.storage.get(key) ?? null; 59 | const now = Date.now(); 60 | if (counter === null) { 61 | counter = { 62 | timeout: 0, 63 | updatedAt: now 64 | }; 65 | this.storage.set(key, counter); 66 | return true; 67 | } 68 | const allowed = now - counter.updatedAt >= this.timeoutSeconds[counter.timeout] * 1000; 69 | if (!allowed) { 70 | return false; 71 | } 72 | counter.updatedAt = now; 73 | counter.timeout = Math.min(counter.timeout + 1, this.timeoutSeconds.length - 1); 74 | this.storage.set(key, counter); 75 | return true; 76 | } 77 | 78 | public reset(key: _Key): void { 79 | this.storage.delete(key); 80 | } 81 | } 82 | 83 | export class ExpiringTokenBucket<_Key> { 84 | public max: number; 85 | public expiresInSeconds: number; 86 | 87 | private storage = new Map<_Key, ExpiringBucket>(); 88 | 89 | constructor(max: number, expiresInSeconds: number) { 90 | this.max = max; 91 | this.expiresInSeconds = expiresInSeconds; 92 | } 93 | 94 | public check(key: _Key, cost: number): boolean { 95 | const bucket = this.storage.get(key) ?? null; 96 | const now = Date.now(); 97 | if (bucket === null) { 98 | return true; 99 | } 100 | if (now - bucket.createdAt >= this.expiresInSeconds * 1000) { 101 | return true; 102 | } 103 | return bucket.count >= cost; 104 | } 105 | 106 | public consume(key: _Key, cost: number): boolean { 107 | let bucket = this.storage.get(key) ?? null; 108 | const now = Date.now(); 109 | if (bucket === null) { 110 | bucket = { 111 | count: this.max - cost, 112 | createdAt: now 113 | }; 114 | this.storage.set(key, bucket); 115 | return true; 116 | } 117 | if (now - bucket.createdAt >= this.expiresInSeconds * 1000) { 118 | bucket.count = this.max; 119 | } 120 | if (bucket.count < cost) { 121 | return false; 122 | } 123 | bucket.count -= cost; 124 | this.storage.set(key, bucket); 125 | return true; 126 | } 127 | 128 | public reset(key: _Key): void { 129 | this.storage.delete(key); 130 | } 131 | } 132 | 133 | interface RefillBucket { 134 | count: number; 135 | refilledAt: number; 136 | } 137 | 138 | interface ExpiringBucket { 139 | count: number; 140 | createdAt: number; 141 | } 142 | 143 | interface ThrottlingCounter { 144 | timeout: number; 145 | updatedAt: number; 146 | } 147 | -------------------------------------------------------------------------------- /src/lib/server/session.ts: -------------------------------------------------------------------------------- 1 | import { db } from "./db"; 2 | import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from "@oslojs/encoding"; 3 | import { sha256 } from "@oslojs/crypto/sha2"; 4 | 5 | import type { User } from "./user"; 6 | import type { APIContext } from "astro"; 7 | 8 | export function validateSessionToken(token: string): SessionValidationResult { 9 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 10 | const row = db.queryOne( 11 | ` 12 | SELECT session.id, session.user_id, session.expires_at, session.two_factor_verified, user.id, user.email, user.username, user.email_verified, IIF(totp_credential.id IS NOT NULL, 1, 0), IIF(passkey_credential.id IS NOT NULL, 1, 0), IIF(security_key_credential.id IS NOT NULL, 1, 0) FROM session 13 | INNER JOIN user ON session.user_id = user.id 14 | LEFT JOIN totp_credential ON session.user_id = totp_credential.user_id 15 | LEFT JOIN passkey_credential ON user.id = passkey_credential.user_id 16 | LEFT JOIN security_key_credential ON user.id = security_key_credential.user_id 17 | WHERE session.id = ? 18 | `, 19 | [sessionId] 20 | ); 21 | 22 | if (row === null) { 23 | return { session: null, user: null }; 24 | } 25 | const session: Session = { 26 | id: row.string(0), 27 | userId: row.number(1), 28 | expiresAt: new Date(row.number(2) * 1000), 29 | twoFactorVerified: Boolean(row.number(3)) 30 | }; 31 | const user: User = { 32 | id: row.number(4), 33 | email: row.string(5), 34 | username: row.string(6), 35 | emailVerified: Boolean(row.number(7)), 36 | registeredTOTP: Boolean(row.number(8)), 37 | registeredPasskey: Boolean(row.number(9)), 38 | registeredSecurityKey: Boolean(row.number(10)), 39 | registered2FA: false 40 | }; 41 | if (user.registeredPasskey || user.registeredSecurityKey || user.registeredTOTP) { 42 | user.registered2FA = true; 43 | } 44 | if (Date.now() >= session.expiresAt.getTime()) { 45 | db.execute("DELETE FROM session WHERE id = ?", [sessionId]); 46 | return { session: null, user: null }; 47 | } 48 | if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { 49 | session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); 50 | db.execute("UPDATE session SET expires_at = ? WHERE session.id = ?", [ 51 | Math.floor(session.expiresAt.getTime() / 1000), 52 | sessionId 53 | ]); 54 | } 55 | return { session, user }; 56 | } 57 | 58 | export function invalidateSession(sessionId: string): void { 59 | db.execute("DELETE FROM session WHERE id = ?", [sessionId]); 60 | } 61 | 62 | export function invalidateUserSessions(userId: number): void { 63 | db.execute("DELETE FROM session WHERE user_id = ?", [userId]); 64 | } 65 | 66 | export function setSessionTokenCookie(context: APIContext, token: string, expiresAt: Date): void { 67 | context.cookies.set("session", token, { 68 | httpOnly: true, 69 | path: "/", 70 | secure: import.meta.env.PROD, 71 | sameSite: "lax", 72 | expires: expiresAt 73 | }); 74 | } 75 | 76 | export function deleteSessionTokenCookie(context: APIContext): void { 77 | context.cookies.set("session", "", { 78 | httpOnly: true, 79 | path: "/", 80 | secure: import.meta.env.PROD, 81 | sameSite: "lax", 82 | maxAge: 0 83 | }); 84 | } 85 | 86 | export function generateSessionToken(): string { 87 | const tokenBytes = new Uint8Array(20); 88 | crypto.getRandomValues(tokenBytes); 89 | const token = encodeBase32LowerCaseNoPadding(tokenBytes); 90 | return token; 91 | } 92 | 93 | export function createSession(token: string, userId: number, flags: SessionFlags): Session { 94 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 95 | const session: Session = { 96 | id: sessionId, 97 | userId, 98 | expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), 99 | twoFactorVerified: flags.twoFactorVerified 100 | }; 101 | db.execute("INSERT INTO session (id, user_id, expires_at, two_factor_verified) VALUES (?, ?, ?, ?)", [ 102 | session.id, 103 | session.userId, 104 | Math.floor(session.expiresAt.getTime() / 1000), 105 | Number(session.twoFactorVerified) 106 | ]); 107 | return session; 108 | } 109 | 110 | export function setSessionAs2FAVerified(sessionId: string): void { 111 | db.execute("UPDATE session SET two_factor_verified = 1 WHERE id = ?", [sessionId]); 112 | } 113 | 114 | export interface SessionFlags { 115 | twoFactorVerified: boolean; 116 | } 117 | 118 | export interface Session extends SessionFlags { 119 | id: string; 120 | expiresAt: Date; 121 | userId: number; 122 | } 123 | 124 | type SessionValidationResult = { session: Session; user: User } | { session: null; user: null }; 125 | -------------------------------------------------------------------------------- /src/lib/server/totp.ts: -------------------------------------------------------------------------------- 1 | import { db } from "./db"; 2 | import { decrypt, encrypt } from "./encryption"; 3 | import { ExpiringTokenBucket } from "./rate-limit"; 4 | 5 | export const totpBucket = new ExpiringTokenBucket(5, 60 * 30); 6 | 7 | export function getUserTOTPKey(userId: number): Uint8Array | null { 8 | const row = db.queryOne("SELECT totp_credential.key FROM totp_credential WHERE user_id = ?", [userId]); 9 | if (row === null) { 10 | throw new Error("Invalid user ID"); 11 | } 12 | const encrypted = row.bytesNullable(0); 13 | if (encrypted === null) { 14 | return null; 15 | } 16 | return decrypt(encrypted); 17 | } 18 | 19 | export function updateUserTOTPKey(userId: number, key: Uint8Array): void { 20 | const encrypted = encrypt(key); 21 | try { 22 | db.execute("BEGIN TRANSACTION", []); 23 | db.execute("DELETE FROM totp_credential WHERE user_id = ?", [userId]); 24 | db.execute("INSERT INTO totp_credential (user_id, key) VALUES (?, ?)", [userId, encrypted]); 25 | db.execute("COMMIT", []); 26 | } catch (e) { 27 | if (db.inTransaction()) { 28 | db.execute("ROLLBACK", []); 29 | } 30 | throw e; 31 | } 32 | } 33 | 34 | export function deleteUserTOTPKey(userId: number): void { 35 | db.execute("DELETE FROM totp_credential WHERE user_id = ?", [userId]); 36 | } 37 | -------------------------------------------------------------------------------- /src/lib/server/user.ts: -------------------------------------------------------------------------------- 1 | import { db } from "./db"; 2 | import { decryptToString, encryptString } from "./encryption"; 3 | import { hashPassword } from "./password"; 4 | import { generateRandomRecoveryCode } from "./utils"; 5 | 6 | export function verifyUsernameInput(username: string): boolean { 7 | return username.length > 3 && username.length < 32 && username.trim() === username; 8 | } 9 | 10 | export async function createUser(email: string, username: string, password: string): Promise { 11 | const passwordHash = await hashPassword(password); 12 | const recoveryCode = generateRandomRecoveryCode(); 13 | const encryptedRecoveryCode = encryptString(recoveryCode); 14 | const row = db.queryOne( 15 | "INSERT INTO user (email, username, password_hash, recovery_code) VALUES (?, ?, ?, ?) RETURNING user.id", 16 | [email, username, passwordHash, encryptedRecoveryCode] 17 | ); 18 | if (row === null) { 19 | throw new Error("Unexpected error"); 20 | } 21 | const user: User = { 22 | id: row.number(0), 23 | username, 24 | email, 25 | emailVerified: false, 26 | registeredTOTP: false, 27 | registeredPasskey: false, 28 | registeredSecurityKey: false, 29 | registered2FA: false 30 | }; 31 | return user; 32 | } 33 | 34 | export async function updateUserPassword(userId: number, password: string): Promise { 35 | const passwordHash = await hashPassword(password); 36 | db.execute("UPDATE user SET password_hash = ? WHERE id = ?", [passwordHash, userId]); 37 | } 38 | 39 | export function updateUserEmailAndSetEmailAsVerified(userId: number, email: string): void { 40 | db.execute("UPDATE user SET email = ?, email_verified = 1 WHERE id = ?", [email, userId]); 41 | } 42 | 43 | export function setUserAsEmailVerifiedIfEmailMatches(userId: number, email: string): boolean { 44 | const result = db.execute("UPDATE user SET email_verified = 1 WHERE id = ? AND email = ?", [userId, email]); 45 | return result.changes > 0; 46 | } 47 | 48 | export function getUserPasswordHash(userId: number): string { 49 | const row = db.queryOne("SELECT password_hash FROM user WHERE id = ?", [userId]); 50 | if (row === null) { 51 | throw new Error("Invalid user ID"); 52 | } 53 | return row.string(0); 54 | } 55 | 56 | export function getUserRecoverCode(userId: number): string { 57 | const row = db.queryOne("SELECT recovery_code FROM user WHERE id = ?", [userId]); 58 | if (row === null) { 59 | throw new Error("Invalid user ID"); 60 | } 61 | return decryptToString(row.bytes(0)); 62 | } 63 | 64 | export function resetUserRecoveryCode(userId: number): string { 65 | const recoveryCode = generateRandomRecoveryCode(); 66 | const encrypted = encryptString(recoveryCode); 67 | db.execute("UPDATE user SET recovery_code = ? WHERE id = ?", [encrypted, userId]); 68 | return recoveryCode; 69 | } 70 | 71 | export function getUserFromEmail(email: string): User | null { 72 | const row = db.queryOne( 73 | `SELECT user.id, user.email, user.username, user.email_verified, IIF(totp_credential.id IS NOT NULL, 1, 0), IIF(passkey_credential.id IS NOT NULL, 1, 0), IIF(security_key_credential.id IS NOT NULL, 1, 0) FROM user 74 | LEFT JOIN totp_credential ON user.id = totp_credential.user_id 75 | LEFT JOIN passkey_credential ON user.id = passkey_credential.user_id 76 | LEFT JOIN security_key_credential ON user.id = security_key_credential.user_id 77 | WHERE user.email = ?`, 78 | [email] 79 | ); 80 | if (row === null) { 81 | return null; 82 | } 83 | const user: User = { 84 | id: row.number(0), 85 | email: row.string(1), 86 | username: row.string(2), 87 | emailVerified: Boolean(row.number(3)), 88 | registeredTOTP: Boolean(row.number(4)), 89 | registeredPasskey: Boolean(row.number(5)), 90 | registeredSecurityKey: Boolean(row.number(6)), 91 | registered2FA: false 92 | }; 93 | if (user.registeredPasskey || user.registeredSecurityKey || user.registeredTOTP) { 94 | user.registered2FA = true; 95 | } 96 | return user; 97 | } 98 | 99 | export interface User { 100 | id: number; 101 | email: string; 102 | username: string; 103 | emailVerified: boolean; 104 | registeredTOTP: boolean; 105 | registeredSecurityKey: boolean; 106 | registeredPasskey: boolean; 107 | registered2FA: boolean; 108 | } 109 | -------------------------------------------------------------------------------- /src/lib/server/utils.ts: -------------------------------------------------------------------------------- 1 | import { encodeBase32UpperCaseNoPadding } from "@oslojs/encoding"; 2 | 3 | export function generateRandomOTP(): string { 4 | const bytes = new Uint8Array(5); 5 | crypto.getRandomValues(bytes); 6 | const code = encodeBase32UpperCaseNoPadding(bytes); 7 | return code; 8 | } 9 | 10 | export function generateRandomRecoveryCode(): string { 11 | const recoveryCodeBytes = new Uint8Array(10); 12 | crypto.getRandomValues(recoveryCodeBytes); 13 | const recoveryCode = encodeBase32UpperCaseNoPadding(recoveryCodeBytes); 14 | return recoveryCode; 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/server/webauthn.ts: -------------------------------------------------------------------------------- 1 | import { encodeHexLowerCase } from "@oslojs/encoding"; 2 | import { db } from "./db"; 3 | 4 | const challengeBucket = new Set(); 5 | 6 | export function createWebAuthnChallenge(): Uint8Array { 7 | const challenge = new Uint8Array(20); 8 | crypto.getRandomValues(challenge); 9 | const encoded = encodeHexLowerCase(challenge); 10 | challengeBucket.add(encoded); 11 | return challenge; 12 | } 13 | 14 | export function verifyWebAuthnChallenge(challenge: Uint8Array): boolean { 15 | const encoded = encodeHexLowerCase(challenge); 16 | return challengeBucket.delete(encoded); 17 | } 18 | 19 | export function getUserPasskeyCredentials(userId: number): WebAuthnUserCredential[] { 20 | const rows = db.query("SELECT id, user_id, name, algorithm, public_key FROM passkey_credential WHERE user_id = ?", [ 21 | userId 22 | ]); 23 | const credentials: WebAuthnUserCredential[] = []; 24 | for (const row of rows) { 25 | const credential: WebAuthnUserCredential = { 26 | id: row.bytes(0), 27 | userId: row.number(1), 28 | name: row.string(2), 29 | algorithmId: row.number(3), 30 | publicKey: row.bytes(4) 31 | }; 32 | credentials.push(credential); 33 | } 34 | return credentials; 35 | } 36 | 37 | export function getPasskeyCredential(credentialId: Uint8Array): WebAuthnUserCredential | null { 38 | const row = db.queryOne("SELECT id, user_id, name, algorithm, public_key FROM passkey_credential WHERE id = ?", [ 39 | credentialId 40 | ]); 41 | if (row === null) { 42 | return null; 43 | } 44 | const credential: WebAuthnUserCredential = { 45 | id: row.bytes(0), 46 | userId: row.number(1), 47 | name: row.string(2), 48 | algorithmId: row.number(3), 49 | publicKey: row.bytes(4) 50 | }; 51 | return credential; 52 | } 53 | 54 | export function getUserPasskeyCredential(userId: number, credentialId: Uint8Array): WebAuthnUserCredential | null { 55 | const row = db.queryOne( 56 | "SELECT id, user_id, name, algorithm, public_key FROM passkey_credential WHERE id = ? AND user_id = ?", 57 | [credentialId, userId] 58 | ); 59 | if (row === null) { 60 | return null; 61 | } 62 | const credential: WebAuthnUserCredential = { 63 | id: row.bytes(0), 64 | userId: row.number(1), 65 | name: row.string(2), 66 | algorithmId: row.number(3), 67 | publicKey: row.bytes(4) 68 | }; 69 | return credential; 70 | } 71 | 72 | export function createPasskeyCredential(credential: WebAuthnUserCredential): void { 73 | db.execute("INSERT INTO passkey_credential (id, user_id, name, algorithm, public_key) VALUES (?, ?, ?, ?, ?)", [ 74 | credential.id, 75 | credential.userId, 76 | credential.name, 77 | credential.algorithmId, 78 | credential.publicKey 79 | ]); 80 | } 81 | 82 | export function deleteUserPasskeyCredential(userId: number, credentialId: Uint8Array): boolean { 83 | const result = db.execute("DELETE FROM passkey_credential WHERE id = ? AND user_id = ?", [credentialId, userId]); 84 | return result.changes > 0; 85 | } 86 | 87 | export function getUserSecurityKeyCredentials(userId: number): WebAuthnUserCredential[] { 88 | const rows = db.query( 89 | "SELECT id, user_id, name, algorithm, public_key FROM security_key_credential WHERE user_id = ?", 90 | [userId] 91 | ); 92 | const credentials: WebAuthnUserCredential[] = []; 93 | for (const row of rows) { 94 | const credential: WebAuthnUserCredential = { 95 | id: row.bytes(0), 96 | userId: row.number(1), 97 | name: row.string(2), 98 | algorithmId: row.number(3), 99 | publicKey: row.bytes(4) 100 | }; 101 | credentials.push(credential); 102 | } 103 | return credentials; 104 | } 105 | 106 | export function getUserSecurityKeyCredential(userId: number, credentialId: Uint8Array): WebAuthnUserCredential | null { 107 | const row = db.queryOne( 108 | "SELECT id, user_id, name, algorithm, public_key FROM security_key_credential WHERE id = ? AND user_id = ?", 109 | [credentialId, userId] 110 | ); 111 | if (row === null) { 112 | return null; 113 | } 114 | const credential: WebAuthnUserCredential = { 115 | id: row.bytes(0), 116 | userId: row.number(1), 117 | name: row.string(2), 118 | algorithmId: row.number(3), 119 | publicKey: row.bytes(4) 120 | }; 121 | return credential; 122 | } 123 | 124 | export function createSecurityKeyCredential(credential: WebAuthnUserCredential): void { 125 | db.execute("INSERT INTO security_key_credential (id, user_id, name, algorithm, public_key) VALUES (?, ?, ?, ?, ?)", [ 126 | credential.id, 127 | credential.userId, 128 | credential.name, 129 | credential.algorithmId, 130 | credential.publicKey 131 | ]); 132 | } 133 | 134 | export function deleteUserSecurityKeyCredential(userId: number, credentialId: Uint8Array): boolean { 135 | const result = db.execute("DELETE FROM security_key_credential WHERE id = ? AND user_id = ?", [credentialId, userId]); 136 | return result.changes > 0; 137 | } 138 | 139 | export interface WebAuthnUserCredential { 140 | id: Uint8Array; 141 | userId: number; 142 | name: string; 143 | algorithmId: number; 144 | publicKey: Uint8Array; 145 | } 146 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { defineMiddleware, sequence } from "astro:middleware"; 2 | import { RefillingTokenBucket } from "@lib/server/rate-limit"; 3 | import { deleteSessionTokenCookie, setSessionTokenCookie, validateSessionToken } from "@lib/server/session"; 4 | 5 | const bucket = new RefillingTokenBucket(100, 1); 6 | 7 | const rateLimitMiddleware = defineMiddleware((context, next) => { 8 | // TODO: Assumes X-Forwarded-For is always included. 9 | const clientIP = context.request.headers.get("X-Forwarded-For"); 10 | if (clientIP === null) { 11 | return next(); 12 | } 13 | let cost: number; 14 | if (context.request.method === "GET" || context.request.method === "OPTIONS") { 15 | cost = 1; 16 | } else { 17 | cost = 3; 18 | } 19 | if (!bucket.consume(clientIP, cost)) { 20 | return new Response("Too many requests", { 21 | status: 429 22 | }); 23 | } 24 | return next(); 25 | }); 26 | 27 | const authMiddleware = defineMiddleware((context, next) => { 28 | const token = context.cookies.get("session")?.value ?? null; 29 | if (token === null) { 30 | context.locals.session = null; 31 | context.locals.user = null; 32 | return next(); 33 | } 34 | const { user, session } = validateSessionToken(token); 35 | if (session !== null) { 36 | setSessionTokenCookie(context, token, session.expiresAt); 37 | } else { 38 | deleteSessionTokenCookie(context); 39 | } 40 | context.locals.session = session; 41 | context.locals.user = user; 42 | return next(); 43 | }); 44 | 45 | export const onRequest = sequence(rateLimitMiddleware, authMiddleware); 46 | -------------------------------------------------------------------------------- /src/pages/2fa/index.ts: -------------------------------------------------------------------------------- 1 | import { getPasswordReset2FARedirect } from "@lib/server/2fa"; 2 | import { validatePasswordResetSessionRequest } from "@lib/server/password-reset"; 3 | 4 | import type { APIContext } from "astro"; 5 | 6 | export function GET(context: APIContext): Response { 7 | const { session, user } = validatePasswordResetSessionRequest(context); 8 | if (session === null) { 9 | return context.redirect("/login"); 10 | } 11 | if (!user.registered2FA || session.twoFactorVerified) { 12 | return context.redirect("/"); 13 | } 14 | return context.redirect(getPasswordReset2FARedirect(user)); 15 | } 16 | -------------------------------------------------------------------------------- /src/pages/2fa/passkey/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { get2FARedirect } from "@lib/server/2fa"; 3 | import { getUserPasskeyCredentials } from "@lib/server/webauthn"; 4 | import { encodeBase64 } from "@oslojs/encoding"; 5 | 6 | if (Astro.locals.user === null || Astro.locals.session === null) { 7 | return Astro.redirect("/login"); 8 | } 9 | if (!Astro.locals.user.emailVerified) { 10 | return Astro.redirect("/verify-email"); 11 | } 12 | if (!Astro.locals.user.registered2FA) { 13 | return Astro.redirect("/"); 14 | } 15 | if (Astro.locals.session.twoFactorVerified) { 16 | return Astro.redirect("/"); 17 | } 18 | if (!Astro.locals.user.registeredPasskey) { 19 | return Astro.redirect(get2FARedirect(Astro.locals.user)); 20 | } 21 | 22 | const credentials = getUserPasskeyCredentials(Astro.locals.user.id); 23 | const encodedCredentialUserId = credentials.map((c) => encodeBase64(c.id)).join(","); 24 | --- 25 | 26 | 27 | 28 | 29 | 30 | Email and password example with 2FA and WebAuthn in Astro 31 | 32 | 33 |
34 |

Authenticate with passkeys

35 |
36 | 37 |

38 |
39 | Use recovery code 40 | {Astro.locals.user.registeredTOTP && Use authenticator apps} 41 | {Astro.locals.user.registeredSecurityKey && Use security keys} 42 |
43 | 44 | 45 | 46 | 47 | 96 | -------------------------------------------------------------------------------- /src/pages/2fa/passkey/register.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { get2FARedirect } from "@lib/server/2fa"; 3 | import { getUserPasskeyCredentials } from "@lib/server/webauthn"; 4 | import { bigEndian } from "@oslojs/binary"; 5 | import { encodeBase64 } from "@oslojs/encoding"; 6 | 7 | if (Astro.locals.user === null || Astro.locals.session === null) { 8 | return Astro.redirect("/login"); 9 | } 10 | if (!Astro.locals.user.emailVerified) { 11 | return Astro.redirect("/verify-email"); 12 | } 13 | if (Astro.locals.user.registered2FA && !Astro.locals.session.twoFactorVerified) { 14 | return Astro.redirect(get2FARedirect(Astro.locals.user)); 15 | } 16 | 17 | const credentials = getUserPasskeyCredentials(Astro.locals.user.id); 18 | const encodedCredentialIds = credentials.map((c) => encodeBase64(c.id)).join(","); 19 | 20 | const credentialUserId = new Uint8Array(8); 21 | bigEndian.putUint64(credentialUserId, BigInt(Astro.locals.user.id), 0); 22 | const encodedCredentialUserId = encodeBase64(credentialUserId); 23 | --- 24 | 25 | 26 | 27 | 28 | 29 | Email and password example with 2FA and WebAuthn in Astro 30 | 31 | 32 |
33 |

Register passkey

34 | 35 |
36 | 37 | 38 | 39 |

40 |
41 |
42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 143 | -------------------------------------------------------------------------------- /src/pages/2fa/reset.astro: -------------------------------------------------------------------------------- 1 | --- 2 | if (Astro.locals.user === null || Astro.locals.session === null) { 3 | return Astro.redirect("/login"); 4 | } 5 | if (!Astro.locals.user.emailVerified) { 6 | return Astro.redirect("/verify-email"); 7 | } 8 | if (!Astro.locals.user.registered2FA) { 9 | return Astro.redirect("/2fa/setup"); 10 | } 11 | if (Astro.locals.session.twoFactorVerified) { 12 | return Astro.redirect("/"); 13 | } 14 | --- 15 | 16 | 17 | 18 | 19 | 20 | Email and password example with 2FA and WebAuthn in Astro 21 | 22 | 23 |
24 |

Recover your account

25 |
26 | 27 |
28 | 29 |

30 |
31 |
32 | 33 | 34 | 35 | 56 | -------------------------------------------------------------------------------- /src/pages/2fa/security-key/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { get2FARedirect } from "@lib/server/2fa"; 3 | import { getUserSecurityKeyCredentials } from "@lib/server/webauthn"; 4 | import { encodeBase64 } from "@oslojs/encoding"; 5 | 6 | if (Astro.locals.user === null || Astro.locals.session === null) { 7 | return Astro.redirect("/login"); 8 | } 9 | if (!Astro.locals.user.emailVerified) { 10 | return Astro.redirect("/verify-email"); 11 | } 12 | if (!Astro.locals.user.registered2FA) { 13 | return Astro.redirect("/"); 14 | } 15 | if (Astro.locals.session.twoFactorVerified) { 16 | return Astro.redirect("/"); 17 | } 18 | if (!Astro.locals.user.registeredSecurityKey) { 19 | return Astro.redirect(get2FARedirect(Astro.locals.user)); 20 | } 21 | 22 | const credentials = getUserSecurityKeyCredentials(Astro.locals.user.id); 23 | const encodedCredentialUserId = credentials.map((c) => encodeBase64(c.id)).join(","); 24 | --- 25 | 26 | 27 | 28 | 29 | 30 | Email and password example with 2FA and WebAuthn in Astro 31 | 32 | 33 |
34 |

Authenticate with security keys

35 |
36 | 37 |

38 |
39 | Use recovery code 40 | {Astro.locals.user.registeredTOTP && Use authenticator apps} 41 | {Astro.locals.user.registeredPasskey && Use passkeys} 42 |
43 | 44 | 45 | 46 | 47 | 97 | -------------------------------------------------------------------------------- /src/pages/2fa/security-key/register.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { get2FARedirect } from "@lib/server/2fa"; 3 | import { getUserSecurityKeyCredentials } from "@lib/server/webauthn"; 4 | import { bigEndian } from "@oslojs/binary"; 5 | import { encodeBase64 } from "@oslojs/encoding"; 6 | 7 | if (Astro.locals.user === null || Astro.locals.session === null) { 8 | return Astro.redirect("/login"); 9 | } 10 | if (!Astro.locals.user.emailVerified) { 11 | return Astro.redirect("/verify-email"); 12 | } 13 | if (Astro.locals.user.registered2FA && !Astro.locals.session.twoFactorVerified) { 14 | return Astro.redirect(get2FARedirect(Astro.locals.user)); 15 | } 16 | 17 | const credentials = getUserSecurityKeyCredentials(Astro.locals.user.id); 18 | const encodedCredentialIds = credentials.map((c) => encodeBase64(c.id)).join(","); 19 | 20 | const credentialUserId = new Uint8Array(8); 21 | bigEndian.putUint64(credentialUserId, BigInt(Astro.locals.user.id), 0); 22 | const encodedCredentialUserId = encodeBase64(credentialUserId); 23 | --- 24 | 25 | 26 | 27 | 28 | 29 | Email and password example with 2FA and WebAuthn in Astro 30 | 31 | 32 |
33 |

Register security key

34 | 35 |
36 | 37 | 38 | 39 |

40 |
41 |
42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 144 | -------------------------------------------------------------------------------- /src/pages/2fa/setup.astro: -------------------------------------------------------------------------------- 1 | --- 2 | if (Astro.locals.user === null || Astro.locals.session === null) { 3 | return Astro.redirect("/login"); 4 | } 5 | if (!Astro.locals.user.emailVerified) { 6 | return Astro.redirect("/verify-email"); 7 | } 8 | if (Astro.locals.user.registered2FA) { 9 | return Astro.redirect("/"); 10 | } 11 | --- 12 | 13 | 14 | 15 | 16 | 17 | Email and password example with 2FA and WebAuthn in Astro 18 | 19 | 20 |
21 |

Set up two-factor authentication

22 | 27 |
28 | 29 | 30 | -------------------------------------------------------------------------------- /src/pages/2fa/totp/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { get2FARedirect } from "@lib/server/2fa"; 3 | 4 | if (Astro.locals.user === null || Astro.locals.session === null) { 5 | return Astro.redirect("/login"); 6 | } 7 | if (!Astro.locals.user.emailVerified) { 8 | return Astro.redirect("/verify-email"); 9 | } 10 | if (!Astro.locals.user.registered2FA) { 11 | return Astro.redirect("/"); 12 | } 13 | if (Astro.locals.session.twoFactorVerified) { 14 | return Astro.redirect("/"); 15 | } 16 | if (!Astro.locals.user.registeredTOTP) { 17 | return Astro.redirect(get2FARedirect(Astro.locals.user)); 18 | } 19 | --- 20 | 21 | 22 | 23 | 24 | 25 | Email and password example with 2FA and WebAuthn in Astro 26 | 27 | 28 |
29 |

Authenticate with authenticator app

30 |

Enter the code from your app.

31 |
32 | 33 |
34 | 35 |

36 |
37 | Use recovery code 38 | {Astro.locals.user.registeredPasskey && Use passkeys} 39 | {Astro.locals.user.registeredSecurityKey && Use security keys} 40 |
41 | 42 | 43 | 44 | 65 | -------------------------------------------------------------------------------- /src/pages/2fa/totp/setup.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { encodeBase64 } from "@oslojs/encoding"; 3 | import { createTOTPKeyURI } from "@oslojs/otp"; 4 | import { renderSVG } from "uqr"; 5 | import { get2FARedirect } from "@lib/server/2fa"; 6 | 7 | if (Astro.locals.user === null || Astro.locals.session === null) { 8 | return Astro.redirect("/login"); 9 | } 10 | if (!Astro.locals.user.emailVerified) { 11 | return Astro.redirect("/verify-email"); 12 | } 13 | if (Astro.locals.user.registeredTOTP && !Astro.locals.session.twoFactorVerified) { 14 | return Astro.redirect(get2FARedirect(Astro.locals.user)); 15 | } 16 | 17 | const totpKey = new Uint8Array(20); 18 | crypto.getRandomValues(totpKey); 19 | const encodedTOTPKey = encodeBase64(totpKey); 20 | const keyURI = createTOTPKeyURI("Demo", Astro.locals.user.username, totpKey, 30, 6); 21 | const qrcode = renderSVG(keyURI); 22 | --- 23 | 24 | 25 | 26 | 27 | 28 | Email and password example with 2FA and WebAuthn in Astro 29 | 30 | 31 |
32 |

Set up authenticator app

33 |
34 |
35 | 36 | 37 |
38 | 39 |

40 |
41 |
42 | 43 | 44 | 45 | 67 | -------------------------------------------------------------------------------- /src/pages/api/email-verification/index.ts: -------------------------------------------------------------------------------- 1 | import { ObjectParser } from "@pilcrowjs/object-parser"; 2 | import { 3 | createEmailVerificationRequest, 4 | sendVerificationEmailBucket, 5 | sendVerificationEmail, 6 | setEmailVerificationRequestCookie 7 | } from "@lib/server/email-verification"; 8 | import { verifyEmailInput, checkEmailAvailability } from "@lib/server/email"; 9 | 10 | import type { APIContext } from "astro"; 11 | 12 | export async function POST(context: APIContext): Promise { 13 | if (context.locals.session === null || context.locals.user === null) { 14 | return new Response("Not authenticated", { 15 | status: 401 16 | }); 17 | } 18 | if (context.locals.user.registered2FA && !context.locals.session.twoFactorVerified) { 19 | return new Response("Forbidden", { 20 | status: 403 21 | }); 22 | } 23 | if (!sendVerificationEmailBucket.check(context.locals.user.id, 1)) { 24 | return new Response("Too many requests", { 25 | status: 429 26 | }); 27 | } 28 | 29 | const data: unknown = await context.request.json(); 30 | const parser = new ObjectParser(data); 31 | let email: string; 32 | try { 33 | email = parser.getString("email").toLowerCase(); 34 | } catch { 35 | return new Response("Invalid or missing fields", { 36 | status: 400 37 | }); 38 | } 39 | if (email === "") { 40 | return new Response("Please enter your email", { 41 | status: 400 42 | }); 43 | } 44 | if (!verifyEmailInput(email)) { 45 | return new Response("Please enter a valid email", { 46 | status: 400 47 | }); 48 | } 49 | const emailAvailable = checkEmailAvailability(email); 50 | if (!emailAvailable) { 51 | return new Response("This email is already used", { 52 | status: 400 53 | }); 54 | } 55 | if (!sendVerificationEmailBucket.consume(context.locals.user.id, 1)) { 56 | return new Response("Too many requests", { 57 | status: 429 58 | }); 59 | } 60 | const verificationRequest = createEmailVerificationRequest(context.locals.user.id, email); 61 | sendVerificationEmail(verificationRequest.email, verificationRequest.code); 62 | setEmailVerificationRequestCookie(context, verificationRequest); 63 | return new Response(null, { status: 204 }); 64 | } 65 | -------------------------------------------------------------------------------- /src/pages/api/email-verification/resend-code.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createEmailVerificationRequest, 3 | getUserEmailVerificationRequestFromRequest, 4 | sendVerificationEmailBucket, 5 | sendVerificationEmail, 6 | setEmailVerificationRequestCookie 7 | } from "@lib/server/email-verification"; 8 | 9 | import type { APIContext } from "astro"; 10 | 11 | export async function POST(context: APIContext): Promise { 12 | if (context.locals.session === null || context.locals.user === null) { 13 | return new Response("Not authenticated", { 14 | status: 401 15 | }); 16 | } 17 | if (context.locals.user.registered2FA && !context.locals.session.twoFactorVerified) { 18 | return new Response("Forbidden", { 19 | status: 403 20 | }); 21 | } 22 | 23 | if (!sendVerificationEmailBucket.check(context.locals.user.id, 1)) { 24 | return new Response("Too many requests", { 25 | status: 429 26 | }); 27 | } 28 | 29 | let verificationRequest = getUserEmailVerificationRequestFromRequest(context); 30 | if (verificationRequest === null) { 31 | if (context.locals.user.emailVerified) { 32 | return new Response("Forbidden", { 33 | status: 403 34 | }); 35 | } 36 | if (!sendVerificationEmailBucket.consume(context.locals.user.id, 1)) { 37 | return new Response("Too many requests", { 38 | status: 429 39 | }); 40 | } 41 | verificationRequest = createEmailVerificationRequest(context.locals.user.id, context.locals.user.email); 42 | } else { 43 | if (!sendVerificationEmailBucket.consume(context.locals.user.id, 1)) { 44 | return new Response("Too many requests", { 45 | status: 429 46 | }); 47 | } 48 | verificationRequest = createEmailVerificationRequest(context.locals.user.id, verificationRequest.email); 49 | } 50 | 51 | sendVerificationEmail(verificationRequest.email, verificationRequest.code); 52 | setEmailVerificationRequestCookie(context, verificationRequest); 53 | return new Response(null, { status: 204 }); 54 | } 55 | -------------------------------------------------------------------------------- /src/pages/api/email-verification/verify.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createEmailVerificationRequest, 3 | deleteEmailVerificationRequestCookie, 4 | deleteUserEmailVerificationRequest, 5 | getUserEmailVerificationRequestFromRequest, 6 | sendVerificationEmail 7 | } from "@lib/server/email-verification"; 8 | import { ObjectParser } from "@pilcrowjs/object-parser"; 9 | import { updateUserEmailAndSetEmailAsVerified } from "@lib/server/user"; 10 | import { invalidateUserPasswordResetSessions } from "@lib/server/password-reset"; 11 | import { ExpiringTokenBucket } from "@lib/server/rate-limit"; 12 | 13 | import type { APIContext } from "astro"; 14 | 15 | const bucket = new ExpiringTokenBucket(5, 60 * 30); 16 | 17 | export async function POST(context: APIContext): Promise { 18 | if (context.locals.session === null || context.locals.user === null) { 19 | return new Response("Not authenticated", { 20 | status: 401 21 | }); 22 | } 23 | if (context.locals.user.registered2FA && !context.locals.session.twoFactorVerified) { 24 | return new Response("Forbidden", { 25 | status: 403 26 | }); 27 | } 28 | if (!bucket.check(context.locals.user.id, 1)) { 29 | return new Response("Too many requests", { 30 | status: 429 31 | }); 32 | } 33 | 34 | let verificationRequest = getUserEmailVerificationRequestFromRequest(context); 35 | if (verificationRequest === null) { 36 | return new Response("Forbidden", { 37 | status: 403 38 | }); 39 | } 40 | const data = await context.request.json(); 41 | const parser = new ObjectParser(data); 42 | let code: string; 43 | try { 44 | code = parser.getString("code"); 45 | } catch { 46 | return new Response("Invalid or missing fields", { 47 | status: 400 48 | }); 49 | } 50 | if (code === "") { 51 | return new Response("Enter your code", { 52 | status: 400 53 | }); 54 | } 55 | if (!bucket.consume(context.locals.user.id, 1)) { 56 | return new Response("Too many requests", { 57 | status: 429 58 | }); 59 | } 60 | if (Date.now() >= verificationRequest.expiresAt.getTime()) { 61 | verificationRequest = createEmailVerificationRequest(verificationRequest.userId, verificationRequest.email); 62 | sendVerificationEmail(verificationRequest.email, verificationRequest.code); 63 | return new Response("The verification code was expired. We sent another code to your inbox.", { 64 | status: 400 65 | }); 66 | } 67 | if (verificationRequest.code !== code) { 68 | return new Response("Incorrect code.", { 69 | status: 400 70 | }); 71 | } 72 | deleteUserEmailVerificationRequest(context.locals.user.id); 73 | invalidateUserPasswordResetSessions(context.locals.user.id); 74 | updateUserEmailAndSetEmailAsVerified(context.locals.user.id, verificationRequest.email); 75 | deleteEmailVerificationRequestCookie(context); 76 | return new Response(null, { status: 204 }); 77 | } 78 | -------------------------------------------------------------------------------- /src/pages/api/login-passkey.ts: -------------------------------------------------------------------------------- 1 | import { 2 | parseClientDataJSON, 3 | coseAlgorithmES256, 4 | ClientDataType, 5 | parseAuthenticatorData, 6 | createAssertionSignatureMessage, 7 | coseAlgorithmRS256 8 | } from "@oslojs/webauthn"; 9 | import { decodePKIXECDSASignature, decodeSEC1PublicKey, p256, verifyECDSASignature } from "@oslojs/crypto/ecdsa"; 10 | import { ObjectParser } from "@pilcrowjs/object-parser"; 11 | import { decodeBase64 } from "@oslojs/encoding"; 12 | import { verifyWebAuthnChallenge, getPasskeyCredential } from "@lib/server/webauthn"; 13 | import { createSession, generateSessionToken, setSessionTokenCookie } from "@lib/server/session"; 14 | import { sha256 } from "@oslojs/crypto/sha2"; 15 | import { decodePKCS1RSAPublicKey, sha256ObjectIdentifier, verifyRSASSAPKCS1v15Signature } from "@oslojs/crypto/rsa"; 16 | 17 | import type { APIContext } from "astro"; 18 | import type { ClientData, AuthenticatorData } from "@oslojs/webauthn"; 19 | import type { SessionFlags } from "@lib/server/session"; 20 | 21 | // Stricter rate limiting can be omitted here since creating challenges are rate-limited 22 | export async function POST(context: APIContext): Promise { 23 | const data: unknown = await context.request.json(); 24 | const parser = new ObjectParser(data); 25 | let encodedAuthenticatorData: string; 26 | let encodedClientDataJSON: string; 27 | let encodedCredentialId: string; 28 | let encodedSignature: string; 29 | try { 30 | encodedAuthenticatorData = parser.getString("authenticator_data"); 31 | encodedClientDataJSON = parser.getString("client_data_json"); 32 | encodedCredentialId = parser.getString("credential_id"); 33 | encodedSignature = parser.getString("signature"); 34 | } catch { 35 | return new Response("Invalid or missing fields", { 36 | status: 400 37 | }); 38 | } 39 | let authenticatorDataBytes: Uint8Array; 40 | let clientDataJSON: Uint8Array; 41 | let credentialId: Uint8Array; 42 | let signatureBytes: Uint8Array; 43 | try { 44 | authenticatorDataBytes = decodeBase64(encodedAuthenticatorData); 45 | clientDataJSON = decodeBase64(encodedClientDataJSON); 46 | credentialId = decodeBase64(encodedCredentialId); 47 | signatureBytes = decodeBase64(encodedSignature); 48 | } catch { 49 | return new Response("Invalid or missing fields", { 50 | status: 400 51 | }); 52 | } 53 | 54 | let authenticatorData: AuthenticatorData; 55 | try { 56 | authenticatorData = parseAuthenticatorData(authenticatorDataBytes); 57 | } catch { 58 | return new Response("Invalid data", { 59 | status: 400 60 | }); 61 | } 62 | // TODO: Update host 63 | if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) { 64 | return new Response("Invalid data", { 65 | status: 400 66 | }); 67 | } 68 | if (!authenticatorData.userPresent || !authenticatorData.userVerified) { 69 | return new Response("Invalid data", { 70 | status: 400 71 | }); 72 | } 73 | 74 | let clientData: ClientData; 75 | try { 76 | clientData = parseClientDataJSON(clientDataJSON); 77 | } catch { 78 | return new Response("Invalid data", { 79 | status: 400 80 | }); 81 | } 82 | if (clientData.type !== ClientDataType.Get) { 83 | return new Response("Invalid data", { 84 | status: 400 85 | }); 86 | } 87 | 88 | if (!verifyWebAuthnChallenge(clientData.challenge)) { 89 | return new Response("Invalid data", { 90 | status: 400 91 | }); 92 | } 93 | // TODO: Update origin 94 | if (clientData.origin !== "http://localhost:4321") { 95 | return new Response("Invalid data", { 96 | status: 400 97 | }); 98 | } 99 | if (clientData.crossOrigin !== null && clientData.crossOrigin) { 100 | return new Response("Invalid data", { 101 | status: 400 102 | }); 103 | } 104 | 105 | const credential = getPasskeyCredential(credentialId); 106 | if (credential === null) { 107 | return new Response("Invalid credential", { 108 | status: 400 109 | }); 110 | } 111 | 112 | let validSignature: boolean; 113 | if (credential.algorithmId === coseAlgorithmES256) { 114 | const ecdsaSignature = decodePKIXECDSASignature(signatureBytes); 115 | const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey); 116 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 117 | validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature); 118 | } else if (credential.algorithmId === coseAlgorithmRS256) { 119 | const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey); 120 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 121 | validSignature = verifyRSASSAPKCS1v15Signature(rsaPublicKey, sha256ObjectIdentifier, hash, signatureBytes); 122 | } else { 123 | return new Response("Internal error", { 124 | status: 500 125 | }); 126 | } 127 | 128 | if (!validSignature) { 129 | return new Response("Invalid signature", { 130 | status: 400 131 | }); 132 | } 133 | const sessionFlags: SessionFlags = { 134 | twoFactorVerified: true 135 | }; 136 | const sessionToken = generateSessionToken(); 137 | const session = createSession(sessionToken, credential.userId, sessionFlags); 138 | setSessionTokenCookie(context, sessionToken, session.expiresAt); 139 | return new Response(null, { 140 | status: 204 141 | }); 142 | } 143 | -------------------------------------------------------------------------------- /src/pages/api/login.ts: -------------------------------------------------------------------------------- 1 | import { ObjectParser } from "@pilcrowjs/object-parser"; 2 | import { verifyPasswordHash } from "@lib/server/password"; 3 | import { createSession, generateSessionToken, setSessionTokenCookie } from "@lib/server/session"; 4 | import { verifyEmailInput } from "@lib/server/email"; 5 | import { RefillingTokenBucket, Throttler } from "@lib/server/rate-limit"; 6 | import { getUserFromEmail, getUserPasswordHash } from "@lib/server/user"; 7 | 8 | import type { APIContext } from "astro"; 9 | import type { SessionFlags } from "@lib/server/session"; 10 | 11 | const throttler = new Throttler([0, 1, 2, 4, 8, 16, 30, 60, 180, 300]); 12 | const ipBucket = new RefillingTokenBucket(20, 1); 13 | 14 | export async function POST(context: APIContext): Promise { 15 | // TODO: Assumes X-Forwarded-For is always included. 16 | const clientIP = context.request.headers.get("X-Forwarded-For"); 17 | if (clientIP !== null && !ipBucket.check(clientIP, 1)) { 18 | return new Response("Too many requests", { 19 | status: 429 20 | }); 21 | } 22 | 23 | const data: unknown = await context.request.json(); 24 | const parser = new ObjectParser(data); 25 | let email: string, password: string; 26 | try { 27 | email = parser.getString("email").toLowerCase(); 28 | password = parser.getString("password"); 29 | } catch { 30 | return new Response("Invalid or missing fields", { 31 | status: 400 32 | }); 33 | } 34 | if (email === "" || password === "") { 35 | return new Response("Please enter your email and password.", { 36 | status: 400 37 | }); 38 | } 39 | if (!verifyEmailInput(email)) { 40 | return new Response("Invalid email", { 41 | status: 400 42 | }); 43 | } 44 | const user = getUserFromEmail(email); 45 | if (user === null) { 46 | return new Response("Account does not exist", { 47 | status: 400 48 | }); 49 | } 50 | if (clientIP !== null && !ipBucket.consume(clientIP, 1)) { 51 | return new Response("Too many requests", { 52 | status: 429 53 | }); 54 | } 55 | if (!throttler.consume(user.id)) { 56 | return new Response("Too many requests", { 57 | status: 429 58 | }); 59 | } 60 | const passwordHash = getUserPasswordHash(user.id); 61 | const validPassword = await verifyPasswordHash(passwordHash, password); 62 | if (!validPassword) { 63 | return new Response("Invalid password", { 64 | status: 400 65 | }); 66 | } 67 | throttler.reset(user.id); 68 | const sessionFlags: SessionFlags = { 69 | twoFactorVerified: false 70 | }; 71 | const sessionToken = generateSessionToken(); 72 | const session = createSession(sessionToken, user.id, sessionFlags); 73 | setSessionTokenCookie(context, sessionToken, session.expiresAt); 74 | return new Response(null, { status: 204 }); 75 | } 76 | -------------------------------------------------------------------------------- /src/pages/api/password-reset/session.ts: -------------------------------------------------------------------------------- 1 | import { ObjectParser } from "@pilcrowjs/object-parser"; 2 | import { verifyEmailInput } from "@lib/server/email"; 3 | import { getUserFromEmail } from "@lib/server/user"; 4 | import { 5 | createPasswordResetSession, 6 | invalidateUserPasswordResetSessions, 7 | sendPasswordResetEmail, 8 | setPasswordResetSessionTokenCookie 9 | } from "@lib/server/password-reset"; 10 | import { RefillingTokenBucket } from "@lib/server/rate-limit"; 11 | 12 | import type { APIContext } from "astro"; 13 | import { generateSessionToken } from "@lib/server/session"; 14 | 15 | const ipBucket = new RefillingTokenBucket(3, 60); 16 | const userBucket = new RefillingTokenBucket(3, 60); 17 | 18 | export async function POST(context: APIContext): Promise { 19 | // TODO: Assumes X-Forwarded-For is always included. 20 | const clientIP = context.request.headers.get("X-Forwarded-For"); 21 | if (clientIP !== null && !ipBucket.check(clientIP, 1)) { 22 | return new Response("Too many requests", { 23 | status: 429 24 | }); 25 | } 26 | 27 | const data: unknown = await context.request.json(); 28 | const parser = new ObjectParser(data); 29 | let email: string; 30 | try { 31 | email = parser.getString("email").toLowerCase(); 32 | } catch { 33 | return new Response("Invalid or missing fields", { 34 | status: 400 35 | }); 36 | } 37 | if (!verifyEmailInput(email)) { 38 | return new Response("Invalid email", { 39 | status: 400 40 | }); 41 | } 42 | const user = getUserFromEmail(email); 43 | if (user === null) { 44 | return new Response("Account does not exist", { 45 | status: 400 46 | }); 47 | } 48 | if (clientIP !== null && !ipBucket.consume(clientIP, 1)) { 49 | return new Response("Too many requests", { 50 | status: 429 51 | }); 52 | } 53 | if (!userBucket.consume(user.id, 1)) { 54 | return new Response("Too many requests", { 55 | status: 429 56 | }); 57 | } 58 | invalidateUserPasswordResetSessions(user.id); 59 | const sessionToken = generateSessionToken(); 60 | const session = createPasswordResetSession(sessionToken, user.id, user.email); 61 | sendPasswordResetEmail(session.email, session.code); 62 | setPasswordResetSessionTokenCookie(context, sessionToken, session.expiresAt); 63 | return new Response(); 64 | } 65 | -------------------------------------------------------------------------------- /src/pages/api/password-reset/update-password.ts: -------------------------------------------------------------------------------- 1 | import { 2 | deletePasswordResetSessionTokenCookie, 3 | invalidateUserPasswordResetSessions, 4 | validatePasswordResetSessionRequest 5 | } from "@lib/server/password-reset"; 6 | import { ObjectParser } from "@pilcrowjs/object-parser"; 7 | import { verifyPasswordStrength } from "@lib/server/password"; 8 | import { 9 | createSession, 10 | generateSessionToken, 11 | invalidateUserSessions, 12 | setSessionTokenCookie 13 | } from "@lib/server/session"; 14 | import { updateUserPassword } from "@lib/server/user"; 15 | 16 | import type { APIContext } from "astro"; 17 | import type { SessionFlags } from "@lib/server/session"; 18 | 19 | export async function POST(context: APIContext): Promise { 20 | const { session: passwordResetSession, user } = validatePasswordResetSessionRequest(context); 21 | if (passwordResetSession === null) { 22 | return new Response("Not authenticated", { 23 | status: 401 24 | }); 25 | } 26 | if (!passwordResetSession.emailVerified) { 27 | return new Response("Forbidden", { 28 | status: 403 29 | }); 30 | } 31 | if (user.registered2FA && !passwordResetSession.twoFactorVerified) { 32 | return new Response("Forbidden", { 33 | status: 403 34 | }); 35 | } 36 | 37 | const data = await context.request.json(); 38 | const parser = new ObjectParser(data); 39 | let password: string; 40 | try { 41 | password = parser.getString("password"); 42 | } catch { 43 | return new Response("Invalid or missing fields", { 44 | status: 400 45 | }); 46 | } 47 | const strongPassword = await verifyPasswordStrength(password); 48 | if (!strongPassword) { 49 | return new Response("Weak password", { 50 | status: 400 51 | }); 52 | } 53 | invalidateUserPasswordResetSessions(passwordResetSession.userId); 54 | invalidateUserSessions(passwordResetSession.userId); 55 | await updateUserPassword(passwordResetSession.userId, password); 56 | 57 | const sessionFlags: SessionFlags = { 58 | twoFactorVerified: passwordResetSession.twoFactorVerified 59 | }; 60 | const sessionToken = generateSessionToken(); 61 | const session = createSession(sessionToken, passwordResetSession.userId, sessionFlags); 62 | setSessionTokenCookie(context, sessionToken, session.expiresAt); 63 | deletePasswordResetSessionTokenCookie(context); 64 | return new Response(null, { 65 | status: 204 66 | }); 67 | } 68 | -------------------------------------------------------------------------------- /src/pages/api/password-reset/verify-2fa/passkey.ts: -------------------------------------------------------------------------------- 1 | import { 2 | parseClientDataJSON, 3 | coseAlgorithmES256, 4 | ClientDataType, 5 | parseAuthenticatorData, 6 | createAssertionSignatureMessage, 7 | coseAlgorithmRS256 8 | } from "@oslojs/webauthn"; 9 | import { decodePKIXECDSASignature, decodeSEC1PublicKey, p256, verifyECDSASignature } from "@oslojs/crypto/ecdsa"; 10 | import { ObjectParser } from "@pilcrowjs/object-parser"; 11 | import { decodeBase64 } from "@oslojs/encoding"; 12 | import { verifyWebAuthnChallenge, getUserPasskeyCredential } from "@lib/server/webauthn"; 13 | import { sha256 } from "@oslojs/crypto/sha2"; 14 | import { decodePKCS1RSAPublicKey, sha256ObjectIdentifier, verifyRSASSAPKCS1v15Signature } from "@oslojs/crypto/rsa"; 15 | import { setPasswordResetSessionAs2FAVerified, validatePasswordResetSessionRequest } from "@lib/server/password-reset"; 16 | 17 | import type { APIContext } from "astro"; 18 | import type { ClientData, AuthenticatorData } from "@oslojs/webauthn"; 19 | 20 | // Stricter rate limiting can be omitted here since creating challenges are rate-limited 21 | export async function POST(context: APIContext): Promise { 22 | const { session, user } = validatePasswordResetSessionRequest(context); 23 | if (session === null) { 24 | return new Response("Not authenticated", { 25 | status: 401 26 | }); 27 | } 28 | if (!session.emailVerified || !user.registeredPasskey || session.twoFactorVerified) { 29 | return new Response("Forbidden", { 30 | status: 403 31 | }); 32 | } 33 | 34 | const data: unknown = await context.request.json(); 35 | const parser = new ObjectParser(data); 36 | let encodedAuthenticatorData: string; 37 | let encodedClientDataJSON: string; 38 | let encodedCredentialId: string; 39 | let encodedSignature: string; 40 | try { 41 | encodedAuthenticatorData = parser.getString("authenticator_data"); 42 | encodedClientDataJSON = parser.getString("client_data_json"); 43 | encodedCredentialId = parser.getString("credential_id"); 44 | encodedSignature = parser.getString("signature"); 45 | } catch { 46 | return new Response("Invalid or missing fields", { 47 | status: 400 48 | }); 49 | } 50 | let authenticatorDataBytes: Uint8Array; 51 | let clientDataJSON: Uint8Array; 52 | let credentialId: Uint8Array; 53 | let signatureBytes: Uint8Array; 54 | try { 55 | authenticatorDataBytes = decodeBase64(encodedAuthenticatorData); 56 | clientDataJSON = decodeBase64(encodedClientDataJSON); 57 | credentialId = decodeBase64(encodedCredentialId); 58 | signatureBytes = decodeBase64(encodedSignature); 59 | } catch { 60 | return new Response("Invalid or missing fields", { 61 | status: 400 62 | }); 63 | } 64 | 65 | let authenticatorData: AuthenticatorData; 66 | try { 67 | authenticatorData = parseAuthenticatorData(authenticatorDataBytes); 68 | } catch { 69 | return new Response("Invalid data", { 70 | status: 400 71 | }); 72 | } 73 | // TODO: Update host 74 | if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) { 75 | return new Response("Invalid data", { 76 | status: 400 77 | }); 78 | } 79 | if (!authenticatorData.userPresent || !authenticatorData.userVerified) { 80 | return new Response("Invalid data", { 81 | status: 400 82 | }); 83 | } 84 | 85 | let clientData: ClientData; 86 | try { 87 | clientData = parseClientDataJSON(clientDataJSON); 88 | } catch { 89 | return new Response("Invalid data", { 90 | status: 400 91 | }); 92 | } 93 | if (clientData.type !== ClientDataType.Get) { 94 | return new Response("Invalid data", { 95 | status: 400 96 | }); 97 | } 98 | 99 | if (!verifyWebAuthnChallenge(clientData.challenge)) { 100 | return new Response("Invalid data", { 101 | status: 400 102 | }); 103 | } 104 | // TODO: Update origin 105 | if (clientData.origin !== "http://localhost:4321") { 106 | return new Response("Invalid data", { 107 | status: 400 108 | }); 109 | } 110 | if (clientData.crossOrigin !== null && clientData.crossOrigin) { 111 | return new Response("Invalid data", { 112 | status: 400 113 | }); 114 | } 115 | 116 | const credential = getUserPasskeyCredential(user.id, credentialId); 117 | if (credential === null) { 118 | return new Response("Invalid credential", { 119 | status: 400 120 | }); 121 | } 122 | 123 | let validSignature: boolean; 124 | if (credential.algorithmId === coseAlgorithmES256) { 125 | const ecdsaSignature = decodePKIXECDSASignature(signatureBytes); 126 | const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey); 127 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 128 | validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature); 129 | } else if (credential.algorithmId === coseAlgorithmRS256) { 130 | const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey); 131 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 132 | validSignature = verifyRSASSAPKCS1v15Signature(rsaPublicKey, sha256ObjectIdentifier, hash, signatureBytes); 133 | } else { 134 | return new Response("Internal error", { 135 | status: 500 136 | }); 137 | } 138 | 139 | if (!validSignature) { 140 | return new Response("Invalid signature", { 141 | status: 400 142 | }); 143 | } 144 | 145 | setPasswordResetSessionAs2FAVerified(session.id); 146 | return new Response(null, { 147 | status: 204 148 | }); 149 | } 150 | -------------------------------------------------------------------------------- /src/pages/api/password-reset/verify-2fa/recovery-code.ts: -------------------------------------------------------------------------------- 1 | import { ObjectParser } from "@pilcrowjs/object-parser"; 2 | import { resetUser2FAWithRecoveryCode } from "@lib/server/2fa"; 3 | import { validatePasswordResetSessionRequest } from "@lib/server/password-reset"; 4 | import { recoveryCodeBucket } from "@lib/server/2fa"; 5 | 6 | import type { APIContext } from "astro"; 7 | 8 | export async function POST(context: APIContext): Promise { 9 | const { session, user } = validatePasswordResetSessionRequest(context); 10 | if (session === null) { 11 | return new Response("Not authenticated", { 12 | status: 401 13 | }); 14 | } 15 | if (!session.emailVerified || !user.registered2FA || session.twoFactorVerified) { 16 | return new Response("Forbidden", { 17 | status: 403 18 | }); 19 | } 20 | 21 | if (!recoveryCodeBucket.check(session.userId, 1)) { 22 | return new Response("Too many requests", { 23 | status: 429 24 | }); 25 | } 26 | const data: unknown = await context.request.json(); 27 | const parser = new ObjectParser(data); 28 | let code: string; 29 | try { 30 | code = parser.getString("code"); 31 | } catch { 32 | return new Response("Invalid or missing fields", { 33 | status: 400 34 | }); 35 | } 36 | if (code === "") { 37 | return new Response("Please enter your code", { 38 | status: 400 39 | }); 40 | } 41 | if (!recoveryCodeBucket.consume(session.userId, 1)) { 42 | return new Response("Too many requests", { 43 | status: 429 44 | }); 45 | } 46 | const valid = resetUser2FAWithRecoveryCode(session.userId, code); 47 | if (!valid) { 48 | return new Response("Invalid code", { 49 | status: 400 50 | }); 51 | } 52 | recoveryCodeBucket.reset(session.userId); 53 | return new Response(); 54 | } 55 | -------------------------------------------------------------------------------- /src/pages/api/password-reset/verify-2fa/security-key.ts: -------------------------------------------------------------------------------- 1 | import { 2 | parseClientDataJSON, 3 | coseAlgorithmES256, 4 | ClientDataType, 5 | parseAuthenticatorData, 6 | createAssertionSignatureMessage, 7 | coseAlgorithmRS256 8 | } from "@oslojs/webauthn"; 9 | import { decodePKIXECDSASignature, decodeSEC1PublicKey, p256, verifyECDSASignature } from "@oslojs/crypto/ecdsa"; 10 | import { ObjectParser } from "@pilcrowjs/object-parser"; 11 | import { decodeBase64 } from "@oslojs/encoding"; 12 | import { verifyWebAuthnChallenge, getUserSecurityKeyCredential } from "@lib/server/webauthn"; 13 | import { sha256 } from "@oslojs/crypto/sha2"; 14 | import { decodePKCS1RSAPublicKey, sha256ObjectIdentifier, verifyRSASSAPKCS1v15Signature } from "@oslojs/crypto/rsa"; 15 | import { setPasswordResetSessionAs2FAVerified, validatePasswordResetSessionRequest } from "@lib/server/password-reset"; 16 | 17 | import type { APIContext } from "astro"; 18 | import type { ClientData, AuthenticatorData } from "@oslojs/webauthn"; 19 | 20 | export async function POST(context: APIContext): Promise { 21 | const { session, user } = validatePasswordResetSessionRequest(context); 22 | if (session === null) { 23 | return new Response("Not authenticated", { 24 | status: 401 25 | }); 26 | } 27 | if (!session.emailVerified || !user.registeredSecurityKey || session.twoFactorVerified) { 28 | return new Response("Forbidden", { 29 | status: 403 30 | }); 31 | } 32 | 33 | const data: unknown = await context.request.json(); 34 | const parser = new ObjectParser(data); 35 | let encodedAuthenticatorData: string; 36 | let encodedClientDataJSON: string; 37 | let encodedCredentialId: string; 38 | let encodedSignature: string; 39 | try { 40 | encodedAuthenticatorData = parser.getString("authenticator_data"); 41 | encodedClientDataJSON = parser.getString("client_data_json"); 42 | encodedCredentialId = parser.getString("credential_id"); 43 | encodedSignature = parser.getString("signature"); 44 | } catch { 45 | return new Response("Invalid or missing fields", { 46 | status: 400 47 | }); 48 | } 49 | let authenticatorDataBytes: Uint8Array; 50 | let clientDataJSON: Uint8Array; 51 | let credentialId: Uint8Array; 52 | let signatureBytes: Uint8Array; 53 | try { 54 | authenticatorDataBytes = decodeBase64(encodedAuthenticatorData); 55 | clientDataJSON = decodeBase64(encodedClientDataJSON); 56 | credentialId = decodeBase64(encodedCredentialId); 57 | signatureBytes = decodeBase64(encodedSignature); 58 | } catch { 59 | return new Response("Invalid or missing fields", { 60 | status: 400 61 | }); 62 | } 63 | 64 | let authenticatorData: AuthenticatorData; 65 | try { 66 | authenticatorData = parseAuthenticatorData(authenticatorDataBytes); 67 | } catch { 68 | return new Response("Invalid data", { 69 | status: 400 70 | }); 71 | } 72 | // TODO: Update host 73 | if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) { 74 | return new Response("Invalid data", { 75 | status: 400 76 | }); 77 | } 78 | if (!authenticatorData.userPresent) { 79 | return new Response("Invalid data", { 80 | status: 400 81 | }); 82 | } 83 | 84 | let clientData: ClientData; 85 | try { 86 | clientData = parseClientDataJSON(clientDataJSON); 87 | } catch { 88 | return new Response("Invalid data", { 89 | status: 400 90 | }); 91 | } 92 | if (clientData.type !== ClientDataType.Get) { 93 | return new Response("Invalid data", { 94 | status: 400 95 | }); 96 | } 97 | 98 | if (!verifyWebAuthnChallenge(clientData.challenge)) { 99 | return new Response("Invalid data", { 100 | status: 400 101 | }); 102 | } 103 | // TODO: Update origin 104 | if (clientData.origin !== "http://localhost:4321") { 105 | return new Response("Invalid data", { 106 | status: 400 107 | }); 108 | } 109 | if (clientData.crossOrigin !== null && clientData.crossOrigin) { 110 | return new Response("Invalid data", { 111 | status: 400 112 | }); 113 | } 114 | 115 | const credential = getUserSecurityKeyCredential(user.id, credentialId); 116 | if (credential === null) { 117 | return new Response("Invalid credential", { 118 | status: 400 119 | }); 120 | } 121 | 122 | let validSignature: boolean; 123 | if (credential.algorithmId === coseAlgorithmES256) { 124 | const ecdsaSignature = decodePKIXECDSASignature(signatureBytes); 125 | const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey); 126 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 127 | validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature); 128 | } else if (credential.algorithmId === coseAlgorithmRS256) { 129 | const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey); 130 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 131 | validSignature = verifyRSASSAPKCS1v15Signature(rsaPublicKey, sha256ObjectIdentifier, hash, signatureBytes); 132 | } else { 133 | return new Response("Internal error", { 134 | status: 500 135 | }); 136 | } 137 | 138 | if (!validSignature) { 139 | return new Response("Invalid signature", { 140 | status: 400 141 | }); 142 | } 143 | 144 | setPasswordResetSessionAs2FAVerified(session.id); 145 | return new Response(null, { 146 | status: 204 147 | }); 148 | } 149 | -------------------------------------------------------------------------------- /src/pages/api/password-reset/verify-2fa/totp.ts: -------------------------------------------------------------------------------- 1 | import { verifyTOTP } from "@oslojs/otp"; 2 | import { ObjectParser } from "@pilcrowjs/object-parser"; 3 | import { getUserTOTPKey } from "@lib/server/totp"; 4 | import { validatePasswordResetSessionRequest, setPasswordResetSessionAs2FAVerified } from "@lib/server/password-reset"; 5 | import { totpBucket } from "@lib/server/totp"; 6 | 7 | import type { APIContext } from "astro"; 8 | 9 | export async function POST(context: APIContext): Promise { 10 | const { session, user } = validatePasswordResetSessionRequest(context); 11 | if (session === null) { 12 | return new Response("Not authenticated", { 13 | status: 401 14 | }); 15 | } 16 | if (!session.emailVerified || !user.registeredTOTP || session.twoFactorVerified) { 17 | return new Response("Forbidden", { 18 | status: 403 19 | }); 20 | } 21 | if (!totpBucket.check(session.userId, 1)) { 22 | return new Response("Too many requests", { 23 | status: 429 24 | }); 25 | } 26 | 27 | const data: unknown = await context.request.json(); 28 | const parser = new ObjectParser(data); 29 | let code: string; 30 | try { 31 | code = parser.getString("code"); 32 | } catch { 33 | return new Response("Invalid or missing fields", { 34 | status: 400 35 | }); 36 | } 37 | if (code === "") { 38 | return new Response("Please enter your code", { 39 | status: 400 40 | }); 41 | } 42 | const totpKey = getUserTOTPKey(session.userId); 43 | if (totpKey === null) { 44 | return new Response("Forbidden", { 45 | status: 403 46 | }); 47 | } 48 | if (!totpBucket.consume(session.userId, 1)) { 49 | return new Response("Too many requests", { 50 | status: 429 51 | }); 52 | } 53 | if (!verifyTOTP(totpKey, 30, 6, code)) { 54 | return new Response("Invalid code", { 55 | status: 400 56 | }); 57 | } 58 | totpBucket.reset(session.userId); 59 | setPasswordResetSessionAs2FAVerified(session.id); 60 | return new Response(null, { status: 204 }); 61 | } 62 | -------------------------------------------------------------------------------- /src/pages/api/password-reset/verify-email.ts: -------------------------------------------------------------------------------- 1 | import { 2 | validatePasswordResetSessionRequest, 3 | setPasswordResetSessionAsEmailVerified 4 | } from "@lib/server/password-reset"; 5 | import { ObjectParser } from "@pilcrowjs/object-parser"; 6 | import { ExpiringTokenBucket } from "@lib/server/rate-limit"; 7 | import { setUserAsEmailVerifiedIfEmailMatches } from "@lib/server/user"; 8 | 9 | import type { APIContext } from "astro"; 10 | 11 | const bucket = new ExpiringTokenBucket(5, 60 * 30); 12 | 13 | export async function POST(context: APIContext): Promise { 14 | const { session } = validatePasswordResetSessionRequest(context); 15 | if (session === null) { 16 | return new Response("Not authenticated", { 17 | status: 401 18 | }); 19 | } 20 | if (session.emailVerified) { 21 | return new Response("Forbidden", { 22 | status: 403 23 | }); 24 | } 25 | if (!bucket.check(session.userId, 1)) { 26 | return new Response("Too many requests", { 27 | status: 429 28 | }); 29 | } 30 | 31 | const data = await context.request.json(); 32 | const parser = new ObjectParser(data); 33 | let code: string; 34 | try { 35 | code = parser.getString("code"); 36 | } catch { 37 | return new Response("Invalid or missing fields", { 38 | status: 400 39 | }); 40 | } 41 | if (code === "") { 42 | return new Response("Please enter your code", { 43 | status: 400 44 | }); 45 | } 46 | if (!bucket.consume(session.userId, 1)) { 47 | return new Response("Too many requests", { 48 | status: 429 49 | }); 50 | } 51 | if (code !== session.code) { 52 | return new Response("Incorrect code", { 53 | status: 400 54 | }); 55 | } 56 | bucket.reset(session.userId); 57 | setPasswordResetSessionAsEmailVerified(session.id); 58 | const emailMatches = setUserAsEmailVerifiedIfEmailMatches(session.userId, session.email); 59 | if (!emailMatches) { 60 | return new Response("Please restart the process", { 61 | status: 400 62 | }); 63 | } 64 | return new Response(null, { status: 204 }); 65 | } 66 | -------------------------------------------------------------------------------- /src/pages/api/session.ts: -------------------------------------------------------------------------------- 1 | import { invalidateSession, deleteSessionTokenCookie } from "@lib/server/session"; 2 | 3 | import type { APIContext } from "astro"; 4 | 5 | export async function DELETE(context: APIContext): Promise { 6 | if (context.locals.session === null) { 7 | return new Response("Not authenticated", { 8 | status: 401 9 | }); 10 | } 11 | invalidateSession(context.locals.session.id); 12 | deleteSessionTokenCookie(context); 13 | return new Response(null, { status: 204 }); 14 | } 15 | -------------------------------------------------------------------------------- /src/pages/api/user/index.ts: -------------------------------------------------------------------------------- 1 | import { ObjectParser } from "@pilcrowjs/object-parser"; 2 | import { verifyPasswordStrength } from "@lib/server/password"; 3 | import { createSession, generateSessionToken, setSessionTokenCookie } from "@lib/server/session"; 4 | import { createUser, verifyUsernameInput } from "@lib/server/user"; 5 | import { checkEmailAvailability, verifyEmailInput } from "@lib/server/email"; 6 | import { 7 | createEmailVerificationRequest, 8 | sendVerificationEmail, 9 | setEmailVerificationRequestCookie 10 | } from "@lib/server/email-verification"; 11 | import { RefillingTokenBucket } from "@lib/server/rate-limit"; 12 | 13 | import type { APIContext } from "astro"; 14 | import type { SessionFlags } from "@lib/server/session"; 15 | 16 | const bucket = new RefillingTokenBucket(10, 5); 17 | 18 | export async function POST(context: APIContext): Promise { 19 | // TODO: Assumes X-Forwarded-For is always included. 20 | const clientIP = context.request.headers.get("X-Forwarded-For"); 21 | if (clientIP !== null && !bucket.check(clientIP, 1)) { 22 | return new Response("Too many requests", { 23 | status: 429 24 | }); 25 | } 26 | 27 | const data: unknown = await context.request.json(); 28 | const parser = new ObjectParser(data); 29 | let email: string, username: string, password: string; 30 | try { 31 | email = parser.getString("email").toLowerCase(); 32 | username = parser.getString("username"); 33 | password = parser.getString("password"); 34 | } catch { 35 | return new Response("Invalid or missing fields", { 36 | status: 400 37 | }); 38 | } 39 | if (email === "" || password === "" || username === "") { 40 | return new Response("Please enter your username, email, and password", { 41 | status: 400 42 | }); 43 | } 44 | if (!verifyEmailInput(email)) { 45 | return new Response("Invalid email", { 46 | status: 400 47 | }); 48 | } 49 | const emailAvailable = checkEmailAvailability(email); 50 | if (!emailAvailable) { 51 | return new Response("Email is already used", { 52 | status: 400 53 | }); 54 | } 55 | if (!verifyUsernameInput(username)) { 56 | return new Response("Invalid username", { 57 | status: 400 58 | }); 59 | } 60 | const strongPassword = await verifyPasswordStrength(password); 61 | if (!strongPassword) { 62 | return new Response("Weak password", { 63 | status: 400 64 | }); 65 | } 66 | if (clientIP !== null && !bucket.consume(clientIP, 1)) { 67 | return new Response("Too many requests", { 68 | status: 429 69 | }); 70 | } 71 | const user = await createUser(email, username, password); 72 | const emailVerificationRequest = createEmailVerificationRequest(user.id, user.email); 73 | sendVerificationEmail(emailVerificationRequest.email, emailVerificationRequest.code); 74 | setEmailVerificationRequestCookie(context, emailVerificationRequest); 75 | 76 | const sessionFlags: SessionFlags = { 77 | twoFactorVerified: false 78 | }; 79 | const sessionToken = generateSessionToken(); 80 | const session = createSession(sessionToken, user.id, sessionFlags); 81 | setSessionTokenCookie(context, sessionToken, session.expiresAt); 82 | return new Response(null, { status: 204 }); 83 | } 84 | -------------------------------------------------------------------------------- /src/pages/api/user/passkey/credential.ts: -------------------------------------------------------------------------------- 1 | import { 2 | parseAttestationObject, 3 | AttestationStatementFormat, 4 | parseClientDataJSON, 5 | coseAlgorithmES256, 6 | coseEllipticCurveP256, 7 | ClientDataType, 8 | coseAlgorithmRS256 9 | } from "@oslojs/webauthn"; 10 | import { ECDSAPublicKey, p256 } from "@oslojs/crypto/ecdsa"; 11 | import { ObjectParser } from "@pilcrowjs/object-parser"; 12 | import { decodeBase64 } from "@oslojs/encoding"; 13 | import { verifyWebAuthnChallenge, createPasskeyCredential, getUserPasskeyCredentials } from "@lib/server/webauthn"; 14 | import { setSessionAs2FAVerified } from "@lib/server/session"; 15 | import { RSAPublicKey } from "@oslojs/crypto/rsa"; 16 | import { SqliteError } from "better-sqlite3"; 17 | 18 | import type { APIContext } from "astro"; 19 | import type { WebAuthnUserCredential } from "@lib/server/webauthn"; 20 | import type { 21 | AttestationStatement, 22 | AuthenticatorData, 23 | ClientData, 24 | COSEEC2PublicKey, 25 | COSERSAPublicKey 26 | } from "@oslojs/webauthn"; 27 | 28 | export async function POST(context: APIContext): Promise { 29 | if (context.locals.session === null || context.locals.user === null) { 30 | return new Response("Not authenticated", { 31 | status: 401 32 | }); 33 | } 34 | if (!context.locals.user.emailVerified) { 35 | return new Response("Forbidden", { 36 | status: 403 37 | }); 38 | } 39 | if (context.locals.user.registered2FA && !context.locals.session.twoFactorVerified) { 40 | return new Response("Forbidden", { 41 | status: 403 42 | }); 43 | } 44 | 45 | const data: unknown = await context.request.json(); 46 | const parser = new ObjectParser(data); 47 | let name: string, encodedAttestationObject: string, encodedClientDataJSON: string; 48 | try { 49 | name = parser.getString("name"); 50 | encodedAttestationObject = parser.getString("attestation_object"); 51 | encodedClientDataJSON = parser.getString("client_data_json"); 52 | } catch { 53 | return new Response("Invalid or missing fields", { 54 | status: 400 55 | }); 56 | } 57 | let attestationObjectBytes: Uint8Array, clientDataJSON: Uint8Array; 58 | try { 59 | attestationObjectBytes = decodeBase64(encodedAttestationObject); 60 | clientDataJSON = decodeBase64(encodedClientDataJSON); 61 | } catch { 62 | return new Response("Invalid or missing fields", { 63 | status: 400 64 | }); 65 | } 66 | 67 | let attestationStatement: AttestationStatement; 68 | let authenticatorData: AuthenticatorData; 69 | try { 70 | let attestationObject = parseAttestationObject(attestationObjectBytes); 71 | attestationStatement = attestationObject.attestationStatement; 72 | authenticatorData = attestationObject.authenticatorData; 73 | } catch { 74 | return new Response("Invalid data", { 75 | status: 400 76 | }); 77 | } 78 | if (attestationStatement.format !== AttestationStatementFormat.None) { 79 | return new Response("Invalid data", { 80 | status: 400 81 | }); 82 | } 83 | // TODO: Update host 84 | if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) { 85 | return new Response("Invalid data", { 86 | status: 400 87 | }); 88 | } 89 | if (!authenticatorData.userPresent || !authenticatorData.userVerified) { 90 | return new Response("Invalid data", { 91 | status: 400 92 | }); 93 | } 94 | if (authenticatorData.credential === null) { 95 | return new Response("Invalid data", { 96 | status: 400 97 | }); 98 | } 99 | 100 | let clientData: ClientData; 101 | try { 102 | clientData = parseClientDataJSON(clientDataJSON); 103 | } catch { 104 | return new Response("Invalid data", { 105 | status: 400 106 | }); 107 | } 108 | if (clientData.type !== ClientDataType.Create) { 109 | return new Response("Invalid data", { 110 | status: 400 111 | }); 112 | } 113 | 114 | if (!verifyWebAuthnChallenge(clientData.challenge)) { 115 | return new Response("Invalid data", { 116 | status: 400 117 | }); 118 | } 119 | // TODO: Update origin 120 | if (clientData.origin !== "http://localhost:4321") { 121 | return new Response("Invalid data", { 122 | status: 400 123 | }); 124 | } 125 | if (clientData.crossOrigin !== null && clientData.crossOrigin) { 126 | return new Response("Invalid data", { 127 | status: 400 128 | }); 129 | } 130 | 131 | let credential: WebAuthnUserCredential; 132 | if (authenticatorData.credential.publicKey.algorithm() === coseAlgorithmES256) { 133 | let cosePublicKey: COSEEC2PublicKey; 134 | try { 135 | cosePublicKey = authenticatorData.credential.publicKey.ec2(); 136 | } catch { 137 | return new Response("Invalid data", { 138 | status: 400 139 | }); 140 | } 141 | if (cosePublicKey.curve !== coseEllipticCurveP256) { 142 | return new Response("Unsupported algorithm", { 143 | status: 400 144 | }); 145 | } 146 | const encodedPublicKey = new ECDSAPublicKey(p256, cosePublicKey.x, cosePublicKey.y).encodeSEC1Uncompressed(); 147 | credential = { 148 | id: authenticatorData.credential.id, 149 | userId: context.locals.user.id, 150 | algorithmId: coseAlgorithmES256, 151 | name, 152 | publicKey: encodedPublicKey 153 | }; 154 | } else if (authenticatorData.credential.publicKey.algorithm() === coseAlgorithmRS256) { 155 | let cosePublicKey: COSERSAPublicKey; 156 | try { 157 | cosePublicKey = authenticatorData.credential.publicKey.rsa(); 158 | } catch { 159 | return new Response("Invalid data", { 160 | status: 400 161 | }); 162 | } 163 | const encodedPublicKey = new RSAPublicKey(cosePublicKey.n, cosePublicKey.e).encodePKCS1(); 164 | credential = { 165 | id: authenticatorData.credential.id, 166 | userId: context.locals.user.id, 167 | algorithmId: coseAlgorithmRS256, 168 | name, 169 | publicKey: encodedPublicKey 170 | }; 171 | } else { 172 | return new Response("Unsupported algorithm", { 173 | status: 400 174 | }); 175 | } 176 | 177 | // We don't have to worry about race conditions since queries are synchronous 178 | const credentials = getUserPasskeyCredentials(context.locals.user.id); 179 | if (credentials.length >= 5) { 180 | return new Response("Too many credentials", { 181 | status: 400 182 | }); 183 | } 184 | 185 | try { 186 | createPasskeyCredential(credential); 187 | } catch (e) { 188 | if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_PRIMARYKEY") { 189 | return new Response("Invalid data", { 190 | status: 400 191 | }); 192 | } 193 | return new Response("Internal error", { 194 | status: 500 195 | }); 196 | } 197 | 198 | if (!context.locals.session.twoFactorVerified) { 199 | setSessionAs2FAVerified(context.locals.session.id); 200 | } 201 | 202 | return new Response(null, { 203 | status: 204 204 | }); 205 | } 206 | -------------------------------------------------------------------------------- /src/pages/api/user/passkey/credentials/[credential_id].ts: -------------------------------------------------------------------------------- 1 | import { deleteUserPasskeyCredential } from "@lib/server/webauthn"; 2 | import { decodeBase64urlIgnorePadding } from "@oslojs/encoding"; 3 | 4 | import type { APIContext } from "astro"; 5 | 6 | export async function DELETE(context: APIContext): Promise { 7 | const encodedCredentialId = context.params.id as string; 8 | if (context.locals.user === null || context.locals.session === null) { 9 | return new Response("Not authenticated", { 10 | status: 401 11 | }); 12 | } 13 | if (!context.locals.user.emailVerified) { 14 | return new Response("Forbidden", { 15 | status: 403 16 | }); 17 | } 18 | if (context.locals.user.registered2FA && !context.locals.session.twoFactorVerified) { 19 | return new Response("Forbidden", { 20 | status: 403 21 | }); 22 | } 23 | let credentialId: Uint8Array; 24 | try { 25 | credentialId = decodeBase64urlIgnorePadding(encodedCredentialId); 26 | } catch { 27 | return new Response(null, { 28 | status: 404 29 | }); 30 | } 31 | const deleted = deleteUserPasskeyCredential(context.locals.user.id, credentialId); 32 | if (!deleted) { 33 | return new Response(null, { 34 | status: 404 35 | }); 36 | } 37 | return new Response(null, { 38 | status: 204 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /src/pages/api/user/passkey/verify.ts: -------------------------------------------------------------------------------- 1 | import { 2 | parseClientDataJSON, 3 | coseAlgorithmES256, 4 | ClientDataType, 5 | parseAuthenticatorData, 6 | createAssertionSignatureMessage, 7 | coseAlgorithmRS256 8 | } from "@oslojs/webauthn"; 9 | import { decodePKIXECDSASignature, decodeSEC1PublicKey, p256, verifyECDSASignature } from "@oslojs/crypto/ecdsa"; 10 | import { ObjectParser } from "@pilcrowjs/object-parser"; 11 | import { decodeBase64 } from "@oslojs/encoding"; 12 | import { verifyWebAuthnChallenge, getUserPasskeyCredential } from "@lib/server/webauthn"; 13 | import { setSessionAs2FAVerified } from "@lib/server/session"; 14 | import { sha256 } from "@oslojs/crypto/sha2"; 15 | import { decodePKCS1RSAPublicKey, sha256ObjectIdentifier, verifyRSASSAPKCS1v15Signature } from "@oslojs/crypto/rsa"; 16 | 17 | import type { APIContext } from "astro"; 18 | import type { ClientData, AuthenticatorData } from "@oslojs/webauthn"; 19 | 20 | // Stricter rate limiting can be omitted here since creating challenges are rate-limited 21 | export async function POST(context: APIContext): Promise { 22 | if (context.locals.session === null || context.locals.user === null) { 23 | return new Response("Not authenticated", { 24 | status: 401 25 | }); 26 | } 27 | if ( 28 | !context.locals.user.emailVerified || 29 | !context.locals.user.registeredPasskey || 30 | context.locals.session.twoFactorVerified 31 | ) { 32 | return new Response("Forbidden", { 33 | status: 403 34 | }); 35 | } 36 | 37 | const data: unknown = await context.request.json(); 38 | const parser = new ObjectParser(data); 39 | let encodedAuthenticatorData: string; 40 | let encodedClientDataJSON: string; 41 | let encodedCredentialId: string; 42 | let encodedSignature: string; 43 | try { 44 | encodedAuthenticatorData = parser.getString("authenticator_data"); 45 | encodedClientDataJSON = parser.getString("client_data_json"); 46 | encodedCredentialId = parser.getString("credential_id"); 47 | encodedSignature = parser.getString("signature"); 48 | } catch { 49 | return new Response("Invalid or missing fields", { 50 | status: 400 51 | }); 52 | } 53 | let authenticatorDataBytes: Uint8Array; 54 | let clientDataJSON: Uint8Array; 55 | let credentialId: Uint8Array; 56 | let signatureBytes: Uint8Array; 57 | try { 58 | authenticatorDataBytes = decodeBase64(encodedAuthenticatorData); 59 | clientDataJSON = decodeBase64(encodedClientDataJSON); 60 | credentialId = decodeBase64(encodedCredentialId); 61 | signatureBytes = decodeBase64(encodedSignature); 62 | } catch { 63 | return new Response("Invalid or missing fields", { 64 | status: 400 65 | }); 66 | } 67 | 68 | let authenticatorData: AuthenticatorData; 69 | try { 70 | authenticatorData = parseAuthenticatorData(authenticatorDataBytes); 71 | } catch { 72 | return new Response("Invalid data", { 73 | status: 400 74 | }); 75 | } 76 | // TODO: Update host 77 | if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) { 78 | return new Response("Invalid data", { 79 | status: 400 80 | }); 81 | } 82 | if (!authenticatorData.userPresent) { 83 | return new Response("Invalid data", { 84 | status: 400 85 | }); 86 | } 87 | 88 | let clientData: ClientData; 89 | try { 90 | clientData = parseClientDataJSON(clientDataJSON); 91 | } catch { 92 | return new Response("Invalid data", { 93 | status: 400 94 | }); 95 | } 96 | if (clientData.type !== ClientDataType.Get) { 97 | return new Response("Invalid data", { 98 | status: 400 99 | }); 100 | } 101 | 102 | if (!verifyWebAuthnChallenge(clientData.challenge)) { 103 | return new Response("Invalid data", { 104 | status: 400 105 | }); 106 | } 107 | // TODO: Update origin 108 | if (clientData.origin !== "http://localhost:4321") { 109 | return new Response("Invalid data", { 110 | status: 400 111 | }); 112 | } 113 | if (clientData.crossOrigin !== null && clientData.crossOrigin) { 114 | return new Response("Invalid data", { 115 | status: 400 116 | }); 117 | } 118 | 119 | const credential = getUserPasskeyCredential(context.locals.user.id, credentialId); 120 | if (credential === null) { 121 | return new Response("Invalid credential", { 122 | status: 400 123 | }); 124 | } 125 | 126 | let validSignature: boolean; 127 | if (credential.algorithmId === coseAlgorithmES256) { 128 | const ecdsaSignature = decodePKIXECDSASignature(signatureBytes); 129 | const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey); 130 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 131 | validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature); 132 | } else if (credential.algorithmId === coseAlgorithmRS256) { 133 | const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey); 134 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 135 | validSignature = verifyRSASSAPKCS1v15Signature(rsaPublicKey, sha256ObjectIdentifier, hash, signatureBytes); 136 | } else { 137 | return new Response("Internal error", { 138 | status: 500 139 | }); 140 | } 141 | 142 | if (!validSignature) { 143 | return new Response("Invalid signature", { 144 | status: 400 145 | }); 146 | } 147 | 148 | setSessionAs2FAVerified(context.locals.session.id); 149 | return new Response(null, { 150 | status: 204 151 | }); 152 | } 153 | -------------------------------------------------------------------------------- /src/pages/api/user/password.ts: -------------------------------------------------------------------------------- 1 | import { ObjectParser } from "@pilcrowjs/object-parser"; 2 | import { getUserPasswordHash, updateUserPassword } from "@lib/server/user"; 3 | import { verifyPasswordHash, verifyPasswordStrength } from "@lib/server/password"; 4 | import { 5 | createSession, 6 | generateSessionToken, 7 | invalidateUserSessions, 8 | setSessionTokenCookie 9 | } from "@lib/server/session"; 10 | import { ExpiringTokenBucket } from "@lib/server/rate-limit"; 11 | 12 | import type { APIContext } from "astro"; 13 | import type { SessionFlags } from "@lib/server/session"; 14 | 15 | const bucket = new ExpiringTokenBucket(5, 60 * 30); 16 | 17 | export async function PATCH(context: APIContext): Promise { 18 | if (context.locals.user === null || context.locals.session === null) { 19 | return new Response("Not authenticated", { 20 | status: 401 21 | }); 22 | } 23 | if (context.locals.user.registered2FA && !context.locals.session.twoFactorVerified) { 24 | return new Response("Forbidden", { 25 | status: 403 26 | }); 27 | } 28 | if (!bucket.check(context.locals.session.id, 1)) { 29 | return new Response("Too many requests", { 30 | status: 429 31 | }); 32 | } 33 | 34 | const data = await context.request.json(); 35 | const parser = new ObjectParser(data); 36 | let password: string, newPassword: string; 37 | try { 38 | password = parser.getString("password"); 39 | newPassword = parser.getString("new_password"); 40 | } catch { 41 | return new Response("Invalid or missing fields", { 42 | status: 400 43 | }); 44 | } 45 | const strongPassword = await verifyPasswordStrength(newPassword); 46 | if (!strongPassword) { 47 | return new Response("Weak password", { 48 | status: 400 49 | }); 50 | } 51 | if (!bucket.consume(context.locals.session.id, 1)) { 52 | return new Response("Too many requests", { 53 | status: 429 54 | }); 55 | } 56 | const passwordHash = getUserPasswordHash(context.locals.user.id); 57 | const validPassword = await verifyPasswordHash(passwordHash, password); 58 | if (!validPassword) { 59 | return new Response("Incorrect password", { 60 | status: 400 61 | }); 62 | } 63 | bucket.reset(context.locals.session.id); 64 | invalidateUserSessions(context.locals.user.id); 65 | await updateUserPassword(context.locals.user.id, newPassword); 66 | 67 | const sessionToken = generateSessionToken(); 68 | const sessionFlags: SessionFlags = { 69 | twoFactorVerified: context.locals.session.twoFactorVerified 70 | }; 71 | const session = createSession(sessionToken, context.locals.user.id, sessionFlags); 72 | setSessionTokenCookie(context, sessionToken, session.expiresAt); 73 | return new Response(null, { status: 204 }); 74 | } 75 | -------------------------------------------------------------------------------- /src/pages/api/user/recovery-code/index.ts: -------------------------------------------------------------------------------- 1 | import { getUserRecoverCode } from "@lib/server/user"; 2 | 3 | import type { APIContext } from "astro"; 4 | 5 | export async function GET(context: APIContext): Promise { 6 | if (context.locals.session === null || context.locals.user === null) { 7 | return new Response("Not authenticated", { 8 | status: 401 9 | }); 10 | } 11 | if (!context.locals.user.emailVerified) { 12 | return new Response("Forbidden", { 13 | status: 403 14 | }); 15 | } 16 | if (!context.locals.session.twoFactorVerified) { 17 | return new Response("Forbidden", { 18 | status: 403 19 | }); 20 | } 21 | const code = getUserRecoverCode(context.locals.session.userId); 22 | return new Response(code); 23 | } 24 | -------------------------------------------------------------------------------- /src/pages/api/user/recovery-code/reset.ts: -------------------------------------------------------------------------------- 1 | import { resetUserRecoveryCode } from "@lib/server/user"; 2 | 3 | import type { APIContext } from "astro"; 4 | 5 | export async function POST(context: APIContext): Promise { 6 | if (context.locals.session === null || context.locals.user === null) { 7 | return new Response("Not authenticated", { 8 | status: 401 9 | }); 10 | } 11 | if (!context.locals.user.emailVerified) { 12 | return new Response("Forbidden", { 13 | status: 403 14 | }); 15 | } 16 | if (!context.locals.session.twoFactorVerified) { 17 | return new Response("Forbidden", { 18 | status: 403 19 | }); 20 | } 21 | const code = resetUserRecoveryCode(context.locals.session.userId); 22 | return new Response(code); 23 | } 24 | -------------------------------------------------------------------------------- /src/pages/api/user/reset-2fa.ts: -------------------------------------------------------------------------------- 1 | import { ObjectParser } from "@pilcrowjs/object-parser"; 2 | import { recoveryCodeBucket, resetUser2FAWithRecoveryCode } from "@lib/server/2fa"; 3 | 4 | import type { APIContext } from "astro"; 5 | 6 | export async function POST(context: APIContext): Promise { 7 | if (context.locals.session === null || context.locals.user === null) { 8 | return new Response("Not authenticated", { 9 | status: 401 10 | }); 11 | } 12 | if ( 13 | !context.locals.user.emailVerified || 14 | !context.locals.user.registered2FA || 15 | context.locals.session.twoFactorVerified 16 | ) { 17 | return new Response("Forbidden", { 18 | status: 403 19 | }); 20 | } 21 | if (!recoveryCodeBucket.check(context.locals.user.id, 1)) { 22 | return new Response("Too many requests", { 23 | status: 429 24 | }); 25 | } 26 | 27 | const data: unknown = await context.request.json(); 28 | const parser = new ObjectParser(data); 29 | let code: string; 30 | try { 31 | code = parser.getString("code"); 32 | } catch { 33 | return new Response("Invalid or missing fields", { 34 | status: 400 35 | }); 36 | } 37 | if (code === "") { 38 | return new Response("Please enter your code", { 39 | status: 400 40 | }); 41 | } 42 | if (!recoveryCodeBucket.consume(context.locals.user.id, 1)) { 43 | return new Response("Too many requests", { 44 | status: 429 45 | }); 46 | } 47 | const valid = resetUser2FAWithRecoveryCode(context.locals.user.id, code); 48 | if (!valid) { 49 | return new Response("Invalid recovery code", { 50 | status: 400 51 | }); 52 | } 53 | recoveryCodeBucket.reset(context.locals.user.id); 54 | return new Response(null, { 55 | status: 204 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /src/pages/api/user/security-key/credential.ts: -------------------------------------------------------------------------------- 1 | import { 2 | parseAttestationObject, 3 | AttestationStatementFormat, 4 | parseClientDataJSON, 5 | coseAlgorithmES256, 6 | coseEllipticCurveP256, 7 | ClientDataType, 8 | coseAlgorithmRS256 9 | } from "@oslojs/webauthn"; 10 | import { ECDSAPublicKey, p256 } from "@oslojs/crypto/ecdsa"; 11 | import { ObjectParser } from "@pilcrowjs/object-parser"; 12 | import { decodeBase64 } from "@oslojs/encoding"; 13 | import { 14 | verifyWebAuthnChallenge, 15 | createSecurityKeyCredential, 16 | getUserSecurityKeyCredentials 17 | } from "@lib/server/webauthn"; 18 | import { setSessionAs2FAVerified } from "@lib/server/session"; 19 | import { RSAPublicKey } from "@oslojs/crypto/rsa"; 20 | import { SqliteError } from "better-sqlite3"; 21 | 22 | import type { APIContext } from "astro"; 23 | import type { WebAuthnUserCredential } from "@lib/server/webauthn"; 24 | import type { 25 | COSEEC2PublicKey, 26 | COSERSAPublicKey, 27 | AttestationStatement, 28 | AuthenticatorData, 29 | ClientData 30 | } from "@oslojs/webauthn"; 31 | 32 | // Stricter rate limiting can be omitted here since creating challenges are rate-limited 33 | export async function POST(context: APIContext): Promise { 34 | if (context.locals.session === null || context.locals.user === null) { 35 | return new Response("Not authenticated", { 36 | status: 401 37 | }); 38 | } 39 | if (!context.locals.user.emailVerified) { 40 | return new Response("Forbidden", { 41 | status: 403 42 | }); 43 | } 44 | if (context.locals.user.registered2FA && !context.locals.session.twoFactorVerified) { 45 | return new Response("Forbidden", { 46 | status: 403 47 | }); 48 | } 49 | 50 | const data: unknown = await context.request.json(); 51 | const parser = new ObjectParser(data); 52 | let name: string, encodedAttestationObject: string, encodedClientDataJSON: string; 53 | try { 54 | name = parser.getString("name"); 55 | encodedAttestationObject = parser.getString("attestation_object"); 56 | encodedClientDataJSON = parser.getString("client_data_json"); 57 | } catch { 58 | return new Response("Invalid or missing fields", { 59 | status: 400 60 | }); 61 | } 62 | let attestationObjectBytes: Uint8Array, clientDataJSON: Uint8Array; 63 | try { 64 | attestationObjectBytes = decodeBase64(encodedAttestationObject); 65 | clientDataJSON = decodeBase64(encodedClientDataJSON); 66 | } catch { 67 | return new Response("Invalid or missing fields", { 68 | status: 400 69 | }); 70 | } 71 | 72 | let attestationStatement: AttestationStatement; 73 | let authenticatorData: AuthenticatorData; 74 | try { 75 | let attestationObject = parseAttestationObject(attestationObjectBytes); 76 | attestationStatement = attestationObject.attestationStatement; 77 | authenticatorData = attestationObject.authenticatorData; 78 | } catch { 79 | return new Response("Invalid data", { 80 | status: 400 81 | }); 82 | } 83 | if (attestationStatement.format !== AttestationStatementFormat.None) { 84 | return new Response("Invalid data", { 85 | status: 400 86 | }); 87 | } 88 | // TODO: Update host 89 | if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) { 90 | return new Response("Invalid data", { 91 | status: 400 92 | }); 93 | } 94 | if (!authenticatorData.userPresent) { 95 | return new Response("Invalid data", { 96 | status: 400 97 | }); 98 | } 99 | if (authenticatorData.credential === null) { 100 | return new Response("Invalid data", { 101 | status: 400 102 | }); 103 | } 104 | 105 | let clientData: ClientData; 106 | try { 107 | clientData = parseClientDataJSON(clientDataJSON); 108 | } catch { 109 | return new Response("Invalid data", { 110 | status: 400 111 | }); 112 | } 113 | if (clientData.type !== ClientDataType.Create) { 114 | return new Response("Invalid data", { 115 | status: 400 116 | }); 117 | } 118 | if (!verifyWebAuthnChallenge(clientData.challenge)) { 119 | return new Response("Invalid data", { 120 | status: 400 121 | }); 122 | } 123 | // TODO: Update origin 124 | if (clientData.origin !== "http://localhost:4321") { 125 | return new Response("Invalid data", { 126 | status: 400 127 | }); 128 | } 129 | if (clientData.crossOrigin !== null && clientData.crossOrigin) { 130 | return new Response("Invalid data", { 131 | status: 400 132 | }); 133 | } 134 | 135 | let credential: WebAuthnUserCredential; 136 | if (authenticatorData.credential.publicKey.algorithm() === coseAlgorithmES256) { 137 | let cosePublicKey: COSEEC2PublicKey; 138 | try { 139 | cosePublicKey = authenticatorData.credential.publicKey.ec2(); 140 | } catch { 141 | return new Response("Invalid data", { 142 | status: 400 143 | }); 144 | } 145 | if (cosePublicKey.curve !== coseEllipticCurveP256) { 146 | return new Response("Unsupported algorithm", { 147 | status: 400 148 | }); 149 | } 150 | // Store the credential ID, algorithm (ES256), and public key with the user's user ID 151 | const encodedPublicKey = new ECDSAPublicKey(p256, cosePublicKey.x, cosePublicKey.y).encodeSEC1Uncompressed(); 152 | credential = { 153 | id: authenticatorData.credential.id, 154 | userId: context.locals.user.id, 155 | algorithmId: coseAlgorithmES256, 156 | name, 157 | publicKey: encodedPublicKey 158 | }; 159 | } else if (authenticatorData.credential.publicKey.algorithm() === coseAlgorithmRS256) { 160 | let cosePublicKey: COSERSAPublicKey; 161 | try { 162 | cosePublicKey = authenticatorData.credential.publicKey.rsa(); 163 | } catch { 164 | return new Response("Invalid data", { 165 | status: 400 166 | }); 167 | } 168 | const encodedPublicKey = new RSAPublicKey(cosePublicKey.n, cosePublicKey.e).encodePKCS1(); 169 | credential = { 170 | id: authenticatorData.credential.id, 171 | userId: context.locals.user.id, 172 | algorithmId: coseAlgorithmRS256, 173 | name, 174 | publicKey: encodedPublicKey 175 | }; 176 | } else { 177 | return new Response("Unsupported algorithm", { 178 | status: 400 179 | }); 180 | } 181 | 182 | // We don't have to worry about race conditions since queries are synchronous 183 | const credentials = getUserSecurityKeyCredentials(context.locals.user.id); 184 | if (credentials.length >= 5) { 185 | return new Response("Too many credentials", { 186 | status: 400 187 | }); 188 | } 189 | 190 | try { 191 | createSecurityKeyCredential(credential); 192 | } catch (e) { 193 | if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_PRIMARYKEY") { 194 | return new Response("Invalid data", { 195 | status: 400 196 | }); 197 | } 198 | return new Response("Internal error", { 199 | status: 500 200 | }); 201 | } 202 | 203 | if (!context.locals.session.twoFactorVerified) { 204 | setSessionAs2FAVerified(context.locals.session.id); 205 | } 206 | 207 | return new Response(null, { 208 | status: 204 209 | }); 210 | } 211 | -------------------------------------------------------------------------------- /src/pages/api/user/security-key/credentials/[credential_id].ts: -------------------------------------------------------------------------------- 1 | import { deleteUserSecurityKeyCredential } from "@lib/server/webauthn"; 2 | import { decodeBase64urlIgnorePadding } from "@oslojs/encoding"; 3 | 4 | import type { APIContext } from "astro"; 5 | 6 | export async function DELETE(context: APIContext): Promise { 7 | const encodedCredentialId = context.params.id as string; 8 | if (context.locals.user === null || context.locals.session === null) { 9 | return new Response("Not authenticated", { 10 | status: 401 11 | }); 12 | } 13 | if (!context.locals.user.emailVerified) { 14 | return new Response("Forbidden", { 15 | status: 403 16 | }); 17 | } 18 | if (context.locals.user.registered2FA && !context.locals.session.twoFactorVerified) { 19 | return new Response("Forbidden", { 20 | status: 403 21 | }); 22 | } 23 | let credentialId: Uint8Array; 24 | try { 25 | credentialId = decodeBase64urlIgnorePadding(encodedCredentialId); 26 | } catch { 27 | return new Response(null, { 28 | status: 404 29 | }); 30 | } 31 | const deleted = deleteUserSecurityKeyCredential(context.locals.user.id, credentialId); 32 | if (!deleted) { 33 | return new Response(null, { 34 | status: 404 35 | }); 36 | } 37 | return new Response(null, { 38 | status: 204 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /src/pages/api/user/security-key/verify.ts: -------------------------------------------------------------------------------- 1 | import { 2 | parseClientDataJSON, 3 | coseAlgorithmES256, 4 | ClientDataType, 5 | parseAuthenticatorData, 6 | createAssertionSignatureMessage, 7 | coseAlgorithmRS256 8 | } from "@oslojs/webauthn"; 9 | import { decodePKIXECDSASignature, decodeSEC1PublicKey, p256, verifyECDSASignature } from "@oslojs/crypto/ecdsa"; 10 | import { ObjectParser } from "@pilcrowjs/object-parser"; 11 | import { decodeBase64 } from "@oslojs/encoding"; 12 | import { verifyWebAuthnChallenge, getUserSecurityKeyCredential } from "@lib/server/webauthn"; 13 | import { setSessionAs2FAVerified } from "@lib/server/session"; 14 | import { sha256 } from "@oslojs/crypto/sha2"; 15 | import { decodePKCS1RSAPublicKey, sha256ObjectIdentifier, verifyRSASSAPKCS1v15Signature } from "@oslojs/crypto/rsa"; 16 | 17 | import type { APIContext } from "astro"; 18 | import type { ClientData, AuthenticatorData } from "@oslojs/webauthn"; 19 | 20 | export async function POST(context: APIContext): Promise { 21 | if (context.locals.session === null || context.locals.user === null) { 22 | return new Response("Not authenticated", { 23 | status: 401 24 | }); 25 | } 26 | if ( 27 | !context.locals.user.emailVerified || 28 | !context.locals.user.registeredSecurityKey || 29 | context.locals.session.twoFactorVerified 30 | ) { 31 | return new Response("Forbidden", { 32 | status: 403 33 | }); 34 | } 35 | 36 | const data: unknown = await context.request.json(); 37 | const parser = new ObjectParser(data); 38 | let encodedAuthenticatorData: string; 39 | let encodedClientDataJSON: string; 40 | let encodedCredentialId: string; 41 | let encodedSignature: string; 42 | try { 43 | encodedAuthenticatorData = parser.getString("authenticator_data"); 44 | encodedClientDataJSON = parser.getString("client_data_json"); 45 | encodedCredentialId = parser.getString("credential_id"); 46 | encodedSignature = parser.getString("signature"); 47 | } catch { 48 | return new Response("Invalid or missing fields", { 49 | status: 400 50 | }); 51 | } 52 | let authenticatorDataBytes: Uint8Array; 53 | let clientDataJSON: Uint8Array; 54 | let credentialId: Uint8Array; 55 | let signatureBytes: Uint8Array; 56 | try { 57 | authenticatorDataBytes = decodeBase64(encodedAuthenticatorData); 58 | clientDataJSON = decodeBase64(encodedClientDataJSON); 59 | credentialId = decodeBase64(encodedCredentialId); 60 | signatureBytes = decodeBase64(encodedSignature); 61 | } catch { 62 | return new Response("Invalid or missing fields", { 63 | status: 400 64 | }); 65 | } 66 | 67 | let authenticatorData: AuthenticatorData; 68 | try { 69 | authenticatorData = parseAuthenticatorData(authenticatorDataBytes); 70 | } catch { 71 | return new Response("Invalid data", { 72 | status: 400 73 | }); 74 | } 75 | // TODO: Update host 76 | if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) { 77 | return new Response("Invalid data", { 78 | status: 400 79 | }); 80 | } 81 | if (!authenticatorData.userPresent) { 82 | return new Response("Invalid data", { 83 | status: 400 84 | }); 85 | } 86 | 87 | let clientData: ClientData; 88 | try { 89 | clientData = parseClientDataJSON(clientDataJSON); 90 | } catch { 91 | return new Response("Invalid data", { 92 | status: 400 93 | }); 94 | } 95 | if (clientData.type !== ClientDataType.Get) { 96 | return new Response("Invalid data", { 97 | status: 400 98 | }); 99 | } 100 | 101 | if (!verifyWebAuthnChallenge(clientData.challenge)) { 102 | return new Response("Invalid data", { 103 | status: 400 104 | }); 105 | } 106 | // TODO: Update origin 107 | if (clientData.origin !== "http://localhost:4321") { 108 | return new Response("Invalid data", { 109 | status: 400 110 | }); 111 | } 112 | if (clientData.crossOrigin !== null && clientData.crossOrigin) { 113 | return new Response("Invalid data", { 114 | status: 400 115 | }); 116 | } 117 | 118 | const credential = getUserSecurityKeyCredential(context.locals.user.id, credentialId); 119 | if (credential === null) { 120 | return new Response("Invalid credential", { 121 | status: 400 122 | }); 123 | } 124 | 125 | let validSignature: boolean; 126 | if (credential.algorithmId === coseAlgorithmES256) { 127 | const ecdsaSignature = decodePKIXECDSASignature(signatureBytes); 128 | const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey); 129 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 130 | validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature); 131 | } else if (credential.algorithmId === coseAlgorithmRS256) { 132 | const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey); 133 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 134 | validSignature = verifyRSASSAPKCS1v15Signature(rsaPublicKey, sha256ObjectIdentifier, hash, signatureBytes); 135 | } else { 136 | return new Response("Internal error", { 137 | status: 500 138 | }); 139 | } 140 | 141 | if (!validSignature) { 142 | return new Response("Invalid signature", { 143 | status: 400 144 | }); 145 | } 146 | 147 | setSessionAs2FAVerified(context.locals.session.id); 148 | return new Response(null, { 149 | status: 204 150 | }); 151 | } 152 | -------------------------------------------------------------------------------- /src/pages/api/user/totp/index.ts: -------------------------------------------------------------------------------- 1 | import { decodeBase64 } from "@oslojs/encoding"; 2 | import { verifyTOTP } from "@oslojs/otp"; 3 | import { deleteUserTOTPKey, updateUserTOTPKey } from "@lib/server/totp"; 4 | import { ObjectParser } from "@pilcrowjs/object-parser"; 5 | import { setSessionAs2FAVerified } from "@lib/server/session"; 6 | import { RefillingTokenBucket } from "@lib/server/rate-limit"; 7 | 8 | import type { APIContext } from "astro"; 9 | 10 | const totpUpdateBucket = new RefillingTokenBucket(3, 60 * 10); 11 | 12 | export async function POST(context: APIContext): Promise { 13 | if (context.locals.session === null || context.locals.user === null) { 14 | return new Response("Not authenticated", { 15 | status: 401 16 | }); 17 | } 18 | if (!context.locals.user.emailVerified) { 19 | return new Response("Forbidden", { 20 | status: 403 21 | }); 22 | } 23 | if (context.locals.user.registered2FA && !context.locals.session.twoFactorVerified) { 24 | return new Response("Forbidden", { 25 | status: 403 26 | }); 27 | } 28 | 29 | if (!totpUpdateBucket.check(context.locals.user.id, 1)) { 30 | return new Response("Too many requests", { 31 | status: 429 32 | }); 33 | } 34 | const data: unknown = await context.request.json(); 35 | const parser = new ObjectParser(data); 36 | let encodedKey: string, code: string; 37 | try { 38 | encodedKey = parser.getString("key"); 39 | code = parser.getString("code"); 40 | } catch { 41 | return new Response("Invalid or missing fields", { 42 | status: 400 43 | }); 44 | } 45 | if (code === "") { 46 | return new Response("Please enter your code", { 47 | status: 400 48 | }); 49 | } 50 | if (encodedKey.length !== 28) { 51 | return new Response("Invalid key", { 52 | status: 400 53 | }); 54 | } 55 | let key: Uint8Array; 56 | try { 57 | key = decodeBase64(encodedKey); 58 | } catch { 59 | return new Response("Invalid key", { 60 | status: 400 61 | }); 62 | } 63 | if (key.byteLength !== 20) { 64 | return new Response("Invalid key", { 65 | status: 400 66 | }); 67 | } 68 | if (!verifyTOTP(key, 30, 6, code)) { 69 | return new Response("Invalid code", { 70 | status: 400 71 | }); 72 | } 73 | updateUserTOTPKey(context.locals.session.userId, key); 74 | setSessionAs2FAVerified(context.locals.session.id); 75 | return new Response(null, { status: 204 }); 76 | } 77 | 78 | export async function DELETE(context: APIContext): Promise { 79 | if (context.locals.session === null || context.locals.user === null) { 80 | return new Response("Not authenticated", { 81 | status: 401 82 | }); 83 | } 84 | if (!context.locals.user.emailVerified) { 85 | return new Response("Forbidden", { 86 | status: 403 87 | }); 88 | } 89 | if (context.locals.user.registered2FA && !context.locals.session.twoFactorVerified) { 90 | return new Response("Forbidden", { 91 | status: 403 92 | }); 93 | } 94 | if (!totpUpdateBucket.consume(context.locals.user.id, 1)) { 95 | return new Response("Too many requests", { 96 | status: 429 97 | }); 98 | } 99 | deleteUserTOTPKey(context.locals.user.id); 100 | return new Response(null, { status: 204 }); 101 | } 102 | -------------------------------------------------------------------------------- /src/pages/api/user/totp/verify.ts: -------------------------------------------------------------------------------- 1 | import { verifyTOTP } from "@oslojs/otp"; 2 | import { ObjectParser } from "@pilcrowjs/object-parser"; 3 | import { setSessionAs2FAVerified } from "@lib/server/session"; 4 | import { totpBucket } from "@lib/server/totp"; 5 | import { getUserTOTPKey } from "@lib/server/totp"; 6 | 7 | import type { APIContext } from "astro"; 8 | 9 | export async function POST(context: APIContext): Promise { 10 | if (context.locals.session === null || context.locals.user === null) { 11 | return new Response("Not authenticated", { 12 | status: 401 13 | }); 14 | } 15 | if ( 16 | !context.locals.user.emailVerified || 17 | !context.locals.user.registeredTOTP || 18 | context.locals.session.twoFactorVerified 19 | ) { 20 | return new Response("Forbidden", { 21 | status: 403 22 | }); 23 | } 24 | if (!totpBucket.check(context.locals.user.id, 1)) { 25 | return new Response("Too many requests", { 26 | status: 429 27 | }); 28 | } 29 | const data: unknown = await context.request.json(); 30 | const parser = new ObjectParser(data); 31 | let code: string; 32 | try { 33 | code = parser.getString("code"); 34 | } catch { 35 | return new Response("Invalid or missing fields", { 36 | status: 400 37 | }); 38 | } 39 | if (code === "") { 40 | return new Response("Enter your code", { 41 | status: 400 42 | }); 43 | } 44 | const totpKey = getUserTOTPKey(context.locals.user.id); 45 | if (totpKey === null) { 46 | return new Response("Forbidden", { 47 | status: 403 48 | }); 49 | } 50 | if (!verifyTOTP(totpKey, 30, 6, code)) { 51 | return new Response("Invalid code", { 52 | status: 400 53 | }); 54 | } 55 | totpBucket.reset(context.locals.user.id); 56 | setSessionAs2FAVerified(context.locals.session.id); 57 | return new Response(null, { status: 204 }); 58 | } 59 | -------------------------------------------------------------------------------- /src/pages/api/webauthn/challenge.ts: -------------------------------------------------------------------------------- 1 | import { createWebAuthnChallenge } from "@lib/server/webauthn"; 2 | import { encodeBase64 } from "@oslojs/encoding"; 3 | import { RefillingTokenBucket } from "@lib/server/rate-limit"; 4 | 5 | import type { APIContext } from "astro"; 6 | 7 | const webauthnChallengeRateLimitBucket = new RefillingTokenBucket(30, 10); 8 | 9 | export async function POST(context: APIContext): Promise { 10 | // TODO: Assumes X-Forwarded-For is always included. 11 | const clientIP = context.request.headers.get("X-Forwarded-For"); 12 | if (clientIP !== null && !webauthnChallengeRateLimitBucket.consume(clientIP, 1)) { 13 | return new Response("Too many requests", { 14 | status: 429 15 | }); 16 | } 17 | const challenge = createWebAuthnChallenge(); 18 | return new Response(JSON.stringify({ challenge: encodeBase64(challenge) })); 19 | } 20 | -------------------------------------------------------------------------------- /src/pages/forgot-password.astro: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | --- 4 | 5 | 6 | 7 | 8 | 9 | Email and password example with 2FA and WebAuthn in Astro 10 | 11 | 12 |
13 |

Forgot your password?

14 |
15 | 16 |
17 | 18 |

19 |
20 | Sign in 21 |
22 | 23 | 24 | 25 | 46 | -------------------------------------------------------------------------------- /src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { get2FARedirect } from "@lib/server/2fa"; 3 | 4 | if (Astro.locals.user === null || Astro.locals.session === null) { 5 | return Astro.redirect("/login"); 6 | } 7 | if (!Astro.locals.user.emailVerified) { 8 | return Astro.redirect("/verify-email"); 9 | } 10 | if (!Astro.locals.user.registered2FA) { 11 | return Astro.redirect("/2fa/setup"); 12 | } 13 | if (!Astro.locals.session.twoFactorVerified) { 14 | return Astro.redirect(get2FARedirect(Astro.locals.user)); 15 | } 16 | --- 17 | 18 | 19 | 20 | 21 | 22 | Email and password example with 2FA and WebAuthn in Astro 23 | 24 | 25 |
26 | Home 27 | Settings 28 |
29 |
30 |

Hi {Astro.locals.user.username}!

31 |
32 | 33 |
34 |
35 | 36 | 37 | 38 | 49 | -------------------------------------------------------------------------------- /src/pages/login.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { get2FARedirect } from "@lib/server/2fa"; 3 | 4 | if (Astro.locals.session !== null && Astro.locals.user !== null) { 5 | if (!Astro.locals.user.emailVerified) { 6 | return Astro.redirect("/verify-email"); 7 | } 8 | if (!Astro.locals.session.twoFactorVerified) { 9 | return Astro.redirect(get2FARedirect(Astro.locals.user)); 10 | } 11 | return Astro.redirect("/"); 12 | } 13 | --- 14 | 15 | 16 | 17 | Email and password example with 2FA and WebAuthn in Astro 18 | 19 | 20 |
21 |

Sign in

22 |
23 | 24 |
25 | 26 |
28 | 29 |

30 |
31 |
32 | 33 |

34 |
35 | Create an account 36 | Forgot password? 37 |
38 | 39 | 40 | 41 | 104 | -------------------------------------------------------------------------------- /src/pages/recovery-code.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { get2FARedirect } from "@lib/server/2fa"; 3 | import { getUserRecoverCode } from "@lib/server/user"; 4 | 5 | if (Astro.locals.user === null || Astro.locals.session === null) { 6 | return Astro.redirect("/login"); 7 | } 8 | if (!Astro.locals.user.emailVerified) { 9 | return Astro.redirect("/verify-email"); 10 | } 11 | if (!Astro.locals.user.registered2FA) { 12 | return Astro.redirect("/2fa/setup"); 13 | } 14 | if (!Astro.locals.session.twoFactorVerified) { 15 | return Astro.redirect(get2FARedirect(Astro.locals.user)); 16 | } 17 | 18 | const recoveryCode = getUserRecoverCode(Astro.locals.user.id); 19 | --- 20 | 21 | 22 | 23 | 24 | 25 | Email and password example with 2FA and WebAuthn in Astro 26 | 27 | 28 |
29 |

Recovery code

30 |

Your recovery code is: {recoveryCode}

31 |

You can use this recovery code if you lose access to your second factors.

32 | Next 33 |
34 | 35 | 36 | -------------------------------------------------------------------------------- /src/pages/reset-password/2fa/index.ts: -------------------------------------------------------------------------------- 1 | import { getPasswordReset2FARedirect } from "@lib/server/2fa"; 2 | import { validatePasswordResetSessionRequest } from "@lib/server/password-reset"; 3 | 4 | import type { APIContext } from "astro"; 5 | 6 | export function GET(context: APIContext): Response { 7 | const { session, user } = validatePasswordResetSessionRequest(context); 8 | if (session === null) { 9 | return context.redirect("/login"); 10 | } 11 | 12 | if (session.twoFactorVerified) { 13 | return context.redirect("/"); 14 | } 15 | if (!user.registered2FA) { 16 | return context.redirect("/password-reset"); 17 | } 18 | return context.redirect(getPasswordReset2FARedirect(user)); 19 | } 20 | -------------------------------------------------------------------------------- /src/pages/reset-password/2fa/passkey.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { getPasswordReset2FARedirect } from "@lib/server/2fa"; 3 | import { validatePasswordResetSessionRequest } from "@lib/server/password-reset"; 4 | import { getUserPasskeyCredentials } from "@lib/server/webauthn"; 5 | import { encodeBase64 } from "@oslojs/encoding"; 6 | 7 | const { session, user } = validatePasswordResetSessionRequest(Astro); 8 | 9 | if (session === null) { 10 | return Astro.redirect("/forgot-password"); 11 | } 12 | if (!session.emailVerified) { 13 | return Astro.redirect("/reset-password/verify-email"); 14 | } 15 | if (!user.registered2FA) { 16 | return Astro.redirect("/reset-password"); 17 | } 18 | if (session.twoFactorVerified) { 19 | return Astro.redirect("/reset-password"); 20 | } 21 | if (!user.registeredPasskey) { 22 | return Astro.redirect(getPasswordReset2FARedirect(user)); 23 | } 24 | 25 | const credentials = getUserPasskeyCredentials(user.id); 26 | const encodedCredentialUserId = credentials.map((c) => encodeBase64(c.id)).join(","); 27 | --- 28 | 29 | 30 | 31 | 32 | 33 | Email and password example with 2FA and WebAuthn in Astro 34 | 35 | 36 |
37 |

Authenticate with passkey

38 |
39 | 40 |

41 |
42 | Use recovery code 43 | {user.registeredTOTP && Use authenticator apps} 44 | {user.registeredSecurityKey && Use security keys} 45 |
46 | 47 | 48 | 49 | 50 | 100 | -------------------------------------------------------------------------------- /src/pages/reset-password/2fa/recovery-code.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { validatePasswordResetSessionRequest } from "@lib/server/password-reset"; 3 | 4 | const { session, user } = validatePasswordResetSessionRequest(Astro); 5 | 6 | if (session === null) { 7 | return Astro.redirect("/forgot-password"); 8 | } 9 | if (!session.emailVerified) { 10 | return Astro.redirect("/reset-password/verify-email"); 11 | } 12 | if (!user.registered2FA) { 13 | return Astro.redirect("/reset-password"); 14 | } 15 | if (session.twoFactorVerified) { 16 | return Astro.redirect("/reset-password"); 17 | } 18 | --- 19 | 20 | 21 | 22 | 23 | 24 | Email and password example with 2FA and WebAuthn in Astro 25 | 26 | 27 |
28 |

Use your recovery code

29 |
30 | 31 |
32 | 33 |

34 |
35 |
36 | 37 | 38 | 39 | 60 | -------------------------------------------------------------------------------- /src/pages/reset-password/2fa/security-key.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { getPasswordReset2FARedirect } from "@lib/server/2fa"; 3 | import { validatePasswordResetSessionRequest } from "@lib/server/password-reset"; 4 | import { getUserSecurityKeyCredentials } from "@lib/server/webauthn"; 5 | import { encodeBase64 } from "@oslojs/encoding"; 6 | 7 | const { session, user } = validatePasswordResetSessionRequest(Astro); 8 | 9 | if (session === null) { 10 | return Astro.redirect("/forgot-password"); 11 | } 12 | if (!session.emailVerified) { 13 | return Astro.redirect("/reset-password/verify-email"); 14 | } 15 | if (!user.registered2FA) { 16 | return Astro.redirect("/reset-password"); 17 | } 18 | if (session.twoFactorVerified) { 19 | return Astro.redirect("/reset-password"); 20 | } 21 | if (!user.registeredSecurityKey) { 22 | return Astro.redirect(getPasswordReset2FARedirect(user)); 23 | } 24 | 25 | const credentials = getUserSecurityKeyCredentials(user.id); 26 | const encodedCredentialUserId = credentials.map((c) => encodeBase64(c.id)).join(","); 27 | --- 28 | 29 | 30 | 31 | 32 | 33 | Email and password example with 2FA and WebAuthn in Astro 34 | 35 | 36 |
37 |

Verify with security keys

38 |
39 | 40 |

41 |
42 | Use recovery code 43 | {user.registeredPasskey && Use passkeys} 44 | {user.registeredTOTP && Use authenticator apps} 45 |
46 | 47 | 48 | 49 | 50 | 101 | -------------------------------------------------------------------------------- /src/pages/reset-password/2fa/totp.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { getPasswordReset2FARedirect } from "@lib/server/2fa"; 3 | import { validatePasswordResetSessionRequest } from "@lib/server/password-reset"; 4 | 5 | const { session, user } = validatePasswordResetSessionRequest(Astro); 6 | 7 | if (session === null) { 8 | return Astro.redirect("/forgot-password"); 9 | } 10 | if (!session.emailVerified) { 11 | return Astro.redirect("/reset-password/verify-email"); 12 | } 13 | if (!user.registered2FA) { 14 | return Astro.redirect("/reset-password"); 15 | } 16 | if (session.twoFactorVerified) { 17 | return Astro.redirect("/reset-password"); 18 | } 19 | if (!user.registeredTOTP) { 20 | return Astro.redirect(getPasswordReset2FARedirect(user)); 21 | } 22 | --- 23 | 24 | 25 | 26 | 27 | 28 | Email and password example with 2FA and WebAuthn in Astro 29 | 30 | 31 |
32 |

Authenticate with authenticator app

33 |

Enter the code from your app.

34 |
35 | 36 |
37 | 38 |

39 |
40 | Use recovery code 41 | {user.registeredSecurityKey && Use security keys} 42 | {user.registeredPasskey && Use passkeys} 43 |
44 | 45 | 46 | 47 | 68 | -------------------------------------------------------------------------------- /src/pages/reset-password/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { getPasswordReset2FARedirect } from "@lib/server/2fa"; 3 | import { validatePasswordResetSessionRequest } from "@lib/server/password-reset"; 4 | 5 | const { session, user } = validatePasswordResetSessionRequest(Astro); 6 | if (session === null) { 7 | return Astro.redirect("/forgot-password"); 8 | } 9 | if (!session.emailVerified) { 10 | return Astro.redirect("/reset-password/verify-email"); 11 | } 12 | if (user.registered2FA && !session.twoFactorVerified) { 13 | return Astro.redirect(getPasswordReset2FARedirect(user)); 14 | } 15 | --- 16 | 17 | 18 | 19 | Enter your new password 20 | 21 | 22 |
23 |

Enter your new password

24 |
25 |
26 | 27 |
28 | 29 |

30 |
31 |
32 | 33 | 34 | 35 | 56 | -------------------------------------------------------------------------------- /src/pages/reset-password/verify-email.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { validatePasswordResetSessionRequest } from "@lib/server/password-reset"; 3 | 4 | const { session } = validatePasswordResetSessionRequest(Astro); 5 | if (session === null) { 6 | return Astro.redirect("/forgot-password"); 7 | } 8 | if (session.emailVerified) { 9 | if (!session.twoFactorVerified) { 10 | return Astro.redirect(getPasswordReset2FARedirect(user)); 11 | } 12 | return Astro.redirect("/reset-password"); 13 | } 14 | --- 15 | 16 | 17 | 18 | 19 | 20 | Email and password example with 2FA and WebAuthn in Astro 21 | 22 | 23 |
24 |

Verify your email address

25 |

We sent an 8-digit code to {session.email}.

26 |
27 | 28 | 29 | 30 |

31 |
32 |
33 | 34 | 35 | 36 | 57 | -------------------------------------------------------------------------------- /src/pages/settings.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { getUserRecoverCode } from "@lib/server/user"; 3 | import { getUserPasskeyCredentials, getUserSecurityKeyCredentials } from "@lib/server/webauthn"; 4 | import { get2FARedirect } from "@lib/server/2fa"; 5 | import { encodeBase64, encodeHexLowerCase } from "@oslojs/encoding"; 6 | 7 | if (Astro.locals.user === null || Astro.locals.session === null) { 8 | return Astro.redirect("/login"); 9 | } 10 | if (Astro.locals.user.registered2FA && !Astro.locals.session.twoFactorVerified) { 11 | return Astro.redirect(get2FARedirect(Astro.locals.user)); 12 | } 13 | const passkeyCredentials = getUserPasskeyCredentials(Astro.locals.user.id); 14 | const securityKeyCredentials = getUserSecurityKeyCredentials(Astro.locals.user.id); 15 | const recoveryCode = getUserRecoverCode(Astro.locals.user.id); 16 | --- 17 | 18 | 19 | 20 | 21 | 22 | Email and password example with 2FA and WebAuthn in Astro 23 | 24 | 25 |
26 | Home 27 | Settings 28 |
29 |
30 |

Settings

31 |
32 |

Update email

33 |

Your email: {Astro.locals.user.email}

34 |
35 | 36 |
37 | 38 |

39 |
40 |
41 |
42 |

Update password

43 |
44 | 45 |
47 | 48 |
55 | 56 |

57 |
58 |
59 | 60 |
61 |

Authenticator app

62 | { 63 | Astro.locals.user.registeredTOTP ? ( 64 | <> 65 | Update TOTP 66 |
67 | 68 |

69 |

70 | 71 | ) : ( 72 | Set up TOTP 73 | ) 74 | } 75 |
76 |
77 |

Passkeys

78 |

Passkeys are WebAuthn credentials that validate your identity using your device.

79 |
    80 | { 81 | passkeyCredentials.map((credential) => { 82 | return ( 83 |
  • 84 |

    {credential.name}

    85 | 88 |
  • 89 | ); 90 | }) 91 | } 92 |
93 | Add 94 |
95 |
96 |

Security keys

97 |

Security keys are WebAuthn credentials that can only be used for two-factor authentication.

98 |
    99 | { 100 | securityKeyCredentials.map((credential) => { 101 | return ( 102 |
  • 103 |

    {credential.name}

    104 | 107 |
  • 108 | ); 109 | }) 110 | } 111 |
112 | Add 113 |
114 | 115 | { 116 | recoveryCode !== null && ( 117 |
118 |

Recovery code

119 |

Your recovery code is: {recoveryCode}

120 | 121 |
122 | ) 123 | } 124 |
125 | 126 | 127 | 128 | 245 | -------------------------------------------------------------------------------- /src/pages/signup.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { get2FARedirect } from "@lib/server/2fa"; 3 | 4 | if (Astro.locals.session !== null && Astro.locals.user !== null) { 5 | if (!Astro.locals.user.emailVerified) { 6 | return Astro.redirect("/verify-email"); 7 | } 8 | if (!Astro.locals.user.registered2FA) { 9 | return Astro.redirect("/2fa/setup"); 10 | } 11 | if (!Astro.locals.session.twoFactorVerified) { 12 | return Astro.redirect(get2FARedirect(Astro.locals.user)); 13 | } 14 | return Astro.redirect("/"); 15 | } 16 | --- 17 | 18 | 19 | 20 | 21 | 22 | Email and password example with 2FA and WebAuthn in Astro 23 | 24 | 25 |
26 |

Create an account

27 |

Your username must be at least 3 characters long and your password must be at least 8 characters long.

28 |
29 | 30 |
31 | 32 |
33 | 34 |
35 | 36 |

37 |
38 | Sign in 39 |
40 | 41 | 42 | 43 | 66 | -------------------------------------------------------------------------------- /src/pages/verify-email.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { 3 | getUserEmailVerificationRequestFromRequest, 4 | createEmailVerificationRequest, 5 | sendVerificationEmail, 6 | setEmailVerificationRequestCookie 7 | } from "@lib/server/email-verification"; 8 | import { get2FARedirect } from "@lib/server/2fa"; 9 | 10 | if (Astro.locals.session === null || Astro.locals.user === null) { 11 | return Astro.redirect("/login"); 12 | } 13 | if (Astro.locals.user.registered2FA && !Astro.locals.session.twoFactorVerified) { 14 | return Astro.redirect(get2FARedirect(Astro.locals.user)); 15 | } 16 | 17 | let verificationRequest = getUserEmailVerificationRequestFromRequest(Astro); 18 | if (verificationRequest === null || Date.now() >= verificationRequest.expiresAt.getTime()) { 19 | if (Astro.locals.user.emailVerified) { 20 | return Astro.redirect("/"); 21 | } 22 | // Note: We don't need rate limiting since it takes time before requests expire 23 | verificationRequest = createEmailVerificationRequest(Astro.locals.user.id, Astro.locals.user.email); 24 | sendVerificationEmail(verificationRequest.email, verificationRequest.code); 25 | setEmailVerificationRequestCookie(Astro, verificationRequest); 26 | } 27 | --- 28 | 29 | 30 | 31 | 32 | 33 | Email and password example with 2FA and WebAuthn in Astro 34 | 35 | 36 |
37 |

Verify your email address

38 |

We sent an 8-digit code to {verificationRequest.email}.

39 |
40 | 41 | 42 | 43 |

44 |
45 | 46 |

47 | Change your email 48 |
49 | 50 | 51 | 52 | 86 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict", 3 | "exclude": ["dist"], 4 | "compilerOptions": { 5 | "baseUrl": ".", 6 | "paths": { 7 | "@lib/*": ["src/lib/*"] 8 | } 9 | } 10 | } 11 | --------------------------------------------------------------------------------