This commit is contained in:
pilcrowOnPaper 2024-10-03 18:50:34 +09:00
commit 25cc397095
81 changed files with 6489 additions and 0 deletions

1
.env.example Normal file
View file

@ -0,0 +1 @@
ENCRYPTION_KEY=""

23
.gitignore vendored Normal file
View file

@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
sqlite.db

1
.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

4
.prettierignore Normal file
View file

@ -0,0 +1,4 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock

7
.prettierrc Normal file
View file

@ -0,0 +1,7 @@
{
"useTabs": true,
"trailingComma": "none",
"printWidth": 120,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

50
README.md Normal file
View file

@ -0,0 +1,50 @@
# Email and password example with 2FA and WebAuthn in SvelteKit
Built with SQLite.
- Password checks with HaveIBeenPwned
- Sign in with passkeys
- Email verification
- 2FA with TOTP
- 2FA recovery codes
- 2FA with passkeys and security keys
- Password reset with 2FA
- Login throttling and rate limiting
Emails are just logged to the console. Rate limiting is implemented using JavaScript `Map`.
## Initialize project
Create `sqlite.db` and run `setup.sql`.
```
sqlite3 sqlite.db
```
Create a .env file. Generate a 128 bit (16 byte) string, base64 encode it, and set it as `ENCRYPTION_KEY`.
```bash
ENCRYPTION_KEY="L9pmqRJnO1ZJSQ2svbHuBA=="
```
> You can use OpenSSL to quickly generate a secure key.
>
> ```bash
> openssl rand --base64 16
> ```
Run the application:
```
pnpm dev
```
## Notes
- 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.
- This example does not handle unexpected errors gracefully.
- There are some major code duplications (specifically for 2FA) to keep the codebase simple.
- TODO: Passkeys will only work when hosted on `localhost:5173`. Update the host and origin values before deploying.
- TODO: You may need to rewrite some queries and use transactions to avoid race conditions when using MySQL, Postgres, etc.
- TODO: This project relies on the `X-Forwarded-For` header for getting the client's IP address.
- TODO: Logging should be implemented.

39
package.json Normal file
View file

@ -0,0 +1,39 @@
{
"name": "example-sveltekit-email-password-webauthn",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check .",
"format": "prettier --write ."
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/better-sqlite3": "^7.6.11",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"svelte": "^4.2.7",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0",
"vite": "^5.0.3"
},
"type": "module",
"dependencies": {
"@node-rs/argon2": "^1.8.3",
"@oslojs/binary": "^1.0.0",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"@oslojs/otp": "^1.0.0",
"@oslojs/webauthn": "^1.0.0",
"@pilcrowjs/db-query": "^0.0.2",
"@pilcrowjs/object-parser": "^0.0.4",
"better-sqlite3": "^11.3.0",
"uqr": "^0.1.2"
}
}

1543
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

56
setup.sql Normal file
View file

@ -0,0 +1,56 @@
CREATE TABLE user (
id INTEGER NOT NULL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
email_verified INTEGER NOT NULL DEFAULT 0,
recovery_code BLOB NOT NULL
);
CREATE TABLE session (
id TEXT NOT NULL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES user(id),
expires_at INTEGER NOT NULL,
two_factor_verified INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE email_verification_request (
id TEXT NOT NULL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES user(id),
email TEXT NOT NULL,
code TEXT NOT NULL,
expires_at INTEGER NOT NULL,
email_verified INTEGER NOT NULL NOT NULL DEFAULT 0
);
CREATE TABLE password_reset_session (
id TEXT NOT NULL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES user(id),
email TEXT NOT NULL,
code TEXT NOT NULL,
expires_at INTEGER NOT NULL,
email_verified INTEGER NOT NULL NOT NULL DEFAULT 0,
two_factor_verified INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE totp_credential (
id INTEGER NOT NULL PRIMARY KEY,
user_id INTEGER NOT NULL UNIQUE REFERENCES user(id),
key BLOB NOT NULL
);
CREATE TABLE passkey_credential (
id BLOB NOT NULL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES user(id),
name TEXT NOT NULL,
algorithm INTEGER NOT NULL,
public_key BLOB NOT NULL
);
CREATE TABLE security_key_credential (
id BLOB NOT NULL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES user(id),
name TEXT NOT NULL,
algorithm INTEGER NOT NULL,
public_key BLOB NOT NULL
);

16
src/app.d.ts vendored Normal file
View file

@ -0,0 +1,16 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
interface Locals {
user: import("$lib/server/user").User | null;
session: import("$lib/server/session").Session | null;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

12
src/app.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

49
src/hooks.server.ts Normal file
View file

@ -0,0 +1,49 @@
import { RefillingTokenBucket } from "$lib/server/rate-limit";
import { validateSessionToken, setSessionTokenCookie, deleteSessionTokenCookie } from "$lib/server/session";
import type { Handle } from "@sveltejs/kit";
import { sequence } from "@sveltejs/kit/hooks";
const bucket = new RefillingTokenBucket<string>(100, 1);
const rateLimitHandle: Handle = async ({ event, resolve }) => {
// Note: Assumes X-Forwarded-For will always be defined.
const clientIP = event.request.headers.get("X-Forwarded-For");
if (clientIP === null) {
return resolve(event);
}
let cost: number;
if (event.request.method === "GET" || event.request.method === "OPTIONS") {
cost = 1;
} else {
cost = 3;
}
if (!bucket.consume(clientIP, cost)) {
return new Response("Too many requests", {
status: 429
});
}
return resolve(event);
};
const authHandle: Handle = async ({ event, resolve }) => {
const token = event.cookies.get("session") ?? null;
if (token === null) {
event.locals.user = null;
event.locals.session = null;
return resolve(event);
}
const { session, user } = validateSessionToken(token);
if (session !== null) {
setSessionTokenCookie(event, token, session.expiresAt);
} else {
deleteSessionTokenCookie(event);
}
event.locals.session = session;
event.locals.user = user;
return resolve(event);
};
export const handle = sequence(rateLimitHandle, authHandle);

View file

@ -0,0 +1,14 @@
import { decodeBase64 } from "@oslojs/encoding";
import { ObjectParser } from "@pilcrowjs/object-parser";
export async function createChallenge(): Promise<Uint8Array> {
const response = await fetch("/api/webauthn/challenge", {
method: "POST"
});
if (!response.ok) {
throw new Error("Failed to create challenge");
}
const result = await response.json();
const parser = new ObjectParser(result);
return decodeBase64(parser.getString("challenge"));
}

75
src/lib/server/2fa.ts Normal file
View file

@ -0,0 +1,75 @@
import { db } from "./db";
import { generateRandomRecoveryCode } from "./utils";
import { ExpiringTokenBucket } from "./rate-limit";
import { decryptToString, encryptString } from "./encryption";
import type { User } from "./user";
export const recoveryCodeBucket = new ExpiringTokenBucket<number>(3, 60 * 60);
export function resetUser2FAWithRecoveryCode(userId: number, recoveryCode: string): boolean {
// Note: In Postgres and MySQL, these queries should be done in a transaction using SELECT FOR UPDATE
const row = db.queryOne("SELECT recovery_code FROM user WHERE id = ?", [userId]);
if (row === null) {
return false;
}
const encryptedRecoveryCode = row.bytes(0);
const userRecoveryCode = decryptToString(encryptedRecoveryCode);
if (recoveryCode !== userRecoveryCode) {
return false;
}
const newRecoveryCode = generateRandomRecoveryCode();
const encryptedNewRecoveryCode = encryptString(newRecoveryCode);
try {
db.execute("BEGIN TRANSACTION", []);
// Compare old recovery code to ensure recovery code wasn't updated.
const result = db.execute("UPDATE user SET recovery_code = ? WHERE id = ? AND recovery_code = ?", [
encryptedNewRecoveryCode,
userId,
encryptedRecoveryCode
]);
if (result.changes < 1) {
db.execute("ROLLBACK", []);
return false;
}
db.execute("UPDATE session SET two_factor_verified = 0 WHERE user_id = ?", [userId]);
db.execute("DELETE FROM totp_credential WHERE user_id = ?", [userId]);
db.execute("DELETE FROM passkey_credential WHERE user_id = ?", [userId]);
db.execute("DELETE FROM security_key_credential WHERE user_id = ?", [userId]);
db.execute("COMMIT", []);
} catch (e) {
if (db.inTransaction()) {
db.execute("ROLLBACK", []);
}
throw e;
}
return true;
}
export function get2FARedirect(user: User): string {
if (user.registeredPasskey) {
return "/2fa/passkey";
}
if (user.registeredSecurityKey) {
return "/2fa/security-key";
}
if (user.registeredTOTP) {
return "/2fa/totp";
}
return "/2fa/setup";
}
export function getPasswordReset2FARedirect(user: User): string {
if (user.registeredPasskey) {
return "/reset-password/2fa/passkey";
}
if (user.registeredSecurityKey) {
return "/reset-password/2fa/security-key";
}
if (user.registeredTOTP) {
return "/reset-password/2fa/totp";
}
return "/2fa/setup";
}

35
src/lib/server/db.ts Normal file
View file

@ -0,0 +1,35 @@
import sqlite3 from "better-sqlite3";
import { SyncDatabase } from "@pilcrowjs/db-query";
import type { SyncAdapter } from "@pilcrowjs/db-query";
const sqlite = sqlite3("sqlite.db");
const adapter: SyncAdapter<sqlite3.RunResult> = {
query: (statement: string, params: unknown[]): unknown[][] => {
const result = sqlite
.prepare(statement)
.raw()
.all(...params) as unknown[][];
for (let i = 0; i < result.length; i++) {
for (let j = 0; j < result[i].length; j++) {
if (result[i][j] instanceof Buffer) {
result[i][j] = new Uint8Array(result[i][j] as Buffer);
}
}
}
return result as unknown[][];
},
execute: (statement: string, params: unknown[]): sqlite3.RunResult => {
const result = sqlite.prepare(statement).run(...params);
return result;
}
};
class Database extends SyncDatabase<sqlite3.RunResult> {
public inTransaction(): boolean {
return sqlite.inTransaction;
}
}
export const db = new Database(adapter);

View file

@ -0,0 +1,100 @@
import { generateRandomOTP } from "./utils";
import { db } from "./db";
import { ExpiringTokenBucket } from "./rate-limit";
import { encodeBase32LowerCaseNoPadding } from "@oslojs/encoding";
import type { RequestEvent } from "@sveltejs/kit";
export function getEmailVerificationRequest(id: string): EmailVerificationRequest | null {
const row = db.queryOne("SELECT id, user_id, code, email, expires_at FROM email_verification_request WHERE id = ?", [
id
]);
if (row === null) {
return row;
}
const request: EmailVerificationRequest = {
id: row.string(0),
userId: row.number(1),
code: row.string(2),
email: row.string(3),
expiresAt: new Date(row.number(4) * 1000)
};
return request;
}
export function createEmailVerificationRequest(userId: number, email: string): EmailVerificationRequest {
deleteUserEmailVerificationRequest(userId);
const idBytes = new Uint8Array(20);
crypto.getRandomValues(idBytes);
const id = encodeBase32LowerCaseNoPadding(idBytes);
const code = generateRandomOTP();
const expiresAt = new Date(Date.now() + 1000 * 60 * 10);
db.queryOne(
"INSERT INTO email_verification_request (id, user_id, code, email, expires_at) VALUES (?, ?, ?, ?, ?) RETURNING id",
[id, userId, code, email, Math.floor(expiresAt.getTime() / 1000)]
);
const request: EmailVerificationRequest = {
id,
userId,
code,
email,
expiresAt
};
return request;
}
export function deleteUserEmailVerificationRequest(userId: number): void {
db.execute("DELETE FROM email_verification_request WHERE user_id = ?", [userId]);
}
export function sendVerificationEmail(email: string, code: string): void {
console.log(`To ${email}: Your verification code is ${code}`);
}
export function setEmailVerificationRequestCookie(event: RequestEvent, request: EmailVerificationRequest): void {
event.cookies.set("email_verification", request.id, {
httpOnly: true,
path: "/",
secure: import.meta.env.PROD,
sameSite: "lax",
expires: request.expiresAt
});
}
export function deleteEmailVerificationRequestCookie(event: RequestEvent): void {
event.cookies.set("email_verification", "", {
httpOnly: true,
path: "/",
secure: import.meta.env.PROD,
sameSite: "lax",
maxAge: 0
});
}
export function getUserEmailVerificationRequestFromRequest(event: RequestEvent): EmailVerificationRequest | null {
if (event.locals.user === null) {
return null;
}
const id = event.cookies.get("email_verification") ?? null;
if (id === null) {
return null;
}
const request = getEmailVerificationRequest(id);
if (request !== null && request.userId !== event.locals.user.id) {
deleteEmailVerificationRequestCookie(event);
return null;
}
return request;
}
export const sendVerificationEmailBucket = new ExpiringTokenBucket<number>(3, 60 * 10);
export interface EmailVerificationRequest {
id: string;
userId: number;
code: string;
email: string;
expiresAt: Date;
}

13
src/lib/server/email.ts Normal file
View file

@ -0,0 +1,13 @@
import { db } from "./db";
export function verifyEmailInput(email: string): boolean {
return /^.+@.+\..+$/.test(email) && email.length < 256;
}
export function checkEmailAvailability(email: string): boolean {
const row = db.queryOne("SELECT COUNT(*) FROM user WHERE email = ?", [email]);
if (row === null) {
throw new Error();
}
return row.number(0) === 0;
}

View file

@ -0,0 +1,39 @@
import { decodeBase64 } from "@oslojs/encoding";
import { createCipheriv, createDecipheriv } from "crypto";
import { DynamicBuffer } from "@oslojs/binary";
import { ENCRYPTION_KEY } from "$env/static/private";
const key = decodeBase64(ENCRYPTION_KEY);
export function encrypt(data: Uint8Array): Uint8Array {
const iv = new Uint8Array(16);
crypto.getRandomValues(iv);
const cipher = createCipheriv("aes-128-gcm", key, iv);
const encrypted = new DynamicBuffer(0);
encrypted.write(iv);
encrypted.write(cipher.update(data));
encrypted.write(cipher.final());
encrypted.write(cipher.getAuthTag());
return encrypted.bytes();
}
export function encryptString(data: string): Uint8Array {
return encrypt(new TextEncoder().encode(data));
}
export function decrypt(encrypted: Uint8Array): Uint8Array {
if (encrypted.byteLength < 33) {
throw new Error("Invalid data");
}
const decipher = createDecipheriv("aes-128-gcm", key, encrypted.slice(0, 16));
decipher.setAuthTag(encrypted.slice(encrypted.byteLength - 16));
const decrypted = new DynamicBuffer(0);
decrypted.write(decipher.update(encrypted.slice(16, encrypted.byteLength - 16)));
decrypted.write(decipher.final());
return decrypted.bytes();
}
export function decryptToString(data: Uint8Array): string {
return new TextDecoder().decode(decrypt(data));
}

View file

@ -0,0 +1,134 @@
import { db } from "./db";
import { encodeHexLowerCase } from "@oslojs/encoding";
import { generateRandomOTP } from "./utils";
import { sha256 } from "@oslojs/crypto/sha2";
import type { RequestEvent } from "@sveltejs/kit";
import type { User } from "./user";
export function createPasswordResetSession(token: string, userId: number, email: string): PasswordResetSession {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const session: PasswordResetSession = {
id: sessionId,
userId,
email,
expiresAt: new Date(Date.now() + 1000 * 60 * 10),
code: generateRandomOTP(),
emailVerified: false,
twoFactorVerified: false
};
db.execute("INSERT INTO password_reset_session (id, user_id, email, code, expires_at) VALUES (?, ?, ?, ?, ?)", [
session.id,
session.userId,
session.email,
session.code,
Math.floor(session.expiresAt.getTime() / 1000)
]);
return session;
}
export function validatePasswordResetSessionToken(token: string): PasswordResetSessionValidationResult {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const row = db.queryOne(
`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,
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
INNER JOIN user ON password_reset_session.user_id = user.id
LEFT JOIN totp_credential ON user.id = totp_credential.user_id
LEFT JOIN passkey_credential ON user.id = passkey_credential.user_id
LEFT JOIN security_key_credential ON user.id = security_key_credential.user_id
WHERE password_reset_session.id = ?`,
[sessionId]
);
if (row === null) {
return { session: null, user: null };
}
const session: PasswordResetSession = {
id: row.string(0),
userId: row.number(1),
email: row.string(2),
code: row.string(3),
expiresAt: new Date(row.number(4) * 1000),
emailVerified: Boolean(row.number(5)),
twoFactorVerified: Boolean(row.number(6))
};
const user: User = {
id: row.number(7),
email: row.string(8),
username: row.string(9),
emailVerified: Boolean(row.number(10)),
registeredTOTP: Boolean(row.number(11)),
registeredPasskey: Boolean(row.number(12)),
registeredSecurityKey: Boolean(row.number(13)),
registered2FA: false
};
if (user.registeredPasskey || user.registeredSecurityKey || user.registeredTOTP) {
user.registered2FA = true;
}
if (Date.now() >= session.expiresAt.getTime()) {
db.execute("DELETE FROM password_reset_session WHERE id = ?", [session.id]);
return { session: null, user: null };
}
return { session, user };
}
export function setPasswordResetSessionAsEmailVerified(sessionId: string): void {
db.execute("UPDATE password_reset_session SET email_verified = 1 WHERE id = ?", [sessionId]);
}
export function setPasswordResetSessionAs2FAVerified(sessionId: string): void {
db.execute("UPDATE password_reset_session SET two_factor_verified = 1 WHERE id = ?", [sessionId]);
}
export function invalidateUserPasswordResetSessions(userId: number): void {
db.execute("DELETE FROM password_reset_session WHERE user_id = ?", [userId]);
}
export function validatePasswordResetSessionRequest(event: RequestEvent): PasswordResetSessionValidationResult {
const token = event.cookies.get("password_reset_session") ?? null;
if (token === null) {
return { session: null, user: null };
}
const result = validatePasswordResetSessionToken(token);
if (result.session === null) {
deletePasswordResetSessionTokenCookie(event);
}
return result;
}
export function setPasswordResetSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date): void {
event.cookies.set("password_reset_session", token, {
expires: expiresAt,
sameSite: "lax",
httpOnly: true,
path: "/",
secure: !import.meta.env.DEV
});
}
export function deletePasswordResetSessionTokenCookie(event: RequestEvent): void {
event.cookies.set("password_reset_session", "", {
maxAge: 0,
sameSite: "lax",
httpOnly: true,
path: "/",
secure: !import.meta.env.DEV
});
}
export function sendPasswordResetEmail(email: string, code: string): void {
console.log(`To ${email}: Your reset code is ${code}`);
}
export interface PasswordResetSession {
id: string;
userId: number;
email: string;
expiresAt: Date;
code: string;
emailVerified: boolean;
twoFactorVerified: boolean;
}
export type PasswordResetSessionValidationResult =
| { session: PasswordResetSession; user: User }
| { session: null; user: null };

View file

@ -0,0 +1,34 @@
import { hash, verify } from "@node-rs/argon2";
import { sha1 } from "@oslojs/crypto/sha1";
import { encodeHexLowerCase } from "@oslojs/encoding";
export async function hashPassword(password: string): Promise<string> {
return await hash(password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1
});
}
export async function verifyPasswordHash(hash: string, password: string): Promise<boolean> {
return await verify(hash, password);
}
export async function verifyPasswordStrength(password: string): Promise<boolean> {
if (password.length < 8 || password.length > 255) {
return false;
}
const hash = encodeHexLowerCase(sha1(new TextEncoder().encode(password)));
const hashPrefix = hash.slice(0, 5);
const response = await fetch(`https://api.pwnedpasswords.com/range/${hashPrefix}`);
const data = await response.text();
const items = data.split("\n");
for (const item of items) {
const hashSuffix = item.slice(0, 35).toLowerCase();
if (hash === hashPrefix + hashSuffix) {
return false;
}
}
return true;
}

View file

@ -0,0 +1,150 @@
export class RefillingTokenBucket<_Key> {
public max: number;
public refillIntervalSeconds: number;
constructor(max: number, refillIntervalSeconds: number) {
this.max = max;
this.refillIntervalSeconds = refillIntervalSeconds;
}
private storage = new Map<_Key, RefillBucket>();
public check(key: _Key, cost: number): boolean {
const bucket = this.storage.get(key) ?? null;
if (bucket === null) {
return true;
}
const now = Date.now();
const refill = Math.floor((now - bucket.refilledAt) / (this.refillIntervalSeconds * 1000));
if (refill > 0) {
return Math.min(bucket.count + refill, this.max) >= cost;
}
return bucket.count >= cost;
}
public consume(key: _Key, cost: number): boolean {
let bucket = this.storage.get(key) ?? null;
const now = Date.now();
if (bucket === null) {
bucket = {
count: this.max - cost,
refilledAt: now
};
this.storage.set(key, bucket);
return true;
}
const refill = Math.floor((now - bucket.refilledAt) / (this.refillIntervalSeconds * 1000));
if (refill > 0) {
bucket.count = Math.min(bucket.count + refill, this.max);
bucket.refilledAt = now;
}
if (bucket.count < cost) {
this.storage.set(key, bucket);
return false;
}
bucket.count -= cost;
this.storage.set(key, bucket);
return true;
}
}
export class Throttler<_Key> {
public timeoutSeconds: number[];
private storage = new Map<_Key, ThrottlingCounter>();
constructor(timeoutSeconds: number[]) {
this.timeoutSeconds = timeoutSeconds;
}
public consume(key: _Key): boolean {
let counter = this.storage.get(key) ?? null;
const now = Date.now();
if (counter === null) {
counter = {
timeout: 0,
updatedAt: now
};
this.storage.set(key, counter);
return true;
}
const allowed = now - counter.updatedAt >= this.timeoutSeconds[counter.timeout] * 1000;
if (!allowed) {
return false;
}
counter.updatedAt = now;
counter.timeout = Math.min(counter.timeout + 1, this.timeoutSeconds.length - 1);
this.storage.set(key, counter);
return true;
}
public reset(key: _Key): void {
this.storage.delete(key);
}
}
export class ExpiringTokenBucket<_Key> {
public max: number;
public expiresInSeconds: number;
private storage = new Map<_Key, ExpiringBucket>();
constructor(max: number, expiresInSeconds: number) {
this.max = max;
this.expiresInSeconds = expiresInSeconds;
}
public check(key: _Key, cost: number): boolean {
const bucket = this.storage.get(key) ?? null;
const now = Date.now();
if (bucket === null) {
return true;
}
if (now - bucket.createdAt >= this.expiresInSeconds * 1000) {
return true;
}
return bucket.count >= cost;
}
public consume(key: _Key, cost: number): boolean {
let bucket = this.storage.get(key) ?? null;
const now = Date.now();
if (bucket === null) {
bucket = {
count: this.max - cost,
createdAt: now
};
this.storage.set(key, bucket);
return true;
}
if (now - bucket.createdAt >= this.expiresInSeconds * 1000) {
bucket.count = this.max;
}
if (bucket.count < cost) {
this.storage.set(key, bucket);
return false;
}
bucket.count -= cost;
this.storage.set(key, bucket);
return true;
}
public reset(key: _Key): void {
this.storage.delete(key);
}
}
interface RefillBucket {
count: number;
refilledAt: number;
}
interface ExpiringBucket {
count: number;
createdAt: number;
}
interface ThrottlingCounter {
timeout: number;
updatedAt: number;
}

124
src/lib/server/session.ts Normal file
View file

@ -0,0 +1,124 @@
import { db } from "./db";
import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import type { User } from "./user";
import type { RequestEvent } from "@sveltejs/kit";
export function validateSessionToken(token: string): SessionValidationResult {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const row = db.queryOne(
`
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
INNER JOIN user ON session.user_id = user.id
LEFT JOIN totp_credential ON session.user_id = totp_credential.user_id
LEFT JOIN passkey_credential ON user.id = passkey_credential.user_id
LEFT JOIN security_key_credential ON user.id = security_key_credential.user_id
WHERE session.id = ?
`,
[sessionId]
);
if (row === null) {
return { session: null, user: null };
}
const session: Session = {
id: row.string(0),
userId: row.number(1),
expiresAt: new Date(row.number(2) * 1000),
twoFactorVerified: Boolean(row.number(3))
};
const user: User = {
id: row.number(4),
email: row.string(5),
username: row.string(6),
emailVerified: Boolean(row.number(7)),
registeredTOTP: Boolean(row.number(8)),
registeredPasskey: Boolean(row.number(9)),
registeredSecurityKey: Boolean(row.number(10)),
registered2FA: false
};
if (user.registeredPasskey || user.registeredSecurityKey || user.registeredTOTP) {
user.registered2FA = true;
}
if (Date.now() >= session.expiresAt.getTime()) {
db.execute("DELETE FROM session WHERE id = ?", [sessionId]);
return { session: null, user: null };
}
if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) {
session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30);
db.execute("UPDATE session SET expires_at = ? WHERE session.id = ?", [
Math.floor(session.expiresAt.getTime() / 1000),
sessionId
]);
}
return { session, user };
}
export function invalidateSession(sessionId: string): void {
db.execute("DELETE FROM session WHERE id = ?", [sessionId]);
}
export function invalidateUserSessions(userId: number): void {
db.execute("DELETE FROM session WHERE user_id = ?", [userId]);
}
export function setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date): void {
event.cookies.set("session", token, {
httpOnly: true,
path: "/",
secure: import.meta.env.PROD,
sameSite: "lax",
expires: expiresAt
});
}
export function deleteSessionTokenCookie(event: RequestEvent): void {
event.cookies.set("session", "", {
httpOnly: true,
path: "/",
secure: import.meta.env.PROD,
sameSite: "lax",
maxAge: 0
});
}
export function generateSessionToken(): string {
const tokenBytes = new Uint8Array(20);
crypto.getRandomValues(tokenBytes);
const token = encodeBase32LowerCaseNoPadding(tokenBytes);
return token;
}
export function createSession(token: string, userId: number, flags: SessionFlags): Session {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const session: Session = {
id: sessionId,
userId,
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30),
twoFactorVerified: flags.twoFactorVerified
};
db.execute("INSERT INTO session (id, user_id, expires_at, two_factor_verified) VALUES (?, ?, ?, ?)", [
session.id,
session.userId,
Math.floor(session.expiresAt.getTime() / 1000),
Number(session.twoFactorVerified)
]);
return session;
}
export function setSessionAs2FAVerified(sessionId: string): void {
db.execute("UPDATE session SET two_factor_verified = 1 WHERE id = ?", [sessionId]);
}
export interface SessionFlags {
twoFactorVerified: boolean;
}
export interface Session extends SessionFlags {
id: string;
expiresAt: Date;
userId: number;
}
type SessionValidationResult = { session: Session; user: User } | { session: null; user: null };

