mirror of
https://github.com/BradNut/example-sveltekit-email-password-webauthn
synced 2025-09-08 17:40:27 +00:00
init
This commit is contained in:
commit
25cc397095
81 changed files with 6489 additions and 0 deletions
1
.env.example
Normal file
1
.env.example
Normal file
|
|
@ -0,0 +1 @@
|
|||
ENCRYPTION_KEY=""
|
||||
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal 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
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
engine-strict=true
|
||||
4
.prettierignore
Normal file
4
.prettierignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
7
.prettierrc
Normal file
7
.prettierrc
Normal 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
50
README.md
Normal 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
39
package.json
Normal 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
1543
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
56
setup.sql
Normal file
56
setup.sql
Normal 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
16
src/app.d.ts
vendored
Normal 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
12
src/app.html
Normal 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
49
src/hooks.server.ts
Normal 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);
|
||||
14
src/lib/client/webauthn.ts
Normal file
14
src/lib/client/webauthn.ts
Normal 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
75
src/lib/server/2fa.ts
Normal 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
35
src/lib/server/db.ts
Normal 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);
|
||||
100
src/lib/server/email-verification.ts
Normal file
100
src/lib/server/email-verification.ts
Normal 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
13
src/lib/server/email.ts
Normal 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;
|
||||
}
|
||||
39
src/lib/server/encryption.ts
Normal file
39
src/lib/server/encryption.ts
Normal 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));
|
||||
}
|
||||
134
src/lib/server/password-reset.ts
Normal file
134
src/lib/server/password-reset.ts
Normal 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 };
|
||||
34
src/lib/server/password.ts
Normal file
34
src/lib/server/password.ts
Normal 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;
|
||||
}
|
||||
150
src/lib/server/rate-limit.ts
Normal file
150
src/lib/server/rate-limit.ts
Normal 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
124
src/lib/server/session.ts
Normal 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
37
src/lib/server/totp.ts
Normal 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
108
src/lib/server/user.ts
Normal 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
15
src/lib/server/utils.ts
Normal 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
145
src/lib/server/webauthn.ts
Normal 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;
|
||||
}
|
||||
5
src/routes/+layout.svelte
Normal file
5
src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svelte:head>
|
||||
<title>Email and password example with 2FA and WebAuthn in SvelteKit</title>
|
||||
</svelte:head>
|
||||
|
||||
<slot />
|
||||
38
src/routes/+page.server.ts
Normal file
38
src/routes/+page.server.ts
Normal 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
18
src/routes/+page.svelte
Normal 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
17
src/routes/2fa/+server.ts
Normal 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));
|
||||
}
|
||||
28
src/routes/2fa/passkey/+page.server.ts
Normal file
28
src/routes/2fa/passkey/+page.server.ts
Normal 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
|
||||
};
|
||||
}
|
||||
65
src/routes/2fa/passkey/+page.svelte
Normal file
65
src/routes/2fa/passkey/+page.svelte
Normal 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}
|
||||
152
src/routes/2fa/passkey/+server.ts
Normal file
152
src/routes/2fa/passkey/+server.ts
Normal 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
|
||||
});
|
||||
}
|
||||
237
src/routes/2fa/passkey/register/+page.server.ts
Normal file
237
src/routes/2fa/passkey/register/+page.server.ts
Normal 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, "/");
|
||||
}
|
||||
75
src/routes/2fa/passkey/register/+page.svelte
Normal file
75
src/routes/2fa/passkey/register/+page.svelte
Normal 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>
|
||||
73
src/routes/2fa/reset/+page.server.ts
Normal file
73
src/routes/2fa/reset/+page.server.ts
Normal 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");
|
||||
}
|
||||
15
src/routes/2fa/reset/+page.svelte
Normal file
15
src/routes/2fa/reset/+page.svelte
Normal 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>
|
||||
28
src/routes/2fa/security-key/+page.server.ts
Normal file
28
src/routes/2fa/security-key/+page.server.ts
Normal 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
|
||||
};
|
||||
}
|
||||
65
src/routes/2fa/security-key/+page.svelte
Normal file
65
src/routes/2fa/security-key/+page.svelte
Normal 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}
|
||||
152
src/routes/2fa/security-key/+server.ts
Normal file
152
src/routes/2fa/security-key/+server.ts
Normal 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
|
||||
});
|
||||
}
|
||||
241
src/routes/2fa/security-key/register/+page.server.ts
Normal file
241
src/routes/2fa/security-key/register/+page.server.ts
Normal 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, "/");
|
||||
}
|
||||
76
src/routes/2fa/security-key/register/+page.svelte
Normal file
76
src/routes/2fa/security-key/register/+page.svelte
Normal 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>
|
||||
16
src/routes/2fa/setup/+page.server.ts
Normal file
16
src/routes/2fa/setup/+page.server.ts
Normal 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 {};
|
||||
}
|
||||
6
src/routes/2fa/setup/+page.svelte
Normal file
6
src/routes/2fa/setup/+page.svelte
Normal 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>
|
||||
83
src/routes/2fa/totp/+page.server.ts
Normal file
83
src/routes/2fa/totp/+page.server.ts
Normal 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, "/");
|
||||
}
|
||||
24
src/routes/2fa/totp/+page.svelte
Normal file
24
src/routes/2fa/totp/+page.svelte
Normal 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}
|
||||
106
src/routes/2fa/totp/setup/+page.server.ts
Normal file
106
src/routes/2fa/totp/setup/+page.server.ts
Normal 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, "/");
|
||||
}
|
||||
20
src/routes/2fa/totp/setup/+page.svelte
Normal file
20
src/routes/2fa/totp/setup/+page.svelte
Normal 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>
|
||||
19
src/routes/api/webauthn/challenge/+server.ts
Normal file
19
src/routes/api/webauthn/challenge/+server.ts
Normal 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) }));
|
||||
}
|
||||
71
src/routes/forgot-password/+page.server.ts
Normal file
71
src/routes/forgot-password/+page.server.ts
Normal 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");
|
||||
}
|
||||
16
src/routes/forgot-password/+page.svelte
Normal file
16
src/routes/forgot-password/+page.svelte
Normal 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>
|
||||
108
src/routes/login/+page.server.ts
Normal file
108
src/routes/login/+page.server.ts
Normal 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));
|
||||
}
|
||||
70
src/routes/login/+page.svelte
Normal file
70
src/routes/login/+page.svelte
Normal 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>
|
||||
142
src/routes/login/passkey/+server.ts
Normal file
142
src/routes/login/passkey/+server.ts
Normal 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
|
||||
});
|
||||
}
|
||||
24
src/routes/recovery-code/+page.server.ts
Normal file
24
src/routes/recovery-code/+page.server.ts
Normal 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
|
||||
};
|
||||
}
|
||||
10
src/routes/recovery-code/+page.svelte
Normal file
10
src/routes/recovery-code/+page.svelte
Normal 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>
|
||||
81
src/routes/reset-password/+page.server.ts
Normal file
81
src/routes/reset-password/+page.server.ts
Normal 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, "/");
|
||||
}
|
||||
15
src/routes/reset-password/+page.svelte
Normal file
15
src/routes/reset-password/+page.svelte
Normal 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>
|
||||
16
src/routes/reset-password/2fa/+server.ts
Normal file
16
src/routes/reset-password/2fa/+server.ts
Normal 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));
|
||||
}
|
||||
31
src/routes/reset-password/2fa/passkey/+page.server.ts
Normal file
31
src/routes/reset-password/2fa/passkey/+page.server.ts
Normal 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
|
||||
};
|
||||
}
|
||||
64
src/routes/reset-password/2fa/passkey/+page.svelte
Normal file
64
src/routes/reset-password/2fa/passkey/+page.svelte
Normal 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}
|
||||
153
src/routes/reset-password/2fa/passkey/+server.ts
Normal file
153
src/routes/reset-password/2fa/passkey/+server.ts
Normal 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
|
||||
});
|
||||
}
|
||||
79
src/routes/reset-password/2fa/recovery-code/+page.server.ts
Normal file
79
src/routes/reset-password/2fa/recovery-code/+page.server.ts
Normal 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");
|
||||
}
|
||||
25
src/routes/reset-password/2fa/recovery-code/+page.svelte
Normal file
25
src/routes/reset-password/2fa/recovery-code/+page.svelte
Normal 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}
|
||||
31
src/routes/reset-password/2fa/security-key/+page.server.ts
Normal file
31
src/routes/reset-password/2fa/security-key/+page.server.ts
Normal 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
|
||||
};
|
||||
}
|
||||
64
src/routes/reset-password/2fa/security-key/+page.svelte
Normal file
64
src/routes/reset-password/2fa/security-key/+page.svelte
Normal 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}
|
||||
153
src/routes/reset-password/2fa/security-key/+server.ts
Normal file
153
src/routes/reset-password/2fa/security-key/+server.ts
Normal 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
|
||||
});
|
||||
}
|
||||
85
src/routes/reset-password/2fa/totp/+page.server.ts
Normal file
85
src/routes/reset-password/2fa/totp/+page.server.ts
Normal 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");
|
||||
}
|
||||
24
src/routes/reset-password/2fa/totp/+page.svelte
Normal file
24
src/routes/reset-password/2fa/totp/+page.svelte
Normal 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}
|
||||
84
src/routes/reset-password/verify-email/+page.server.ts
Normal file
84
src/routes/reset-password/verify-email/+page.server.ts
Normal 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));
|
||||
}
|
||||
17
src/routes/reset-password/verify-email/+page.svelte
Normal file
17
src/routes/reset-password/verify-email/+page.svelte
Normal 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>
|
||||
277
src/routes/settings/+page.server.ts
Normal file
277
src/routes/settings/+page.server.ts
Normal 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 {};
|
||||
}
|
||||
96
src/routes/settings/+page.svelte
Normal file
96
src/routes/settings/+page.svelte
Normal 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>
|
||||
117
src/routes/signup/+page.server.ts
Normal file
117
src/routes/signup/+page.server.ts
Normal 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");
|
||||
}
|
||||
35
src/routes/signup/+page.svelte
Normal file
35
src/routes/signup/+page.svelte
Normal 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>
|
||||
153
src/routes/verify-email/+page.server.ts
Normal file
153
src/routes/verify-email/+page.server.ts
Normal 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."
|
||||
}
|
||||
};
|
||||
}
|
||||
22
src/routes/verify-email/+page.svelte
Normal file
22
src/routes/verify-email/+page.svelte
Normal 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
BIN
static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
18
svelte.config.js
Normal file
18
svelte.config.js
Normal 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
19
tsconfig.json
Normal 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
6
vite.config.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
});
|
||||
Loading…
Reference in a new issue