37
src/lib/server/totp.ts Normal file
View file

@ -0,0 +1,37 @@
import { db } from "./db";
import { decrypt, encrypt } from "./encryption";
import { ExpiringTokenBucket, RefillingTokenBucket } from "./rate-limit";
export const totpBucket = new ExpiringTokenBucket<number>(5, 60 * 30);
export const totpUpdateBucket = new RefillingTokenBucket<number>(3, 60 * 10);
export function getUserTOTPKey(userId: number): Uint8Array | null {
const row = db.queryOne("SELECT totp_credential.key FROM totp_credential WHERE user_id = ?", [userId]);
if (row === null) {
throw new Error("Invalid user ID");
}
const encrypted = row.bytesNullable(0);
if (encrypted === null) {
return null;
}
return decrypt(encrypted);
}
export function updateUserTOTPKey(userId: number, key: Uint8Array): void {
const encrypted = encrypt(key);
try {
db.execute("BEGIN TRANSACTION", []);
db.execute("DELETE FROM totp_credential WHERE user_id = ?", [userId]);
db.execute("INSERT INTO totp_credential (user_id, key) VALUES (?, ?)", [userId, encrypted]);
db.execute("COMMIT", []);
} catch (e) {
if (db.inTransaction()) {
db.execute("ROLLBACK", []);
}
throw e;
}
}
export function deleteUserTOTPKey(userId: number): void {
db.execute("DELETE FROM totp_credential WHERE user_id = ?", [userId]);
}

108
src/lib/server/user.ts Normal file
View file

@ -0,0 +1,108 @@
import { db } from "./db";
import { decryptToString, encryptString } from "./encryption";
import { hashPassword } from "./password";
import { generateRandomRecoveryCode } from "./utils";
export function verifyUsernameInput(username: string): boolean {
return username.length > 3 && username.length < 32 && username.trim() === username;
}
export async function createUser(email: string, username: string, password: string): Promise<User> {
const passwordHash = await hashPassword(password);
const recoveryCode = generateRandomRecoveryCode();
const encryptedRecoveryCode = encryptString(recoveryCode);
const row = db.queryOne(
"INSERT INTO user (email, username, password_hash, recovery_code) VALUES (?, ?, ?, ?) RETURNING user.id",
[email, username, passwordHash, encryptedRecoveryCode]
);
if (row === null) {
throw new Error("Unexpected error");
}
const user: User = {
id: row.number(0),
username,
email,
emailVerified: false,
registeredTOTP: false,
registeredPasskey: false,
registeredSecurityKey: false,
registered2FA: false
};
return user;
}
export async function updateUserPassword(userId: number, password: string): Promise<void> {
const passwordHash = await hashPassword(password);
db.execute("UPDATE user SET password_hash = ? WHERE id = ?", [passwordHash, userId]);
}
export function updateUserEmailAndSetEmailAsVerified(userId: number, email: string): void {
db.execute("UPDATE user SET email = ?, email_verified = 1 WHERE id = ?", [email, userId]);
}
export function setUserAsEmailVerifiedIfEmailMatches(userId: number, email: string): boolean {
const result = db.execute("UPDATE user SET email_verified = 1 WHERE id = ? AND email = ?", [userId, email]);
return result.changes > 0;
}
export function getUserPasswordHash(userId: number): string {
const row = db.queryOne("SELECT password_hash FROM user WHERE id = ?", [userId]);
if (row === null) {
throw new Error("Invalid user ID");
}
return row.string(0);
}
export function getUserRecoverCode(userId: number): string {
const row = db.queryOne("SELECT recovery_code FROM user WHERE id = ?", [userId]);
if (row === null) {
throw new Error("Invalid user ID");
}
return decryptToString(row.bytes(0));
}
export function resetUserRecoveryCode(userId: number): string {
const recoveryCode = generateRandomRecoveryCode();
const encrypted = encryptString(recoveryCode);
db.execute("UPDATE user SET recovery_code = ? WHERE id = ?", [encrypted, userId]);
return recoveryCode;
}
export function getUserFromEmail(email: string): User | null {
const row = db.queryOne(
`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
LEFT JOIN totp_credential ON user.id = totp_credential.user_id
LEFT JOIN passkey_credential ON user.id = passkey_credential.user_id
LEFT JOIN security_key_credential ON user.id = security_key_credential.user_id
WHERE user.email = ?`,
[email]
);
if (row === null) {
return null;
}
const user: User = {
id: row.number(0),
email: row.string(1),
username: row.string(2),
emailVerified: Boolean(row.number(3)),
registeredTOTP: Boolean(row.number(4)),
registeredPasskey: Boolean(row.number(5)),
registeredSecurityKey: Boolean(row.number(6)),
registered2FA: false
};
if (user.registeredPasskey || user.registeredSecurityKey || user.registeredTOTP) {
user.registered2FA = true;
}
return user;
}
export interface User {
id: number;
email: string;
username: string;
emailVerified: boolean;
registeredTOTP: boolean;
registeredSecurityKey: boolean;
registeredPasskey: boolean;
registered2FA: boolean;
}

15
src/lib/server/utils.ts Normal file
View file

@ -0,0 +1,15 @@
import { encodeBase32UpperCaseNoPadding } from "@oslojs/encoding";
export function generateRandomOTP(): string {
const bytes = new Uint8Array(5);
crypto.getRandomValues(bytes);
const code = encodeBase32UpperCaseNoPadding(bytes);
return code;
}
export function generateRandomRecoveryCode(): string {
const recoveryCodeBytes = new Uint8Array(10);
crypto.getRandomValues(recoveryCodeBytes);
const recoveryCode = encodeBase32UpperCaseNoPadding(recoveryCodeBytes);
return recoveryCode;
}

145
src/lib/server/webauthn.ts Normal file
View file

@ -0,0 +1,145 @@
import { encodeHexLowerCase } from "@oslojs/encoding";
import { db } from "./db";
const challengeBucket = new Set<string>();
export function createWebAuthnChallenge(): Uint8Array {
const challenge = new Uint8Array(20);
crypto.getRandomValues(challenge);
const encoded = encodeHexLowerCase(challenge);
challengeBucket.add(encoded);
return challenge;
}
export function verifyWebAuthnChallenge(challenge: Uint8Array): boolean {
const encoded = encodeHexLowerCase(challenge);
return challengeBucket.delete(encoded);
}
export function getUserPasskeyCredentials(userId: number): WebAuthnUserCredential[] {
const rows = db.query("SELECT id, user_id, name, algorithm, public_key FROM passkey_credential WHERE user_id = ?", [
userId
]);
const credentials: WebAuthnUserCredential[] = [];
for (const row of rows) {
const credential: WebAuthnUserCredential = {
id: row.bytes(0),
userId: row.number(1),
name: row.string(2),
algorithmId: row.number(3),
publicKey: row.bytes(4)
};
credentials.push(credential);
}
return credentials;
}
export function getPasskeyCredential(credentialId: Uint8Array): WebAuthnUserCredential | null {
const row = db.queryOne("SELECT id, user_id, name, algorithm, public_key FROM passkey_credential WHERE id = ?", [
credentialId
]);
if (row === null) {
return null;
}
const credential: WebAuthnUserCredential = {
id: row.bytes(0),
userId: row.number(1),
name: row.string(2),
algorithmId: row.number(3),
publicKey: row.bytes(4)
};
return credential;
}
export function getUserPasskeyCredential(userId: number, credentialId: Uint8Array): WebAuthnUserCredential | null {
const row = db.queryOne(
"SELECT id, user_id, name, algorithm, public_key FROM passkey_credential WHERE id = ? AND user_id = ?",
[credentialId, userId]
);
if (row === null) {
return null;
}
const credential: WebAuthnUserCredential = {
id: row.bytes(0),
userId: row.number(1),
name: row.string(2),
algorithmId: row.number(3),
publicKey: row.bytes(4)
};
return credential;
}
export function createPasskeyCredential(credential: WebAuthnUserCredential): void {
db.execute("INSERT INTO passkey_credential (id, user_id, name, algorithm, public_key) VALUES (?, ?, ?, ?, ?)", [
credential.id,
credential.userId,
credential.name,
credential.algorithmId,
credential.publicKey
]);
}
export function deleteUserPasskeyCredential(userId: number, credentialId: Uint8Array): boolean {
const result = db.execute("DELETE FROM passkey_credential WHERE id = ? AND user_id = ?", [credentialId, userId]);
return result.changes > 0;
}
export function getUserSecurityKeyCredentials(userId: number): WebAuthnUserCredential[] {
const rows = db.query(
"SELECT id, user_id, name, algorithm, public_key FROM security_key_credential WHERE user_id = ?",
[userId]
);
const credentials: WebAuthnUserCredential[] = [];
for (const row of rows) {
const credential: WebAuthnUserCredential = {
id: row.bytes(0),
userId: row.number(1),
name: row.string(2),
algorithmId: row.number(3),
publicKey: row.bytes(4)
};
credentials.push(credential);
}
return credentials;
}
export function getUserSecurityKeyCredential(userId: number, credentialId: Uint8Array): WebAuthnUserCredential | null {
const row = db.queryOne(
"SELECT id, user_id, name, algorithm, public_key FROM security_key_credential WHERE id = ? AND user_id = ?",
[credentialId, userId]
);
if (row === null) {
return null;
}
const credential: WebAuthnUserCredential = {
id: row.bytes(0),
userId: row.number(1),
name: row.string(2),
algorithmId: row.number(3),
publicKey: row.bytes(4)
};
return credential;
}
export function createSecurityKeyCredential(credential: WebAuthnUserCredential): void {
db.execute("INSERT INTO security_key_credential (id, user_id, name, algorithm, public_key) VALUES (?, ?, ?, ?, ?)", [
credential.id,
credential.userId,
credential.name,
credential.algorithmId,
credential.publicKey
]);
}
export function deleteUserSecurityKeyCredential(userId: number, credentialId: Uint8Array): boolean {
const result = db.execute("DELETE FROM security_key_credential WHERE id = ? AND user_id = ?", [credentialId, userId]);
return result.changes > 0;
}
export interface WebAuthnUserCredential {
id: Uint8Array;
userId: number;
name: string;
algorithmId: number;
publicKey: Uint8Array;
}

View file

@ -0,0 +1,5 @@
<svelte:head>
<title>Email and password example with 2FA and WebAuthn in SvelteKit</title>
</svelte:head>
<slot />

View file

@ -0,0 +1,38 @@
import { fail, redirect } from "@sveltejs/kit";
import { deleteSessionTokenCookie, invalidateSession } from "$lib/server/session";
import { get2FARedirect } from "$lib/server/2fa";
import type { Actions, PageServerLoadEvent, RequestEvent } from "./$types";
export function load(event: PageServerLoadEvent) {
if (event.locals.user === null || event.locals.session === null) {
return redirect(302, "/login");
}
if (!event.locals.user.emailVerified) {
return redirect(302, "/verify-email");
}
if (!event.locals.user.registered2FA) {
return redirect(302, "/2fa/setup");
}
if (!event.locals.session.twoFactorVerified) {
return redirect(302, get2FARedirect(event.locals.user));
}
return {
user: event.locals.user
};
}
export const actions: Actions = {
default: action
};
async function action(event: RequestEvent) {
if (event.locals.session === null) {
return fail(401, {
message: "Not authenticated"
});
}
invalidateSession(event.locals.session.id);
deleteSessionTokenCookie(event);
return redirect(302, "/login");
}

18
src/routes/+page.svelte Normal file
View file

@ -0,0 +1,18 @@
<script lang="ts">
import { enhance } from "$app/forms";
import type { PageData } from "./$types";
export let data: PageData;
</script>
<header>
<a href="/">Home</a>
<a href="/settings">Settings</a>
</header>
<main>
<h1>Hi {data.user.username}!</h1>
<form method="post" use:enhance>
<button>Sign out</button>
</form>
</main>

17
src/routes/2fa/+server.ts Normal file
View file

@ -0,0 +1,17 @@
import { get2FARedirect } from "$lib/server/2fa";
import { redirect } from "@sveltejs/kit";
import type { RequestEvent } from "./$types";
export function GET(event: RequestEvent): Response {
if (event.locals.session === null || event.locals.user === null) {
return redirect(302, "/login");
}
if (event.locals.session.twoFactorVerified) {
return redirect(302, "/");
}
if (!event.locals.user.registered2FA) {
return redirect(302, "/2fa/setup");
}
return redirect(302, get2FARedirect(event.locals.user));
}

View file

@ -0,0 +1,28 @@
import { redirect } from "@sveltejs/kit";
import { get2FARedirect } from "$lib/server/2fa";
import { getUserPasskeyCredentials } from "$lib/server/webauthn";
import type { RequestEvent } from "./$types";
export async function load(event: RequestEvent) {
if (event.locals.user === null || event.locals.session === null) {
return redirect(302, "/login");
}
if (!event.locals.user.emailVerified) {
return redirect(302, "/verify-email");
}
if (!event.locals.user.registered2FA) {
return redirect(302, "/");
}
if (event.locals.session.twoFactorVerified) {
return redirect(302, "/");
}
if (!event.locals.user.registeredPasskey) {
return redirect(302, get2FARedirect(event.locals.user));
}
const credentials = getUserPasskeyCredentials(event.locals.user.id);
return {
credentials,
user: event.locals.user
};
}

View file

@ -0,0 +1,65 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { encodeBase64 } from "@oslojs/encoding";
import { createChallenge } from "$lib/client/webauthn";
import type { PageData } from "./$types";
export let data: PageData;
let message = "";
</script>
<h1>Authenticate with passkeys</h1>
<div>
<button
on:click={async () => {
const challenge = await createChallenge();
const credential = await navigator.credentials.get({
publicKey: {
challenge,
userVerification: "discouraged",
allowCredentials: data.credentials.map((credential) => {
return {
id: credential.id,
type: "public-key"
};
})
}
});
if (!(credential instanceof PublicKeyCredential)) {
throw new Error("Failed to create public key");
}
if (!(credential.response instanceof AuthenticatorAssertionResponse)) {
throw new Error("Unexpected error");
}
const response = await fetch("/2fa/passkey", {
method: "POST",
body: JSON.stringify({
credential_id: encodeBase64(new Uint8Array(credential.rawId)),
signature: encodeBase64(new Uint8Array(credential.response.signature)),
authenticator_data: encodeBase64(new Uint8Array(credential.response.authenticatorData)),
client_data_json: encodeBase64(new Uint8Array(credential.response.clientDataJSON))
})
});
if (response.ok) {
goto("/");
} else {
message = await response.text();
}
}}>Authenticate</button
>
<p>{message}</p>
</div>
<a href="/2fa/reset">Use recovery code</a>
{#if data.user.registeredTOTP}
<a href="/2fa/totp">Use authenticator apps</a>
{/if}
{#if data.user.registeredSecurityKey}
<a href="/2fa/security-key">Use security keys</a>
{/if}

View file

@ -0,0 +1,152 @@
import {
parseClientDataJSON,
coseAlgorithmES256,
ClientDataType,
coseAlgorithmRS256,
createAssertionSignatureMessage,
parseAuthenticatorData
} from "@oslojs/webauthn";
import { decodePKIXECDSASignature, decodeSEC1PublicKey, p256, verifyECDSASignature } from "@oslojs/crypto/ecdsa";
import { ObjectParser } from "@pilcrowjs/object-parser";
import { decodeBase64 } from "@oslojs/encoding";
import { verifyWebAuthnChallenge, getUserPasskeyCredential } from "$lib/server/webauthn";
import { setSessionAs2FAVerified } from "$lib/server/session";
import { decodePKCS1RSAPublicKey, sha256ObjectIdentifier, verifyRSASSAPKCS1v15Signature } from "@oslojs/crypto/rsa";
import { sha256 } from "@oslojs/crypto/sha2";
import type { AuthenticatorData, ClientData } from "@oslojs/webauthn";
import type { RequestEvent } from "./$types";
export async function POST(event: RequestEvent) {
if (event.locals.session === null || event.locals.user === null) {
return new Response("Not authenticated", {
status: 401
});
}
if (!event.locals.user.emailVerified) {
return new Response("Forbidden", {
status: 403
});
}
if (!event.locals.user.registeredPasskey) {
return new Response("Forbidden", {
status: 403
});
}
const data: unknown = await event.request.json();
const parser = new ObjectParser(data);
let encodedAuthenticatorData: string;
let encodedClientDataJSON: string;
let encodedCredentialId: string;
let encodedSignature: string;
try {
encodedAuthenticatorData = parser.getString("authenticator_data");
encodedClientDataJSON = parser.getString("client_data_json");
encodedCredentialId = parser.getString("credential_id");
encodedSignature = parser.getString("signature");
} catch {
return new Response("Invalid or missing fields", {
status: 400
});
}
let authenticatorDataBytes: Uint8Array;
let clientDataJSON: Uint8Array;
let credentialId: Uint8Array;
let signatureBytes: Uint8Array;
try {
authenticatorDataBytes = decodeBase64(encodedAuthenticatorData);
clientDataJSON = decodeBase64(encodedClientDataJSON);
credentialId = decodeBase64(encodedCredentialId);
signatureBytes = decodeBase64(encodedSignature);
} catch {
return new Response("Invalid or missing fields", {
status: 400
});
}
let authenticatorData: AuthenticatorData;
try {
authenticatorData = parseAuthenticatorData(authenticatorDataBytes);
} catch {
return new Response("Invalid data", {
status: 400
});
}
// TODO: Update host
if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) {
return new Response("Invalid data", {
status: 400
});
}
if (!authenticatorData.userPresent) {
return new Response("Invalid data", {
status: 400
});
}
let clientData: ClientData;
try {
clientData = parseClientDataJSON(clientDataJSON);
} catch {
return new Response("Invalid data", {
status: 400
});
}
if (clientData.type !== ClientDataType.Get) {
return new Response("Invalid data", {
status: 400
});
}
if (!verifyWebAuthnChallenge(clientData.challenge)) {
return new Response("Invalid data", {
status: 400
});
}
// TODO: Update origin
if (clientData.origin !== "http://localhost:5173") {
return new Response("Invalid data", {
status: 400
});
}
if (clientData.crossOrigin !== null && clientData.crossOrigin) {
return new Response("Invalid data", {
status: 400
});
}
const credential = getUserPasskeyCredential(event.locals.user.id, credentialId);
if (credential === null) {
return new Response("Invalid credential", {
status: 400
});
}
let validSignature: boolean;
if (credential.algorithmId === coseAlgorithmES256) {
const ecdsaSignature = decodePKIXECDSASignature(signatureBytes);
const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey);
const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON));
validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature);
} else if (credential.algorithmId === coseAlgorithmRS256) {
const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey);
const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON));
validSignature = verifyRSASSAPKCS1v15Signature(rsaPublicKey, sha256ObjectIdentifier, hash, signatureBytes);
} else {
return new Response("Internal error", {
status: 500
});
}
if (!validSignature) {
return new Response("Invalid signature", {
status: 400
});
}
setSessionAs2FAVerified(event.locals.session.id);
return new Response(null, {
status: 204
});
}

View file

@ -0,0 +1,237 @@
import { fail, redirect } from "@sveltejs/kit";
import { get2FARedirect } from "$lib/server/2fa";
import { bigEndian } from "@oslojs/binary";
import {
parseAttestationObject,
AttestationStatementFormat,
parseClientDataJSON,
coseAlgorithmES256,
coseEllipticCurveP256,
ClientDataType,
coseAlgorithmRS256
} from "@oslojs/webauthn";
import { ECDSAPublicKey, p256 } from "@oslojs/crypto/ecdsa";
import { decodeBase64 } from "@oslojs/encoding";
import { verifyWebAuthnChallenge, createPasskeyCredential, getUserPasskeyCredentials } from "$lib/server/webauthn";
import { setSessionAs2FAVerified } from "$lib/server/session";
import { RSAPublicKey } from "@oslojs/crypto/rsa";
import { SqliteError } from "better-sqlite3";
import type { WebAuthnUserCredential } from "$lib/server/webauthn";
import type {
AttestationStatement,
AuthenticatorData,
ClientData,
COSEEC2PublicKey,
COSERSAPublicKey
} from "@oslojs/webauthn";
import type { Actions, RequestEvent } from "./$types";
export async function load(event: RequestEvent) {
if (event.locals.user === null || event.locals.session === null) {
return redirect(302, "/login");
}
if (!event.locals.user.emailVerified) {
return redirect(302, "/verify-email");
}
if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) {
return redirect(302, get2FARedirect(event.locals.user));
}
const credentials = getUserPasskeyCredentials(event.locals.user.id);
const credentialUserId = new Uint8Array(8);
bigEndian.putUint64(credentialUserId, BigInt(event.locals.user.id), 0);
return {
credentials,
credentialUserId,
user: event.locals.user
};
}
export const actions: Actions = {
default: action
};
async function action(event: RequestEvent) {
if (event.locals.session === null || event.locals.user === null) {
return new Response("Not authenticated", {
status: 401
});
}
if (!event.locals.user.emailVerified) {
return new Response("Forbidden", {
status: 403
});
}
if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) {
return new Response("Forbidden", {
status: 403
});
}
const formData = await event.request.formData();
let name = formData.get("name");
let encodedAttestationObject = formData.get("attestation_object");
let encodedClientDataJSON = formData.get("client_data_json");
if (
typeof name !== "string" ||
typeof encodedAttestationObject !== "string" ||
typeof encodedClientDataJSON !== "string"
) {
return fail(400, {
message: "Invalid or missing fields"
});
}
let attestationObjectBytes: Uint8Array, clientDataJSON: Uint8Array;
try {
attestationObjectBytes = decodeBase64(encodedAttestationObject);
clientDataJSON = decodeBase64(encodedClientDataJSON);
} catch {
return fail(400, {
message: "Invalid or missing fields"
});
}
let attestationStatement: AttestationStatement;
let authenticatorData: AuthenticatorData;
try {
let attestationObject = parseAttestationObject(attestationObjectBytes);
attestationStatement = attestationObject.attestationStatement;
authenticatorData = attestationObject.authenticatorData;
} catch {
return fail(400, {
message: "Invalid data"
});
}
if (attestationStatement.format !== AttestationStatementFormat.None) {
return fail(400, {
message: "Invalid data"
});
}
// TODO: Update host
if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) {
return fail(400, {
message: "Invalid data"
});
}
if (!authenticatorData.userPresent || !authenticatorData.userVerified) {
return fail(400, {
message: "Invalid data"
});
}
if (authenticatorData.credential === null) {
return fail(400, {
message: "Invalid data"
});
}
let clientData: ClientData;
try {
clientData = parseClientDataJSON(clientDataJSON);
} catch {
return fail(400, {
message: "Invalid data"
});
}
if (clientData.type !== ClientDataType.Create) {
return fail(400, {
message: "Invalid data"
});
}
if (!verifyWebAuthnChallenge(clientData.challenge)) {
return fail(400, {
message: "Invalid data"
});
}
// TODO: Update origin
if (clientData.origin !== "http://localhost:5173") {
return fail(400, {
message: "Invalid data"
});
}
if (clientData.crossOrigin !== null && clientData.crossOrigin) {
return fail(400, {
message: "Invalid data"
});
}
let credential: WebAuthnUserCredential;
if (authenticatorData.credential.publicKey.algorithm() === coseAlgorithmES256) {
let cosePublicKey: COSEEC2PublicKey;
try {
cosePublicKey = authenticatorData.credential.publicKey.ec2();
} catch {
return fail(400, {
message: "Invalid data"
});
}
if (cosePublicKey.curve !== coseEllipticCurveP256) {
return fail(400, {
message: "Unsupported algorithm"
});
}
const encodedPublicKey = new ECDSAPublicKey(p256, cosePublicKey.x, cosePublicKey.y).encodeSEC1Uncompressed();
credential = {
id: authenticatorData.credential.id,
userId: event.locals.user.id,
algorithmId: coseAlgorithmES256,
name,
publicKey: encodedPublicKey
};
} else if (authenticatorData.credential.publicKey.algorithm() === coseAlgorithmRS256) {
let cosePublicKey: COSERSAPublicKey;
try {
cosePublicKey = authenticatorData.credential.publicKey.rsa();
} catch {
return fail(400, {
message: "Invalid data"
});
}
const encodedPublicKey = new RSAPublicKey(cosePublicKey.n, cosePublicKey.e).encodePKCS1();
credential = {
id: authenticatorData.credential.id,
userId: event.locals.user.id,
algorithmId: coseAlgorithmRS256,
name,
publicKey: encodedPublicKey
};
} else {
return fail(400, {
message: "Unsupported algorithm"
});
}
// We don't have to worry about race conditions since queries are synchronous
const credentials = getUserPasskeyCredentials(event.locals.user.id);
if (credentials.length >= 5) {
return fail(400, {
message: "Too many credentials"
});
}
try {
createPasskeyCredential(credential);
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
return fail(400, {
message: "Invalid data"
});
}
return fail(500, {
message: "Internal error"
});
}
if (!event.locals.session.twoFactorVerified) {
setSessionAs2FAVerified(event.locals.session.id);
}
if (!event.locals.user.registered2FA) {
return redirect(302, "/recovery-code");
}
return redirect(302, "/");
}

View file

@ -0,0 +1,75 @@
<script lang="ts">
import { encodeBase64 } from "@oslojs/encoding";
import { createChallenge } from "$lib/client/webauthn";
import { enhance } from "$app/forms";
import type { ActionData, PageData } from "./$types";
export let data: PageData;
export let form: ActionData;
let encodedAttestationObject: string | null = null;
let encodedClientDataJSON: string | null = null;
</script>
<h1>Register passkey</h1>
<button
disabled={encodedAttestationObject !== null && encodedClientDataJSON !== null}
on:click={async () => {
const challenge = await createChallenge();
const credential = await navigator.credentials.create({
publicKey: {
challenge,
user: {
displayName: data.user.username,
id: data.credentialUserId,
name: data.user.email
},
rp: {
name: "SvelteKit WebAuthn example"
},
pubKeyCredParams: [
{
alg: -7,
type: "public-key"
},
{
alg: -257,
type: "public-key"
}
],
attestation: "none",
authenticatorSelection: {
userVerification: "required",
residentKey: "required",
requireResidentKey: true
},
excludeCredentials: data.credentials.map((credential) => {
return {
id: credential.id,
type: "public-key"
};
})
}
});
if (!(credential instanceof PublicKeyCredential)) {
throw new Error("Failed to create public key");
}
if (!(credential.response instanceof AuthenticatorAttestationResponse)) {
throw new Error("Unexpected error");
}
encodedAttestationObject = encodeBase64(new Uint8Array(credential.response.attestationObject));
encodedClientDataJSON = encodeBase64(new Uint8Array(credential.response.clientDataJSON));
}}>Create credential</button
>
<form method="post" use:enhance>
<label for="form-register-credential.name">Credential name</label>
<input id="form-register-credential.name" name="name" />
<input type="hidden" name="attestation_object" value={encodedAttestationObject} />
<input type="hidden" name="client_data_json" value={encodedClientDataJSON} />
<button disabled={encodedAttestationObject === null && encodedClientDataJSON === null}>Continue</button>
<p>{form?.message ?? ""}</p>
</form>

View file

@ -0,0 +1,73 @@
import { recoveryCodeBucket, resetUser2FAWithRecoveryCode } from "$lib/server/2fa";
import { fail, redirect } from "@sveltejs/kit";
import type { Actions, RequestEvent } from "./$types";
export const actions: Actions = {
default: action
};
export async function load(event: RequestEvent) {
if (event.locals.user === null || event.locals.session === null) {
return redirect(302, "/login");
}
if (!event.locals.user.emailVerified) {
return redirect(302, "/verify-email");
}
if (!event.locals.user.registered2FA) {
return redirect(302, "/2fa/setup");
}
if (event.locals.session.twoFactorVerified) {
return redirect(302, "/");
}
return {};
}
async function action(event: RequestEvent) {
if (event.locals.session === null || event.locals.user === null) {
return fail(401, {
message: "Not authenticated"
});
}
if (!event.locals.user.emailVerified) {
return fail(403, {
message: "Forbidden"
});
}
if (!event.locals.user.registered2FA) {
return fail(403, {
message: "Forbidden"
});
}
if (!recoveryCodeBucket.check(event.locals.user.id, 1)) {
return fail(429, {
message: "Too many requests"
});
}
const formData = await event.request.formData();
const code = formData.get("code");
if (typeof code !== "string") {
return fail(400, {
message: "Invalid or missing fields"
});
}
if (code === "") {
return fail(400, {
message: "Please enter your code"
});
}
if (!recoveryCodeBucket.consume(event.locals.user.id, 1)) {
return fail(429, {
message: "Too many requests"
});
}
const valid = resetUser2FAWithRecoveryCode(event.locals.user.id, code);
if (!valid) {
return fail(400, {
message: "Invalid recovery code"
});
}
recoveryCodeBucket.reset(event.locals.user.id);
return redirect(302, "/2fa/setup");
}

View file

@ -0,0 +1,15 @@
<script lang="ts">
import { enhance } from "$app/forms";
import type { ActionData } from "./$types";
export let form: ActionData;
</script>
<h1>Recover your account</h1>
<form method="post" use:enhance>
<label for="form-totp.code">Recovery code</label>
<input id="form-totp.code" name="code" required /><br />
<button>Verify</button>
<p>{form?.message ?? ""}</p>
</form>

View file

@ -0,0 +1,28 @@
import { redirect } from "@sveltejs/kit";
import { get2FARedirect } from "$lib/server/2fa";
import { getUserSecurityKeyCredentials } from "$lib/server/webauthn";
import type { RequestEvent } from "./$types";
export async function load(event: RequestEvent) {
if (event.locals.user === null || event.locals.session === null) {
return redirect(302, "/login");
}
if (!event.locals.user.emailVerified) {
return redirect(302, "/verify-email");
}
if (!event.locals.user.registered2FA) {
return redirect(302, "/");
}
if (event.locals.session.twoFactorVerified) {
return redirect(302, "/");
}
if (!event.locals.user.registeredSecurityKey) {
return redirect(302, get2FARedirect(event.locals.user));
}
const credentials = getUserSecurityKeyCredentials(event.locals.user.id);
return {
credentials,
user: event.locals.user
};
}

View file

@ -0,0 +1,65 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { encodeBase64 } from "@oslojs/encoding";
import { createChallenge } from "$lib/client/webauthn";
import type { PageData } from "./$types";
export let data: PageData;
let message = "";
</script>
<h1>Authenticate with security keys</h1>
<div>
<button
on:click={async () => {
const challenge = await createChallenge();
const credential = await navigator.credentials.get({
publicKey: {
challenge,
userVerification: "discouraged",
allowCredentials: data.credentials.map((credential) => {
return {
id: credential.id,
type: "public-key"
};
})
}
});
if (!(credential instanceof PublicKeyCredential)) {
throw new Error("Failed to create public key");
}
if (!(credential.response instanceof AuthenticatorAssertionResponse)) {
throw new Error("Unexpected error");
}
const response = await fetch("/2fa/security-key", {
method: "POST",
body: JSON.stringify({
credential_id: encodeBase64(new Uint8Array(credential.rawId)),
signature: encodeBase64(new Uint8Array(credential.response.signature)),
authenticator_data: encodeBase64(new Uint8Array(credential.response.authenticatorData)),
client_data_json: encodeBase64(new Uint8Array(credential.response.clientDataJSON))
})
});
if (response.ok) {
goto("/");
} else {
message = await response.text();
}
}}>Authenticate</button
>
<p>{message}</p>
</div>
<a href="/2fa/reset">Use recovery code</a>
{#if data.user.registeredTOTP}
<a href="/2fa/totp">Use authenticator apps</a>
{/if}
{#if data.user.registeredPasskey}
<a href="/2fa/passkey">Use passkeys</a>
{/if}

View file

@ -0,0 +1,152 @@
import {
parseClientDataJSON,
coseAlgorithmES256,
ClientDataType,
coseAlgorithmRS256,
createAssertionSignatureMessage,
parseAuthenticatorData
} from "@oslojs/webauthn";
import { decodePKIXECDSASignature, decodeSEC1PublicKey, p256, verifyECDSASignature } from "@oslojs/crypto/ecdsa";
import { ObjectParser } from "@pilcrowjs/object-parser";
import { decodeBase64 } from "@oslojs/encoding";
import { verifyWebAuthnChallenge, getUserSecurityKeyCredential } from "$lib/server/webauthn";
import { setSessionAs2FAVerified } from "$lib/server/session";
import { decodePKCS1RSAPublicKey, sha256ObjectIdentifier, verifyRSASSAPKCS1v15Signature } from "@oslojs/crypto/rsa";
import { sha256 } from "@oslojs/crypto/sha2";
import type { AuthenticatorData, ClientData } from "@oslojs/webauthn";
import type { RequestEvent } from "./$types";
export async function POST(event: RequestEvent) {
if (event.locals.session === null || event.locals.user === null) {
return new Response("Not authenticated", {
status: 401
});
}
if (!event.locals.user.emailVerified) {
return new Response("Forbidden", {
status: 403
});
}
if (!event.locals.user.registeredSecurityKey) {
return new Response("Forbidden", {
status: 403
});
}
const data: unknown = await event.request.json();
const parser = new ObjectParser(data);
let encodedAuthenticatorData: string;
let encodedClientDataJSON: string;
let encodedCredentialId: string;
let encodedSignature: string;
try {
encodedAuthenticatorData = parser.getString("authenticator_data");
encodedClientDataJSON = parser.getString("client_data_json");
encodedCredentialId = parser.getString("credential_id");
encodedSignature = parser.getString("signature");
} catch {
return new Response("Invalid or missing fields", {
status: 400
});
}
let authenticatorDataBytes: Uint8Array;
let clientDataJSON: Uint8Array;
let credentialId: Uint8Array;
let signatureBytes: Uint8Array;
try {
authenticatorDataBytes = decodeBase64(encodedAuthenticatorData);
clientDataJSON = decodeBase64(encodedClientDataJSON);
credentialId = decodeBase64(encodedCredentialId);
signatureBytes = decodeBase64(encodedSignature);
} catch {
return new Response("Invalid or missing fields", {
status: 400
});
}
let authenticatorData: AuthenticatorData;
try {
authenticatorData = parseAuthenticatorData(authenticatorDataBytes);
} catch {
return new Response("Invalid data", {
status: 400
});
}
// TODO: Update host
if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) {
return new Response("Invalid data", {
status: 400
});
}
if (!authenticatorData.userPresent) {
return new Response("Invalid data", {
status: 400
});
}
let clientData: ClientData;
try {
clientData = parseClientDataJSON(clientDataJSON);
} catch {
return new Response("Invalid data", {
status: 400
});
}
if (clientData.type !== ClientDataType.Get) {
return new Response("Invalid data", {
status: 400
});
}
if (!verifyWebAuthnChallenge(clientData.challenge)) {
return new Response("Invalid data", {
status: 400
});
}
// TODO: Update origin
if (clientData.origin !== "http://localhost:5173") {
return new Response("Invalid data", {
status: 400
});
}
if (clientData.crossOrigin !== null && clientData.crossOrigin) {
return new Response("Invalid data", {
status: 400
});
}
const credential = getUserSecurityKeyCredential(event.locals.user.id, credentialId);
if (credential === null) {
return new Response("Invalid credential", {
status: 400
});
}
let validSignature: boolean;
if (credential.algorithmId === coseAlgorithmES256) {
const ecdsaSignature = decodePKIXECDSASignature(signatureBytes);
const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey);
const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON));
validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature);
} else if (credential.algorithmId === coseAlgorithmRS256) {
const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey);
const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON));
validSignature = verifyRSASSAPKCS1v15Signature(rsaPublicKey, sha256ObjectIdentifier, hash, signatureBytes);
} else {
return new Response("Internal error", {
status: 500
});
}
if (!validSignature) {
return new Response("Invalid signature", {
status: 400
});
}
setSessionAs2FAVerified(event.locals.session.id);
return new Response(null, {
status: 204
});
}

View file

@ -0,0 +1,241 @@
import { fail, redirect } from "@sveltejs/kit";
import { get2FARedirect } from "$lib/server/2fa";
import { bigEndian } from "@oslojs/binary";
import {
parseAttestationObject,
AttestationStatementFormat,
parseClientDataJSON,
coseAlgorithmES256,
coseEllipticCurveP256,
ClientDataType,
coseAlgorithmRS256
} from "@oslojs/webauthn";
import { ECDSAPublicKey, p256 } from "@oslojs/crypto/ecdsa";
import { decodeBase64 } from "@oslojs/encoding";
import {
verifyWebAuthnChallenge,
createSecurityKeyCredential,
getUserSecurityKeyCredentials
} from "$lib/server/webauthn";
import { setSessionAs2FAVerified } from "$lib/server/session";
import { RSAPublicKey } from "@oslojs/crypto/rsa";
import { SqliteError } from "better-sqlite3";
import type { WebAuthnUserCredential } from "$lib/server/webauthn";
import type {
AttestationStatement,
AuthenticatorData,
ClientData,
COSEEC2PublicKey,
COSERSAPublicKey
} from "@oslojs/webauthn";
import type { Actions, RequestEvent } from "./$types";
export async function load(event: RequestEvent) {
if (event.locals.user === null || event.locals.session === null) {
return redirect(302, "/login");
}
if (!event.locals.user.emailVerified) {
return redirect(302, "/verify-email");
}
if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) {
return redirect(302, get2FARedirect(event.locals.user));
}
const credentials = getUserSecurityKeyCredentials(event.locals.user.id);
const credentialUserId = new Uint8Array(8);
bigEndian.putUint64(credentialUserId, BigInt(event.locals.user.id), 0);
return {
credentials,
credentialUserId,
user: event.locals.user
};
}
export const actions: Actions = {
default: action
};
async function action(event: RequestEvent) {
if (event.locals.session === null || event.locals.user === null) {
return new Response("Not authenticated", {
status: 401
});
}
if (!event.locals.user.emailVerified) {
return new Response("Forbidden", {
status: 403
});
}
if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) {
return new Response("Forbidden", {
status: 403
});
}
const formData = await event.request.formData();
let name = formData.get("name");
let encodedAttestationObject = formData.get("attestation_object");
let encodedClientDataJSON = formData.get("client_data_json");
if (
typeof name !== "string" ||
typeof encodedAttestationObject !== "string" ||
typeof encodedClientDataJSON !== "string"
) {
return fail(400, {
message: "Invalid or missing fields"
});
}
let attestationObjectBytes: Uint8Array, clientDataJSON: Uint8Array;
try {
attestationObjectBytes = decodeBase64(encodedAttestationObject);
clientDataJSON = decodeBase64(encodedClientDataJSON);
} catch {
return fail(400, {
message: "Invalid or missing fields"
});
}
let attestationStatement: AttestationStatement;
let authenticatorData: AuthenticatorData;
try {
let attestationObject = parseAttestationObject(attestationObjectBytes);
attestationStatement = attestationObject.attestationStatement;
authenticatorData = attestationObject.authenticatorData;
} catch {
return fail(400, {
message: "Invalid data"
});
}
if (attestationStatement.format !== AttestationStatementFormat.None) {
return fail(400, {
message: "Invalid data"
});
}
// TODO: Update host
if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) {
return fail(400, {
message: "Invalid data"
});
}
if (!authenticatorData.userPresent) {
return fail(400, {
message: "Invalid data"
});
}
if (authenticatorData.credential === null) {
return fail(400, {
message: "Invalid data"
});
}
let clientData: ClientData;
try {
clientData = parseClientDataJSON(clientDataJSON);
} catch {
return fail(400, {
message: "Invalid data"
});
}
if (clientData.type !== ClientDataType.Create) {
return fail(400, {
message: "Invalid data"
});
}
if (!verifyWebAuthnChallenge(clientData.challenge)) {
return fail(400, {
message: "Invalid data"
});
}
// TODO: Update origin
if (clientData.origin !== "http://localhost:5173") {
return fail(400, {
message: "Invalid data"
});
}
if (clientData.crossOrigin !== null && clientData.crossOrigin) {
return fail(400, {
message: "Invalid data"
});
}
let credential: WebAuthnUserCredential;
if (authenticatorData.credential.publicKey.algorithm() === coseAlgorithmES256) {
let cosePublicKey: COSEEC2PublicKey;
try {
cosePublicKey = authenticatorData.credential.publicKey.ec2();
} catch {
return fail(400, {
message: "Invalid data"
});
}
if (cosePublicKey.curve !== coseEllipticCurveP256) {
return fail(400, {
message: "Unsupported algorithm"
});
}
const encodedPublicKey = new ECDSAPublicKey(p256, cosePublicKey.x, cosePublicKey.y).encodeSEC1Uncompressed();
credential = {
id: authenticatorData.credential.id,
userId: event.locals.user.id,
algorithmId: coseAlgorithmES256,
name,
publicKey: encodedPublicKey
};
} else if (authenticatorData.credential.publicKey.algorithm() === coseAlgorithmRS256) {
let cosePublicKey: COSERSAPublicKey;
try {
cosePublicKey = authenticatorData.credential.publicKey.rsa();
} catch {
return fail(400, {
message: "Invalid data"
});
}
const encodedPublicKey = new RSAPublicKey(cosePublicKey.n, cosePublicKey.e).encodePKCS1();
credential = {
id: authenticatorData.credential.id,
userId: event.locals.user.id,
algorithmId: coseAlgorithmRS256,
name,
publicKey: encodedPublicKey
};
} else {
return fail(400, {
message: "Unsupported algorithm"
});
}
// We don't have to worry about race conditions since queries are synchronous
const credentials = getUserSecurityKeyCredentials(event.locals.user.id);
if (credentials.length >= 5) {
return fail(400, {
message: "Too many credentials"
});
}
try {
createSecurityKeyCredential(credential);
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
return fail(400, {
message: "Invalid data"
});
}
return fail(500, {
message: "Internal error"
});
}
if (!event.locals.session.twoFactorVerified) {
setSessionAs2FAVerified(event.locals.session.id);
}
if (!event.locals.user.registered2FA) {
return redirect(302, "/recovery-code");
}
return redirect(302, "/");
}

View file

@ -0,0 +1,76 @@
<script lang="ts">
import { encodeBase64 } from "@oslojs/encoding";
import { createChallenge } from "$lib/client/webauthn";
import { enhance } from "$app/forms";
import type { ActionData, PageData } from "./$types";
export let data: PageData;
export let form: ActionData;
let encodedAttestationObject: string | null = null;
let encodedClientDataJSON: string | null = null;
</script>
<h1>Register security key</h1>
<button
disabled={encodedAttestationObject !== null && encodedClientDataJSON !== null}
on:click={async () => {
const challenge = await createChallenge();
const credential = await navigator.credentials.create({
publicKey: {
challenge,
user: {
displayName: data.user.username,
id: data.credentialUserId,
name: data.user.email
},
rp: {
name: "SvelteKit WebAuthn example"
},
pubKeyCredParams: [
{
alg: -7,
type: "public-key"
},
{
alg: -257,
type: "public-key"
}
],
attestation: "none",
authenticatorSelection: {
userVerification: "discouraged",
residentKey: "discouraged",
requireResidentKey: false,
authenticatorAttachment: "cross-platform"
},
excludeCredentials: data.credentials.map((credential) => {
return {
id: credential.id,
type: "public-key"
};
})
}
});
if (!(credential instanceof PublicKeyCredential)) {
throw new Error("Failed to create public key");
}
if (!(credential.response instanceof AuthenticatorAttestationResponse)) {
throw new Error("Unexpected error");
}
encodedAttestationObject = encodeBase64(new Uint8Array(credential.response.attestationObject));
encodedClientDataJSON = encodeBase64(new Uint8Array(credential.response.clientDataJSON));
}}>Create credential</button
>
<form method="post" use:enhance>
<label for="form-register-credential.name">Credential name</label>
<input id="form-register-credential.name" name="name" />
<input type="hidden" name="attestation_object" value={encodedAttestationObject} />
<input type="hidden" name="client_data_json" value={encodedClientDataJSON} />
<button disabled={encodedAttestationObject === null && encodedClientDataJSON === null}>Continue</button>
<p>{form?.message ?? ""}</p>
</form>

View file

@ -0,0 +1,16 @@
import { redirect } from "@sveltejs/kit";
import type { RequestEvent } from "./$types";
export async function load(event: RequestEvent) {
if (event.locals.user === null || event.locals.session === null) {
return redirect(302, "/login");
}
if (!event.locals.user.emailVerified) {
return redirect(302, "/verify-email");
}
if (event.locals.user.registered2FA) {
return redirect(302, "/");
}
return {};
}

View file

@ -0,0 +1,6 @@
<h1>Set up two-factor authentication</h1>
<ul>
<li><a href="/2fa/totp/setup">Authenticator apps</a></li>
<li><a href="/2fa/passkey/register">Passkeys</a></li>
<li><a href="/2fa/security-key/register">Security keys</a></li>
</ul>

View file

@ -0,0 +1,83 @@
import { totpBucket, getUserTOTPKey } from "$lib/server/totp";
import { fail, redirect } from "@sveltejs/kit";
import { verifyTOTP } from "@oslojs/otp";
import { setSessionAs2FAVerified } from "$lib/server/session";
import type { Actions, RequestEvent } from "./$types";
export async function load(event: RequestEvent) {
if (event.locals.user === null || event.locals.session === null) {
return redirect(302, "/login");
}
if (!event.locals.user.emailVerified) {
return redirect(302, "/verify-email");
}
if (!event.locals.user.registered2FA) {
return redirect(302, "/2fa/setup");
}
if (event.locals.session.twoFactorVerified) {
return redirect(302, "/");
}
return {
user: event.locals.user
};
}
export const actions: Actions = {
default: action
};
async function action(event: RequestEvent) {
if (event.locals.session === null || event.locals.user === null) {
return fail(401, {
message: "Not authenticated"
});
}
if (!event.locals.user.emailVerified) {
return fail(403, {
message: "Forbidden"
});
}
if (!event.locals.user.registered2FA) {
return fail(403, {
message: "Forbidden"
});
}
if (!totpBucket.check(event.locals.user.id, 1)) {
return fail(429, {
message: "Too many requests"
});
}
const formData = await event.request.formData();
const code = formData.get("code");
if (typeof code !== "string") {
return fail(400, {
message: "Invalid or missing fields"
});
}
if (code === "") {
return fail(400, {
message: "Enter your code"
});
}
if (!totpBucket.consume(event.locals.user.id, 1)) {
return fail(429, {
message: "Too many requests"
});
}
const totpKey = getUserTOTPKey(event.locals.user.id);
if (totpKey === null) {
return fail(403, {
message: "Forbidden"
});
}
if (!verifyTOTP(totpKey, 30, 6, code)) {
return fail(400, {
message: "Invalid code"
});
}
totpBucket.reset(event.locals.user.id);
setSessionAs2FAVerified(event.locals.session.id);
return redirect(302, "/");
}

View file

@ -0,0 +1,24 @@
<script lang="ts">
import { enhance } from "$app/forms";
import type { ActionData, PageData } from "./$types";
export let data: PageData;
export let form: ActionData;
</script>
<h1>Two-factor authentication</h1>
<form method="post" use:enhance>
<label for="form-totp.code">Enter the code from your app</label>
<input id="form-totp.code" name="code" autocomplete="one-time-code" required /><br />
<button>Verify</button>
<p>{form?.message ?? ""}</p>
</form>
<a href="/2fa/reset">Use recovery code</a>
{#if data.user.registeredPasskey}
<a href="/2fa/passkey">Use passkeys</a>
{/if}
{#if data.user.registeredSecurityKey}
<a href="/2fa/security-key">Use security keys</a>
{/if}

View file

@ -0,0 +1,106 @@
import { createTOTPKeyURI, verifyTOTP } from "@oslojs/otp";
import { fail, redirect } from "@sveltejs/kit";
import { decodeBase64, encodeBase64 } from "@oslojs/encoding";
import { totpUpdateBucket, updateUserTOTPKey } from "$lib/server/totp";
import { setSessionAs2FAVerified } from "$lib/server/session";
import { renderSVG } from "uqr";
import { get2FARedirect } from "$lib/server/2fa";
import type { Actions, RequestEvent } from "./$types";
export async function load(event: RequestEvent) {
if (event.locals.user === null || event.locals.session === null) {
return redirect(302, "/login");
}
if (!event.locals.user.emailVerified) {
return redirect(302, "/verify-email");
}
if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) {
return redirect(302, get2FARedirect(event.locals.user));
}
const totpKey = new Uint8Array(20);
crypto.getRandomValues(totpKey);
const encodedTOTPKey = encodeBase64(totpKey);
const keyURI = createTOTPKeyURI("Demo", event.locals.user.username, totpKey, 30, 6);
const qrcode = renderSVG(keyURI);
return {
encodedTOTPKey,
qrcode
};
}
export const actions: Actions = {
default: action
};
async function action(event: RequestEvent) {
if (event.locals.session === null || event.locals.user === null) {
return fail(401, {
message: "Not authenticated"
});
}
if (!event.locals.user.emailVerified) {
return fail(403, {
message: "Forbidden"
});
}
if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) {
return fail(403, {
message: "Forbidden"
});
}
if (!totpUpdateBucket.check(event.locals.user.id, 1)) {
return fail(429, {
message: "Too many requests"
});
}
const formData = await event.request.formData();
const encodedKey = formData.get("key");
const code = formData.get("code");
if (typeof encodedKey !== "string" || typeof code !== "string") {
return fail(400, {
message: "Invalid or missing fields"
});
}
if (code === "") {
return fail(400, {
message: "Please enter your code"
});
}
if (encodedKey.length !== 28) {
return fail(400, {
message: "Please enter your code"
});
}
let key: Uint8Array;
try {
key = decodeBase64(encodedKey);
} catch {
return fail(400, {
message: "Invalid key"
});
}
if (key.byteLength !== 20) {
return fail(400, {
message: "Invalid key"
});
}
if (!totpUpdateBucket.consume(event.locals.user.id, 1)) {
return fail(429, {
message: "Too many requests"
});
}
if (!verifyTOTP(key, 30, 6, code)) {
return fail(400, {
message: "Invalid code"
});
}
updateUserTOTPKey(event.locals.session.userId, key);
setSessionAs2FAVerified(event.locals.session.id);
if (!event.locals.user.registered2FA) {
return redirect(302, "/recovery-code");
}
return redirect(302, "/");
}

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { enhance } from "$app/forms";
import type { ActionData, PageData } from "./$types";
export let data: PageData;
export let form: ActionData;
</script>
<h1>Set up two-factor authentication</h1>
<div style="width:200px; height: 200px;">
{@html data.qrcode}
</div>
<form method="post" use:enhance>
<input name="key" value={data.encodedTOTPKey} hidden required />
<label for="form-totp.code">Verify the code from the app</label>
<input id="form-totp.code" name="code" required /><br />
<button>Save</button>
<p>{form?.message ?? ""}</p>
</form>

View file

@ -0,0 +1,19 @@
import { createWebAuthnChallenge } from "$lib/server/webauthn";
import { encodeBase64 } from "@oslojs/encoding";
import { RefillingTokenBucket } from "$lib/server/rate-limit";
import type { RequestEvent } from "./$types";
const webauthnChallengeRateLimitBucket = new RefillingTokenBucket<string>(30, 10);
export async function POST(event: RequestEvent) {
// TODO: Assumes X-Forwarded-For is always included.
const clientIP = event.request.headers.get("X-Forwarded-For");
if (clientIP !== null && !webauthnChallengeRateLimitBucket.consume(clientIP, 1)) {
return new Response("Too many requests", {
status: 429
});
}
const challenge = createWebAuthnChallenge();
return new Response(JSON.stringify({ challenge: encodeBase64(challenge) }));
}

View file

@ -0,0 +1,71 @@
import { verifyEmailInput } from "$lib/server/email";
import { getUserFromEmail } from "$lib/server/user";
import {
createPasswordResetSession,
invalidateUserPasswordResetSessions,
sendPasswordResetEmail,
setPasswordResetSessionTokenCookie
} from "$lib/server/password-reset";
import { RefillingTokenBucket } from "$lib/server/rate-limit";
import { generateSessionToken } from "$lib/server/session";
import { fail, redirect } from "@sveltejs/kit";
import type { Actions, RequestEvent } from "./$types";
const ipBucket = new RefillingTokenBucket<string>(3, 60);
const userBucket = new RefillingTokenBucket<number>(3, 60);
export const actions: Actions = {
default: action
};
async function action(event: RequestEvent) {
// TODO: Assumes X-Forwarded-For is always included.
const clientIP = event.request.headers.get("X-Forwarded-For");
if (clientIP !== null && !ipBucket.check(clientIP, 1)) {
return fail(429, {
message: "Too many requests",
email: ""
});
}
const formData = await event.request.formData();
const email = formData.get("email");
if (typeof email !== "string") {
return fail(400, {
message: "Invalid or missing fields",
email: ""
});
}
if (!verifyEmailInput(email)) {
return fail(400, {
message: "Invalid email",
email
});
}
const user = getUserFromEmail(email);
if (user === null) {
return fail(400, {
message: "Account does not exist",
email
});
}
if (clientIP !== null && !ipBucket.consume(clientIP, 1)) {
return fail(400, {
message: "Too many requests",
email
});
}
if (!userBucket.consume(user.id, 1)) {
return fail(400, {
message: "Too many requests",
email
});
}
invalidateUserPasswordResetSessions(user.id);
const sessionToken = generateSessionToken();
const session = createPasswordResetSession(sessionToken, user.id, user.email);
sendPasswordResetEmail(session.email, session.code);
setPasswordResetSessionTokenCookie(event, sessionToken, session.expiresAt);
return redirect(302, "/reset-password/verify-email");
}

View file

@ -0,0 +1,16 @@
<script lang="ts">
import { enhance } from "$app/forms";
import type { ActionData } from "./$types";
export let form: ActionData;
</script>
<h1>Forgot your password?</h1>
<form method="post" use:enhance>
<label for="form-forgot.email">Email</label>
<input type="email" id="form-forgot.email" name="email" required value={form?.email ?? ""} /><br />
<button>Send</button>
<p>{form?.message ?? ""}</p>
</form>
<a href="/login">Sign in</a>

View file

@ -0,0 +1,108 @@
import { fail, redirect } from "@sveltejs/kit";
import { verifyEmailInput } from "$lib/server/email";
import { getUserFromEmail, getUserPasswordHash } from "$lib/server/user";
import { RefillingTokenBucket, Throttler } from "$lib/server/rate-limit";
import { verifyPasswordHash } from "$lib/server/password";
import { createSession, generateSessionToken, setSessionTokenCookie } from "$lib/server/session";
import { get2FARedirect } from "$lib/server/2fa";
import type { SessionFlags } from "$lib/server/session";
import type { Actions, PageServerLoadEvent, RequestEvent } from "./$types";
export function load(event: PageServerLoadEvent) {
if (event.locals.session !== null && event.locals.user !== null) {
if (!event.locals.user.emailVerified) {
return redirect(302, "/verify-email");
}
if (!event.locals.user.registered2FA) {
return redirect(302, "/2fa/setup");
}
if (!event.locals.session.twoFactorVerified) {
return redirect(302, get2FARedirect(event.locals.user));
}
return redirect(302, "/");
}
return {};
}
const throttler = new Throttler<number>([0, 1, 2, 4, 8, 16, 30, 60, 180, 300]);
const ipBucket = new RefillingTokenBucket<string>(20, 1);
export const actions: Actions = {
default: action
};
async function action(event: RequestEvent) {
// TODO: Assumes X-Forwarded-For is always included.
const clientIP = event.request.headers.get("X-Forwarded-For");
if (clientIP !== null && !ipBucket.check(clientIP, 1)) {
return fail(429, {
message: "Too many requests",
email: ""
});
}
const formData = await event.request.formData();
const email = formData.get("email");
const password = formData.get("password");
if (typeof email !== "string" || typeof password !== "string") {
return fail(400, {
message: "Invalid or missing fields",
email: ""
});
}
if (email === "" || password === "") {
return fail(400, {
message: "Please enter your email and password.",
email
});
}
if (!verifyEmailInput(email)) {
return fail(400, {
message: "Invalid email",
email
});
}
const user = getUserFromEmail(email);
if (user === null) {
return fail(400, {
message: "Account does not exist",
email
});
}
if (clientIP !== null && !ipBucket.consume(clientIP, 1)) {
return fail(429, {
message: "Too many requests",
email: ""
});
}
if (!throttler.consume(user.id)) {
return fail(429, {
message: "Too many requests",
email: ""
});
}
const passwordHash = getUserPasswordHash(user.id);
const validPassword = await verifyPasswordHash(passwordHash, password);
if (!validPassword) {
return fail(400, {
message: "Invalid password",
email
});
}
throttler.reset(user.id);
const sessionFlags: SessionFlags = {
twoFactorVerified: false
};
const sessionToken = generateSessionToken();
const session = createSession(sessionToken, user.id, sessionFlags);
setSessionTokenCookie(event, sessionToken, session.expiresAt);
if (!user.emailVerified) {
return redirect(302, "/verify-email");
}
if (!user.registered2FA) {
return redirect(302, "/2fa/setup");
}
return redirect(302, get2FARedirect(user));
}

View file

@ -0,0 +1,70 @@
<script lang="ts">
import { enhance } from "$app/forms";
import { createChallenge } from "$lib/client/webauthn";
import { encodeBase64 } from "@oslojs/encoding";
import { goto } from "$app/navigation";
import type { ActionData } from "./$types";
export let form: ActionData;
let passkeyErrorMessage = "";
</script>
<h1>Sign in</h1>
<form method="post" use:enhance>
<label for="form-login.email">Email</label>
<input
type="email"
id="form-login.email"
name="email"
autocomplete="username"
required
value={form?.email ?? ""}
/><br />
<label for="form-login.password">Password</label>
<input type="password" id="form-login.password" name="password" autocomplete="current-password" required /><br />
<button>Continue</button>
<p>{form?.message ?? ""}</p>
</form>
<div>
<button
on:click={async () => {
const challenge = await createChallenge();
const credential = await navigator.credentials.get({
publicKey: {
challenge,
userVerification: "required"
}
});
if (!(credential instanceof PublicKeyCredential)) {
throw new Error("Failed to create public key");
}
if (!(credential.response instanceof AuthenticatorAssertionResponse)) {
throw new Error("Unexpected error");
}
const response = await fetch("/login/passkey", {
method: "POST",
// this example uses JSON but you can use something like CBOR to get something more compact
body: JSON.stringify({
credential_id: encodeBase64(new Uint8Array(credential.rawId)),
signature: encodeBase64(new Uint8Array(credential.response.signature)),
authenticator_data: encodeBase64(new Uint8Array(credential.response.authenticatorData)),
client_data_json: encodeBase64(new Uint8Array(credential.response.clientDataJSON))
})
});
if (response.ok) {
goto("/");
} else {
passkeyErrorMessage = await response.text();
}
}}>Sign in with passkeys</button
>
<p>{passkeyErrorMessage}</p>
</div>
<a href="/signup">Create an account</a>
<a href="/forgot-password">Forgot password?</a>

View file

@ -0,0 +1,142 @@
import {
parseClientDataJSON,
coseAlgorithmES256,
ClientDataType,
parseAuthenticatorData,
createAssertionSignatureMessage,
coseAlgorithmRS256
} from "@oslojs/webauthn";
import { decodePKIXECDSASignature, decodeSEC1PublicKey, p256, verifyECDSASignature } from "@oslojs/crypto/ecdsa";
import { ObjectParser } from "@pilcrowjs/object-parser";
import { decodeBase64 } from "@oslojs/encoding";
import { verifyWebAuthnChallenge, getPasskeyCredential } from "$lib/server/webauthn";
import { createSession, generateSessionToken, setSessionTokenCookie } from "$lib/server/session";
import { sha256 } from "@oslojs/crypto/sha2";
import { decodePKCS1RSAPublicKey, sha256ObjectIdentifier, verifyRSASSAPKCS1v15Signature } from "@oslojs/crypto/rsa";
import type { RequestEvent } from "./$types";
import type { ClientData, AuthenticatorData } from "@oslojs/webauthn";
import type { SessionFlags } from "$lib/server/session";
// Stricter rate limiting can be omitted here since creating challenges are rate-limited
export async function POST(context: RequestEvent): Promise<Response> {
const data: unknown = await context.request.json();
const parser = new ObjectParser(data);
let encodedAuthenticatorData: string;
let encodedClientDataJSON: string;
let encodedCredentialId: string;
let encodedSignature: string;
try {
encodedAuthenticatorData = parser.getString("authenticator_data");
encodedClientDataJSON = parser.getString("client_data_json");
encodedCredentialId = parser.getString("credential_id");
encodedSignature = parser.getString("signature");
} catch {
return new Response("Invalid or missing fields", {
status: 400
});
}
let authenticatorDataBytes: Uint8Array;
let clientDataJSON: Uint8Array;
let credentialId: Uint8Array;
let signatureBytes: Uint8Array;
try {
authenticatorDataBytes = decodeBase64(encodedAuthenticatorData);
clientDataJSON = decodeBase64(encodedClientDataJSON);
credentialId = decodeBase64(encodedCredentialId);
signatureBytes = decodeBase64(encodedSignature);
} catch {
return new Response("Invalid or missing fields", {
status: 400
});
}
let authenticatorData: AuthenticatorData;
try {
authenticatorData = parseAuthenticatorData(authenticatorDataBytes);
} catch {
return new Response("Invalid data", {
status: 400
});
}
// TODO: Update host
if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) {
return new Response("Invalid data", {
status: 400
});
}
if (!authenticatorData.userPresent || !authenticatorData.userVerified) {
return new Response("Invalid data", {
status: 400
});
}
let clientData: ClientData;
try {
clientData = parseClientDataJSON(clientDataJSON);
} catch {
return new Response("Invalid data", {
status: 400
});
}
if (clientData.type !== ClientDataType.Get) {
return new Response("Invalid data", {
status: 400
});
}
if (!verifyWebAuthnChallenge(clientData.challenge)) {
return new Response("Invalid data", {
status: 400
});
}
// TODO: Update origin
if (clientData.origin !== "http://localhost:5173") {
return new Response("Invalid data", {
status: 400
});
}
if (clientData.crossOrigin !== null && clientData.crossOrigin) {
return new Response("Invalid data", {
status: 400
});
}
const credential = getPasskeyCredential(credentialId);
if (credential === null) {
return new Response("Invalid credential", {
status: 400
});
}
let validSignature: boolean;
if (credential.algorithmId === coseAlgorithmES256) {
const ecdsaSignature = decodePKIXECDSASignature(signatureBytes);
const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey);
const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON));
validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature);
} else if (credential.algorithmId === coseAlgorithmRS256) {
const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey);
const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON));
validSignature = verifyRSASSAPKCS1v15Signature(rsaPublicKey, sha256ObjectIdentifier, hash, signatureBytes);
} else {
return new Response("Internal error", {
status: 500
});
}
if (!validSignature) {
return new Response("Invalid signature", {
status: 400
});
}
const sessionFlags: SessionFlags = {
twoFactorVerified: true
};
const sessionToken = generateSessionToken();
const session = createSession(sessionToken, credential.userId, sessionFlags);
setSessionTokenCookie(context, sessionToken, session.expiresAt);
return new Response(null, {
status: 204
});
}

View file

@ -0,0 +1,24 @@
import { getUserRecoverCode } from "$lib/server/user";
import { redirect } from "@sveltejs/kit";
import { get2FARedirect } from "$lib/server/2fa";
import type { RequestEvent } from "./$types";
export async function load(event: RequestEvent) {
if (event.locals.user === null || event.locals.session === null) {
return redirect(302, "/login");
}
if (!event.locals.user.emailVerified) {
return redirect(302, "/verify-email");
}
if (!event.locals.user.registered2FA) {
return redirect(302, "/2fa/setup");
}
if (!event.locals.session.twoFactorVerified) {
return redirect(302, get2FARedirect(event.locals.user));
}
const recoveryCode = getUserRecoverCode(event.locals.user.id);
return {
recoveryCode
};
}

View file

@ -0,0 +1,10 @@
<script lang="ts">
import type { PageData } from "./$types";
export let data: PageData;
</script>
<h1>Recovery code</h1>
<p>Your recovery code is: {data.recoveryCode}</p>
<p>You can use this recovery code if you lose access to your second factors.</p>
<a href="/">Next</a>

View file

@ -0,0 +1,81 @@
import {
deletePasswordResetSessionTokenCookie,
invalidateUserPasswordResetSessions,
validatePasswordResetSessionRequest
} from "$lib/server/password-reset";
import { fail, redirect } from "@sveltejs/kit";
import { verifyPasswordStrength } from "$lib/server/password";
import {
createSession,
generateSessionToken,
invalidateUserSessions,
setSessionTokenCookie
} from "$lib/server/session";
import { updateUserPassword } from "$lib/server/user";
import { getPasswordReset2FARedirect } from "$lib/server/2fa";
import type { Actions, RequestEvent } from "./$types";
import type { SessionFlags } from "$lib/server/session";
export async function load(event: RequestEvent) {
const { session, user } = validatePasswordResetSessionRequest(event);
if (session === null) {
return redirect(302, "/forgot-password");
}
if (!session.emailVerified) {
return redirect(302, "/reset-password/verify-email");
}
if (user.registered2FA && !session.twoFactorVerified) {
return redirect(302, getPasswordReset2FARedirect(user));
}
return {};
}
export const actions: Actions = {
default: action
};
async function action(event: RequestEvent) {
const { session: passwordResetSession, user } = validatePasswordResetSessionRequest(event);
if (passwordResetSession === null) {
return fail(401, {
message: "Not authenticated"
});
}
if (!passwordResetSession.emailVerified) {
return fail(403, {
message: "Forbidden"
});
}
if (user.registered2FA && !passwordResetSession.twoFactorVerified) {
return fail(403, {
message: "Forbidden"
});
}
const formData = await event.request.formData();
const password = formData.get("password");
if (typeof password !== "string") {
return fail(400, {
message: "Invalid or missing fields"
});
}
const strongPassword = await verifyPasswordStrength(password);
if (!strongPassword) {
return fail(400, {
message: "Weak password"
});
}
invalidateUserPasswordResetSessions(passwordResetSession.userId);
invalidateUserSessions(passwordResetSession.userId);
await updateUserPassword(passwordResetSession.userId, password);
const sessionFlags: SessionFlags = {
twoFactorVerified: passwordResetSession.twoFactorVerified
};
const sessionToken = generateSessionToken();
const session = createSession(sessionToken, user.id, sessionFlags);
setSessionTokenCookie(event, sessionToken, session.expiresAt);
deletePasswordResetSessionTokenCookie(event);
return redirect(302, "/");
}

View file

@ -0,0 +1,15 @@
<script lang="ts">
import { enhance } from "$app/forms";
import type { ActionData } from "./$types";
export let form: ActionData;
</script>
<h1>Enter your new password</h1>
<form method="post" use:enhance>
<label for="form-reset.password">Password</label>
<input type="password" id="form-reset.password" name="password" autocomplete="new-password" required /><br />
<button>Reset password</button>
<p>{form?.message ?? ""}</p>
</form>

View file

@ -0,0 +1,16 @@
import { redirect } from "@sveltejs/kit";
import { getPasswordReset2FARedirect } from "$lib/server/2fa";
import { validatePasswordResetSessionRequest } from "$lib/server/password-reset";
import type { RequestEvent } from "./$types";
export async function GET(event: RequestEvent) {
const { session, user } = validatePasswordResetSessionRequest(event);
if (session === null) {
return redirect(302, "/login");
}
if (!user.registered2FA || session.twoFactorVerified) {
return redirect(302, "/reset-password");
}
return redirect(302, getPasswordReset2FARedirect(user));
}

View file

@ -0,0 +1,31 @@
import { redirect } from "@sveltejs/kit";
import { getPasswordReset2FARedirect } from "$lib/server/2fa";
import { getUserPasskeyCredentials } from "$lib/server/webauthn";
import { validatePasswordResetSessionRequest } from "$lib/server/password-reset";
import type { RequestEvent } from "./$types";
export async function load(event: RequestEvent) {
const { session, user } = validatePasswordResetSessionRequest(event);
if (session === null) {
return redirect(302, "/forgot-password");
}
if (!session.emailVerified) {
return redirect(302, "/reset-password/verify-email");
}
if (!user.registered2FA) {
return redirect(302, "/reset-password");
}
if (session.twoFactorVerified) {
return redirect(302, "/reset-password");
}
if (!user.registeredPasskey) {
return redirect(302, getPasswordReset2FARedirect(user));
}
const credentials = getUserPasskeyCredentials(user.id);
return {
user,
credentials
};
}

View file

@ -0,0 +1,64 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { encodeBase64 } from "@oslojs/encoding";
import { createChallenge } from "$lib/client/webauthn";
import type { PageData } from "./$types";
export let data: PageData;
let message = "";
</script>
<h1>Authenticate with passkeys</h1>
<div>
<button
on:click={async () => {
const challenge = await createChallenge();
const credential = await navigator.credentials.get({
publicKey: {
challenge,
userVerification: "discouraged",
allowCredentials: data.credentials.map((credential) => {
return {
id: credential.id,
type: "public-key"
};
})
}
});
if (!(credential instanceof PublicKeyCredential)) {
throw new Error("Failed to create public key");
}
if (!(credential.response instanceof AuthenticatorAssertionResponse)) {
throw new Error("Unexpected error");
}
const response = await fetch("/reset-password/2fa/passkey", {
method: "POST",
body: JSON.stringify({
credential_id: encodeBase64(new Uint8Array(credential.rawId)),
signature: encodeBase64(new Uint8Array(credential.response.signature)),
authenticator_data: encodeBase64(new Uint8Array(credential.response.authenticatorData)),
client_data_json: encodeBase64(new Uint8Array(credential.response.clientDataJSON))
})
});
if (response.ok) {
goto("/reset-password");
} else {
message = await response.text();
}
}}>Authenticate</button
>
<p>{message}</p>
</div>
<a href="/reset-password/2fa/recovery-code">Use recovery code</a>
{#if data.user.registeredSecurityKey}
<a href="/reset-password/2fa/security-key">Use security keys</a>
{/if}
{#if data.user.registeredTOTP}
<a href="/reset-password/2fa/totp">Use authenticator apps</a>
{/if}

View file

@ -0,0 +1,153 @@
import {
parseClientDataJSON,
coseAlgorithmES256,
ClientDataType,
coseAlgorithmRS256,
createAssertionSignatureMessage,
parseAuthenticatorData
} from "@oslojs/webauthn";
import { decodePKIXECDSASignature, decodeSEC1PublicKey, p256, verifyECDSASignature } from "@oslojs/crypto/ecdsa";
import { ObjectParser } from "@pilcrowjs/object-parser";
import { decodeBase64 } from "@oslojs/encoding";
import { verifyWebAuthnChallenge, getUserPasskeyCredential } from "$lib/server/webauthn";
import { decodePKCS1RSAPublicKey, sha256ObjectIdentifier, verifyRSASSAPKCS1v15Signature } from "@oslojs/crypto/rsa";
import { sha256 } from "@oslojs/crypto/sha2";
import { setPasswordResetSessionAs2FAVerified, validatePasswordResetSessionRequest } from "$lib/server/password-reset";
import type { AuthenticatorData, ClientData } from "@oslojs/webauthn";
import type { RequestEvent } from "./$types";
export async function POST(event: RequestEvent) {
const { session, user } = validatePasswordResetSessionRequest(event);
if (session === null || user === null) {
return new Response("Not authenticated", {
status: 401
});
}
if (!user.emailVerified) {
return new Response("Forbidden", {
status: 403
});
}
if (!user.registeredPasskey) {
return new Response("Forbidden", {
status: 403
});
}
const data: unknown = await event.request.json();
const parser = new ObjectParser(data);
let encodedAuthenticatorData: string;
let encodedClientDataJSON: string;
let encodedCredentialId: string;
let encodedSignature: string;
try {
encodedAuthenticatorData = parser.getString("authenticator_data");
encodedClientDataJSON = parser.getString("client_data_json");
encodedCredentialId = parser.getString("credential_id");
encodedSignature = parser.getString("signature");
} catch {
return new Response("Invalid or missing fields", {
status: 400
});
}
let authenticatorDataBytes: Uint8Array;
let clientDataJSON: Uint8Array;
let credentialId: Uint8Array;
let signatureBytes: Uint8Array;
try {
authenticatorDataBytes = decodeBase64(encodedAuthenticatorData);
clientDataJSON = decodeBase64(encodedClientDataJSON);
credentialId = decodeBase64(encodedCredentialId);
signatureBytes = decodeBase64(encodedSignature);
} catch {
return new Response("Invalid or missing fields", {
status: 400
});
}
let authenticatorData: AuthenticatorData;
try {
authenticatorData = parseAuthenticatorData(authenticatorDataBytes);
} catch {
return new Response("Invalid data", {
status: 400
});
}
// TODO: Update host
if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) {
return new Response("Invalid data", {
status: 400
});
}
if (!authenticatorData.userPresent) {
return new Response("Invalid data", {
status: 400
});
}
let clientData: ClientData;
try {
clientData = parseClientDataJSON(clientDataJSON);
} catch {
return new Response("Invalid data", {
status: 400
});
}
if (clientData.type !== ClientDataType.Get) {
return new Response("Invalid data", {
status: 400
});
}
if (!verifyWebAuthnChallenge(clientData.challenge)) {
return new Response("Invalid data", {
status: 400
});
}
// TODO: Update origin
if (clientData.origin !== "http://localhost:5173") {
return new Response("Invalid data", {
status: 400
});
}
if (clientData.crossOrigin !== null && clientData.crossOrigin) {
return new Response("Invalid data", {
status: 400
});
}
const credential = getUserPasskeyCredential(user.id, credentialId);
if (credential === null) {
return new Response("Invalid credential", {
status: 400
});
}
let validSignature: boolean;
if (credential.algorithmId === coseAlgorithmES256) {
const ecdsaSignature = decodePKIXECDSASignature(signatureBytes);
const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey);
const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON));
validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature);
} else if (credential.algorithmId === coseAlgorithmRS256) {
const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey);
const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON));
validSignature = verifyRSASSAPKCS1v15Signature(rsaPublicKey, sha256ObjectIdentifier, hash, signatureBytes);
} else {
return new Response("Internal error", {
status: 500
});
}
if (!validSignature) {
return new Response("Invalid signature", {
status: 400
});
}
setPasswordResetSessionAs2FAVerified(session.id);
return new Response(null, {
status: 204
});
}

View file

@ -0,0 +1,79 @@
import { validatePasswordResetSessionRequest } from "$lib/server/password-reset";
import { fail, redirect } from "@sveltejs/kit";
import { recoveryCodeBucket, resetUser2FAWithRecoveryCode } from "$lib/server/2fa";
import type { Actions, RequestEvent } from "./$types";
export async function load(event: RequestEvent) {
const { session, user } = validatePasswordResetSessionRequest(event);
if (session === null) {
return redirect(302, "/forgot-password");
}
if (!session.emailVerified) {
return redirect(302, "/reset-password/verify-email");
}
if (!user.registered2FA) {
return redirect(302, "/reset-password");
}
if (session.twoFactorVerified) {
return redirect(302, "/reset-password");
}
return {
user
};
}
export const actions: Actions = {
default: action
};
async function action(event: RequestEvent) {
const { session } = validatePasswordResetSessionRequest(event);
if (session === null) {
return fail(401, {
message: "Not authenticated"
});
}
if (!session.emailVerified) {
return fail(403, {
message: "Forbidden"
});
}
if (!recoveryCodeBucket.check(session.userId, 1)) {
return fail(429, {
message: "Too many requests"
});
}
if (session.twoFactorVerified) {
return fail(400, {
message: "Already verified"
});
}
const formData = await event.request.formData();
const code = formData.get("code");
if (typeof code !== "string") {
return fail(400, {
message: "Invalid or missing fields"
});
}
if (code === "") {
return fail(400, {
message: "Please enter your code"
});
}
if (!recoveryCodeBucket.consume(session.userId, 1)) {
return fail(429, {
message: "Too many requests"
});
}
const valid = resetUser2FAWithRecoveryCode(session.userId, code);
if (!valid) {
return fail(400, {
message: "Invalid code"
});
}
recoveryCodeBucket.reset(session.userId);
return redirect(302, "/reset-password");
}

View file

@ -0,0 +1,25 @@
<script lang="ts">
import { enhance } from "$app/forms";
import type { ActionData, PageData } from "./$types";
export let data: PageData;
export let form: ActionData;
</script>
<h1>Use your recovery code</h1>
<form method="post" use:enhance>
<label for="form-recovery-code.code">Recovery code</label>
<input id="form-recovery-code.code" name="code" required /><br />
<button>Verify</button>
<p>{form?.message ?? ""}</p>
</form>
{#if data.user.registeredSecurityKey}
<a href="/reset-password/2fa/security-key">Use security keys</a>
{/if}
{#if data.user.registeredPasskey}
<a href="/reset-password/2fa/passkey">Use passkeys</a>
{/if}
{#if data.user.registeredTOTP}
<a href="/reset-password/2fa/totp">Use authenticator apps</a>
{/if}

View file

@ -0,0 +1,31 @@
import { redirect } from "@sveltejs/kit";
import { getPasswordReset2FARedirect } from "$lib/server/2fa";
import { getUserSecurityKeyCredentials } from "$lib/server/webauthn";
import { validatePasswordResetSessionRequest } from "$lib/server/password-reset";
import type { RequestEvent } from "./$types";
export async function load(event: RequestEvent) {
const { session, user } = validatePasswordResetSessionRequest(event);
if (session === null) {
return redirect(302, "/forgot-password");
}
if (!session.emailVerified) {
return redirect(302, "/reset-password/verify-email");
}
if (!user.registered2FA) {
return redirect(302, "/reset-password");
}
if (session.twoFactorVerified) {
return redirect(302, "/reset-password");
}
if (!user.registeredSecurityKey) {
return redirect(302, getPasswordReset2FARedirect(user));
}
const credentials = getUserSecurityKeyCredentials(user.id);
return {
credentials,
user
};
}

View file

@ -0,0 +1,64 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { encodeBase64 } from "@oslojs/encoding";
import { createChallenge } from "$lib/client/webauthn";
import type { PageData } from "./$types";
export let data: PageData;
let message = "";
</script>
<h1>Authenticate with security keys</h1>
<div>
<button
on:click={async () => {
const challenge = await createChallenge();
const credential = await navigator.credentials.get({
publicKey: {
challenge,
userVerification: "discouraged",
allowCredentials: data.credentials.map((credential) => {
return {
id: credential.id,
type: "public-key"
};
})
}
});
if (!(credential instanceof PublicKeyCredential)) {
throw new Error("Failed to create public key");
}
if (!(credential.response instanceof AuthenticatorAssertionResponse)) {
throw new Error("Unexpected error");
}
const response = await fetch("/reset-password/2fa/security-key", {
method: "POST",
body: JSON.stringify({
credential_id: encodeBase64(new Uint8Array(credential.rawId)),
signature: encodeBase64(new Uint8Array(credential.response.signature)),
authenticator_data: encodeBase64(new Uint8Array(credential.response.authenticatorData)),
client_data_json: encodeBase64(new Uint8Array(credential.response.clientDataJSON))
})
});
if (response.ok) {
goto("/reset-password");
} else {
message = await response.text();
}
}}>Authenticate</button
>
<p>{message}</p>
</div>
<a href="/reset-password/2fa/recovery-code">Use recovery code</a>
{#if data.user.registeredPasskey}
<a href="/reset-password/2fa/passkey">Use passkeys</a>
{/if}
{#if data.user.registeredTOTP}
<a href="/reset-password/2fa/totp">Use authenticator apps</a>
{/if}

View file

@ -0,0 +1,153 @@
import {
parseClientDataJSON,
coseAlgorithmES256,
ClientDataType,
coseAlgorithmRS256,
createAssertionSignatureMessage,
parseAuthenticatorData
} from "@oslojs/webauthn";
import { decodePKIXECDSASignature, decodeSEC1PublicKey, p256, verifyECDSASignature } from "@oslojs/crypto/ecdsa";
import { ObjectParser } from "@pilcrowjs/object-parser";
import { decodeBase64 } from "@oslojs/encoding";
import { verifyWebAuthnChallenge, getUserSecurityKeyCredential } from "$lib/server/webauthn";
import { decodePKCS1RSAPublicKey, sha256ObjectIdentifier, verifyRSASSAPKCS1v15Signature } from "@oslojs/crypto/rsa";
import { sha256 } from "@oslojs/crypto/sha2";
import { setPasswordResetSessionAs2FAVerified, validatePasswordResetSessionRequest } from "$lib/server/password-reset";
import type { AuthenticatorData, ClientData } from "@oslojs/webauthn";
import type { RequestEvent } from "./$types";
export async function POST(event: RequestEvent) {
const { session, user } = validatePasswordResetSessionRequest(event);
if (session === null || user === null) {
return new Response("Not authenticated", {
status: 401
});
}
if (!user.emailVerified) {
return new Response("Forbidden", {
status: 403
});
}
if (!user.registeredSecurityKey) {
return new Response("Forbidden", {
status: 403
});
}
const data: unknown = await event.request.json();
const parser = new ObjectParser(data);
let encodedAuthenticatorData: string;
let encodedClientDataJSON: string;
let encodedCredentialId: string;
let encodedSignature: string;
try {
encodedAuthenticatorData = parser.getString("authenticator_data");
encodedClientDataJSON = parser.getString("client_data_json");
encodedCredentialId = parser.getString("credential_id");
encodedSignature = parser.getString("signature");
} catch {
return new Response("Invalid or missing fields", {
status: 400
});
}
let authenticatorDataBytes: Uint8Array;
let clientDataJSON: Uint8Array;
let credentialId: Uint8Array;
let signatureBytes: Uint8Array;
try {
authenticatorDataBytes = decodeBase64(encodedAuthenticatorData);
clientDataJSON = decodeBase64(encodedClientDataJSON);
credentialId = decodeBase64(encodedCredentialId);
signatureBytes = decodeBase64(encodedSignature);
} catch {
return new Response("Invalid or missing fields", {
status: 400
});
}
let authenticatorData: AuthenticatorData;
try {
authenticatorData = parseAuthenticatorData(authenticatorDataBytes);
} catch {
return new Response("Invalid data", {
status: 400
});
}
// TODO: Update host
if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) {
return new Response("Invalid data", {
status: 400
});
}
if (!authenticatorData.userPresent) {
return new Response("Invalid data", {
status: 400
});
}
let clientData: ClientData;
try {
clientData = parseClientDataJSON(clientDataJSON);
} catch {
return new Response("Invalid data", {
status: 400
});
}
if (clientData.type !== ClientDataType.Get) {
return new Response("Invalid data", {
status: 400
});
}
if (!verifyWebAuthnChallenge(clientData.challenge)) {
return new Response("Invalid data", {
status: 400
});
}
// TODO: Update origin
if (clientData.origin !== "http://localhost:5173") {
return new Response("Invalid data", {
status: 400
});
}
if (clientData.crossOrigin !== null && clientData.crossOrigin) {
return new Response("Invalid data", {
status: 400
});
}
const credential = getUserSecurityKeyCredential(user.id, credentialId);
if (credential === null) {
return new Response("Invalid credential", {
status: 400
});
}
let validSignature: boolean;
if (credential.algorithmId === coseAlgorithmES256) {
const ecdsaSignature = decodePKIXECDSASignature(signatureBytes);
const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey);
const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON));
validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature);
} else if (credential.algorithmId === coseAlgorithmRS256) {
const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey);
const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON));
validSignature = verifyRSASSAPKCS1v15Signature(rsaPublicKey, sha256ObjectIdentifier, hash, signatureBytes);
} else {
return new Response("Internal error", {
status: 500
});
}
if (!validSignature) {
return new Response("Invalid signature", {
status: 400
});
}
setPasswordResetSessionAs2FAVerified(session.id);
return new Response(null, {
status: 204
});
}

View file

@ -0,0 +1,85 @@
import { verifyTOTP } from "@oslojs/otp";
import { validatePasswordResetSessionRequest, setPasswordResetSessionAs2FAVerified } from "$lib/server/password-reset";
import { totpBucket, getUserTOTPKey } from "$lib/server/totp";
import { fail, redirect } from "@sveltejs/kit";
import { getPasswordReset2FARedirect } from "$lib/server/2fa";
import type { Actions, RequestEvent } from "./$types";
export async function load(event: RequestEvent) {
const { session, user } = validatePasswordResetSessionRequest(event);
if (session === null) {
return redirect(302, "/forgot-password");
}
if (!session.emailVerified) {
return redirect(302, "/reset-password/verify-email");
}
if (!user.registered2FA) {
return redirect(302, "/reset-password");
}
if (session.twoFactorVerified) {
return redirect(302, "/reset-password");
}
if (!user.registeredTOTP) {
return redirect(302, getPasswordReset2FARedirect(user));
}
return {
user
};
}
export const actions: Actions = {
default: action
};
async function action(event: RequestEvent) {
const { session, user } = validatePasswordResetSessionRequest(event);
if (session === null) {
return fail(401, {
message: "Not authenticated"
});
}
if (!totpBucket.check(session.userId, 1)) {
return fail(429, {
message: "Too many requests"
});
}
if (!user.registered2FA || session.twoFactorVerified || !session.emailVerified) {
return fail(403, {
message: "Forbidden"
});
}
const formData = await event.request.formData();
const code = formData.get("code");
if (typeof code !== "string") {
return fail(400, {
message: "Invalid or missing fields"
});
}
if (code === "") {
return fail(400, {
message: "Please enter your code"
});
}
const totpKey = getUserTOTPKey(session.userId);
if (totpKey === null) {
return fail(403, {
message: "Forbidden"
});
}
if (!totpBucket.consume(session.userId, 1)) {
return fail(429, {
message: "Too many requests"
});
}
if (!verifyTOTP(totpKey, 30, 6, code)) {
return fail(400, {
message: "Invalid code"
});
}
totpBucket.reset(session.userId);
setPasswordResetSessionAs2FAVerified(session.id);
return redirect(302, "/reset-password");
}

View file

@ -0,0 +1,24 @@
<script lang="ts">
import { enhance } from "$app/forms";
import type { ActionData, PageData } from "./$types";
export let data: PageData;
export let form: ActionData;
</script>
<h1>Two-factor authentication</h1>
<p>Enter the code in your authenticator app.</p>
<form method="post" use:enhance>
<label for="form-totp.code">Code</label>
<input id="form-totp.code" name="code" required /><br />
<button>Verify</button>
<p>{form?.message ?? ""}</p>
</form>
<a href="/reset-password/2fa/recovery-code">Use recovery code</a>
{#if data.user.registeredSecurityKey}
<a href="/reset-password/2fa/security-key">Use security keys</a>
{/if}
{#if data.user.registeredPasskey}
<a href="/reset-password/2fa/passkey">Use passkeys</a>
{/if}

View file

@ -0,0 +1,84 @@
import {
validatePasswordResetSessionRequest,
setPasswordResetSessionAsEmailVerified
} from "$lib/server/password-reset";
import { ExpiringTokenBucket } from "$lib/server/rate-limit";
import { setUserAsEmailVerifiedIfEmailMatches } from "$lib/server/user";
import { fail, redirect } from "@sveltejs/kit";
import type { Actions, RequestEvent } from "./$types";
import { getPasswordReset2FARedirect } from "$lib/server/2fa";
const bucket = new ExpiringTokenBucket<number>(5, 60 * 30);
export async function load(event: RequestEvent) {
const { session, user } = validatePasswordResetSessionRequest(event);
if (session === null) {
return redirect(302, "/forgot-password");
}
if (session.emailVerified) {
if (!session.twoFactorVerified) {
return redirect(302, getPasswordReset2FARedirect(user));
}
return redirect(302, "/reset-password");
}
return {
email: session.email
};
}
export const actions: Actions = {
default: action
};
async function action(event: RequestEvent) {
const { session, user } = validatePasswordResetSessionRequest(event);
if (session === null) {
return fail(401, {
message: "Not authenticated"
});
}
if (!bucket.check(session.userId, 1)) {
return fail(429, {
message: "Too many requests"
});
}
if (session.emailVerified) {
return fail(400, {
message: "Already verified"
});
}
const formData = await event.request.formData();
const code = formData.get("code");
if (typeof code !== "string") {
return fail(400, {
message: "Invalid or missing fields"
});
}
if (code === "") {
return fail(400, {
message: "Please enter your code"
});
}
if (!bucket.consume(session.userId, 1)) {
return fail(429, { message: "Too many requests" });
}
if (code !== session.code) {
return fail(400, {
message: "Incorrect code"
});
}
bucket.reset(session.userId);
setPasswordResetSessionAsEmailVerified(session.id);
const emailMatches = setUserAsEmailVerifiedIfEmailMatches(session.userId, session.email);
if (!emailMatches) {
return fail(400, {
message: "Please restart the process"
});
}
if (!user.registered2FA) {
return redirect(302, "/reset-password");
}
return redirect(302, getPasswordReset2FARedirect(user));
}

View file

@ -0,0 +1,17 @@
<script lang="ts">
import { enhance } from "$app/forms";
import type { ActionData, PageData } from "./$types";
export let data: PageData;
export let form: ActionData;
</script>
<h1>Verify your email address</h1>
<p>We sent an 8-digit code to {data.email}.</p>
<form method="post" use:enhance>
<label for="form-verify.code">Code</label>
<input id="form-verify.code" name="code" required />
<button>verify</button>
<p>{form?.message ?? ""}</p>
</form>

View file

@ -0,0 +1,277 @@
import {
createEmailVerificationRequest,
sendVerificationEmail,
sendVerificationEmailBucket,
setEmailVerificationRequestCookie
} from "$lib/server/email-verification";
import { fail, redirect } from "@sveltejs/kit";
import { checkEmailAvailability, verifyEmailInput } from "$lib/server/email";
import { verifyPasswordHash, verifyPasswordStrength } from "$lib/server/password";
import { getUserPasswordHash, getUserRecoverCode, resetUserRecoveryCode, updateUserPassword } from "$lib/server/user";
import {
createSession,
generateSessionToken,
invalidateUserSessions,
setSessionTokenCookie
} from "$lib/server/session";
import {
deleteUserPasskeyCredential,
deleteUserSecurityKeyCredential,
getUserPasskeyCredentials,
getUserSecurityKeyCredentials
} from "$lib/server/webauthn";
import { decodeBase64 } from "@oslojs/encoding";
import { get2FARedirect } from "$lib/server/2fa";
import { deleteUserTOTPKey, totpUpdateBucket } from "$lib/server/totp";
import type { Actions, RequestEvent } from "./$types";
import type { SessionFlags } from "$lib/server/session";
export async function load(event: RequestEvent) {
if (event.locals.user === null || event.locals.session === null) {
return redirect(302, "/login");
}
if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) {
return redirect(302, get2FARedirect(event.locals.user));
}
let recoveryCode: string | null = null;
if (event.locals.user.registered2FA) {
recoveryCode = getUserRecoverCode(event.locals.user.id);
}
const passkeyCredentials = getUserPasskeyCredentials(event.locals.user.id);
const securityKeyCredentials = getUserSecurityKeyCredentials(event.locals.user.id);
return {
recoveryCode,
user: event.locals.user,
passkeyCredentials,
securityKeyCredentials
};
}
export const actions: Actions = {
update_password: updatePasswordAction,
update_email: updateEmailAction,
disconnect_totp: disconnectTOTPAction,
delete_passkey: deletePasskeyAction,
delete_security_key: deleteSecurityKeyAction,
regenerate_recovery_code: regenerateRecoveryCodeAction
};
async function updatePasswordAction(event: RequestEvent) {
if (event.locals.user === null || event.locals.session === null) {
return fail(401, {
password: {
message: "Not authenticated"
}
});
}
if (!event.locals.user.emailVerified) {
return fail(403, {
password: {
message: "Forbidden"
}
});
}
if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) {
return fail(403, {
password: {
message: "Forbidden"
}
});
}
const formData = await event.request.formData();
const password = formData.get("password");
const newPassword = formData.get("new_password");
if (typeof password !== "string" || typeof newPassword !== "string") {
return fail(400, {
password: {
message: "Invalid or missing fields"
}
});
}
const strongPassword = await verifyPasswordStrength(newPassword);
if (!strongPassword) {
return fail(400, {
password: {
message: "Weak password"
}
});
}
const passwordHash = getUserPasswordHash(event.locals.user.id);
const validPassword = await verifyPasswordHash(passwordHash, password);
if (!validPassword) {
return fail(400, {
password: {
message: "Incorrect password"
}
});
}
invalidateUserSessions(event.locals.user.id);
await updateUserPassword(event.locals.user.id, newPassword);
const sessionToken = generateSessionToken();
const sessionFlags: SessionFlags = {
twoFactorVerified: event.locals.session.twoFactorVerified
};
const session = createSession(sessionToken, event.locals.user.id, sessionFlags);
setSessionTokenCookie(event, sessionToken, session.expiresAt);
return {
password: {
message: "Updated password"
}
};
}
async function updateEmailAction(event: RequestEvent) {
if (event.locals.session === null || event.locals.user === null) {
return fail(401, {
email: {
message: "Not authenticated"
}
});
}
if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) {
return fail(403, {
email: {
message: "Forbidden"
}
});
}
if (!sendVerificationEmailBucket.check(event.locals.user.id, 1)) {
return fail(429, {
email: {
message: "Too many requests"
}
});
}
const formData = await event.request.formData();
const email = formData.get("email");
if (typeof email !== "string") {
return fail(400, {
email: {
message: "Invalid or missing fields"
}
});
}
if (email === "") {
return fail(400, {
email: {
message: "Please enter your email"
}
});
}
if (!verifyEmailInput(email)) {
return fail(400, {
email: {
message: "Please enter a valid email"
}
});
}
const emailAvailable = checkEmailAvailability(email);
if (!emailAvailable) {
return fail(400, {
email: {
message: "This email is already used"
}
});
}
if (!sendVerificationEmailBucket.consume(event.locals.user.id, 1)) {
return fail(429, {
email: {
message: "Too many requests"
}
});
}
const verificationRequest = createEmailVerificationRequest(event.locals.user.id, email);
sendVerificationEmail(verificationRequest.email, verificationRequest.code);
setEmailVerificationRequestCookie(event, verificationRequest);
return redirect(302, "/verify-email");
}
async function disconnectTOTPAction(event: RequestEvent) {
if (event.locals.session === null || event.locals.user === null) {
return fail(401);
}
if (!event.locals.user.emailVerified) {
return fail(403);
}
if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) {
return fail(403);
}
if (!totpUpdateBucket.consume(event.locals.user.id, 1)) {
return fail(429);
}
deleteUserTOTPKey(event.locals.user.id);
return {};
}
async function deletePasskeyAction(event: RequestEvent) {
if (event.locals.user === null || event.locals.session === null) {
return fail(401);
}
if (!event.locals.user.emailVerified) {
return fail(403);
}
if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) {
return fail(403);
}
const formData = await event.request.formData();
const encodedCredentialId = formData.get("credential_id");
if (typeof encodedCredentialId !== "string") {
return fail(400);
}
let credentialId: Uint8Array;
try {
credentialId = decodeBase64(encodedCredentialId);
} catch {
return fail(400);
}
const deleted = deleteUserPasskeyCredential(event.locals.user.id, credentialId);
if (!deleted) {
return fail(400);
}
return {};
}
async function deleteSecurityKeyAction(event: RequestEvent) {
if (event.locals.user === null || event.locals.session === null) {
return fail(401);
}
if (!event.locals.user.emailVerified) {
return fail(403);
}
if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) {
return fail(403);
}
const formData = await event.request.formData();
const encodedCredentialId = formData.get("credential_id");
if (typeof encodedCredentialId !== "string") {
return fail(400);
}
let credentialId: Uint8Array;
try {
credentialId = decodeBase64(encodedCredentialId);
} catch {
return fail(400);
}
const deleted = deleteUserSecurityKeyCredential(event.locals.user.id, credentialId);
if (!deleted) {
return fail(400);
}
return {};
}
async function regenerateRecoveryCodeAction(event: RequestEvent) {
if (event.locals.session === null || event.locals.user === null) {
return fail(401);
}
if (!event.locals.user.emailVerified) {
return fail(403);
}
if (!event.locals.session.twoFactorVerified) {
return fail(403);
}
resetUserRecoveryCode(event.locals.session.userId);
return {};
}

View file

@ -0,0 +1,96 @@
<script lang="ts">
import { enhance } from "$app/forms";
import { encodeBase64 } from "@oslojs/encoding";
import type { PageData, ActionData } from "./$types";
export let data: PageData;
export let form: ActionData;
</script>
<header>
<a href="/">Home</a>
<a href="/settings">Settings</a>
</header>
<main>
<h1>Settings</h1>
<section>
<h2>Update email</h2>
<p>Your email: {data.user.email}</p>
<form method="post" use:enhance action="?/update_email">
<label for="form-email.email">New email</label>
<input type="email" id="form-email.email" name="email" required /><br />
<button>Update</button>
<p>{form?.email?.message ?? ""}</p>
</form>
</section>
<section>
<h2>Update password</h2>
<form method="post" use:enhance action="?/update_password">
<label for="form-password.password">Current password</label>
<input type="password" id="form-email.password" name="password" autocomplete="current-password" required /><br />
<label for="form-password.new-password">New password</label>
<input
type="password"
id="form-password.new-password"
name="new_password"
autocomplete="new-password"
required
/><br />
<button>Update</button>
<p>{form?.password?.message ?? ""}</p>
</form>
</section>
<section>
<h2>Authenticator app</h2>
{#if data.user.registeredTOTP}
<a href="/2fa/totp/setup">Update TOTP</a>
<form method="post" use:enhance action="?/disconnect_totp">
<button>Disconnect</button>
</form>
{:else}
<a href="/2fa/totp/setup">Set up TOTP</a>
{/if}
</section>
<section>
<h2>Passkeys</h2>
<p>Passkeys are WebAuthn credentials that validate your identity using your device.</p>
<ul>
{#each data.passkeyCredentials as credential}
<li>
<p>{credential.name}</p>
<form method="post" use:enhance action="?/delete_passkey">
<input type="hidden" name="credential_id" value={encodeBase64(credential.id)} />
<button> Delete </button>
</form>
</li>
{/each}
</ul>
<a href="/2fa/passkey/register">Add</a>
</section>
<section>
<h2>Security keys</h2>
<p>Security keys are WebAuthn credentials that can only be used for two-factor authentication.</p>
<ul>
{#each data.securityKeyCredentials as credential}
<li>
<p>{credential.name}</p>
<form method="post" use:enhance action="?/delete_security_key">
<input type="hidden" name="credential_id" value={encodeBase64(credential.id)} />
<button>Delete</button>
</form>
</li>
{/each}
</ul>
<a href="/2fa/security-key/register">Add</a>
</section>
{#if data.recoveryCode !== null}
<section>
<h1>Recovery code</h1>
<p>Your recovery code is: {data.recoveryCode}}</p>
<form method="post" use:enhance action="?/regenerate_recovery_code">
<button>Generate new code</button>
</form>
</section>
{/if}
</main>

View file

@ -0,0 +1,117 @@
import { fail, redirect } from "@sveltejs/kit";
import { checkEmailAvailability, verifyEmailInput } from "$lib/server/email";
import { createUser, verifyUsernameInput } from "$lib/server/user";
import { RefillingTokenBucket } from "$lib/server/rate-limit";
import { verifyPasswordStrength } from "$lib/server/password";
import { createSession, generateSessionToken, setSessionTokenCookie } from "$lib/server/session";
import {
createEmailVerificationRequest,
sendVerificationEmail,
setEmailVerificationRequestCookie
} from "$lib/server/email-verification";
import { get2FARedirect } from "$lib/server/2fa";
import type { SessionFlags } from "$lib/server/session";
import type { Actions, PageServerLoadEvent, RequestEvent } from "./$types";
const ipBucket = new RefillingTokenBucket<string>(3, 10);
export function load(event: PageServerLoadEvent) {
if (event.locals.session !== null && event.locals.user !== null) {
if (!event.locals.user.emailVerified) {
return redirect(302, "/verify-email");
}
if (!event.locals.user.registered2FA) {
return redirect(302, "/2fa/setup");
}
if (!event.locals.session.twoFactorVerified) {
return redirect(302, get2FARedirect(event.locals.user));
}
return redirect(302, "/");
}
return {};
}
export const actions: Actions = {
default: action
};
async function action(event: RequestEvent) {
// TODO: Assumes X-Forwarded-For is always included.
const clientIP = event.request.headers.get("X-Forwarded-For");
if (clientIP !== null && !ipBucket.check(clientIP, 1)) {
return fail(429, {
message: "Too many requests",
email: "",
username: ""
});
}
const formData = await event.request.formData();
const email = formData.get("email");
const username = formData.get("username");
const password = formData.get("password");
if (typeof email !== "string" || typeof username !== "string" || typeof password !== "string") {
return fail(400, {
message: "Invalid or missing fields",
email: "",
username: ""
});
}
if (email === "" || password === "" || username === "") {
return fail(400, {
message: "Please enter your username, email, and password",
email: "",
username: ""
});
}
if (!verifyEmailInput(email)) {
return fail(400, {
message: "Invalid email",
email,
username
});
}
const emailAvailable = checkEmailAvailability(email);
if (!emailAvailable) {
return fail(400, {
message: "Email is already used",
email,
username
});
}
if (!verifyUsernameInput(username)) {
return fail(400, {
message: "Invalid username",
email,
username
});
}
const strongPassword = await verifyPasswordStrength(password);
if (!strongPassword) {
return fail(400, {
message: "Weak password",
email,
username
});
}
if (clientIP !== null && !ipBucket.consume(clientIP, 1)) {
return fail(429, {
message: "Too many requests",
email,
username
});
}
const user = await createUser(email, username, password);
const emailVerificationRequest = createEmailVerificationRequest(user.id, user.email);
sendVerificationEmail(emailVerificationRequest.email, emailVerificationRequest.code);
setEmailVerificationRequestCookie(event, emailVerificationRequest);
const sessionFlags: SessionFlags = {
twoFactorVerified: false
};
const sessionToken = generateSessionToken();
const session = createSession(sessionToken, user.id, sessionFlags);
setSessionTokenCookie(event, sessionToken, session.expiresAt);
throw redirect(302, "/2fa/setup");
}

View file

@ -0,0 +1,35 @@
<script lang="ts">
import { enhance } from "$app/forms";
import type { ActionData } from "./$types";
export let form: ActionData;
</script>
<h1>Create an account</h1>
<p>Your username must be at least 3 characters long and your password must be at least 8 characters long.</p>
<form method="post" use:enhance>
<label for="form-signup.username">Username</label>
<input
id="form-signup.username"
name="username"
required
value={form?.username ?? ""}
minlength="4"
maxlength="31"
/><br />
<label for="form-signup.email">Email</label>
<input
type="email"
id="form-signup.email"
name="email"
autocomplete="username"
required
value={form?.email ?? ""}
/><br />
<label for="form-signup.password">Password</label>
<input type="password" id="form-signup.password" name="password" autocomplete="new-password" required /><br />
<button>Continue</button>
<p>{form?.message ?? ""}</p>
</form>
<a href="/login">Sign in</a>

View file

@ -0,0 +1,153 @@
import { fail, redirect } from "@sveltejs/kit";
import {
createEmailVerificationRequest,
deleteEmailVerificationRequestCookie,
deleteUserEmailVerificationRequest,
getUserEmailVerificationRequestFromRequest,
sendVerificationEmail,
sendVerificationEmailBucket,
setEmailVerificationRequestCookie
} from "$lib/server/email-verification";
import { invalidateUserPasswordResetSessions } from "$lib/server/password-reset";
import { updateUserEmailAndSetEmailAsVerified } from "$lib/server/user";
import { ExpiringTokenBucket } from "$lib/server/rate-limit";
import type { Actions, RequestEvent } from "./$types";
export async function load(event: RequestEvent) {
if (event.locals.user === null) {
return redirect(302, "/redirect");
}
let verificationRequest = getUserEmailVerificationRequestFromRequest(event);
if (verificationRequest === null || Date.now() >= verificationRequest.expiresAt.getTime()) {
if (event.locals.user.emailVerified) {
return redirect(302, "/");
}
// Note: We don't need rate limiting since it takes time before requests expire
verificationRequest = createEmailVerificationRequest(event.locals.user.id, event.locals.user.email);
sendVerificationEmail(verificationRequest.email, verificationRequest.code);
setEmailVerificationRequestCookie(event, verificationRequest);
}
return {
email: verificationRequest.email
};
}
const bucket = new ExpiringTokenBucket<number>(5, 60 * 30);
export const actions: Actions = {
verify: verifyCode,
resend: resendEmail
};
async function verifyCode(event: RequestEvent) {
if (event.locals.session === null || event.locals.user === null) {
return fail(401);
}
if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) {
return fail(401);
}
if (!bucket.check(event.locals.user.id, 1)) {
return fail(429, {
verify: {
message: "Too many requests"
}
});
}
let verificationRequest = getUserEmailVerificationRequestFromRequest(event);
if (verificationRequest === null) {
return fail(401);
}
const formData = await event.request.formData();
const code = formData.get("code");
if (typeof code !== "string") {
return fail(400, {
verify: {
message: "Invalid or missing fields"
}
});
}
if (code === "") {
return fail(400, {
verify: {
message: "Enter your code"
}
});
}
if (!bucket.consume(event.locals.user.id, 1)) {
return fail(400, {
verify: {
message: "Too many requests"
}
});
}
if (Date.now() >= verificationRequest.expiresAt.getTime()) {
verificationRequest = createEmailVerificationRequest(verificationRequest.userId, verificationRequest.email);
sendVerificationEmail(verificationRequest.email, verificationRequest.code);
return {
verify: {
message: "The verification code was expired. We sent another code to your inbox."
}
};
}
if (verificationRequest.code !== code) {
return fail(400, {
verify: {
message: "Incorrect code."
}
});
}
deleteUserEmailVerificationRequest(event.locals.user.id);
invalidateUserPasswordResetSessions(event.locals.user.id);
updateUserEmailAndSetEmailAsVerified(event.locals.user.id, verificationRequest.email);
deleteEmailVerificationRequestCookie(event);
if (!event.locals.user.registered2FA) {
return redirect(302, "/2fa/setup");
}
return redirect(302, "/");
}
async function resendEmail(event: RequestEvent) {
if (event.locals.session === null || event.locals.user === null) {
return fail(401, {
resend: {
message: "Not authenticated"
}
});
}
if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) {
return fail(401, {
resend: {
message: "Forbidden"
}
});
}
if (!sendVerificationEmailBucket.check(event.locals.user.id, 1)) {
return fail(429, {
resend: {
message: "Too many requests"
}
});
}
let verificationRequest = getUserEmailVerificationRequestFromRequest(event);
if (verificationRequest === null) {
return fail(401);
}
if (!sendVerificationEmailBucket.consume(event.locals.user.id, 1)) {
return fail(429, {
resend: {
message: "Too many requests"
}
});
}
verificationRequest = createEmailVerificationRequest(verificationRequest.userId, verificationRequest.email);
sendVerificationEmail(verificationRequest.email, verificationRequest.code);
setEmailVerificationRequestCookie(event, verificationRequest);
return {
resend: {
message: "A new code was sent to your inbox."
}
};
}

View file

@ -0,0 +1,22 @@
<script lang="ts">
import { enhance } from "$app/forms";
import type { ActionData, PageData } from "./$types";
export let data: PageData;
export let form: ActionData;
</script>
<h1>Verify your email address</h1>
<p>We sent an 8-digit code to {data.email}.</p>
<form method="post" use:enhance action="?/verify">
<label for="form-verify.code">Code</label>
<input id="form-verify.code" name="code" required />
<button>Verify</button>
<p>{form?.verify?.message ?? ""}</p>
</form>
<form method="post" use:enhance action="?/resend">
<button>Resend code</button>
<p>{form?.resend?.message ?? ""}</p>
</form>
<a href="/settings">Change your email</a>

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

18
svelte.config.js Normal file
View file

@ -0,0 +1,18 @@
import adapter from "@sveltejs/adapter-auto";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

19
tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
// except $lib which is handled by https://kit.svelte.dev/docs/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

6
vite.config.ts Normal file
View file

@ -0,0 +1,6 @@
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [sveltekit()]
});