├── .env.example ├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── actions └── webauthn.ts ├── app ├── 2fa │ ├── passkey │ │ ├── actions.ts │ │ ├── components.tsx │ │ ├── page.tsx │ │ └── register │ │ │ ├── actions.ts │ │ │ ├── components.tsx │ │ │ └── page.tsx │ ├── reset │ │ ├── actions.ts │ │ ├── components.tsx │ │ └── page.tsx │ ├── route.ts │ ├── security-key │ │ ├── actions.ts │ │ ├── components.tsx │ │ ├── page.tsx │ │ └── register │ │ │ ├── actions.ts │ │ │ ├── components.tsx │ │ │ └── page.tsx │ ├── setup │ │ └── page.tsx │ └── totp │ │ ├── actions.ts │ │ ├── components.tsx │ │ ├── page.tsx │ │ └── setup │ │ ├── actions.ts │ │ ├── components.tsx │ │ └── page.tsx ├── actions.ts ├── components.tsx ├── forgot-password │ ├── actions.ts │ ├── components.tsx │ └── page.tsx ├── layout.tsx ├── login │ ├── actions.ts │ ├── components.tsx │ └── page.tsx ├── page.tsx ├── recovery-code │ └── page.tsx ├── reset-password │ ├── 2fa │ │ ├── passkey │ │ │ ├── actions.ts │ │ │ ├── components.tsx │ │ │ └── page.tsx │ │ ├── recovery-code │ │ │ ├── actions.ts │ │ │ ├── components.tsx │ │ │ └── page.tsx │ │ ├── route.ts │ │ ├── security-key │ │ │ ├── actions.ts │ │ │ ├── components.tsx │ │ │ └── page.tsx │ │ └── totp │ │ │ ├── actions.ts │ │ │ ├── components.tsx │ │ │ └── page.tsx │ ├── actions.ts │ ├── components.tsx │ ├── page.tsx │ └── verify-email │ │ ├── actions.ts │ │ ├── components.tsx │ │ └── page.tsx ├── settings │ ├── actions.ts │ ├── components.tsx │ └── page.tsx ├── signup │ ├── actions.ts │ ├── components.tsx │ └── page.tsx └── verify-email │ ├── actions.ts │ ├── components.tsx │ └── page.tsx ├── lib ├── client │ └── webauthn.ts └── server │ ├── 2fa.ts │ ├── db.ts │ ├── email-verification.ts │ ├── email.ts │ ├── encryption.ts │ ├── password-reset.ts │ ├── password.ts │ ├── rate-limit.ts │ ├── request.ts │ ├── session.ts │ ├── totp.ts │ ├── user.ts │ ├── utils.ts │ └── webauthn.ts ├── middleware.ts ├── next.config.mjs ├── package.json ├── pnpm-lock.yaml ├── setup.sql └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | ENCRYPTION_KEY="" -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript", "prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | .env 39 | sqlite.db -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "trailingComma": "none", 4 | "printWidth": 120 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 pilcrowOnPaper and contributors 2 | 3 | Permission to use, copy, modify, and/or distribute this software for 4 | any purpose with or without fee is hereby granted. 5 | 6 | THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL 7 | WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES 8 | OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE 9 | FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY 10 | DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN 11 | AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT 12 | OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Email and password example with 2FA and WebAuthn in Next.js 2 | 3 | Built with SQLite. 4 | 5 | - Password checks with HaveIBeenPwned 6 | - Sign in with passkeys 7 | - Email verification 8 | - 2FA with TOTP 9 | - 2FA recovery codes 10 | - 2FA with passkeys and security keys 11 | - Password reset with 2FA 12 | - Login throttling and rate limiting 13 | 14 | Emails are just logged to the console. Rate limiting is implemented using JavaScript `Map`. 15 | 16 | ## Initialize project 17 | 18 | Create `sqlite.db` and run `setup.sql`. 19 | 20 | ``` 21 | sqlite3 sqlite.db 22 | ``` 23 | 24 | Create a .env file. Generate a 128 bit (16 byte) string, base64 encode it, and set it as `ENCRYPTION_KEY`. 25 | 26 | ```bash 27 | ENCRYPTION_KEY="L9pmqRJnO1ZJSQ2svbHuBA==" 28 | ``` 29 | 30 | > You can use OpenSSL to quickly generate a secure key. 31 | > 32 | > ```bash 33 | > openssl rand --base64 16 34 | > ``` 35 | 36 | Install dependencies and run the application: 37 | 38 | ``` 39 | pnpm i 40 | pnpm dev 41 | ``` 42 | 43 | ## Notes 44 | 45 | - We do not consider user enumeration to be a real vulnerability so please don't open issues on it. If you really need to prevent it, just don't use emails. 46 | - This example does not handle unexpected errors gracefully. 47 | - There are some major code duplications (specifically for 2FA) to keep the codebase simple. 48 | - TODO: Passkeys will only work when hosted on `localhost:3000`. Update the host and origin values before deploying. 49 | - TODO: You may need to rewrite some queries and use transactions to avoid race conditions when using MySQL, Postgres, etc. 50 | - TODO: This project relies on the `X-Forwarded-For` header for getting the client's IP address. 51 | - TODO: Logging should be implemented. 52 | -------------------------------------------------------------------------------- /actions/webauthn.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { RefillingTokenBucket } from "@/lib/server/rate-limit"; 4 | import { createWebAuthnChallenge } from "@/lib/server/webauthn"; 5 | import { encodeBase64 } from "@oslojs/encoding"; 6 | import { headers } from "next/headers"; 7 | 8 | const webauthnChallengeRateLimitBucket = new RefillingTokenBucket(30, 10); 9 | 10 | export async function createWebAuthnChallengeAction(): Promise { 11 | console.log("create"); 12 | const clientIP = headers().get("X-Forwarded-For"); 13 | if (clientIP !== null && !webauthnChallengeRateLimitBucket.consume(clientIP, 1)) { 14 | throw new Error("Too many requests"); 15 | } 16 | const challenge = createWebAuthnChallenge(); 17 | return encodeBase64(challenge); 18 | } 19 | -------------------------------------------------------------------------------- /app/2fa/passkey/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { getCurrentSession, setSessionAs2FAVerified } from "@/lib/server/session"; 4 | import { decodeBase64 } from "@oslojs/encoding"; 5 | import { ObjectParser } from "@pilcrowjs/object-parser"; 6 | import { 7 | ClientDataType, 8 | coseAlgorithmES256, 9 | coseAlgorithmRS256, 10 | createAssertionSignatureMessage, 11 | parseAuthenticatorData, 12 | parseClientDataJSON 13 | } from "@oslojs/webauthn"; 14 | import { getUserPasskeyCredential, verifyWebAuthnChallenge } from "@/lib/server/webauthn"; 15 | import { decodePKIXECDSASignature, decodeSEC1PublicKey, p256, verifyECDSASignature } from "@oslojs/crypto/ecdsa"; 16 | import { sha256 } from "@oslojs/crypto/sha2"; 17 | import { decodePKCS1RSAPublicKey, sha256ObjectIdentifier, verifyRSASSAPKCS1v15Signature } from "@oslojs/crypto/rsa"; 18 | import { globalPOSTRateLimit } from "@/lib/server/request"; 19 | 20 | import type { AuthenticatorData, ClientData } from "@oslojs/webauthn"; 21 | 22 | export async function verify2FAWithPasskeyAction(data: unknown): Promise { 23 | if (!globalPOSTRateLimit()) { 24 | return { 25 | error: "Too many requests" 26 | }; 27 | } 28 | 29 | const { session, user } = getCurrentSession(); 30 | if (session === null || user === null) { 31 | return { 32 | error: "Not authenticated" 33 | }; 34 | } 35 | if (!user.emailVerified || !user.registeredPasskey || session.twoFactorVerified) { 36 | return { 37 | error: "Forbidden" 38 | }; 39 | } 40 | 41 | const parser = new ObjectParser(data); 42 | let encodedAuthenticatorData: string; 43 | let encodedClientDataJSON: string; 44 | let encodedCredentialId: string; 45 | let encodedSignature: string; 46 | try { 47 | encodedAuthenticatorData = parser.getString("authenticator_data"); 48 | encodedClientDataJSON = parser.getString("client_data_json"); 49 | encodedCredentialId = parser.getString("credential_id"); 50 | encodedSignature = parser.getString("signature"); 51 | } catch { 52 | return { 53 | error: "Invalid or missing fields" 54 | }; 55 | } 56 | let authenticatorDataBytes: Uint8Array; 57 | let clientDataJSON: Uint8Array; 58 | let credentialId: Uint8Array; 59 | let signatureBytes: Uint8Array; 60 | try { 61 | authenticatorDataBytes = decodeBase64(encodedAuthenticatorData); 62 | clientDataJSON = decodeBase64(encodedClientDataJSON); 63 | credentialId = decodeBase64(encodedCredentialId); 64 | signatureBytes = decodeBase64(encodedSignature); 65 | } catch { 66 | return { 67 | error: "Invalid or missing fields" 68 | }; 69 | } 70 | 71 | let authenticatorData: AuthenticatorData; 72 | try { 73 | authenticatorData = parseAuthenticatorData(authenticatorDataBytes); 74 | } catch { 75 | return { 76 | error: "Invalid data" 77 | }; 78 | } 79 | // TODO: Update host 80 | if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) { 81 | return { 82 | error: "Invalid data" 83 | }; 84 | } 85 | if (!authenticatorData.userPresent) { 86 | return { 87 | error: "Invalid data" 88 | }; 89 | } 90 | 91 | let clientData: ClientData; 92 | try { 93 | clientData = parseClientDataJSON(clientDataJSON); 94 | } catch { 95 | return { 96 | error: "Invalid data" 97 | }; 98 | } 99 | if (clientData.type !== ClientDataType.Get) { 100 | return { 101 | error: "Invalid data" 102 | }; 103 | } 104 | 105 | if (!verifyWebAuthnChallenge(clientData.challenge)) { 106 | return { 107 | error: "Invalid data" 108 | }; 109 | } 110 | // TODO: Update origin 111 | if (clientData.origin !== "http://localhost:3000") { 112 | return { 113 | error: "Invalid data" 114 | }; 115 | } 116 | if (clientData.crossOrigin !== null && clientData.crossOrigin) { 117 | return { 118 | error: "Invalid data" 119 | }; 120 | } 121 | 122 | const credential = getUserPasskeyCredential(user.id, credentialId); 123 | if (credential === null) { 124 | return { 125 | error: "Invalid credential" 126 | }; 127 | } 128 | 129 | let validSignature: boolean; 130 | if (credential.algorithmId === coseAlgorithmES256) { 131 | const ecdsaSignature = decodePKIXECDSASignature(signatureBytes); 132 | const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey); 133 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 134 | validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature); 135 | } else if (credential.algorithmId === coseAlgorithmRS256) { 136 | const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey); 137 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 138 | validSignature = verifyRSASSAPKCS1v15Signature(rsaPublicKey, sha256ObjectIdentifier, hash, signatureBytes); 139 | } else { 140 | return { 141 | error: "Internal error" 142 | }; 143 | } 144 | 145 | if (!validSignature) { 146 | return { 147 | error: "Invalid data" 148 | }; 149 | } 150 | 151 | setSessionAs2FAVerified(session.id); 152 | return { 153 | error: null 154 | }; 155 | } 156 | 157 | interface ActionResult { 158 | error: string | null; 159 | } 160 | -------------------------------------------------------------------------------- /app/2fa/passkey/components.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { createChallenge } from "@/lib/client/webauthn"; 4 | import { decodeBase64, encodeBase64 } from "@oslojs/encoding"; 5 | import { verify2FAWithPasskeyAction } from "./actions"; 6 | import { useState } from "react"; 7 | import { useRouter } from "next/navigation"; 8 | 9 | export function Verify2FAWithPasskeyButton(props: { encodedCredentialIds: string[] }) { 10 | const router = useRouter(); 11 | const [message, setMessage] = useState(""); 12 | return ( 13 |
14 | 53 |

{message}

54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /app/2fa/passkey/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Verify2FAWithPasskeyButton } from "./components"; 3 | 4 | import { get2FARedirect } from "@/lib/server/2fa"; 5 | import { getCurrentSession } from "@/lib/server/session"; 6 | import { getUserPasskeyCredentials } from "@/lib/server/webauthn"; 7 | import { redirect } from "next/navigation"; 8 | import { encodeBase64 } from "@oslojs/encoding"; 9 | import { globalGETRateLimit } from "@/lib/server/request"; 10 | 11 | export default function Page() { 12 | if (!globalGETRateLimit()) { 13 | return "Too many requests"; 14 | } 15 | 16 | const { session, user } = getCurrentSession(); 17 | if (session === null || user === null) { 18 | return redirect("/login"); 19 | } 20 | if (!user.emailVerified) { 21 | return redirect("/verify-email"); 22 | } 23 | if (!user.registered2FA) { 24 | return redirect("/"); 25 | } 26 | if (session.twoFactorVerified) { 27 | return redirect("/"); 28 | } 29 | if (!user.registeredPasskey) { 30 | return redirect(get2FARedirect(user)); 31 | } 32 | const credentials = getUserPasskeyCredentials(user.id); 33 | return ( 34 | <> 35 |

Authenticate with passkeys

36 | encodeBase64(credential.id))} /> 37 | Use recovery code 38 | {user.registeredTOTP && Use authenticator apps} 39 | {user.registeredSecurityKey && Use security keys} 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /app/2fa/passkey/register/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { getCurrentSession, setSessionAs2FAVerified } from "@/lib/server/session"; 4 | import { decodeBase64 } from "@oslojs/encoding"; 5 | import { redirect } from "next/navigation"; 6 | import { 7 | AttestationStatementFormat, 8 | ClientDataType, 9 | coseAlgorithmES256, 10 | coseAlgorithmRS256, 11 | coseEllipticCurveP256, 12 | parseAttestationObject, 13 | parseClientDataJSON 14 | } from "@oslojs/webauthn"; 15 | import { createPasskeyCredential, getUserPasskeyCredentials, verifyWebAuthnChallenge } from "@/lib/server/webauthn"; 16 | import { ECDSAPublicKey, p256 } from "@oslojs/crypto/ecdsa"; 17 | import { RSAPublicKey } from "@oslojs/crypto/rsa"; 18 | import { SqliteError } from "better-sqlite3"; 19 | import { globalPOSTRateLimit } from "@/lib/server/request"; 20 | 21 | import type { 22 | AttestationStatement, 23 | AuthenticatorData, 24 | ClientData, 25 | COSEEC2PublicKey, 26 | COSERSAPublicKey 27 | } from "@oslojs/webauthn"; 28 | import type { WebAuthnUserCredential } from "@/lib/server/webauthn"; 29 | 30 | export async function registerPasskeyAction(_prev: ActionResult, formData: FormData): Promise { 31 | if (!globalPOSTRateLimit()) { 32 | return { 33 | message: "Too many requests" 34 | }; 35 | } 36 | 37 | const { session, user } = getCurrentSession(); 38 | if (session === null || user === null) { 39 | return { 40 | message: "Not authenticated" 41 | }; 42 | } 43 | if (!user.emailVerified) { 44 | return { 45 | message: "Forbidden" 46 | }; 47 | } 48 | if (user.registered2FA && !session.twoFactorVerified) { 49 | return { 50 | message: "Forbidden" 51 | }; 52 | } 53 | 54 | const name = formData.get("name"); 55 | const encodedAttestationObject = formData.get("attestation_object"); 56 | const encodedClientDataJSON = formData.get("client_data_json"); 57 | if ( 58 | typeof name !== "string" || 59 | typeof encodedAttestationObject !== "string" || 60 | typeof encodedClientDataJSON !== "string" 61 | ) { 62 | return { 63 | message: "Invalid or missing fields" 64 | }; 65 | } 66 | 67 | let attestationObjectBytes: Uint8Array, clientDataJSON: Uint8Array; 68 | try { 69 | attestationObjectBytes = decodeBase64(encodedAttestationObject); 70 | clientDataJSON = decodeBase64(encodedClientDataJSON); 71 | } catch { 72 | return { 73 | message: "Invalid or missing fields" 74 | }; 75 | } 76 | 77 | let attestationStatement: AttestationStatement; 78 | let authenticatorData: AuthenticatorData; 79 | try { 80 | const attestationObject = parseAttestationObject(attestationObjectBytes); 81 | attestationStatement = attestationObject.attestationStatement; 82 | authenticatorData = attestationObject.authenticatorData; 83 | } catch { 84 | return { 85 | message: "Invalid data" 86 | }; 87 | } 88 | if (attestationStatement.format !== AttestationStatementFormat.None) { 89 | return { 90 | message: "Invalid data" 91 | }; 92 | } 93 | // TODO: Update host 94 | if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) { 95 | return { 96 | message: "Invalid data" 97 | }; 98 | } 99 | if (!authenticatorData.userPresent || !authenticatorData.userVerified) { 100 | return { 101 | message: "Invalid data" 102 | }; 103 | } 104 | if (authenticatorData.credential === null) { 105 | return { 106 | message: "Invalid data" 107 | }; 108 | } 109 | 110 | let clientData: ClientData; 111 | try { 112 | clientData = parseClientDataJSON(clientDataJSON); 113 | } catch { 114 | return { 115 | message: "Invalid data" 116 | }; 117 | } 118 | if (clientData.type !== ClientDataType.Create) { 119 | return { 120 | message: "Invalid data" 121 | }; 122 | } 123 | 124 | if (!verifyWebAuthnChallenge(clientData.challenge)) { 125 | return { 126 | message: "Invalid data" 127 | }; 128 | } 129 | // TODO: Update origin 130 | if (clientData.origin !== "http://localhost:3000") { 131 | return { 132 | message: "Invalid data" 133 | }; 134 | } 135 | if (clientData.crossOrigin !== null && clientData.crossOrigin) { 136 | return { 137 | message: "Invalid data" 138 | }; 139 | } 140 | 141 | let credential: WebAuthnUserCredential; 142 | if (authenticatorData.credential.publicKey.algorithm() === coseAlgorithmES256) { 143 | let cosePublicKey: COSEEC2PublicKey; 144 | try { 145 | cosePublicKey = authenticatorData.credential.publicKey.ec2(); 146 | } catch { 147 | return { 148 | message: "Invalid data" 149 | }; 150 | } 151 | if (cosePublicKey.curve !== coseEllipticCurveP256) { 152 | return { 153 | message: "Unsupported algorithm" 154 | }; 155 | } 156 | const encodedPublicKey = new ECDSAPublicKey(p256, cosePublicKey.x, cosePublicKey.y).encodeSEC1Uncompressed(); 157 | credential = { 158 | id: authenticatorData.credential.id, 159 | userId: user.id, 160 | algorithmId: coseAlgorithmES256, 161 | name, 162 | publicKey: encodedPublicKey 163 | }; 164 | } else if (authenticatorData.credential.publicKey.algorithm() === coseAlgorithmRS256) { 165 | let cosePublicKey: COSERSAPublicKey; 166 | try { 167 | cosePublicKey = authenticatorData.credential.publicKey.rsa(); 168 | } catch { 169 | return { 170 | message: "Invalid data" 171 | }; 172 | } 173 | const encodedPublicKey = new RSAPublicKey(cosePublicKey.n, cosePublicKey.e).encodePKCS1(); 174 | credential = { 175 | id: authenticatorData.credential.id, 176 | userId: user.id, 177 | algorithmId: coseAlgorithmRS256, 178 | name, 179 | publicKey: encodedPublicKey 180 | }; 181 | } else { 182 | return { 183 | message: "Unsupported algorithm" 184 | }; 185 | } 186 | 187 | // We don't have to worry about race conditions since queries are synchronous 188 | const credentials = getUserPasskeyCredentials(user.id); 189 | if (credentials.length >= 5) { 190 | return { 191 | message: "Too many credentials" 192 | }; 193 | } 194 | 195 | try { 196 | createPasskeyCredential(credential); 197 | } catch (e) { 198 | if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_PRIMARYKEY") { 199 | return { 200 | message: "Invalid data" 201 | }; 202 | } 203 | return { 204 | message: "Internal error" 205 | }; 206 | } 207 | 208 | if (!session.twoFactorVerified) { 209 | setSessionAs2FAVerified(session.id); 210 | } 211 | 212 | if (!user.registered2FA) { 213 | return redirect("/recovery-code"); 214 | } 215 | return redirect("/"); 216 | } 217 | 218 | interface ActionResult { 219 | message: string; 220 | } 221 | -------------------------------------------------------------------------------- /app/2fa/passkey/register/components.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { createChallenge } from "@/lib/client/webauthn"; 4 | import { decodeBase64, encodeBase64 } from "@oslojs/encoding"; 5 | import { useState } from "react"; 6 | import { useFormState } from "react-dom"; 7 | import { registerPasskeyAction } from "./actions"; 8 | 9 | import type { User } from "@/lib/server/user"; 10 | 11 | const initialRegisterPasskeyState = { 12 | message: "" 13 | }; 14 | 15 | export function RegisterPasskeyForm(props: { 16 | encodedCredentialUserId: string; 17 | user: User; 18 | encodedCredentialIds: string[]; 19 | }) { 20 | const [encodedAttestationObject, setEncodedAttestationObject] = useState(null); 21 | const [encodedClientDataJSON, setEncodedClientDataJSON] = useState(null); 22 | const [formState, action] = useFormState(registerPasskeyAction, initialRegisterPasskeyState); 23 | return ( 24 | <> 25 | 78 |
79 | 80 | 81 | 82 | 83 | 84 |

{formState.message}

85 |
86 | 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /app/2fa/passkey/register/page.tsx: -------------------------------------------------------------------------------- 1 | import { RegisterPasskeyForm } from "./components"; 2 | 3 | import { getCurrentSession } from "@/lib/server/session"; 4 | import { redirect } from "next/navigation"; 5 | import { get2FARedirect } from "@/lib/server/2fa"; 6 | import { getUserPasskeyCredentials } from "@/lib/server/webauthn"; 7 | import { bigEndian } from "@oslojs/binary"; 8 | import { encodeBase64 } from "@oslojs/encoding"; 9 | import { globalGETRateLimit } from "@/lib/server/request"; 10 | 11 | export default function Page() { 12 | if (!globalGETRateLimit()) { 13 | return "Too many requests"; 14 | } 15 | 16 | const { session, user } = getCurrentSession(); 17 | if (session === null || user === null) { 18 | return redirect("/login"); 19 | } 20 | if (!user.emailVerified) { 21 | return redirect("/verify-email"); 22 | } 23 | if (user.registered2FA && !session.twoFactorVerified) { 24 | return redirect(get2FARedirect(user)); 25 | } 26 | 27 | const credentials = getUserPasskeyCredentials(user.id); 28 | 29 | const credentialUserId = new Uint8Array(8); 30 | bigEndian.putUint64(credentialUserId, BigInt(user.id), 0); 31 | return ( 32 | <> 33 |

Register passkey

34 | encodeBase64(credential.id))} 36 | user={user} 37 | encodedCredentialUserId={encodeBase64(credentialUserId)} 38 | /> 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /app/2fa/reset/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { recoveryCodeBucket, resetUser2FAWithRecoveryCode } from "@/lib/server/2fa"; 4 | import { getCurrentSession } from "@/lib/server/session"; 5 | import { globalPOSTRateLimit } from "@/lib/server/request"; 6 | import { redirect } from "next/navigation"; 7 | 8 | export async function reset2FAAction(_prev: ActionResult, formData: FormData): Promise { 9 | if (!globalPOSTRateLimit()) { 10 | return { 11 | message: "Too many requests" 12 | }; 13 | } 14 | 15 | const { session, user } = getCurrentSession(); 16 | if (session === null) { 17 | return { 18 | message: "Not authenticated" 19 | }; 20 | } 21 | if (!user.emailVerified || !user.registered2FA || session.twoFactorVerified) { 22 | return { 23 | message: "Forbidden" 24 | }; 25 | } 26 | if (!recoveryCodeBucket.check(user.id, 1)) { 27 | return { 28 | message: "Too many requests" 29 | }; 30 | } 31 | 32 | const code = formData.get("code"); 33 | if (typeof code !== "string") { 34 | return { 35 | message: "Invalid or missing fields" 36 | }; 37 | } 38 | if (code === "") { 39 | return { 40 | message: "Please enter your code" 41 | }; 42 | } 43 | if (!recoveryCodeBucket.consume(user.id, 1)) { 44 | return { 45 | message: "Too many requests" 46 | }; 47 | } 48 | const valid = resetUser2FAWithRecoveryCode(user.id, code); 49 | if (!valid) { 50 | return { 51 | message: "Invalid recovery code" 52 | }; 53 | } 54 | recoveryCodeBucket.reset(user.id); 55 | return redirect("/2fa/setup"); 56 | } 57 | 58 | interface ActionResult { 59 | message: string; 60 | } 61 | -------------------------------------------------------------------------------- /app/2fa/reset/components.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { reset2FAAction } from "./actions"; 4 | import { useFormState } from "react-dom"; 5 | 6 | const initial2FAResetState = { 7 | message: "" 8 | }; 9 | 10 | export function TwoFactorResetForm() { 11 | const [state, action] = useFormState(reset2FAAction, initial2FAResetState); 12 | return ( 13 |
14 | 15 | 16 |
17 | 18 |

{state.message ?? ""}

19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /app/2fa/reset/page.tsx: -------------------------------------------------------------------------------- 1 | import { TwoFactorResetForm } from "./components"; 2 | 3 | import { getCurrentSession } from "@/lib/server/session"; 4 | import { redirect } from "next/navigation"; 5 | import { globalGETRateLimit } from "@/lib/server/request"; 6 | 7 | export default function Page() { 8 | if (!globalGETRateLimit()) { 9 | return "Too many requests"; 10 | } 11 | 12 | const { session, user } = getCurrentSession(); 13 | if (session === null) { 14 | return redirect("/login"); 15 | } 16 | if (!user.emailVerified) { 17 | return redirect("/verify-email"); 18 | } 19 | if (!user.registered2FA) { 20 | return redirect("/2fa/setup"); 21 | } 22 | if (session.twoFactorVerified) { 23 | return redirect("/"); 24 | } 25 | return ( 26 | <> 27 |

Recover your account

28 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /app/2fa/route.ts: -------------------------------------------------------------------------------- 1 | import { get2FARedirect } from "@/lib/server/2fa"; 2 | import { globalGETRateLimit } from "@/lib/server/request"; 3 | import { getCurrentSession } from "@/lib/server/session"; 4 | 5 | export async function GET() { 6 | if (!globalGETRateLimit()) { 7 | return new Response("Too many requests", { 8 | status: 429 9 | }); 10 | } 11 | const { session, user } = getCurrentSession(); 12 | if (session === null || user === null) { 13 | return new Response(null, { 14 | status: 302, 15 | headers: { 16 | Location: "/login" 17 | } 18 | }); 19 | } 20 | if (session.twoFactorVerified) { 21 | return new Response(null, { 22 | status: 302, 23 | headers: { 24 | Location: "/" 25 | } 26 | }); 27 | } 28 | if (!user.registered2FA) { 29 | return new Response(null, { 30 | status: 302, 31 | headers: { 32 | Location: "/2fa/setup" 33 | } 34 | }); 35 | } 36 | return new Response(null, { 37 | status: 302, 38 | headers: { 39 | Location: get2FARedirect(user) 40 | } 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /app/2fa/security-key/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { getCurrentSession, setSessionAs2FAVerified } from "@/lib/server/session"; 4 | import { decodeBase64 } from "@oslojs/encoding"; 5 | import { ObjectParser } from "@pilcrowjs/object-parser"; 6 | import { 7 | ClientDataType, 8 | coseAlgorithmES256, 9 | coseAlgorithmRS256, 10 | createAssertionSignatureMessage, 11 | parseAuthenticatorData, 12 | parseClientDataJSON 13 | } from "@oslojs/webauthn"; 14 | import { getUserSecurityKeyCredential, verifyWebAuthnChallenge } from "@/lib/server/webauthn"; 15 | import { decodePKIXECDSASignature, decodeSEC1PublicKey, p256, verifyECDSASignature } from "@oslojs/crypto/ecdsa"; 16 | import { sha256 } from "@oslojs/crypto/sha2"; 17 | import { decodePKCS1RSAPublicKey, sha256ObjectIdentifier, verifyRSASSAPKCS1v15Signature } from "@oslojs/crypto/rsa"; 18 | import { globalPOSTRateLimit } from "@/lib/server/request"; 19 | 20 | import type { AuthenticatorData, ClientData } from "@oslojs/webauthn"; 21 | 22 | export async function verify2FAWithSecurityKeyAction(data: unknown): Promise { 23 | if (!globalPOSTRateLimit()) { 24 | return { 25 | error: "Too many requests" 26 | }; 27 | } 28 | 29 | const { session, user } = getCurrentSession(); 30 | if (session === null || user === null) { 31 | return { 32 | error: "Not authenticated" 33 | }; 34 | } 35 | if (!user.emailVerified || !user.registeredSecurityKey || session.twoFactorVerified) { 36 | return { 37 | error: "Forbidden" 38 | }; 39 | } 40 | 41 | const parser = new ObjectParser(data); 42 | let encodedAuthenticatorData: string; 43 | let encodedClientDataJSON: string; 44 | let encodedCredentialId: string; 45 | let encodedSignature: string; 46 | try { 47 | encodedAuthenticatorData = parser.getString("authenticator_data"); 48 | encodedClientDataJSON = parser.getString("client_data_json"); 49 | encodedCredentialId = parser.getString("credential_id"); 50 | encodedSignature = parser.getString("signature"); 51 | } catch { 52 | return { 53 | error: "Invalid or missing fields" 54 | }; 55 | } 56 | let authenticatorDataBytes: Uint8Array; 57 | let clientDataJSON: Uint8Array; 58 | let credentialId: Uint8Array; 59 | let signatureBytes: Uint8Array; 60 | try { 61 | authenticatorDataBytes = decodeBase64(encodedAuthenticatorData); 62 | clientDataJSON = decodeBase64(encodedClientDataJSON); 63 | credentialId = decodeBase64(encodedCredentialId); 64 | signatureBytes = decodeBase64(encodedSignature); 65 | } catch { 66 | return { 67 | error: "Invalid or missing fields" 68 | }; 69 | } 70 | 71 | let authenticatorData: AuthenticatorData; 72 | try { 73 | authenticatorData = parseAuthenticatorData(authenticatorDataBytes); 74 | } catch { 75 | return { 76 | error: "Invalid data" 77 | }; 78 | } 79 | // TODO: Update host 80 | if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) { 81 | return { 82 | error: "Invalid data" 83 | }; 84 | } 85 | if (!authenticatorData.userPresent) { 86 | return { 87 | error: "Invalid data" 88 | }; 89 | } 90 | 91 | let clientData: ClientData; 92 | try { 93 | clientData = parseClientDataJSON(clientDataJSON); 94 | } catch { 95 | return { 96 | error: "Invalid data" 97 | }; 98 | } 99 | if (clientData.type !== ClientDataType.Get) { 100 | return { 101 | error: "Invalid data" 102 | }; 103 | } 104 | 105 | if (!verifyWebAuthnChallenge(clientData.challenge)) { 106 | return { 107 | error: "Invalid data" 108 | }; 109 | } 110 | // TODO: Update origin 111 | if (clientData.origin !== "http://localhost:3000") { 112 | return { 113 | error: "Invalid data" 114 | }; 115 | } 116 | if (clientData.crossOrigin !== null && clientData.crossOrigin) { 117 | return { 118 | error: "Invalid data" 119 | }; 120 | } 121 | 122 | const credential = getUserSecurityKeyCredential(user.id, credentialId); 123 | if (credential === null) { 124 | return { 125 | error: "Invalid credential" 126 | }; 127 | } 128 | 129 | let validSignature: boolean; 130 | if (credential.algorithmId === coseAlgorithmES256) { 131 | const ecdsaSignature = decodePKIXECDSASignature(signatureBytes); 132 | const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey); 133 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 134 | validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature); 135 | } else if (credential.algorithmId === coseAlgorithmRS256) { 136 | const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey); 137 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 138 | validSignature = verifyRSASSAPKCS1v15Signature(rsaPublicKey, sha256ObjectIdentifier, hash, signatureBytes); 139 | } else { 140 | return { 141 | error: "Internal error" 142 | }; 143 | } 144 | 145 | if (!validSignature) { 146 | return { 147 | error: "Invalid data" 148 | }; 149 | } 150 | 151 | setSessionAs2FAVerified(session.id); 152 | return { 153 | error: null 154 | }; 155 | } 156 | 157 | interface ActionResult { 158 | error: string | null; 159 | } 160 | -------------------------------------------------------------------------------- /app/2fa/security-key/components.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { createChallenge } from "@/lib/client/webauthn"; 4 | import { decodeBase64, encodeBase64 } from "@oslojs/encoding"; 5 | import { verify2FAWithSecurityKeyAction } from "./actions"; 6 | import { useState } from "react"; 7 | import { useRouter } from "next/navigation"; 8 | 9 | export function Verify2FAWithSecurityKeyButton(props: { encodedCredentialIds: string[] }) { 10 | const router = useRouter(); 11 | const [message, setMessage] = useState(""); 12 | return ( 13 |
14 | 53 |

{message}

54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /app/2fa/security-key/page.tsx: -------------------------------------------------------------------------------- 1 | import { Verify2FAWithSecurityKeyButton } from "./components"; 2 | import Link from "next/link"; 3 | 4 | import { get2FARedirect } from "@/lib/server/2fa"; 5 | import { getCurrentSession } from "@/lib/server/session"; 6 | import { getUserSecurityKeyCredentials } from "@/lib/server/webauthn"; 7 | import { redirect } from "next/navigation"; 8 | import { encodeBase64 } from "@oslojs/encoding"; 9 | import { globalGETRateLimit } from "@/lib/server/request"; 10 | 11 | export default function Page() { 12 | if (!globalGETRateLimit()) { 13 | return "Too many requests"; 14 | } 15 | 16 | const { session, user } = getCurrentSession(); 17 | if (session === null || user === null) { 18 | return redirect("/login"); 19 | } 20 | if (!user.emailVerified) { 21 | return redirect("/verify-email"); 22 | } 23 | if (!user.registered2FA) { 24 | return redirect("/"); 25 | } 26 | if (session.twoFactorVerified) { 27 | return redirect("/"); 28 | } 29 | if (!user.registeredSecurityKey) { 30 | return redirect(get2FARedirect(user)); 31 | } 32 | const credentials = getUserSecurityKeyCredentials(user.id); 33 | return ( 34 | <> 35 |

Authenticate with security keys

36 | encodeBase64(credential.id))} 38 | /> 39 | Use recovery code 40 | {user.registeredTOTP && Use authenticator apps} 41 | {user.registeredPasskey && Use passkeys} 42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /app/2fa/security-key/register/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { getCurrentSession, setSessionAs2FAVerified } from "@/lib/server/session"; 4 | import { decodeBase64 } from "@oslojs/encoding"; 5 | import { redirect } from "next/navigation"; 6 | import { 7 | AttestationStatementFormat, 8 | ClientDataType, 9 | coseAlgorithmES256, 10 | coseAlgorithmRS256, 11 | coseEllipticCurveP256, 12 | parseAttestationObject, 13 | parseClientDataJSON 14 | } from "@oslojs/webauthn"; 15 | import { 16 | createSecurityKeyCredential, 17 | getUserSecurityKeyCredentials, 18 | verifyWebAuthnChallenge 19 | } from "@/lib/server/webauthn"; 20 | import { ECDSAPublicKey, p256 } from "@oslojs/crypto/ecdsa"; 21 | import { RSAPublicKey } from "@oslojs/crypto/rsa"; 22 | import { SqliteError } from "better-sqlite3"; 23 | import { globalPOSTRateLimit } from "@/lib/server/request"; 24 | 25 | import type { 26 | AttestationStatement, 27 | AuthenticatorData, 28 | ClientData, 29 | COSEEC2PublicKey, 30 | COSERSAPublicKey 31 | } from "@oslojs/webauthn"; 32 | import type { WebAuthnUserCredential } from "@/lib/server/webauthn"; 33 | 34 | export async function registerSecurityKeyAction(_prev: ActionResult, formData: FormData): Promise { 35 | if (!globalPOSTRateLimit()) { 36 | return { 37 | message: "Too many requests" 38 | }; 39 | } 40 | 41 | const { session, user } = getCurrentSession(); 42 | if (session === null || user === null) { 43 | return { 44 | message: "Not authenticated" 45 | }; 46 | } 47 | if (!user.emailVerified) { 48 | return { 49 | message: "Forbidden" 50 | }; 51 | } 52 | if (user.registered2FA && !session.twoFactorVerified) { 53 | return { 54 | message: "Forbidden" 55 | }; 56 | } 57 | 58 | const name = formData.get("name"); 59 | const encodedAttestationObject = formData.get("attestation_object"); 60 | const encodedClientDataJSON = formData.get("client_data_json"); 61 | if ( 62 | typeof name !== "string" || 63 | typeof encodedAttestationObject !== "string" || 64 | typeof encodedClientDataJSON !== "string" 65 | ) { 66 | return { 67 | message: "Invalid or missing fields" 68 | }; 69 | } 70 | 71 | let attestationObjectBytes: Uint8Array, clientDataJSON: Uint8Array; 72 | try { 73 | attestationObjectBytes = decodeBase64(encodedAttestationObject); 74 | clientDataJSON = decodeBase64(encodedClientDataJSON); 75 | } catch { 76 | return { 77 | message: "Invalid or missing fields" 78 | }; 79 | } 80 | 81 | let attestationStatement: AttestationStatement; 82 | let authenticatorData: AuthenticatorData; 83 | try { 84 | const attestationObject = parseAttestationObject(attestationObjectBytes); 85 | attestationStatement = attestationObject.attestationStatement; 86 | authenticatorData = attestationObject.authenticatorData; 87 | } catch { 88 | return { 89 | message: "Invalid data" 90 | }; 91 | } 92 | if (attestationStatement.format !== AttestationStatementFormat.None) { 93 | return { 94 | message: "Invalid data" 95 | }; 96 | } 97 | // TODO: Update host 98 | if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) { 99 | return { 100 | message: "Invalid data" 101 | }; 102 | } 103 | if (!authenticatorData.userPresent) { 104 | return { 105 | message: "Invalid data" 106 | }; 107 | } 108 | if (authenticatorData.credential === null) { 109 | return { 110 | message: "Invalid data" 111 | }; 112 | } 113 | 114 | let clientData: ClientData; 115 | try { 116 | clientData = parseClientDataJSON(clientDataJSON); 117 | } catch { 118 | return { 119 | message: "Invalid data" 120 | }; 121 | } 122 | if (clientData.type !== ClientDataType.Create) { 123 | return { 124 | message: "Invalid data" 125 | }; 126 | } 127 | 128 | if (!verifyWebAuthnChallenge(clientData.challenge)) { 129 | return { 130 | message: "Invalid data" 131 | }; 132 | } 133 | // TODO: Update origin 134 | if (clientData.origin !== "http://localhost:3000") { 135 | return { 136 | message: "Invalid data" 137 | }; 138 | } 139 | if (clientData.crossOrigin !== null && clientData.crossOrigin) { 140 | return { 141 | message: "Invalid data" 142 | }; 143 | } 144 | 145 | let credential: WebAuthnUserCredential; 146 | if (authenticatorData.credential.publicKey.algorithm() === coseAlgorithmES256) { 147 | let cosePublicKey: COSEEC2PublicKey; 148 | try { 149 | cosePublicKey = authenticatorData.credential.publicKey.ec2(); 150 | } catch { 151 | return { 152 | message: "Invalid data" 153 | }; 154 | } 155 | if (cosePublicKey.curve !== coseEllipticCurveP256) { 156 | return { 157 | message: "Unsupported algorithm" 158 | }; 159 | } 160 | const encodedPublicKey = new ECDSAPublicKey(p256, cosePublicKey.x, cosePublicKey.y).encodeSEC1Uncompressed(); 161 | credential = { 162 | id: authenticatorData.credential.id, 163 | userId: user.id, 164 | algorithmId: coseAlgorithmES256, 165 | name, 166 | publicKey: encodedPublicKey 167 | }; 168 | } else if (authenticatorData.credential.publicKey.algorithm() === coseAlgorithmRS256) { 169 | let cosePublicKey: COSERSAPublicKey; 170 | try { 171 | cosePublicKey = authenticatorData.credential.publicKey.rsa(); 172 | } catch { 173 | return { 174 | message: "Invalid data" 175 | }; 176 | } 177 | const encodedPublicKey = new RSAPublicKey(cosePublicKey.n, cosePublicKey.e).encodePKCS1(); 178 | credential = { 179 | id: authenticatorData.credential.id, 180 | userId: user.id, 181 | algorithmId: coseAlgorithmRS256, 182 | name, 183 | publicKey: encodedPublicKey 184 | }; 185 | } else { 186 | return { 187 | message: "Unsupported algorithm" 188 | }; 189 | } 190 | 191 | // We don't have to worry about race conditions since queries are synchronous 192 | const credentials = getUserSecurityKeyCredentials(user.id); 193 | if (credentials.length >= 5) { 194 | return { 195 | message: "Too many credentials" 196 | }; 197 | } 198 | 199 | try { 200 | createSecurityKeyCredential(credential); 201 | } catch (e) { 202 | if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_PRIMARYKEY") { 203 | return { 204 | message: "Invalid data" 205 | }; 206 | } 207 | return { 208 | message: "Internal error" 209 | }; 210 | } 211 | 212 | if (!session.twoFactorVerified) { 213 | setSessionAs2FAVerified(session.id); 214 | } 215 | 216 | if (!user.registered2FA) { 217 | return redirect("/recovery-code"); 218 | } 219 | return redirect("/"); 220 | } 221 | 222 | interface ActionResult { 223 | message: string; 224 | } 225 | -------------------------------------------------------------------------------- /app/2fa/security-key/register/components.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { createChallenge } from "@/lib/client/webauthn"; 4 | import { decodeBase64, encodeBase64 } from "@oslojs/encoding"; 5 | import { useState } from "react"; 6 | import { useFormState } from "react-dom"; 7 | import { registerSecurityKeyAction } from "./actions"; 8 | 9 | import type { User } from "@/lib/server/user"; 10 | 11 | const initialRegisterSecurityKeyState = { 12 | message: "" 13 | }; 14 | 15 | export function RegisterSecurityKey(props: { 16 | encodedCredentialUserId: string; 17 | user: User; 18 | encodedCredentialIds: string[]; 19 | }) { 20 | const [encodedAttestationObject, setEncodedAttestationObject] = useState(null); 21 | const [encodedClientDataJSON, setEncodedClientDataJSON] = useState(null); 22 | const [formState, action] = useFormState(registerSecurityKeyAction, initialRegisterSecurityKeyState); 23 | return ( 24 | <> 25 | 80 |
81 | 82 | 83 | 84 | 85 | 86 |

{formState.message}

87 |
88 | 89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /app/2fa/security-key/register/page.tsx: -------------------------------------------------------------------------------- 1 | import { getCurrentSession } from "@/lib/server/session"; 2 | import { RegisterSecurityKey } from "./components"; 3 | import { redirect } from "next/navigation"; 4 | import { get2FARedirect } from "@/lib/server/2fa"; 5 | import { getUserSecurityKeyCredentials } from "@/lib/server/webauthn"; 6 | import { bigEndian } from "@oslojs/binary"; 7 | import { encodeBase64 } from "@oslojs/encoding"; 8 | import { globalGETRateLimit } from "@/lib/server/request"; 9 | 10 | export default function Page() { 11 | if (!globalGETRateLimit()) { 12 | return "Too many requests"; 13 | } 14 | 15 | const { session, user } = getCurrentSession(); 16 | if (session === null || user === null) { 17 | return redirect("/login"); 18 | } 19 | if (!user.emailVerified) { 20 | return redirect("/verify-email"); 21 | } 22 | if (user.registered2FA && !session.twoFactorVerified) { 23 | return redirect(get2FARedirect(user)); 24 | } 25 | 26 | const credentials = getUserSecurityKeyCredentials(user.id); 27 | 28 | const credentialUserId = new Uint8Array(8); 29 | bigEndian.putUint64(credentialUserId, BigInt(user.id), 0); 30 | return ( 31 | <> 32 |

Register security key

33 | encodeBase64(credential.id))} 35 | user={user} 36 | encodedCredentialUserId={encodeBase64(credentialUserId)} 37 | /> 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /app/2fa/setup/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | import { getCurrentSession } from "@/lib/server/session"; 4 | import { redirect } from "next/navigation"; 5 | import { globalGETRateLimit } from "@/lib/server/request"; 6 | 7 | export default function Page() { 8 | if (!globalGETRateLimit()) { 9 | return "Too many requests"; 10 | } 11 | 12 | const { session, user } = getCurrentSession(); 13 | if (session === null || user === null) { 14 | return redirect("/login"); 15 | } 16 | if (!user.emailVerified) { 17 | return redirect("/verify-email"); 18 | } 19 | if (user.registered2FA) { 20 | return redirect("/"); 21 | } 22 | return ( 23 | <> 24 |

Set up two-factor authentication

25 |
    26 |
  • 27 | Authenticator apps 28 |
  • 29 |
  • 30 | Passkeys 31 |
  • 32 |
  • 33 | Security keys 34 |
  • 35 |
36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /app/2fa/totp/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { totpBucket } from "@/lib/server/totp"; 4 | import { getCurrentSession, setSessionAs2FAVerified } from "@/lib/server/session"; 5 | import { getUserTOTPKey } from "@/lib/server/totp"; 6 | import { verifyTOTP } from "@oslojs/otp"; 7 | import { redirect } from "next/navigation"; 8 | import { globalPOSTRateLimit } from "@/lib/server/request"; 9 | 10 | export async function verify2FAAction(_prev: ActionResult, formData: FormData): Promise { 11 | if (!globalPOSTRateLimit()) { 12 | return { 13 | message: "Too many requests" 14 | }; 15 | } 16 | 17 | const { session, user } = getCurrentSession(); 18 | if (session === null) { 19 | return { 20 | message: "Not authenticated" 21 | }; 22 | } 23 | if (!user.emailVerified || !user.registeredTOTP || session.twoFactorVerified) { 24 | return { 25 | message: "Forbidden" 26 | }; 27 | } 28 | if (!totpBucket.check(user.id, 1)) { 29 | return { 30 | message: "Too many requests" 31 | }; 32 | } 33 | 34 | const code = formData.get("code"); 35 | if (typeof code !== "string") { 36 | return { 37 | message: "Invalid or missing fields" 38 | }; 39 | } 40 | if (code === "") { 41 | return { 42 | message: "Enter your code" 43 | }; 44 | } 45 | if (!totpBucket.consume(user.id, 1)) { 46 | return { 47 | message: "Too many requests" 48 | }; 49 | } 50 | const totpKey = getUserTOTPKey(user.id); 51 | if (totpKey === null) { 52 | return { 53 | message: "Forbidden" 54 | }; 55 | } 56 | if (!verifyTOTP(totpKey, 30, 6, code)) { 57 | return { 58 | message: "Invalid code" 59 | }; 60 | } 61 | totpBucket.reset(user.id); 62 | setSessionAs2FAVerified(session.id); 63 | return redirect("/"); 64 | } 65 | 66 | interface ActionResult { 67 | message: string; 68 | } 69 | -------------------------------------------------------------------------------- /app/2fa/totp/components.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { verify2FAAction } from "./actions"; 4 | import { useFormState } from "react-dom"; 5 | 6 | const initial2FAVerificationState = { 7 | message: "" 8 | }; 9 | 10 | export function TwoFactorVerificationForm() { 11 | const [state, action] = useFormState(verify2FAAction, initial2FAVerificationState); 12 | return ( 13 |
14 | 15 | 16 |
17 | 18 |

{state.message}

19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /app/2fa/totp/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | import { TwoFactorVerificationForm } from "./components"; 4 | import { getCurrentSession } from "@/lib/server/session"; 5 | import { redirect } from "next/navigation"; 6 | import { globalGETRateLimit } from "@/lib/server/request"; 7 | 8 | export default function Page() { 9 | if (!globalGETRateLimit()) { 10 | return "Too many requests"; 11 | } 12 | 13 | const { session, user } = getCurrentSession(); 14 | if (session === null) { 15 | return redirect("/login"); 16 | } 17 | if (!user.emailVerified) { 18 | return redirect("/verify-email"); 19 | } 20 | if (!user.registered2FA) { 21 | return redirect("/2fa/setup"); 22 | } 23 | if (session.twoFactorVerified) { 24 | return redirect("/"); 25 | } 26 | return ( 27 | <> 28 |

Authenticate with authenticator app

29 |

Enter the code from your app.

30 | 31 | Use recovery code 32 | {user.registeredPasskey && Use passkeys} 33 | {user.registeredSecurityKey && Use security keys} 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /app/2fa/totp/setup/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { RefillingTokenBucket } from "@/lib/server/rate-limit"; 4 | import { getCurrentSession, setSessionAs2FAVerified } from "@/lib/server/session"; 5 | import { updateUserTOTPKey } from "@/lib/server/totp"; 6 | import { decodeBase64 } from "@oslojs/encoding"; 7 | import { verifyTOTP } from "@oslojs/otp"; 8 | import { redirect } from "next/navigation"; 9 | import { globalPOSTRateLimit } from "@/lib/server/request"; 10 | 11 | const totpUpdateBucket = new RefillingTokenBucket(3, 60 * 10); 12 | 13 | export async function setup2FAAction(_prev: ActionResult, formData: FormData): Promise { 14 | if (!globalPOSTRateLimit()) { 15 | return { 16 | message: "Too many requests" 17 | }; 18 | } 19 | 20 | const { session, user } = getCurrentSession(); 21 | if (session === null) { 22 | return { 23 | message: "Not authenticated" 24 | }; 25 | } 26 | if (!user.emailVerified) { 27 | return { 28 | message: "Forbidden" 29 | }; 30 | } 31 | if (user.registered2FA && !session.twoFactorVerified) { 32 | return { 33 | message: "Forbidden" 34 | }; 35 | } 36 | if (!totpUpdateBucket.check(user.id, 1)) { 37 | return { 38 | message: "Too many requests" 39 | }; 40 | } 41 | 42 | const encodedKey = formData.get("key"); 43 | const code = formData.get("code"); 44 | if (typeof encodedKey !== "string" || typeof code !== "string") { 45 | return { 46 | message: "Invalid or missing fields" 47 | }; 48 | } 49 | if (code === "") { 50 | return { 51 | message: "Please enter your code" 52 | }; 53 | } 54 | if (encodedKey.length !== 28) { 55 | return { 56 | message: "Please enter your code" 57 | }; 58 | } 59 | let key: Uint8Array; 60 | try { 61 | key = decodeBase64(encodedKey); 62 | } catch { 63 | return { 64 | message: "Invalid key" 65 | }; 66 | } 67 | if (key.byteLength !== 20) { 68 | return { 69 | message: "Invalid key" 70 | }; 71 | } 72 | if (!totpUpdateBucket.consume(user.id, 1)) { 73 | return { 74 | message: "Too many requests" 75 | }; 76 | } 77 | if (!verifyTOTP(key, 30, 6, code)) { 78 | return { 79 | message: "Invalid code" 80 | }; 81 | } 82 | updateUserTOTPKey(session.userId, key); 83 | setSessionAs2FAVerified(session.id); 84 | if (!user.registered2FA) { 85 | return redirect("/recovery-code"); 86 | } 87 | return redirect("/"); 88 | } 89 | 90 | interface ActionResult { 91 | message: string; 92 | } 93 | -------------------------------------------------------------------------------- /app/2fa/totp/setup/components.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { setup2FAAction } from "./actions"; 4 | import { useFormState } from "react-dom"; 5 | 6 | const initial2FASetUpState = { 7 | message: "" 8 | }; 9 | 10 | export function TwoFactorSetUpForm(props: { encodedTOTPKey: string }) { 11 | const [state, action] = useFormState(setup2FAAction, initial2FASetUpState); 12 | return ( 13 |
14 | 15 | 16 | 17 |
18 | 19 |

{state.message}

20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /app/2fa/totp/setup/page.tsx: -------------------------------------------------------------------------------- 1 | import { TwoFactorSetUpForm } from "./components"; 2 | 3 | import { getCurrentSession } from "@/lib/server/session"; 4 | import { encodeBase64 } from "@oslojs/encoding"; 5 | import { createTOTPKeyURI } from "@oslojs/otp"; 6 | import { redirect } from "next/navigation"; 7 | import { renderSVG } from "uqr"; 8 | import { get2FARedirect } from "@/lib/server/2fa"; 9 | import { globalGETRateLimit } from "@/lib/server/request"; 10 | 11 | export default function Page() { 12 | if (!globalGETRateLimit()) { 13 | return "Too many requests"; 14 | } 15 | 16 | const { session, user } = getCurrentSession(); 17 | if (session === null) { 18 | return redirect("/login"); 19 | } 20 | if (!user.emailVerified) { 21 | return redirect("/verify-email"); 22 | } 23 | if (user.registered2FA && !session.twoFactorVerified) { 24 | return redirect(get2FARedirect(user)); 25 | } 26 | 27 | const totpKey = new Uint8Array(20); 28 | crypto.getRandomValues(totpKey); 29 | const encodedTOTPKey = encodeBase64(totpKey); 30 | const keyURI = createTOTPKeyURI("Demo", user.username, totpKey, 30, 6); 31 | const qrcode = renderSVG(keyURI); 32 | return ( 33 | <> 34 |

Set up authenticator app

35 |
44 | 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /app/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { deleteSessionTokenCookie, getCurrentSession, invalidateSession } from "@/lib/server/session"; 4 | import { redirect } from "next/navigation"; 5 | import { globalPOSTRateLimit } from "@/lib/server/request"; 6 | 7 | export async function logoutAction(): Promise { 8 | if (!globalPOSTRateLimit()) { 9 | return { 10 | message: "Too many requests" 11 | }; 12 | } 13 | 14 | const { session } = getCurrentSession(); 15 | if (session === null) { 16 | return { 17 | message: "Not authenticated" 18 | }; 19 | } 20 | invalidateSession(session.id); 21 | deleteSessionTokenCookie(); 22 | return redirect("/login"); 23 | } 24 | 25 | interface ActionResult { 26 | message: string; 27 | } 28 | -------------------------------------------------------------------------------- /app/components.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { logoutAction } from "./actions"; 4 | import { useFormState } from "react-dom"; 5 | 6 | const initialState = { 7 | message: "" 8 | }; 9 | 10 | export function LogoutButton() { 11 | const [, action] = useFormState(logoutAction, initialState); 12 | return ( 13 |
14 | 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /app/forgot-password/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { verifyEmailInput } from "@/lib/server/email"; 4 | import { 5 | createPasswordResetSession, 6 | invalidateUserPasswordResetSessions, 7 | sendPasswordResetEmail, 8 | setPasswordResetSessionTokenCookie 9 | } from "@/lib/server/password-reset"; 10 | import { RefillingTokenBucket } from "@/lib/server/rate-limit"; 11 | import { generateSessionToken } from "@/lib/server/session"; 12 | import { getUserFromEmail } from "@/lib/server/user"; 13 | import { headers } from "next/headers"; 14 | import { redirect } from "next/navigation"; 15 | import { globalPOSTRateLimit } from "@/lib/server/request"; 16 | 17 | const passwordResetEmailIPBucket = new RefillingTokenBucket(3, 60); 18 | const passwordResetEmailUserBucket = new RefillingTokenBucket(3, 60); 19 | 20 | export async function forgotPasswordAction(_prev: ActionResult, formData: FormData): Promise { 21 | if (!globalPOSTRateLimit()) { 22 | return { 23 | message: "Too many requests" 24 | }; 25 | } 26 | 27 | // TODO: Assumes X-Forwarded-For is always included. 28 | const clientIP = headers().get("X-Forwarded-For"); 29 | if (clientIP !== null && !passwordResetEmailIPBucket.check(clientIP, 1)) { 30 | return { 31 | message: "Too many requests" 32 | }; 33 | } 34 | 35 | const email = formData.get("email"); 36 | if (typeof email !== "string") { 37 | return { 38 | message: "Invalid or missing fields" 39 | }; 40 | } 41 | if (!verifyEmailInput(email)) { 42 | return { 43 | message: "Invalid email" 44 | }; 45 | } 46 | const user = getUserFromEmail(email); 47 | if (user === null) { 48 | return { 49 | message: "Account does not exist" 50 | }; 51 | } 52 | if (clientIP !== null && !passwordResetEmailIPBucket.consume(clientIP, 1)) { 53 | return { 54 | message: "Too many requests" 55 | }; 56 | } 57 | if (!passwordResetEmailUserBucket.consume(user.id, 1)) { 58 | return { 59 | message: "Too many requests" 60 | }; 61 | } 62 | invalidateUserPasswordResetSessions(user.id); 63 | const sessionToken = generateSessionToken(); 64 | const session = createPasswordResetSession(sessionToken, user.id, user.email); 65 | 66 | sendPasswordResetEmail(session.email, session.code); 67 | setPasswordResetSessionTokenCookie(sessionToken, session.expiresAt); 68 | return redirect("/reset-password/verify-email"); 69 | } 70 | 71 | interface ActionResult { 72 | message: string; 73 | } 74 | -------------------------------------------------------------------------------- /app/forgot-password/components.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useFormState } from "react-dom"; 4 | import { forgotPasswordAction } from "./actions"; 5 | 6 | const initialForgotPasswordState = { 7 | message: "" 8 | }; 9 | 10 | export function ForgotPasswordForm() { 11 | const [state, action] = useFormState(forgotPasswordAction, initialForgotPasswordState); 12 | return ( 13 |
14 | 15 | 16 |
17 | 18 |

{state.message}

19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /app/forgot-password/page.tsx: -------------------------------------------------------------------------------- 1 | import { ForgotPasswordForm } from "./components"; 2 | import Link from "next/link"; 3 | 4 | import { globalGETRateLimit } from "@/lib/server/request"; 5 | 6 | export default function Page() { 7 | if (!globalGETRateLimit()) { 8 | return "Too many requests"; 9 | } 10 | 11 | return ( 12 | <> 13 |

Forgot your password?

14 | 15 | Sign in 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | 3 | export const metadata: Metadata = { 4 | title: "Email and password example with 2FA and WebAuthn in Next.js" 5 | }; 6 | 7 | export default function RootLayout({ 8 | children 9 | }: Readonly<{ 10 | children: React.ReactNode; 11 | }>) { 12 | return ( 13 | 14 | {children} 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /app/login/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { verifyEmailInput } from "@/lib/server/email"; 4 | import { verifyPasswordHash } from "@/lib/server/password"; 5 | import { RefillingTokenBucket, Throttler } from "@/lib/server/rate-limit"; 6 | import { createSession, generateSessionToken, setSessionTokenCookie } from "@/lib/server/session"; 7 | import { getUserFromEmail, getUserPasswordHash } from "@/lib/server/user"; 8 | import { headers } from "next/headers"; 9 | import { redirect } from "next/navigation"; 10 | import { 11 | ClientDataType, 12 | coseAlgorithmES256, 13 | coseAlgorithmRS256, 14 | createAssertionSignatureMessage, 15 | parseAuthenticatorData, 16 | parseClientDataJSON 17 | } from "@oslojs/webauthn"; 18 | import { ObjectParser } from "@pilcrowjs/object-parser"; 19 | import { decodeBase64 } from "@oslojs/encoding"; 20 | import { getPasskeyCredential, verifyWebAuthnChallenge } from "@/lib/server/webauthn"; 21 | import { decodePKIXECDSASignature, decodeSEC1PublicKey, p256, verifyECDSASignature } from "@oslojs/crypto/ecdsa"; 22 | import { sha256 } from "@oslojs/crypto/sha2"; 23 | import { decodePKCS1RSAPublicKey, sha256ObjectIdentifier, verifyRSASSAPKCS1v15Signature } from "@oslojs/crypto/rsa"; 24 | import { get2FARedirect } from "@/lib/server/2fa"; 25 | import { globalPOSTRateLimit } from "@/lib/server/request"; 26 | 27 | import type { SessionFlags } from "@/lib/server/session"; 28 | import type { AuthenticatorData, ClientData } from "@oslojs/webauthn"; 29 | 30 | const throttler = new Throttler([1, 2, 4, 8, 16, 30, 60, 180, 300]); 31 | const ipBucket = new RefillingTokenBucket(20, 1); 32 | 33 | export async function loginAction(_prev: ActionResult, formData: FormData): Promise { 34 | if (!globalPOSTRateLimit()) { 35 | return { 36 | message: "Too many requests" 37 | }; 38 | } 39 | // TODO: Assumes X-Forwarded-For is always included. 40 | const clientIP = headers().get("X-Forwarded-For"); 41 | if (clientIP !== null && !ipBucket.check(clientIP, 1)) { 42 | return { 43 | message: "Too many requests" 44 | }; 45 | } 46 | 47 | const email = formData.get("email"); 48 | const password = formData.get("password"); 49 | if (typeof email !== "string" || typeof password !== "string") { 50 | return { 51 | message: "Invalid or missing fields" 52 | }; 53 | } 54 | if (email === "" || password === "") { 55 | return { 56 | message: "Please enter your email and password." 57 | }; 58 | } 59 | if (!verifyEmailInput(email)) { 60 | return { 61 | message: "Invalid email" 62 | }; 63 | } 64 | const user = getUserFromEmail(email); 65 | if (user === null) { 66 | return { 67 | message: "Account does not exist" 68 | }; 69 | } 70 | if (clientIP !== null && !ipBucket.consume(clientIP, 1)) { 71 | return { 72 | message: "Too many requests" 73 | }; 74 | } 75 | if (!throttler.consume(user.id)) { 76 | return { 77 | message: "Too many requests" 78 | }; 79 | } 80 | const passwordHash = getUserPasswordHash(user.id); 81 | const validPassword = await verifyPasswordHash(passwordHash, password); 82 | if (!validPassword) { 83 | return { 84 | message: "Invalid password" 85 | }; 86 | } 87 | throttler.reset(user.id); 88 | const sessionFlags: SessionFlags = { 89 | twoFactorVerified: false 90 | }; 91 | const sessionToken = generateSessionToken(); 92 | const session = createSession(sessionToken, user.id, sessionFlags); 93 | setSessionTokenCookie(sessionToken, session.expiresAt); 94 | 95 | if (!user.emailVerified) { 96 | return redirect("/verify-email"); 97 | } 98 | if (!user.registered2FA) { 99 | return redirect("/2fa/setup"); 100 | } 101 | return redirect(get2FARedirect(user)); 102 | } 103 | 104 | export async function loginWithPasskeyAction(data: unknown): Promise { 105 | if (!globalPOSTRateLimit()) { 106 | return { 107 | message: "Too many requests" 108 | }; 109 | } 110 | 111 | const parser = new ObjectParser(data); 112 | let encodedAuthenticatorData: string; 113 | let encodedClientDataJSON: string; 114 | let encodedCredentialId: string; 115 | let encodedSignature: string; 116 | try { 117 | encodedAuthenticatorData = parser.getString("authenticator_data"); 118 | encodedClientDataJSON = parser.getString("client_data_json"); 119 | encodedCredentialId = parser.getString("credential_id"); 120 | encodedSignature = parser.getString("signature"); 121 | } catch { 122 | return { 123 | message: "Invalid or missing fields" 124 | }; 125 | } 126 | let authenticatorDataBytes: Uint8Array; 127 | let clientDataJSON: Uint8Array; 128 | let credentialId: Uint8Array; 129 | let signatureBytes: Uint8Array; 130 | try { 131 | authenticatorDataBytes = decodeBase64(encodedAuthenticatorData); 132 | clientDataJSON = decodeBase64(encodedClientDataJSON); 133 | credentialId = decodeBase64(encodedCredentialId); 134 | signatureBytes = decodeBase64(encodedSignature); 135 | } catch { 136 | return { 137 | message: "Invalid or missing fields" 138 | }; 139 | } 140 | 141 | let authenticatorData: AuthenticatorData; 142 | try { 143 | authenticatorData = parseAuthenticatorData(authenticatorDataBytes); 144 | } catch { 145 | return { 146 | message: "Invalid data" 147 | }; 148 | } 149 | // TODO: Update host 150 | if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) { 151 | return { 152 | message: "Invalid data" 153 | }; 154 | } 155 | if (!authenticatorData.userPresent || !authenticatorData.userVerified) { 156 | return { 157 | message: "Invalid data" 158 | }; 159 | } 160 | 161 | let clientData: ClientData; 162 | try { 163 | clientData = parseClientDataJSON(clientDataJSON); 164 | } catch { 165 | return { 166 | message: "Invalid data" 167 | }; 168 | } 169 | if (clientData.type !== ClientDataType.Get) { 170 | return { 171 | message: "Invalid data" 172 | }; 173 | } 174 | 175 | if (!verifyWebAuthnChallenge(clientData.challenge)) { 176 | return { 177 | message: "Invalid data" 178 | }; 179 | } 180 | // TODO: Update origin 181 | if (clientData.origin !== "http://localhost:3000") { 182 | return { 183 | message: "Invalid data" 184 | }; 185 | } 186 | if (clientData.crossOrigin !== null && clientData.crossOrigin) { 187 | return { 188 | message: "Invalid data" 189 | }; 190 | } 191 | 192 | const credential = getPasskeyCredential(credentialId); 193 | if (credential === null) { 194 | return { 195 | message: "Invalid credential" 196 | }; 197 | } 198 | 199 | let validSignature: boolean; 200 | if (credential.algorithmId === coseAlgorithmES256) { 201 | const ecdsaSignature = decodePKIXECDSASignature(signatureBytes); 202 | const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey); 203 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 204 | validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature); 205 | } else if (credential.algorithmId === coseAlgorithmRS256) { 206 | const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey); 207 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 208 | validSignature = verifyRSASSAPKCS1v15Signature(rsaPublicKey, sha256ObjectIdentifier, hash, signatureBytes); 209 | } else { 210 | return { 211 | message: "Internal error" 212 | }; 213 | } 214 | 215 | if (!validSignature) { 216 | return { 217 | message: "Invalid signature" 218 | }; 219 | } 220 | const sessionFlags: SessionFlags = { 221 | twoFactorVerified: true 222 | }; 223 | const sessionToken = generateSessionToken(); 224 | const session = createSession(sessionToken, credential.userId, sessionFlags); 225 | setSessionTokenCookie(sessionToken, session.expiresAt); 226 | return redirect("/"); 227 | } 228 | 229 | interface ActionResult { 230 | message: string; 231 | } 232 | -------------------------------------------------------------------------------- /app/login/components.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { loginAction, loginWithPasskeyAction } from "./actions"; 5 | import { useFormState } from "react-dom"; 6 | import { encodeBase64 } from "@oslojs/encoding"; 7 | import { createChallenge } from "@/lib/client/webauthn"; 8 | 9 | const initialState = { 10 | message: "" 11 | }; 12 | 13 | export function LoginForm() { 14 | const [state, action] = useFormState(loginAction, initialState); 15 | 16 | return ( 17 |
18 | 19 | 20 |
21 | 22 | 23 |
24 | 25 |

{state.message}

26 |
27 | ); 28 | } 29 | 30 | export function PasskeyLoginButton() { 31 | const [message, setMessage] = useState(""); 32 | return ( 33 | <> 34 | 63 |

{message}

64 | 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /app/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { LoginForm } from "./components"; 2 | import Link from "next/link"; 3 | 4 | import { getCurrentSession } from "@/lib/server/session"; 5 | import { redirect } from "next/navigation"; 6 | import { get2FARedirect } from "@/lib/server/2fa"; 7 | import { globalGETRateLimit } from "@/lib/server/request"; 8 | 9 | export default function Page() { 10 | if (!globalGETRateLimit()) { 11 | return "Too many requests"; 12 | } 13 | 14 | const { session, user } = getCurrentSession(); 15 | if (session !== null) { 16 | if (!user.emailVerified) { 17 | return redirect("/verify-email"); 18 | } 19 | if (!user.registered2FA) { 20 | return redirect("/2fa/setup"); 21 | } 22 | if (!session.twoFactorVerified) { 23 | return redirect(get2FARedirect(user)); 24 | } 25 | return redirect("/"); 26 | } 27 | return ( 28 | <> 29 |

Sign in

30 | 31 | Create an account 32 | Forgot password? 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { LogoutButton } from "./components"; 2 | import Link from "next/link"; 3 | 4 | import { getCurrentSession } from "@/lib/server/session"; 5 | import { redirect } from "next/navigation"; 6 | import { get2FARedirect } from "@/lib/server/2fa"; 7 | import { globalGETRateLimit } from "@/lib/server/request"; 8 | 9 | export default function Page() { 10 | if (!globalGETRateLimit()) { 11 | return "Too many requests"; 12 | } 13 | 14 | const { session, user } = getCurrentSession(); 15 | if (session === null) { 16 | return redirect("/login"); 17 | } 18 | if (!user.emailVerified) { 19 | return redirect("/verify-email"); 20 | } 21 | if (!user.registered2FA) { 22 | return redirect("/2fa/setup"); 23 | } 24 | if (!session.twoFactorVerified) { 25 | return redirect(get2FARedirect(user)); 26 | } 27 | return ( 28 | <> 29 |
30 | Home 31 | Settings 32 |
33 |
34 |

Hi {user.username}!

35 | 36 |
37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /app/recovery-code/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | import { get2FARedirect } from "@/lib/server/2fa"; 4 | import { getCurrentSession } from "@/lib/server/session"; 5 | import { getUserRecoverCode } from "@/lib/server/user"; 6 | import { redirect } from "next/navigation"; 7 | import { globalGETRateLimit } from "@/lib/server/request"; 8 | 9 | export default function Page() { 10 | if (!globalGETRateLimit()) { 11 | return "Too many requests"; 12 | } 13 | 14 | const { session, user } = getCurrentSession(); 15 | if (session === null) { 16 | return redirect("/login"); 17 | } 18 | if (!user.emailVerified) { 19 | return redirect("/verify-email"); 20 | } 21 | if (!user.registered2FA) { 22 | return redirect("/2fa/setup"); 23 | } 24 | if (!session.twoFactorVerified) { 25 | return redirect(get2FARedirect(user)); 26 | } 27 | const recoveryCode = getUserRecoverCode(user.id); 28 | return ( 29 | <> 30 |

Recovery code

31 |

Your recovery code is: {recoveryCode}

32 |

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

33 | Next 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /app/reset-password/2fa/passkey/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { getCurrentPasswordResetSession, setPasswordResetSessionAs2FAVerified } from "@/lib/server/password-reset"; 4 | import { decodeBase64 } from "@oslojs/encoding"; 5 | import { ObjectParser } from "@pilcrowjs/object-parser"; 6 | import { 7 | ClientDataType, 8 | coseAlgorithmES256, 9 | coseAlgorithmRS256, 10 | createAssertionSignatureMessage, 11 | parseAuthenticatorData, 12 | parseClientDataJSON 13 | } from "@oslojs/webauthn"; 14 | import { getUserPasskeyCredential, verifyWebAuthnChallenge } from "@/lib/server/webauthn"; 15 | import { decodePKIXECDSASignature, decodeSEC1PublicKey, p256, verifyECDSASignature } from "@oslojs/crypto/ecdsa"; 16 | import { sha256 } from "@oslojs/crypto/sha2"; 17 | import { decodePKCS1RSAPublicKey, sha256ObjectIdentifier, verifyRSASSAPKCS1v15Signature } from "@oslojs/crypto/rsa"; 18 | import { globalPOSTRateLimit } from "@/lib/server/request"; 19 | 20 | import type { AuthenticatorData, ClientData } from "@oslojs/webauthn"; 21 | 22 | export async function verify2FAWithPasskeyAction(data: unknown): Promise { 23 | if (!globalPOSTRateLimit()) { 24 | return { 25 | error: "Too many requests" 26 | }; 27 | } 28 | 29 | const { session, user } = getCurrentPasswordResetSession(); 30 | if (session === null || user === null) { 31 | return { 32 | error: "Not authenticated" 33 | }; 34 | } 35 | if (!session.emailVerified || !user.registeredPasskey || session.twoFactorVerified) { 36 | return { 37 | error: "Forbidden" 38 | }; 39 | } 40 | 41 | const parser = new ObjectParser(data); 42 | let encodedAuthenticatorData: string; 43 | let encodedClientDataJSON: string; 44 | let encodedCredentialId: string; 45 | let encodedSignature: string; 46 | try { 47 | encodedAuthenticatorData = parser.getString("authenticator_data"); 48 | encodedClientDataJSON = parser.getString("client_data_json"); 49 | encodedCredentialId = parser.getString("credential_id"); 50 | encodedSignature = parser.getString("signature"); 51 | } catch { 52 | return { 53 | error: "Invalid or missing fields" 54 | }; 55 | } 56 | let authenticatorDataBytes: Uint8Array; 57 | let clientDataJSON: Uint8Array; 58 | let credentialId: Uint8Array; 59 | let signatureBytes: Uint8Array; 60 | try { 61 | authenticatorDataBytes = decodeBase64(encodedAuthenticatorData); 62 | clientDataJSON = decodeBase64(encodedClientDataJSON); 63 | credentialId = decodeBase64(encodedCredentialId); 64 | signatureBytes = decodeBase64(encodedSignature); 65 | } catch { 66 | return { 67 | error: "Invalid or missing fields" 68 | }; 69 | } 70 | 71 | let authenticatorData: AuthenticatorData; 72 | try { 73 | authenticatorData = parseAuthenticatorData(authenticatorDataBytes); 74 | } catch { 75 | return { 76 | error: "Invalid data" 77 | }; 78 | } 79 | // TODO: Update host 80 | if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) { 81 | return { 82 | error: "Invalid data" 83 | }; 84 | } 85 | if (!authenticatorData.userPresent) { 86 | return { 87 | error: "Invalid data" 88 | }; 89 | } 90 | 91 | let clientData: ClientData; 92 | try { 93 | clientData = parseClientDataJSON(clientDataJSON); 94 | } catch { 95 | return { 96 | error: "Invalid data" 97 | }; 98 | } 99 | if (clientData.type !== ClientDataType.Get) { 100 | return { 101 | error: "Invalid data" 102 | }; 103 | } 104 | 105 | if (!verifyWebAuthnChallenge(clientData.challenge)) { 106 | return { 107 | error: "Invalid data" 108 | }; 109 | } 110 | // TODO: Update origin 111 | if (clientData.origin !== "http://localhost:3000") { 112 | return { 113 | error: "Invalid data" 114 | }; 115 | } 116 | if (clientData.crossOrigin !== null && clientData.crossOrigin) { 117 | return { 118 | error: "Invalid data" 119 | }; 120 | } 121 | 122 | const credential = getUserPasskeyCredential(user.id, credentialId); 123 | if (credential === null) { 124 | return { 125 | error: "Invalid credential" 126 | }; 127 | } 128 | 129 | let validSignature: boolean; 130 | if (credential.algorithmId === coseAlgorithmES256) { 131 | const ecdsaSignature = decodePKIXECDSASignature(signatureBytes); 132 | const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey); 133 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 134 | validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature); 135 | } else if (credential.algorithmId === coseAlgorithmRS256) { 136 | const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey); 137 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 138 | validSignature = verifyRSASSAPKCS1v15Signature(rsaPublicKey, sha256ObjectIdentifier, hash, signatureBytes); 139 | } else { 140 | return { 141 | error: "Internal error" 142 | }; 143 | } 144 | 145 | if (!validSignature) { 146 | return { 147 | error: "Invalid data" 148 | }; 149 | } 150 | 151 | setPasswordResetSessionAs2FAVerified(session.id); 152 | return { 153 | error: null 154 | }; 155 | } 156 | 157 | interface ActionResult { 158 | error: string | null; 159 | } 160 | -------------------------------------------------------------------------------- /app/reset-password/2fa/passkey/components.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { createChallenge } from "@/lib/client/webauthn"; 4 | import { decodeBase64, encodeBase64 } from "@oslojs/encoding"; 5 | import { verify2FAWithPasskeyAction } from "./actions"; 6 | import { useState } from "react"; 7 | import { useRouter } from "next/navigation"; 8 | 9 | export function Verify2FAWithPasskeyButton(props: { encodedCredentialIds: string[] }) { 10 | const router = useRouter(); 11 | const [message, setMessage] = useState(""); 12 | return ( 13 |
14 | 53 |

{message}

54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /app/reset-password/2fa/passkey/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Verify2FAWithPasskeyButton } from "./components"; 3 | 4 | import { getUserPasskeyCredentials } from "@/lib/server/webauthn"; 5 | import { redirect } from "next/navigation"; 6 | import { getCurrentPasswordResetSession } from "@/lib/server/password-reset"; 7 | import { getPasswordReset2FARedirect } from "@/lib/server/2fa"; 8 | import { encodeBase64 } from "@oslojs/encoding"; 9 | import { globalGETRateLimit } from "@/lib/server/request"; 10 | 11 | export default function Page() { 12 | if (!globalGETRateLimit()) { 13 | return "Too many requests"; 14 | } 15 | 16 | const { session, user } = getCurrentPasswordResetSession(); 17 | 18 | if (session === null) { 19 | return redirect("/forgot-password"); 20 | } 21 | if (!session.emailVerified) { 22 | return redirect("/reset-password/verify-email"); 23 | } 24 | if (!user.registered2FA) { 25 | return redirect("/reset-password"); 26 | } 27 | if (session.twoFactorVerified) { 28 | return redirect("/reset-password"); 29 | } 30 | if (!user.registeredPasskey) { 31 | return redirect(getPasswordReset2FARedirect(user)); 32 | } 33 | const credentials = getUserPasskeyCredentials(user.id); 34 | return ( 35 | <> 36 |

Authenticate with passkeys

37 | encodeBase64(credential.id))} /> 38 | Use recovery code 39 | {user.registeredTOTP && Use authenticator apps} 40 | {user.registeredSecurityKey && Use security keys} 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /app/reset-password/2fa/recovery-code/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { recoveryCodeBucket, resetUser2FAWithRecoveryCode } from "@/lib/server/2fa"; 4 | import { getCurrentPasswordResetSession } from "@/lib/server/password-reset"; 5 | import { globalPOSTRateLimit } from "@/lib/server/request"; 6 | import { redirect } from "next/navigation"; 7 | 8 | export async function verifyPasswordReset2FAWithRecoveryCodeAction( 9 | _prev: ActionResult, 10 | formData: FormData 11 | ): Promise { 12 | if (!globalPOSTRateLimit()) { 13 | return { 14 | message: "Too many requests" 15 | }; 16 | } 17 | 18 | const { session, user } = getCurrentPasswordResetSession(); 19 | if (session === null) { 20 | return { 21 | message: "Not authenticated" 22 | }; 23 | } 24 | if (!session.emailVerified || !user.registered2FA || session.twoFactorVerified) { 25 | return { 26 | message: "Forbidden" 27 | }; 28 | } 29 | 30 | if (!recoveryCodeBucket.check(session.userId, 1)) { 31 | return { 32 | message: "Too many requests" 33 | }; 34 | } 35 | const code = formData.get("code"); 36 | if (typeof code !== "string") { 37 | return { 38 | message: "Invalid or missing fields" 39 | }; 40 | } 41 | if (code === "") { 42 | return { 43 | message: "Please enter your code" 44 | }; 45 | } 46 | if (!recoveryCodeBucket.consume(session.userId, 1)) { 47 | return { 48 | message: "Too many requests" 49 | }; 50 | } 51 | const valid = resetUser2FAWithRecoveryCode(session.userId, code); 52 | if (!valid) { 53 | return { 54 | message: "Invalid code" 55 | }; 56 | } 57 | recoveryCodeBucket.reset(session.userId); 58 | return redirect("/reset-password"); 59 | } 60 | 61 | interface ActionResult { 62 | message: string; 63 | } 64 | -------------------------------------------------------------------------------- /app/reset-password/2fa/recovery-code/components.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useFormState } from "react-dom"; 4 | import { verifyPasswordReset2FAWithRecoveryCodeAction } from "./actions"; 5 | 6 | const initialPasswordResetRecoveryCodeState = { 7 | message: "" 8 | }; 9 | 10 | export function PasswordResetRecoveryCodeForm() { 11 | const [state, action] = useFormState( 12 | verifyPasswordReset2FAWithRecoveryCodeAction, 13 | initialPasswordResetRecoveryCodeState 14 | ); 15 | return ( 16 |
17 | 18 | 19 |
20 |
21 | 22 |

{state.message}

23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /app/reset-password/2fa/recovery-code/page.tsx: -------------------------------------------------------------------------------- 1 | import { PasswordResetRecoveryCodeForm } from "./components"; 2 | 3 | import { getCurrentPasswordResetSession } from "@/lib/server/password-reset"; 4 | import { redirect } from "next/navigation"; 5 | import { globalGETRateLimit } from "@/lib/server/request"; 6 | 7 | export default function Page() { 8 | if (!globalGETRateLimit()) { 9 | return "Too many requests"; 10 | } 11 | 12 | const { session, user } = getCurrentPasswordResetSession(); 13 | 14 | if (session === null) { 15 | return redirect("/forgot-password"); 16 | } 17 | if (!session.emailVerified) { 18 | return redirect("/reset-password/verify-email"); 19 | } 20 | if (!user.registered2FA) { 21 | return redirect("/reset-password"); 22 | } 23 | if (session.twoFactorVerified) { 24 | return redirect("/reset-password"); 25 | } 26 | return ( 27 | <> 28 |

Use your recovery code

29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /app/reset-password/2fa/route.ts: -------------------------------------------------------------------------------- 1 | import { getPasswordReset2FARedirect } from "@/lib/server/2fa"; 2 | import { getCurrentPasswordResetSession } from "@/lib/server/password-reset"; 3 | import { globalGETRateLimit } from "@/lib/server/request"; 4 | 5 | export async function GET() { 6 | if (!globalGETRateLimit()) { 7 | return new Response("Too many requests", { 8 | status: 429 9 | }); 10 | } 11 | const { session, user } = getCurrentPasswordResetSession(); 12 | if (session === null) { 13 | return new Response(null, { 14 | status: 302, 15 | headers: { 16 | Location: "/login" 17 | } 18 | }); 19 | } 20 | if (!user.registered2FA || session.twoFactorVerified) { 21 | return new Response(null, { 22 | status: 302, 23 | headers: { 24 | Location: "/reset-password" 25 | } 26 | }); 27 | } 28 | return new Response(null, { 29 | status: 302, 30 | headers: { 31 | Location: getPasswordReset2FARedirect(user) 32 | } 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /app/reset-password/2fa/security-key/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { getCurrentPasswordResetSession, setPasswordResetSessionAs2FAVerified } from "@/lib/server/password-reset"; 4 | import { decodeBase64 } from "@oslojs/encoding"; 5 | import { ObjectParser } from "@pilcrowjs/object-parser"; 6 | import { 7 | ClientDataType, 8 | coseAlgorithmES256, 9 | coseAlgorithmRS256, 10 | createAssertionSignatureMessage, 11 | parseAuthenticatorData, 12 | parseClientDataJSON 13 | } from "@oslojs/webauthn"; 14 | import { getUserSecurityKeyCredential, verifyWebAuthnChallenge } from "@/lib/server/webauthn"; 15 | import { decodePKIXECDSASignature, decodeSEC1PublicKey, p256, verifyECDSASignature } from "@oslojs/crypto/ecdsa"; 16 | import { sha256 } from "@oslojs/crypto/sha2"; 17 | import { decodePKCS1RSAPublicKey, sha256ObjectIdentifier, verifyRSASSAPKCS1v15Signature } from "@oslojs/crypto/rsa"; 18 | import { globalPOSTRateLimit } from "@/lib/server/request"; 19 | 20 | import type { AuthenticatorData, ClientData } from "@oslojs/webauthn"; 21 | 22 | export async function verify2FAWithSecurityKeyAction(data: unknown): Promise { 23 | if (!globalPOSTRateLimit()) { 24 | return { 25 | error: "Too many requests" 26 | }; 27 | } 28 | 29 | const { session, user } = getCurrentPasswordResetSession(); 30 | if (session === null || user === null) { 31 | return { 32 | error: "Not authenticated" 33 | }; 34 | } 35 | if (!session.emailVerified || !user.registeredSecurityKey || session.twoFactorVerified) { 36 | return { 37 | error: "Forbidden" 38 | }; 39 | } 40 | 41 | const parser = new ObjectParser(data); 42 | let encodedAuthenticatorData: string; 43 | let encodedClientDataJSON: string; 44 | let encodedCredentialId: string; 45 | let encodedSignature: string; 46 | try { 47 | encodedAuthenticatorData = parser.getString("authenticator_data"); 48 | encodedClientDataJSON = parser.getString("client_data_json"); 49 | encodedCredentialId = parser.getString("credential_id"); 50 | encodedSignature = parser.getString("signature"); 51 | } catch { 52 | return { 53 | error: "Invalid or missing fields" 54 | }; 55 | } 56 | let authenticatorDataBytes: Uint8Array; 57 | let clientDataJSON: Uint8Array; 58 | let credentialId: Uint8Array; 59 | let signatureBytes: Uint8Array; 60 | try { 61 | authenticatorDataBytes = decodeBase64(encodedAuthenticatorData); 62 | clientDataJSON = decodeBase64(encodedClientDataJSON); 63 | credentialId = decodeBase64(encodedCredentialId); 64 | signatureBytes = decodeBase64(encodedSignature); 65 | } catch { 66 | return { 67 | error: "Invalid or missing fields" 68 | }; 69 | } 70 | 71 | let authenticatorData: AuthenticatorData; 72 | try { 73 | authenticatorData = parseAuthenticatorData(authenticatorDataBytes); 74 | } catch { 75 | return { 76 | error: "Invalid data" 77 | }; 78 | } 79 | // TODO: Update host 80 | if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) { 81 | return { 82 | error: "Invalid data" 83 | }; 84 | } 85 | if (!authenticatorData.userPresent) { 86 | return { 87 | error: "Invalid data" 88 | }; 89 | } 90 | 91 | let clientData: ClientData; 92 | try { 93 | clientData = parseClientDataJSON(clientDataJSON); 94 | } catch { 95 | return { 96 | error: "Invalid data" 97 | }; 98 | } 99 | if (clientData.type !== ClientDataType.Get) { 100 | return { 101 | error: "Invalid data" 102 | }; 103 | } 104 | 105 | if (!verifyWebAuthnChallenge(clientData.challenge)) { 106 | return { 107 | error: "Invalid data" 108 | }; 109 | } 110 | // TODO: Update origin 111 | if (clientData.origin !== "http://localhost:3000") { 112 | return { 113 | error: "Invalid data" 114 | }; 115 | } 116 | if (clientData.crossOrigin !== null && clientData.crossOrigin) { 117 | return { 118 | error: "Invalid data" 119 | }; 120 | } 121 | 122 | const credential = getUserSecurityKeyCredential(user.id, credentialId); 123 | if (credential === null) { 124 | return { 125 | error: "Invalid credential" 126 | }; 127 | } 128 | 129 | let validSignature: boolean; 130 | if (credential.algorithmId === coseAlgorithmES256) { 131 | const ecdsaSignature = decodePKIXECDSASignature(signatureBytes); 132 | const ecdsaPublicKey = decodeSEC1PublicKey(p256, credential.publicKey); 133 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 134 | validSignature = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature); 135 | } else if (credential.algorithmId === coseAlgorithmRS256) { 136 | const rsaPublicKey = decodePKCS1RSAPublicKey(credential.publicKey); 137 | const hash = sha256(createAssertionSignatureMessage(authenticatorDataBytes, clientDataJSON)); 138 | validSignature = verifyRSASSAPKCS1v15Signature(rsaPublicKey, sha256ObjectIdentifier, hash, signatureBytes); 139 | } else { 140 | return { 141 | error: "Internal error" 142 | }; 143 | } 144 | 145 | if (!validSignature) { 146 | return { 147 | error: "Invalid data" 148 | }; 149 | } 150 | 151 | setPasswordResetSessionAs2FAVerified(session.id); 152 | return { 153 | error: null 154 | }; 155 | } 156 | 157 | interface ActionResult { 158 | error: string | null; 159 | } 160 | -------------------------------------------------------------------------------- /app/reset-password/2fa/security-key/components.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { createChallenge } from "@/lib/client/webauthn"; 4 | import { decodeBase64, encodeBase64 } from "@oslojs/encoding"; 5 | import { verify2FAWithSecurityKeyAction } from "./actions"; 6 | import { useState } from "react"; 7 | import { useRouter } from "next/navigation"; 8 | 9 | export function Verify2FAWithSecurityKeyButton(props: { encodedCredentialIds: string[] }) { 10 | const router = useRouter(); 11 | const [message, setMessage] = useState(""); 12 | return ( 13 |
14 | 53 |

{message}

54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /app/reset-password/2fa/security-key/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Verify2FAWithSecurityKeyButton } from "./components"; 3 | 4 | import { getUserSecurityKeyCredentials } from "@/lib/server/webauthn"; 5 | import { redirect } from "next/navigation"; 6 | import { getCurrentPasswordResetSession } from "@/lib/server/password-reset"; 7 | import { getPasswordReset2FARedirect } from "@/lib/server/2fa"; 8 | import { encodeBase64 } from "@oslojs/encoding"; 9 | import { globalGETRateLimit } from "@/lib/server/request"; 10 | 11 | export default function Page() { 12 | if (!globalGETRateLimit()) { 13 | return "Too many requests"; 14 | } 15 | 16 | const { session, user } = getCurrentPasswordResetSession(); 17 | 18 | if (session === null) { 19 | return redirect("/forgot-password"); 20 | } 21 | if (!session.emailVerified) { 22 | return redirect("/reset-password/verify-email"); 23 | } 24 | if (!user.registered2FA) { 25 | return redirect("/reset-password"); 26 | } 27 | if (session.twoFactorVerified) { 28 | return redirect("/reset-password"); 29 | } 30 | if (!user.registeredSecurityKey) { 31 | return redirect(getPasswordReset2FARedirect(user)); 32 | } 33 | const credentials = getUserSecurityKeyCredentials(user.id); 34 | return ( 35 | <> 36 |

Authenticate with security keys

37 | encodeBase64(credential.id))} 39 | /> 40 | Use recovery code 41 | {user.registeredTOTP && Use authenticator apps} 42 | {user.registeredPasskey && Use passkeys} 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /app/reset-password/2fa/totp/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { setPasswordResetSessionAs2FAVerified, getCurrentPasswordResetSession } from "@/lib/server/password-reset"; 4 | import { getUserTOTPKey, totpBucket } from "@/lib/server/totp"; 5 | import { verifyTOTP } from "@oslojs/otp"; 6 | import { redirect } from "next/navigation"; 7 | import { globalPOSTRateLimit } from "@/lib/server/request"; 8 | 9 | export async function verifyPasswordReset2FAWithTOTPAction( 10 | _prev: ActionResult, 11 | formData: FormData 12 | ): Promise { 13 | if (!globalPOSTRateLimit()) { 14 | return { 15 | message: "Too many requests" 16 | }; 17 | } 18 | 19 | const { session, user } = getCurrentPasswordResetSession(); 20 | if (session === null) { 21 | return { 22 | message: "Not authenticated" 23 | }; 24 | } 25 | if (!session.emailVerified || !user.registeredTOTP || session.twoFactorVerified) { 26 | return { 27 | message: "Forbidden" 28 | }; 29 | } 30 | if (!totpBucket.check(session.userId, 1)) { 31 | return { 32 | message: "Too many requests" 33 | }; 34 | } 35 | 36 | const code = formData.get("code"); 37 | if (typeof code !== "string") { 38 | return { 39 | message: "Invalid or missing fields" 40 | }; 41 | } 42 | if (code === "") { 43 | return { 44 | message: "Please enter your code" 45 | }; 46 | } 47 | const totpKey = getUserTOTPKey(session.userId); 48 | if (totpKey === null) { 49 | return { 50 | message: "Forbidden" 51 | }; 52 | } 53 | if (!totpBucket.consume(session.userId, 1)) { 54 | return { 55 | message: "Too many requests" 56 | }; 57 | } 58 | if (!verifyTOTP(totpKey, 30, 6, code)) { 59 | return { 60 | message: "Invalid code" 61 | }; 62 | } 63 | totpBucket.reset(session.userId); 64 | setPasswordResetSessionAs2FAVerified(session.id); 65 | return redirect("/reset-password"); 66 | } 67 | 68 | interface ActionResult { 69 | message: string; 70 | } 71 | -------------------------------------------------------------------------------- /app/reset-password/2fa/totp/components.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useFormState } from "react-dom"; 4 | import { verifyPasswordReset2FAWithTOTPAction } from "./actions"; 5 | 6 | const initialPasswordResetTOTPState = { 7 | message: "" 8 | }; 9 | 10 | export function PasswordResetTOTPForm() { 11 | const [state, action] = useFormState(verifyPasswordReset2FAWithTOTPAction, initialPasswordResetTOTPState); 12 | return ( 13 |
14 | 15 | 16 |
17 | 18 |

{state.message}

19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /app/reset-password/2fa/totp/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { PasswordResetTOTPForm } from "./components"; 3 | 4 | import { getCurrentPasswordResetSession } from "@/lib/server/password-reset"; 5 | import { redirect } from "next/navigation"; 6 | import { getPasswordReset2FARedirect } from "@/lib/server/2fa"; 7 | import { globalGETRateLimit } from "@/lib/server/request"; 8 | 9 | export default function Page() { 10 | if (!globalGETRateLimit()) { 11 | return "Too many requests"; 12 | } 13 | 14 | const { session, user } = getCurrentPasswordResetSession(); 15 | 16 | if (session === null) { 17 | return redirect("/forgot-password"); 18 | } 19 | if (!session.emailVerified) { 20 | return redirect("/reset-password/verify-email"); 21 | } 22 | if (!user.registered2FA) { 23 | return redirect("/reset-password"); 24 | } 25 | if (session.twoFactorVerified) { 26 | return redirect("/reset-password"); 27 | } 28 | if (!user.registeredTOTP) { 29 | return redirect(getPasswordReset2FARedirect(user)); 30 | } 31 | return ( 32 | <> 33 |

Authenticate with authenticator app

34 |

Enter the code from your app.

35 | 36 | Use recovery code 37 | {user.registeredSecurityKey && Use security keys} 38 | {user.registeredPasskey && Use passkeys} 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /app/reset-password/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { verifyPasswordStrength } from "@/lib/server/password"; 4 | import { 5 | deletePasswordResetSessionTokenCookie, 6 | invalidateUserPasswordResetSessions, 7 | getCurrentPasswordResetSession 8 | } from "@/lib/server/password-reset"; 9 | import { 10 | createSession, 11 | generateSessionToken, 12 | invalidateUserSessions, 13 | setSessionTokenCookie 14 | } from "@/lib/server/session"; 15 | import { updateUserPassword } from "@/lib/server/user"; 16 | import { redirect } from "next/navigation"; 17 | import { globalPOSTRateLimit } from "@/lib/server/request"; 18 | 19 | import type { SessionFlags } from "@/lib/server/session"; 20 | 21 | export async function resetPasswordAction(_prev: ActionResult, formData: FormData): Promise { 22 | if (!globalPOSTRateLimit()) { 23 | return { 24 | message: "Too many requests" 25 | }; 26 | } 27 | 28 | const { session: passwordResetSession, user } = getCurrentPasswordResetSession(); 29 | if (passwordResetSession === null) { 30 | return { 31 | message: "Not authenticated" 32 | }; 33 | } 34 | if (!passwordResetSession.emailVerified) { 35 | return { 36 | message: "Forbidden" 37 | }; 38 | } 39 | if (user.registered2FA && !passwordResetSession.twoFactorVerified) { 40 | return { 41 | message: "Forbidden" 42 | }; 43 | } 44 | 45 | const password = formData.get("password"); 46 | if (typeof password !== "string") { 47 | return { 48 | message: "Invalid or missing fields" 49 | }; 50 | } 51 | 52 | const strongPassword = await verifyPasswordStrength(password); 53 | if (!strongPassword) { 54 | return { 55 | message: "Weak password" 56 | }; 57 | } 58 | invalidateUserPasswordResetSessions(passwordResetSession.userId); 59 | invalidateUserSessions(passwordResetSession.userId); 60 | await updateUserPassword(passwordResetSession.userId, password); 61 | 62 | const sessionFlags: SessionFlags = { 63 | twoFactorVerified: passwordResetSession.twoFactorVerified 64 | }; 65 | const sessionToken = generateSessionToken(); 66 | const session = createSession(sessionToken, user.id, sessionFlags); 67 | setSessionTokenCookie(sessionToken, session.expiresAt); 68 | deletePasswordResetSessionTokenCookie(); 69 | return redirect("/"); 70 | } 71 | 72 | interface ActionResult { 73 | message: string; 74 | } 75 | -------------------------------------------------------------------------------- /app/reset-password/components.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useFormState } from "react-dom"; 4 | import { resetPasswordAction } from "./actions"; 5 | 6 | const initialPasswordResetState = { 7 | message: "" 8 | }; 9 | 10 | export function PasswordResetForm() { 11 | const [state, action] = useFormState(resetPasswordAction, initialPasswordResetState); 12 | return ( 13 |
14 | 15 | 16 |
17 | 18 |

{state.message}

19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /app/reset-password/page.tsx: -------------------------------------------------------------------------------- 1 | import { PasswordResetForm } from "./components"; 2 | 3 | import { getCurrentPasswordResetSession } from "@/lib/server/password-reset"; 4 | import { redirect } from "next/navigation"; 5 | import { globalGETRateLimit } from "@/lib/server/request"; 6 | 7 | export default function Page() { 8 | if (!globalGETRateLimit()) { 9 | return "Too many requests"; 10 | } 11 | 12 | const { session, user } = getCurrentPasswordResetSession(); 13 | if (session === null) { 14 | return redirect("/forgot-password"); 15 | } 16 | if (!session.emailVerified) { 17 | return redirect("/reset-password/verify-email"); 18 | } 19 | if (user.registered2FA && !session.twoFactorVerified) { 20 | return redirect("/reset-password/2fa"); 21 | } 22 | return ( 23 | <> 24 |

Enter your new password

25 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /app/reset-password/verify-email/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { setPasswordResetSessionAsEmailVerified, getCurrentPasswordResetSession } from "@/lib/server/password-reset"; 4 | import { ExpiringTokenBucket } from "@/lib/server/rate-limit"; 5 | import { setUserAsEmailVerifiedIfEmailMatches } from "@/lib/server/user"; 6 | import { redirect } from "next/navigation"; 7 | import { globalPOSTRateLimit } from "@/lib/server/request"; 8 | 9 | const emailVerificationBucket = new ExpiringTokenBucket(5, 60 * 30); 10 | 11 | export async function verifyPasswordResetEmailAction(_prev: ActionResult, formData: FormData): Promise { 12 | if (!globalPOSTRateLimit()) { 13 | return { 14 | message: "Too many requests" 15 | }; 16 | } 17 | 18 | const { session } = getCurrentPasswordResetSession(); 19 | if (session === null) { 20 | return { 21 | message: "Not authenticated" 22 | }; 23 | } 24 | if (session.emailVerified) { 25 | return { 26 | message: "Forbidden" 27 | }; 28 | } 29 | if (!emailVerificationBucket.check(session.userId, 1)) { 30 | return { 31 | message: "Too many requests" 32 | }; 33 | } 34 | 35 | const code = formData.get("code"); 36 | if (typeof code !== "string") { 37 | return { 38 | message: "Invalid or missing fields" 39 | }; 40 | } 41 | if (code === "") { 42 | return { 43 | message: "Please enter your code" 44 | }; 45 | } 46 | if (!emailVerificationBucket.consume(session.userId, 1)) { 47 | return { message: "Too many requests" }; 48 | } 49 | if (code !== session.code) { 50 | return { 51 | message: "Incorrect code" 52 | }; 53 | } 54 | emailVerificationBucket.reset(session.userId); 55 | setPasswordResetSessionAsEmailVerified(session.id); 56 | const emailMatches = setUserAsEmailVerifiedIfEmailMatches(session.userId, session.email); 57 | if (!emailMatches) { 58 | return { 59 | message: "Please restart the process" 60 | }; 61 | } 62 | return redirect("/reset-password/2fa"); 63 | } 64 | 65 | interface ActionResult { 66 | message: string; 67 | } 68 | -------------------------------------------------------------------------------- /app/reset-password/verify-email/components.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useFormState } from "react-dom"; 4 | import { verifyPasswordResetEmailAction } from "./actions"; 5 | 6 | const initialPasswordResetEmailVerificationState = { 7 | message: "" 8 | }; 9 | 10 | export function PasswordResetEmailVerificationForm() { 11 | const [state, action] = useFormState(verifyPasswordResetEmailAction, initialPasswordResetEmailVerificationState); 12 | return ( 13 |
14 | 15 | 16 | 17 |

{state.message}

18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /app/reset-password/verify-email/page.tsx: -------------------------------------------------------------------------------- 1 | import { PasswordResetEmailVerificationForm } from "./components"; 2 | 3 | import { getCurrentPasswordResetSession } from "@/lib/server/password-reset"; 4 | import { redirect } from "next/navigation"; 5 | import { globalGETRateLimit } from "@/lib/server/request"; 6 | 7 | export default function Page() { 8 | if (!globalGETRateLimit()) { 9 | return "Too many requests"; 10 | } 11 | 12 | const { session } = getCurrentPasswordResetSession(); 13 | if (session === null) { 14 | return redirect("/forgot-password"); 15 | } 16 | if (session.emailVerified) { 17 | if (!session.twoFactorVerified) { 18 | return redirect("/reset-password/2fa"); 19 | } 20 | return redirect("/reset-password"); 21 | } 22 | return ( 23 | <> 24 |

Verify your email address

25 |

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

26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /app/settings/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { verifyPasswordHash, verifyPasswordStrength } from "@/lib/server/password"; 4 | import { ExpiringTokenBucket } from "@/lib/server/rate-limit"; 5 | import { 6 | createSession, 7 | generateSessionToken, 8 | getCurrentSession, 9 | invalidateUserSessions, 10 | setSessionTokenCookie 11 | } from "@/lib/server/session"; 12 | import { getUserPasswordHash, resetUserRecoveryCode, updateUserPassword } from "@/lib/server/user"; 13 | import { 14 | createEmailVerificationRequest, 15 | sendVerificationEmail, 16 | sendVerificationEmailBucket, 17 | setEmailVerificationRequestCookie 18 | } from "@/lib/server/email-verification"; 19 | import { checkEmailAvailability, verifyEmailInput } from "@/lib/server/email"; 20 | import { redirect } from "next/navigation"; 21 | import { deleteUserTOTPKey, totpUpdateBucket } from "@/lib/server/totp"; 22 | import { decodeBase64 } from "@oslojs/encoding"; 23 | import { deleteUserPasskeyCredential, deleteUserSecurityKeyCredential } from "@/lib/server/webauthn"; 24 | import { globalPOSTRateLimit } from "@/lib/server/request"; 25 | 26 | import type { SessionFlags } from "@/lib/server/session"; 27 | 28 | const passwordUpdateBucket = new ExpiringTokenBucket(5, 60 * 30); 29 | 30 | export async function updatePasswordAction(_prev: ActionResult, formData: FormData): Promise { 31 | if (!globalPOSTRateLimit()) { 32 | return { 33 | message: "Too many requests" 34 | }; 35 | } 36 | 37 | const { session, user } = getCurrentSession(); 38 | if (session === null) { 39 | return { 40 | message: "Not authenticated" 41 | }; 42 | } 43 | if (user.registered2FA && !session.twoFactorVerified) { 44 | return { 45 | message: "Forbidden" 46 | }; 47 | } 48 | if (!passwordUpdateBucket.check(session.id, 1)) { 49 | return { 50 | message: "Too many requests" 51 | }; 52 | } 53 | 54 | const password = formData.get("password"); 55 | const newPassword = formData.get("new_password"); 56 | if (typeof password !== "string" || typeof newPassword !== "string") { 57 | return { 58 | message: "Invalid or missing fields" 59 | }; 60 | } 61 | const strongPassword = await verifyPasswordStrength(newPassword); 62 | if (!strongPassword) { 63 | return { 64 | message: "Weak password" 65 | }; 66 | } 67 | if (!passwordUpdateBucket.consume(session.id, 1)) { 68 | return { 69 | message: "Too many requests" 70 | }; 71 | } 72 | const passwordHash = getUserPasswordHash(user.id); 73 | const validPassword = await verifyPasswordHash(passwordHash, password); 74 | if (!validPassword) { 75 | return { 76 | message: "Incorrect password" 77 | }; 78 | } 79 | passwordUpdateBucket.reset(session.id); 80 | invalidateUserSessions(user.id); 81 | await updateUserPassword(user.id, newPassword); 82 | 83 | const sessionToken = generateSessionToken(); 84 | const sessionFlags: SessionFlags = { 85 | twoFactorVerified: session.twoFactorVerified 86 | }; 87 | const newSession = createSession(sessionToken, user.id, sessionFlags); 88 | setSessionTokenCookie(sessionToken, newSession.expiresAt); 89 | return { 90 | message: "Updated password" 91 | }; 92 | } 93 | 94 | export async function updateEmailAction(_prev: ActionResult, formData: FormData): Promise { 95 | if (!globalPOSTRateLimit()) { 96 | return { 97 | message: "Too many requests" 98 | }; 99 | } 100 | 101 | const { session, user } = getCurrentSession(); 102 | if (session === null) { 103 | return { 104 | message: "Not authenticated" 105 | }; 106 | } 107 | if (user.registered2FA && !session.twoFactorVerified) { 108 | return { 109 | message: "Forbidden" 110 | }; 111 | } 112 | if (!sendVerificationEmailBucket.check(user.id, 1)) { 113 | return { 114 | message: "Too many requests" 115 | }; 116 | } 117 | 118 | const email = formData.get("email"); 119 | if (typeof email !== "string") { 120 | return { message: "Invalid or missing fields" }; 121 | } 122 | if (email === "") { 123 | return { 124 | message: "Please enter your email" 125 | }; 126 | } 127 | if (!verifyEmailInput(email)) { 128 | return { 129 | message: "Please enter a valid email" 130 | }; 131 | } 132 | const emailAvailable = checkEmailAvailability(email); 133 | if (!emailAvailable) { 134 | return { 135 | message: "This email is already used" 136 | }; 137 | } 138 | if (!sendVerificationEmailBucket.consume(user.id, 1)) { 139 | return { 140 | message: "Too many requests" 141 | }; 142 | } 143 | const verificationRequest = createEmailVerificationRequest(user.id, email); 144 | sendVerificationEmail(verificationRequest.email, verificationRequest.code); 145 | setEmailVerificationRequestCookie(verificationRequest); 146 | return redirect("/verify-email"); 147 | } 148 | 149 | export async function disconnectTOTPAction(): Promise { 150 | if (!globalPOSTRateLimit()) { 151 | return { 152 | message: "Too many requests" 153 | }; 154 | } 155 | 156 | const { session, user } = getCurrentSession(); 157 | if (session === null || user === null) { 158 | return { 159 | message: "Not authenticated" 160 | }; 161 | } 162 | if (!user.emailVerified) { 163 | return { 164 | message: "Forbidden" 165 | }; 166 | } 167 | if (user.registered2FA && !session.twoFactorVerified) { 168 | return { 169 | message: "Forbidden" 170 | }; 171 | } 172 | if (!totpUpdateBucket.consume(user.id, 1)) { 173 | return { 174 | message: "" 175 | }; 176 | } 177 | deleteUserTOTPKey(user.id); 178 | return { 179 | message: "Disconnected authenticator app" 180 | }; 181 | } 182 | 183 | export async function deletePasskeyAction(_prev: ActionResult, formData: FormData): Promise { 184 | if (!globalPOSTRateLimit()) { 185 | return { 186 | message: "Too many requests" 187 | }; 188 | } 189 | 190 | const { session, user } = getCurrentSession(); 191 | if (session === null || user === null) { 192 | return { 193 | message: "Not authenticated" 194 | }; 195 | } 196 | if (!user.emailVerified) { 197 | return { 198 | message: "Forbidden" 199 | }; 200 | } 201 | if (user.registered2FA && !session.twoFactorVerified) { 202 | return { 203 | message: "Forbidden" 204 | }; 205 | } 206 | const encodedCredentialId = formData.get("credential_id"); 207 | if (typeof encodedCredentialId !== "string") { 208 | return { 209 | message: "Invalid or missing fields" 210 | }; 211 | } 212 | let credentialId: Uint8Array; 213 | try { 214 | credentialId = decodeBase64(encodedCredentialId); 215 | } catch { 216 | return { 217 | message: "Invalid or missing fields" 218 | }; 219 | } 220 | const deleted = deleteUserPasskeyCredential(user.id, credentialId); 221 | if (!deleted) { 222 | return { 223 | message: "Invalid credential ID" 224 | }; 225 | } 226 | return { 227 | message: "Removed credential" 228 | }; 229 | } 230 | 231 | export async function deleteSecurityKeyAction(_prev: ActionResult, formData: FormData): Promise { 232 | if (!globalPOSTRateLimit()) { 233 | return { 234 | message: "Too many requests" 235 | }; 236 | } 237 | 238 | const { session, user } = getCurrentSession(); 239 | if (session === null || user === null) { 240 | return { 241 | message: "Not authenticated" 242 | }; 243 | } 244 | if (!user.emailVerified) { 245 | return { 246 | message: "Forbidden" 247 | }; 248 | } 249 | if (user.registered2FA && !session.twoFactorVerified) { 250 | return { 251 | message: "Forbidden" 252 | }; 253 | } 254 | 255 | const encodedCredentialId = formData.get("credential_id"); 256 | if (typeof encodedCredentialId !== "string") { 257 | return { 258 | message: "Invalid or missing fields" 259 | }; 260 | } 261 | let credentialId: Uint8Array; 262 | try { 263 | credentialId = decodeBase64(encodedCredentialId); 264 | } catch { 265 | return { 266 | message: "Invalid or missing fields" 267 | }; 268 | } 269 | const deleted = deleteUserSecurityKeyCredential(user.id, credentialId); 270 | if (!deleted) { 271 | return { 272 | message: "Invalid credential ID" 273 | }; 274 | } 275 | return { 276 | message: "Removed credential" 277 | }; 278 | } 279 | 280 | export async function regenerateRecoveryCodeAction(): Promise { 281 | if (!globalPOSTRateLimit()) { 282 | return { 283 | error: "Too many requests", 284 | recoveryCode: null 285 | }; 286 | } 287 | 288 | const { session, user } = getCurrentSession(); 289 | if (session === null || user === null) { 290 | return { 291 | error: "Not authenticated", 292 | recoveryCode: null 293 | }; 294 | } 295 | if (!user.emailVerified) { 296 | return { 297 | error: "Forbidden", 298 | recoveryCode: null 299 | }; 300 | } 301 | if (!session.twoFactorVerified) { 302 | return { 303 | error: "Forbidden", 304 | recoveryCode: null 305 | }; 306 | } 307 | const recoveryCode = resetUserRecoveryCode(session.userId); 308 | return { 309 | error: null, 310 | recoveryCode 311 | }; 312 | } 313 | 314 | interface ActionResult { 315 | message: string; 316 | } 317 | 318 | type RegenerateRecoveryCodeActionResult = 319 | | { 320 | error: string; 321 | recoveryCode: null; 322 | } 323 | | { 324 | error: null; 325 | recoveryCode: string; 326 | }; 327 | -------------------------------------------------------------------------------- /app/settings/components.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { 5 | deletePasskeyAction, 6 | deleteSecurityKeyAction, 7 | disconnectTOTPAction, 8 | regenerateRecoveryCodeAction, 9 | updateEmailAction, 10 | updatePasswordAction 11 | } from "./actions"; 12 | import { useFormState } from "react-dom"; 13 | 14 | const initialUpdatePasswordState = { 15 | message: "" 16 | }; 17 | 18 | export function UpdatePasswordForm() { 19 | const [state, action] = useFormState(updatePasswordAction, initialUpdatePasswordState); 20 | 21 | return ( 22 |
23 | 24 | 25 |
26 | 27 | 28 |
29 | 30 |

{state.message}

31 |
32 | ); 33 | } 34 | 35 | const initialUpdateFormState = { 36 | message: "" 37 | }; 38 | 39 | export function UpdateEmailForm() { 40 | const [state, action] = useFormState(updateEmailAction, initialUpdateFormState); 41 | 42 | return ( 43 |
44 | 45 | 46 |
47 | 48 |

{state.message}

49 |
50 | ); 51 | } 52 | 53 | const initialDisconnectTOTPState = { 54 | message: "" 55 | }; 56 | 57 | export function DisconnectTOTPButton() { 58 | const [state, formAction] = useFormState(disconnectTOTPAction, initialDisconnectTOTPState); 59 | return ( 60 |
61 | 62 |

{state.message}

63 |
64 | ); 65 | } 66 | 67 | const initialPasskeyState = { 68 | message: "" 69 | }; 70 | 71 | export function PasskeyCredentialListItem(props: { encodedId: string; name: string }) { 72 | const [state, formAction] = useFormState(deletePasskeyAction, initialPasskeyState); 73 | return ( 74 |
  • 75 |

    {props.name}

    76 |
    77 | 78 | 79 |

    {state.message}

    80 |
    81 |
  • 82 | ); 83 | } 84 | 85 | const initialSecurityKeyState = { 86 | message: "" 87 | }; 88 | 89 | export function SecurityKeyCredentialListItem(props: { encodedId: string; name: string }) { 90 | const [state, formAction] = useFormState(deleteSecurityKeyAction, initialSecurityKeyState); 91 | return ( 92 |
  • 93 |

    {props.name}

    94 |
    95 | 96 | 97 |

    {state.message}

    98 |
    99 |
  • 100 | ); 101 | } 102 | 103 | export function RecoveryCodeSection(props: { recoveryCode: string }) { 104 | const [recoveryCode, setRecoveryCode] = useState(props.recoveryCode); 105 | return ( 106 |
    107 |

    Recovery code

    108 |

    Your recovery code is: {recoveryCode}

    109 | 119 |
    120 | ); 121 | } 122 | -------------------------------------------------------------------------------- /app/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { 3 | DisconnectTOTPButton, 4 | PasskeyCredentialListItem, 5 | RecoveryCodeSection, 6 | SecurityKeyCredentialListItem, 7 | UpdateEmailForm, 8 | UpdatePasswordForm 9 | } from "./components"; 10 | 11 | import { getCurrentSession } from "@/lib/server/session"; 12 | import { redirect } from "next/navigation"; 13 | import { getUserRecoverCode } from "@/lib/server/user"; 14 | import { get2FARedirect } from "@/lib/server/2fa"; 15 | import { getUserPasskeyCredentials, getUserSecurityKeyCredentials } from "@/lib/server/webauthn"; 16 | import { encodeBase64 } from "@oslojs/encoding"; 17 | import { globalGETRateLimit } from "@/lib/server/request"; 18 | 19 | export default function Page() { 20 | if (!globalGETRateLimit()) { 21 | return "Too many requests"; 22 | } 23 | 24 | const { session, user } = getCurrentSession(); 25 | if (session === null) { 26 | return redirect("/login"); 27 | } 28 | if (user.registered2FA && !session.twoFactorVerified) { 29 | return redirect(get2FARedirect(user)); 30 | } 31 | let recoveryCode: string | null = null; 32 | if (user.registered2FA) { 33 | recoveryCode = getUserRecoverCode(user.id); 34 | } 35 | const passkeyCredentials = getUserPasskeyCredentials(user.id); 36 | const securityKeyCredentials = getUserSecurityKeyCredentials(user.id); 37 | return ( 38 | <> 39 |
    40 | Home 41 | Settings 42 |
    43 |
    44 |

    Settings

    45 |
    46 |

    Update email

    47 |

    Your email: {user.email}

    48 | 49 |
    50 |
    51 |

    Update password

    52 | 53 |
    54 |
    55 |

    Authenticator app

    56 | {user.registeredTOTP ? ( 57 | <> 58 | Update TOTP 59 | 60 | 61 | ) : ( 62 | Set up TOTP 63 | )} 64 |
    65 |
    66 |

    Passkeys

    67 |

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

    68 |
      69 | {passkeyCredentials.map((credential) => { 70 | return ( 71 | 76 | ); 77 | })} 78 |
    79 | Add 80 |
    81 |
    82 |

    Security keys

    83 |

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

    84 |
      85 | {securityKeyCredentials.map((credential) => { 86 | return ( 87 | 92 | ); 93 | })} 94 |
    95 | Add 96 |
    97 | {recoveryCode !== null && } 98 |
    99 | 100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /app/signup/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { checkEmailAvailability, verifyEmailInput } from "@/lib/server/email"; 4 | import { 5 | createEmailVerificationRequest, 6 | sendVerificationEmail, 7 | setEmailVerificationRequestCookie 8 | } from "@/lib/server/email-verification"; 9 | import { verifyPasswordStrength } from "@/lib/server/password"; 10 | import { RefillingTokenBucket } from "@/lib/server/rate-limit"; 11 | import { createSession, generateSessionToken, setSessionTokenCookie } from "@/lib/server/session"; 12 | import { createUser, verifyUsernameInput } from "@/lib/server/user"; 13 | import { headers } from "next/headers"; 14 | import { redirect } from "next/navigation"; 15 | import { globalPOSTRateLimit } from "@/lib/server/request"; 16 | 17 | import type { SessionFlags } from "@/lib/server/session"; 18 | 19 | const ipBucket = new RefillingTokenBucket(3, 10); 20 | 21 | export async function signupAction(_prev: ActionResult, formData: FormData): Promise { 22 | if (!globalPOSTRateLimit()) { 23 | return { 24 | message: "Too many requests" 25 | }; 26 | } 27 | 28 | // TODO: Assumes X-Forwarded-For is always included. 29 | const clientIP = headers().get("X-Forwarded-For"); 30 | if (clientIP !== null && !ipBucket.check(clientIP, 1)) { 31 | return { 32 | message: "Too many requests" 33 | }; 34 | } 35 | 36 | const email = formData.get("email"); 37 | const username = formData.get("username"); 38 | const password = formData.get("password"); 39 | if (typeof email !== "string" || typeof username !== "string" || typeof password !== "string") { 40 | return { 41 | message: "Invalid or missing fields" 42 | }; 43 | } 44 | if (email === "" || password === "" || username === "") { 45 | return { 46 | message: "Please enter your username, email, and password" 47 | }; 48 | } 49 | if (!verifyEmailInput(email)) { 50 | return { 51 | message: "Invalid email" 52 | }; 53 | } 54 | const emailAvailable = checkEmailAvailability(email); 55 | if (!emailAvailable) { 56 | return { 57 | message: "Email is already used" 58 | }; 59 | } 60 | if (!verifyUsernameInput(username)) { 61 | return { 62 | message: "Invalid username" 63 | }; 64 | } 65 | const strongPassword = await verifyPasswordStrength(password); 66 | if (!strongPassword) { 67 | return { 68 | message: "Weak password" 69 | }; 70 | } 71 | if (clientIP !== null && !ipBucket.consume(clientIP, 1)) { 72 | return { 73 | message: "Too many requests" 74 | }; 75 | } 76 | const user = await createUser(email, username, password); 77 | const emailVerificationRequest = createEmailVerificationRequest(user.id, user.email); 78 | sendVerificationEmail(emailVerificationRequest.email, emailVerificationRequest.code); 79 | setEmailVerificationRequestCookie(emailVerificationRequest); 80 | 81 | const sessionFlags: SessionFlags = { 82 | twoFactorVerified: false 83 | }; 84 | const sessionToken = generateSessionToken(); 85 | const session = createSession(sessionToken, user.id, sessionFlags); 86 | setSessionTokenCookie(sessionToken, session.expiresAt); 87 | return redirect("/2fa/setup"); 88 | } 89 | 90 | interface ActionResult { 91 | message: string; 92 | } 93 | -------------------------------------------------------------------------------- /app/signup/components.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { signupAction } from "./actions"; 4 | import { useFormState } from "react-dom"; 5 | 6 | const initialState = { 7 | message: "" 8 | }; 9 | 10 | export function SignUpForm() { 11 | const [state, action] = useFormState(signupAction, initialState); 12 | 13 | return ( 14 |
    15 | 16 | 17 |
    18 | 19 | 20 |
    21 | 22 | 23 |
    24 | 25 |

    {state.message}

    26 |
    27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /app/signup/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignUpForm } from "./components"; 2 | import Link from "next/link"; 3 | 4 | import { getCurrentSession } from "@/lib/server/session"; 5 | import { redirect } from "next/navigation"; 6 | import { get2FARedirect } from "@/lib/server/2fa"; 7 | import { globalGETRateLimit } from "@/lib/server/request"; 8 | 9 | export default function Page() { 10 | if (!globalGETRateLimit()) { 11 | return "Too many requests"; 12 | } 13 | 14 | const { session, user } = getCurrentSession(); 15 | if (session !== null) { 16 | if (!user.emailVerified) { 17 | return redirect("/verify-email"); 18 | } 19 | if (!user.registered2FA) { 20 | return redirect("/2fa/setup"); 21 | } 22 | if (!session.twoFactorVerified) { 23 | return redirect(get2FARedirect(user)); 24 | } 25 | return redirect("/"); 26 | } 27 | return ( 28 | <> 29 |

    Create an account

    30 |

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

    31 | 32 | Sign in 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /app/verify-email/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { 4 | createEmailVerificationRequest, 5 | deleteEmailVerificationRequestCookie, 6 | deleteUserEmailVerificationRequest, 7 | getCurrentUserEmailVerificationRequest, 8 | sendVerificationEmail, 9 | sendVerificationEmailBucket, 10 | setEmailVerificationRequestCookie 11 | } from "@/lib/server/email-verification"; 12 | import { invalidateUserPasswordResetSessions } from "@/lib/server/password-reset"; 13 | import { ExpiringTokenBucket } from "@/lib/server/rate-limit"; 14 | import { getCurrentSession } from "@/lib/server/session"; 15 | import { updateUserEmailAndSetEmailAsVerified } from "@/lib/server/user"; 16 | import { redirect } from "next/navigation"; 17 | import { globalPOSTRateLimit } from "@/lib/server/request"; 18 | 19 | const bucket = new ExpiringTokenBucket(5, 60 * 30); 20 | 21 | export async function verifyEmailAction(_prev: ActionResult, formData: FormData): Promise { 22 | if (!globalPOSTRateLimit()) { 23 | return { 24 | message: "Too many requests" 25 | }; 26 | } 27 | 28 | const { session, user } = getCurrentSession(); 29 | if (session === null) { 30 | return { 31 | message: "Not authenticated" 32 | }; 33 | } 34 | if (user.registered2FA && !session.twoFactorVerified) { 35 | return { 36 | message: "Forbidden" 37 | }; 38 | } 39 | if (!bucket.check(user.id, 1)) { 40 | return { 41 | message: "Too many requests" 42 | }; 43 | } 44 | 45 | let verificationRequest = getCurrentUserEmailVerificationRequest(); 46 | if (verificationRequest === null) { 47 | return { 48 | message: "Not authenticated" 49 | }; 50 | } 51 | const code = formData.get("code"); 52 | if (typeof code !== "string") { 53 | return { 54 | message: "Invalid or missing fields" 55 | }; 56 | } 57 | if (code === "") { 58 | return { 59 | message: "Enter your code" 60 | }; 61 | } 62 | if (!bucket.consume(user.id, 1)) { 63 | return { 64 | message: "Too many requests" 65 | }; 66 | } 67 | if (Date.now() >= verificationRequest.expiresAt.getTime()) { 68 | verificationRequest = createEmailVerificationRequest(verificationRequest.userId, verificationRequest.email); 69 | sendVerificationEmail(verificationRequest.email, verificationRequest.code); 70 | return { 71 | message: "The verification code was expired. We sent another code to your inbox." 72 | }; 73 | } 74 | if (verificationRequest.code !== code) { 75 | return { 76 | message: "Incorrect code." 77 | }; 78 | } 79 | deleteUserEmailVerificationRequest(user.id); 80 | invalidateUserPasswordResetSessions(user.id); 81 | updateUserEmailAndSetEmailAsVerified(user.id, verificationRequest.email); 82 | deleteEmailVerificationRequestCookie(); 83 | if (!user.registered2FA) { 84 | return redirect("/2fa/setup"); 85 | } 86 | return redirect("/"); 87 | } 88 | 89 | export async function resendEmailVerificationCodeAction(): Promise { 90 | if (!globalPOSTRateLimit()) { 91 | return { 92 | message: "Too many requests" 93 | }; 94 | } 95 | 96 | const { session, user } = getCurrentSession(); 97 | if (session === null) { 98 | return { 99 | message: "Not authenticated" 100 | }; 101 | } 102 | if (user.registered2FA && !session.twoFactorVerified) { 103 | return { 104 | message: "Forbidden" 105 | }; 106 | } 107 | if (!sendVerificationEmailBucket.check(user.id, 1)) { 108 | return { 109 | message: "Too many requests" 110 | }; 111 | } 112 | let verificationRequest = getCurrentUserEmailVerificationRequest(); 113 | if (verificationRequest === null) { 114 | if (user.emailVerified) { 115 | return { 116 | message: "Forbidden" 117 | }; 118 | } 119 | if (!sendVerificationEmailBucket.consume(user.id, 1)) { 120 | return { 121 | message: "Too many requests" 122 | }; 123 | } 124 | verificationRequest = createEmailVerificationRequest(user.id, user.email); 125 | } else { 126 | if (!sendVerificationEmailBucket.consume(user.id, 1)) { 127 | return { 128 | message: "Too many requests" 129 | }; 130 | } 131 | verificationRequest = createEmailVerificationRequest(user.id, verificationRequest.email); 132 | } 133 | sendVerificationEmail(verificationRequest.email, verificationRequest.code); 134 | setEmailVerificationRequestCookie(verificationRequest); 135 | return { 136 | message: "A new code was sent to your inbox." 137 | }; 138 | } 139 | 140 | interface ActionResult { 141 | message: string; 142 | } 143 | -------------------------------------------------------------------------------- /app/verify-email/components.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { resendEmailVerificationCodeAction, verifyEmailAction } from "./actions"; 4 | import { useFormState } from "react-dom"; 5 | 6 | const emailVerificationInitialState = { 7 | message: "" 8 | }; 9 | 10 | export function EmailVerificationForm() { 11 | const [state, action] = useFormState(verifyEmailAction, emailVerificationInitialState); 12 | return ( 13 |
    14 | 15 | 16 | 17 |

    {state.message}

    18 |
    19 | ); 20 | } 21 | 22 | const resendEmailInitialState = { 23 | message: "" 24 | }; 25 | 26 | export function ResendEmailVerificationCodeForm() { 27 | const [state, action] = useFormState(resendEmailVerificationCodeAction, resendEmailInitialState); 28 | return ( 29 |
    30 | 31 |

    {state.message}

    32 |
    33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /app/verify-email/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { EmailVerificationForm, ResendEmailVerificationCodeForm } from "./components"; 3 | 4 | import { getCurrentSession } from "@/lib/server/session"; 5 | import { redirect } from "next/navigation"; 6 | import { getCurrentUserEmailVerificationRequest } from "@/lib/server/email-verification"; 7 | import { globalGETRateLimit } from "@/lib/server/request"; 8 | 9 | export default function Page() { 10 | if (!globalGETRateLimit()) { 11 | return "Too many requests"; 12 | } 13 | 14 | const { user } = getCurrentSession(); 15 | if (user === null) { 16 | return redirect("/redirect"); 17 | } 18 | 19 | // TODO: Ideally we'd sent a new verification email automatically if the previous one is expired, 20 | // but we can't set cookies inside server components. 21 | const verificationRequest = getCurrentUserEmailVerificationRequest(); 22 | if (verificationRequest === null && user.emailVerified) { 23 | return redirect("/"); 24 | } 25 | return ( 26 | <> 27 |

    Verify your email address

    28 |

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

    29 | 30 | 31 | Change your email 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /lib/client/webauthn.ts: -------------------------------------------------------------------------------- 1 | import { createWebAuthnChallengeAction } from "@/actions/webauthn"; 2 | import { decodeBase64 } from "@oslojs/encoding"; 3 | 4 | export async function createChallenge(): Promise { 5 | const encoded = await createWebAuthnChallengeAction(); 6 | return decodeBase64(encoded); 7 | } 8 | -------------------------------------------------------------------------------- /lib/server/2fa.ts: -------------------------------------------------------------------------------- 1 | import { db } from "./db"; 2 | import { generateRandomRecoveryCode } from "./utils"; 3 | import { ExpiringTokenBucket } from "./rate-limit"; 4 | import { decryptToString, encryptString } from "./encryption"; 5 | 6 | import type { User } from "./user"; 7 | 8 | export const recoveryCodeBucket = new ExpiringTokenBucket(3, 60 * 60); 9 | 10 | export function resetUser2FAWithRecoveryCode(userId: number, recoveryCode: string): boolean { 11 | // Note: In Postgres and MySQL, these queries should be done in a transaction using SELECT FOR UPDATE 12 | const row = db.queryOne("SELECT recovery_code FROM user WHERE id = ?", [userId]); 13 | if (row === null) { 14 | return false; 15 | } 16 | const encryptedRecoveryCode = row.bytes(0); 17 | const userRecoveryCode = decryptToString(encryptedRecoveryCode); 18 | if (recoveryCode !== userRecoveryCode) { 19 | return false; 20 | } 21 | 22 | const newRecoveryCode = generateRandomRecoveryCode(); 23 | const encryptedNewRecoveryCode = encryptString(newRecoveryCode); 24 | 25 | try { 26 | db.execute("BEGIN TRANSACTION", []); 27 | // Compare old recovery code to ensure recovery code wasn't updated. 28 | const result = db.execute("UPDATE user SET recovery_code = ? WHERE id = ? AND recovery_code = ?", [ 29 | encryptedNewRecoveryCode, 30 | userId, 31 | encryptedRecoveryCode 32 | ]); 33 | if (result.changes < 1) { 34 | db.execute("ROLLBACK", []); 35 | return false; 36 | } 37 | db.execute("UPDATE session SET two_factor_verified = 0 WHERE user_id = ?", [userId]); 38 | db.execute("DELETE FROM totp_credential WHERE user_id = ?", [userId]); 39 | db.execute("DELETE FROM passkey_credential WHERE user_id = ?", [userId]); 40 | db.execute("DELETE FROM security_key_credential WHERE user_id = ?", [userId]); 41 | db.execute("COMMIT", []); 42 | } catch (e) { 43 | if (db.inTransaction()) { 44 | db.execute("ROLLBACK", []); 45 | } 46 | throw e; 47 | } 48 | return true; 49 | } 50 | 51 | export function get2FARedirect(user: User): string { 52 | if (user.registeredPasskey) { 53 | return "/2fa/passkey"; 54 | } 55 | if (user.registeredSecurityKey) { 56 | return "/2fa/security-key"; 57 | } 58 | if (user.registeredTOTP) { 59 | return "/2fa/totp"; 60 | } 61 | return "/2fa/setup"; 62 | } 63 | 64 | export function getPasswordReset2FARedirect(user: User): string { 65 | if (user.registeredPasskey) { 66 | return "/reset-password/2fa/passkey"; 67 | } 68 | if (user.registeredSecurityKey) { 69 | return "/reset-password/2fa/security-key"; 70 | } 71 | if (user.registeredTOTP) { 72 | return "/reset-password/2fa/totp"; 73 | } 74 | return "/2fa/setup"; 75 | } 76 | -------------------------------------------------------------------------------- /lib/server/db.ts: -------------------------------------------------------------------------------- 1 | import sqlite3 from "better-sqlite3"; 2 | import { SyncDatabase } from "@pilcrowjs/db-query"; 3 | 4 | import type { SyncAdapter } from "@pilcrowjs/db-query"; 5 | 6 | const sqlite = sqlite3("sqlite.db"); 7 | 8 | const adapter: SyncAdapter = { 9 | query: (statement: string, params: unknown[]): unknown[][] => { 10 | const result = sqlite 11 | .prepare(statement) 12 | .raw() 13 | .all(...params) as unknown[][]; 14 | return result as unknown[][]; 15 | }, 16 | execute: (statement: string, params: unknown[]): sqlite3.RunResult => { 17 | const result = sqlite.prepare(statement).run(...params); 18 | return result; 19 | } 20 | }; 21 | 22 | class Database extends SyncDatabase { 23 | public inTransaction(): boolean { 24 | return sqlite.inTransaction; 25 | } 26 | } 27 | 28 | export const db = new Database(adapter); 29 | -------------------------------------------------------------------------------- /lib/server/email-verification.ts: -------------------------------------------------------------------------------- 1 | import { generateRandomOTP } from "./utils"; 2 | import { db } from "./db"; 3 | import { ExpiringTokenBucket } from "./rate-limit"; 4 | import { encodeBase32LowerCaseNoPadding } from "@oslojs/encoding"; 5 | import { cookies } from "next/headers"; 6 | import { getCurrentSession } from "./session"; 7 | import { cache } from "react"; 8 | 9 | export function getUserEmailVerificationRequest(userId: number, id: string): EmailVerificationRequest | null { 10 | const row = db.queryOne( 11 | "SELECT id, user_id, code, email, expires_at FROM email_verification_request WHERE id = ? AND user_id = ?", 12 | [id, userId] 13 | ); 14 | if (row === null) { 15 | return row; 16 | } 17 | const request: EmailVerificationRequest = { 18 | id: row.string(0), 19 | userId: row.number(1), 20 | code: row.string(2), 21 | email: row.string(3), 22 | expiresAt: new Date(row.number(4) * 1000) 23 | }; 24 | return request; 25 | } 26 | 27 | export function createEmailVerificationRequest(userId: number, email: string): EmailVerificationRequest { 28 | deleteUserEmailVerificationRequest(userId); 29 | const idBytes = new Uint8Array(20); 30 | crypto.getRandomValues(idBytes); 31 | const id = encodeBase32LowerCaseNoPadding(idBytes); 32 | 33 | const code = generateRandomOTP(); 34 | const expiresAt = new Date(Date.now() + 1000 * 60 * 10); 35 | db.queryOne( 36 | "INSERT INTO email_verification_request (id, user_id, code, email, expires_at) VALUES (?, ?, ?, ?, ?) RETURNING id", 37 | [id, userId, code, email, Math.floor(expiresAt.getTime() / 1000)] 38 | ); 39 | 40 | const request: EmailVerificationRequest = { 41 | id, 42 | userId, 43 | code, 44 | email, 45 | expiresAt 46 | }; 47 | return request; 48 | } 49 | 50 | export function deleteUserEmailVerificationRequest(userId: number): void { 51 | db.execute("DELETE FROM email_verification_request WHERE user_id = ?", [userId]); 52 | } 53 | 54 | export function sendVerificationEmail(email: string, code: string): void { 55 | console.log(`To ${email}: Your verification code is ${code}`); 56 | } 57 | 58 | export function setEmailVerificationRequestCookie(request: EmailVerificationRequest): void { 59 | cookies().set("email_verification", request.id, { 60 | httpOnly: true, 61 | path: "/", 62 | secure: process.env.NODE_ENV === "production", 63 | sameSite: "lax", 64 | expires: request.expiresAt 65 | }); 66 | } 67 | 68 | export function deleteEmailVerificationRequestCookie(): void { 69 | cookies().set("email_verification", "", { 70 | httpOnly: true, 71 | path: "/", 72 | secure: process.env.NODE_ENV === "production", 73 | sameSite: "lax", 74 | maxAge: 0 75 | }); 76 | } 77 | 78 | export const getCurrentUserEmailVerificationRequest = cache(() => { 79 | const { user } = getCurrentSession(); 80 | if (user === null) { 81 | return null; 82 | } 83 | const id = cookies().get("email_verification")?.value ?? null; 84 | if (id === null) { 85 | return null; 86 | } 87 | const request = getUserEmailVerificationRequest(user.id, id); 88 | if (request === null) { 89 | deleteEmailVerificationRequestCookie(); 90 | } 91 | return request; 92 | }); 93 | 94 | export const sendVerificationEmailBucket = new ExpiringTokenBucket(3, 60 * 10); 95 | 96 | export interface EmailVerificationRequest { 97 | id: string; 98 | userId: number; 99 | code: string; 100 | email: string; 101 | expiresAt: Date; 102 | } 103 | -------------------------------------------------------------------------------- /lib/server/email.ts: -------------------------------------------------------------------------------- 1 | import { db } from "./db"; 2 | 3 | export function verifyEmailInput(email: string): boolean { 4 | return /^.+@.+\..+$/.test(email) && email.length < 256; 5 | } 6 | 7 | export function checkEmailAvailability(email: string): boolean { 8 | const row = db.queryOne("SELECT COUNT(*) FROM user WHERE email = ?", [email]); 9 | if (row === null) { 10 | throw new Error(); 11 | } 12 | return row.number(0) === 0; 13 | } 14 | -------------------------------------------------------------------------------- /lib/server/encryption.ts: -------------------------------------------------------------------------------- 1 | import { decodeBase64 } from "@oslojs/encoding"; 2 | import { createCipheriv, createDecipheriv } from "crypto"; 3 | import { DynamicBuffer } from "@oslojs/binary"; 4 | 5 | const key = decodeBase64(process.env.ENCRYPTION_KEY ?? ""); 6 | 7 | export function encrypt(data: Uint8Array): Uint8Array { 8 | const iv = new Uint8Array(16); 9 | crypto.getRandomValues(iv); 10 | const cipher = createCipheriv("aes-128-gcm", key, iv); 11 | const encrypted = new DynamicBuffer(0); 12 | encrypted.write(iv); 13 | encrypted.write(cipher.update(data)); 14 | encrypted.write(cipher.final()); 15 | encrypted.write(cipher.getAuthTag()); 16 | return encrypted.bytes(); 17 | } 18 | 19 | export function encryptString(data: string): Uint8Array { 20 | return encrypt(new TextEncoder().encode(data)); 21 | } 22 | 23 | export function decrypt(encrypted: Uint8Array): Uint8Array { 24 | if (encrypted.byteLength < 33) { 25 | throw new Error("Invalid data"); 26 | } 27 | const decipher = createDecipheriv("aes-128-gcm", key, encrypted.slice(0, 16)); 28 | decipher.setAuthTag(encrypted.slice(encrypted.byteLength - 16)); 29 | const decrypted = new DynamicBuffer(0); 30 | decrypted.write(decipher.update(encrypted.slice(16, encrypted.byteLength - 16))); 31 | decrypted.write(decipher.final()); 32 | return decrypted.bytes(); 33 | } 34 | 35 | export function decryptToString(data: Uint8Array): string { 36 | return new TextDecoder().decode(decrypt(data)); 37 | } 38 | -------------------------------------------------------------------------------- /lib/server/password-reset.ts: -------------------------------------------------------------------------------- 1 | import { db } from "./db"; 2 | import { encodeHexLowerCase } from "@oslojs/encoding"; 3 | import { generateRandomOTP } from "./utils"; 4 | import { sha256 } from "@oslojs/crypto/sha2"; 5 | import { cookies } from "next/headers"; 6 | import { cache } from "react"; 7 | 8 | import type { User } from "./user"; 9 | 10 | export function createPasswordResetSession(token: string, userId: number, email: string): PasswordResetSession { 11 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 12 | const session: PasswordResetSession = { 13 | id: sessionId, 14 | userId, 15 | email, 16 | expiresAt: new Date(Date.now() + 1000 * 60 * 10), 17 | code: generateRandomOTP(), 18 | emailVerified: false, 19 | twoFactorVerified: false 20 | }; 21 | db.execute("INSERT INTO password_reset_session (id, user_id, email, code, expires_at) VALUES (?, ?, ?, ?, ?)", [ 22 | session.id, 23 | session.userId, 24 | session.email, 25 | session.code, 26 | Math.floor(session.expiresAt.getTime() / 1000) 27 | ]); 28 | return session; 29 | } 30 | 31 | export function validatePasswordResetSessionToken(token: string): PasswordResetSessionValidationResult { 32 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 33 | const row = db.queryOne( 34 | `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, 35 | 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 36 | INNER JOIN user ON password_reset_session.user_id = user.id 37 | LEFT JOIN totp_credential ON user.id = totp_credential.user_id 38 | LEFT JOIN passkey_credential ON user.id = passkey_credential.user_id 39 | LEFT JOIN security_key_credential ON user.id = security_key_credential.user_id 40 | WHERE password_reset_session.id = ?`, 41 | [sessionId] 42 | ); 43 | if (row === null) { 44 | return { session: null, user: null }; 45 | } 46 | const session: PasswordResetSession = { 47 | id: row.string(0), 48 | userId: row.number(1), 49 | email: row.string(2), 50 | code: row.string(3), 51 | expiresAt: new Date(row.number(4) * 1000), 52 | emailVerified: Boolean(row.number(5)), 53 | twoFactorVerified: Boolean(row.number(6)) 54 | }; 55 | const user: User = { 56 | id: row.number(7), 57 | email: row.string(8), 58 | username: row.string(9), 59 | emailVerified: Boolean(row.number(10)), 60 | registeredTOTP: Boolean(row.number(11)), 61 | registeredPasskey: Boolean(row.number(12)), 62 | registeredSecurityKey: Boolean(row.number(13)), 63 | registered2FA: false 64 | }; 65 | if (user.registeredPasskey || user.registeredSecurityKey || user.registeredTOTP) { 66 | user.registered2FA = true; 67 | } 68 | if (Date.now() >= session.expiresAt.getTime()) { 69 | db.execute("DELETE FROM password_reset_session WHERE id = ?", [session.id]); 70 | return { session: null, user: null }; 71 | } 72 | return { session, user }; 73 | } 74 | 75 | export function setPasswordResetSessionAsEmailVerified(sessionId: string): void { 76 | db.execute("UPDATE password_reset_session SET email_verified = 1 WHERE id = ?", [sessionId]); 77 | } 78 | 79 | export function setPasswordResetSessionAs2FAVerified(sessionId: string): void { 80 | db.execute("UPDATE password_reset_session SET two_factor_verified = 1 WHERE id = ?", [sessionId]); 81 | } 82 | 83 | export function invalidateUserPasswordResetSessions(userId: number): void { 84 | db.execute("DELETE FROM password_reset_session WHERE user_id = ?", [userId]); 85 | } 86 | 87 | export const getCurrentPasswordResetSession = cache(() => { 88 | const token = cookies().get("password_reset_session")?.value ?? null; 89 | if (token === null) { 90 | return { session: null, user: null }; 91 | } 92 | const result = validatePasswordResetSessionToken(token); 93 | if (result.session === null) { 94 | deletePasswordResetSessionTokenCookie(); 95 | } 96 | return result; 97 | }); 98 | 99 | export function setPasswordResetSessionTokenCookie(token: string, expiresAt: Date): void { 100 | cookies().set("password_reset_session", token, { 101 | expires: expiresAt, 102 | sameSite: "lax", 103 | httpOnly: true, 104 | path: "/", 105 | secure: process.env.NODE_ENV === "production" 106 | }); 107 | } 108 | 109 | export function deletePasswordResetSessionTokenCookie(): void { 110 | cookies().set("password_reset_session", "", { 111 | maxAge: 0, 112 | sameSite: "lax", 113 | httpOnly: true, 114 | path: "/", 115 | secure: process.env.NODE_ENV === "production" 116 | }); 117 | } 118 | 119 | export function sendPasswordResetEmail(email: string, code: string): void { 120 | console.log(`To ${email}: Your reset code is ${code}`); 121 | } 122 | 123 | export interface PasswordResetSession { 124 | id: string; 125 | userId: number; 126 | email: string; 127 | expiresAt: Date; 128 | code: string; 129 | emailVerified: boolean; 130 | twoFactorVerified: boolean; 131 | } 132 | 133 | export type PasswordResetSessionValidationResult = 134 | | { session: PasswordResetSession; user: User } 135 | | { session: null; user: null }; 136 | -------------------------------------------------------------------------------- /lib/server/password.ts: -------------------------------------------------------------------------------- 1 | import { hash, verify } from "@node-rs/argon2"; 2 | import { sha1 } from "@oslojs/crypto/sha1"; 3 | import { encodeHexLowerCase } from "@oslojs/encoding"; 4 | 5 | export async function hashPassword(password: string): Promise { 6 | return await hash(password, { 7 | memoryCost: 19456, 8 | timeCost: 2, 9 | outputLen: 32, 10 | parallelism: 1 11 | }); 12 | } 13 | 14 | export async function verifyPasswordHash(hash: string, password: string): Promise { 15 | return await verify(hash, password); 16 | } 17 | 18 | export async function verifyPasswordStrength(password: string): Promise { 19 | if (password.length < 8 || password.length > 255) { 20 | return false; 21 | } 22 | const hash = encodeHexLowerCase(sha1(new TextEncoder().encode(password))); 23 | const hashPrefix = hash.slice(0, 5); 24 | const response = await fetch(`https://api.pwnedpasswords.com/range/${hashPrefix}`); 25 | const data = await response.text(); 26 | const items = data.split("\n"); 27 | for (const item of items) { 28 | const hashSuffix = item.slice(0, 35).toLowerCase(); 29 | if (hash === hashPrefix + hashSuffix) { 30 | return false; 31 | } 32 | } 33 | return true; 34 | } 35 | -------------------------------------------------------------------------------- /lib/server/rate-limit.ts: -------------------------------------------------------------------------------- 1 | export class RefillingTokenBucket<_Key> { 2 | public max: number; 3 | public refillIntervalSeconds: number; 4 | 5 | constructor(max: number, refillIntervalSeconds: number) { 6 | this.max = max; 7 | this.refillIntervalSeconds = refillIntervalSeconds; 8 | } 9 | 10 | private storage = new Map<_Key, RefillBucket>(); 11 | 12 | public check(key: _Key, cost: number): boolean { 13 | const bucket = this.storage.get(key) ?? null; 14 | if (bucket === null) { 15 | return true; 16 | } 17 | const now = Date.now(); 18 | const refill = Math.floor((now - bucket.refilledAt) / (this.refillIntervalSeconds * 1000)); 19 | if (refill > 0) { 20 | return Math.min(bucket.count + refill, this.max) >= cost; 21 | } 22 | return bucket.count >= cost; 23 | } 24 | 25 | public consume(key: _Key, cost: number): boolean { 26 | let bucket = this.storage.get(key) ?? null; 27 | const now = Date.now(); 28 | if (bucket === null) { 29 | bucket = { 30 | count: this.max - cost, 31 | refilledAt: now 32 | }; 33 | this.storage.set(key, bucket); 34 | return true; 35 | } 36 | const refill = Math.floor((now - bucket.refilledAt) / (this.refillIntervalSeconds * 1000)); 37 | bucket.count = Math.min(bucket.count + refill, this.max); 38 | bucket.refilledAt = now; 39 | if (bucket.count < cost) { 40 | return false; 41 | } 42 | bucket.count -= cost; 43 | this.storage.set(key, bucket); 44 | return true; 45 | } 46 | } 47 | 48 | export class Throttler<_Key> { 49 | public timeoutSeconds: number[]; 50 | 51 | private storage = new Map<_Key, ThrottlingCounter>(); 52 | 53 | constructor(timeoutSeconds: number[]) { 54 | this.timeoutSeconds = timeoutSeconds; 55 | } 56 | 57 | public consume(key: _Key): boolean { 58 | let counter = this.storage.get(key) ?? null; 59 | const now = Date.now(); 60 | if (counter === null) { 61 | counter = { 62 | timeout: 0, 63 | updatedAt: now 64 | }; 65 | this.storage.set(key, counter); 66 | return true; 67 | } 68 | const allowed = now - counter.updatedAt >= this.timeoutSeconds[counter.timeout] * 1000; 69 | if (!allowed) { 70 | return false; 71 | } 72 | counter.updatedAt = now; 73 | counter.timeout = Math.min(counter.timeout + 1, this.timeoutSeconds.length - 1); 74 | this.storage.set(key, counter); 75 | return true; 76 | } 77 | 78 | public reset(key: _Key): void { 79 | this.storage.delete(key); 80 | } 81 | } 82 | 83 | export class ExpiringTokenBucket<_Key> { 84 | public max: number; 85 | public expiresInSeconds: number; 86 | 87 | private storage = new Map<_Key, ExpiringBucket>(); 88 | 89 | constructor(max: number, expiresInSeconds: number) { 90 | this.max = max; 91 | this.expiresInSeconds = expiresInSeconds; 92 | } 93 | 94 | public check(key: _Key, cost: number): boolean { 95 | const bucket = this.storage.get(key) ?? null; 96 | const now = Date.now(); 97 | if (bucket === null) { 98 | return true; 99 | } 100 | if (now - bucket.createdAt >= this.expiresInSeconds * 1000) { 101 | return true; 102 | } 103 | return bucket.count >= cost; 104 | } 105 | 106 | public consume(key: _Key, cost: number): boolean { 107 | let bucket = this.storage.get(key) ?? null; 108 | const now = Date.now(); 109 | if (bucket === null) { 110 | bucket = { 111 | count: this.max - cost, 112 | createdAt: now 113 | }; 114 | this.storage.set(key, bucket); 115 | return true; 116 | } 117 | if (now - bucket.createdAt >= this.expiresInSeconds * 1000) { 118 | bucket.count = this.max; 119 | } 120 | if (bucket.count < cost) { 121 | return false; 122 | } 123 | bucket.count -= cost; 124 | this.storage.set(key, bucket); 125 | return true; 126 | } 127 | 128 | public reset(key: _Key): void { 129 | this.storage.delete(key); 130 | } 131 | } 132 | 133 | interface RefillBucket { 134 | count: number; 135 | refilledAt: number; 136 | } 137 | 138 | interface ExpiringBucket { 139 | count: number; 140 | createdAt: number; 141 | } 142 | 143 | interface ThrottlingCounter { 144 | timeout: number; 145 | updatedAt: number; 146 | } 147 | -------------------------------------------------------------------------------- /lib/server/request.ts: -------------------------------------------------------------------------------- 1 | import { headers } from "next/headers"; 2 | import { RefillingTokenBucket } from "./rate-limit"; 3 | 4 | export const globalBucket = new RefillingTokenBucket(100, 1); 5 | 6 | export function globalGETRateLimit(): boolean { 7 | // Note: Assumes X-Forwarded-For will always be defined. 8 | const clientIP = headers().get("X-Forwarded-For"); 9 | if (clientIP === null) { 10 | return true; 11 | } 12 | return globalBucket.consume(clientIP, 1); 13 | } 14 | 15 | export function globalPOSTRateLimit(): boolean { 16 | // Note: Assumes X-Forwarded-For will always be defined. 17 | const clientIP = headers().get("X-Forwarded-For"); 18 | if (clientIP === null) { 19 | return true; 20 | } 21 | return globalBucket.consume(clientIP, 3); 22 | } 23 | -------------------------------------------------------------------------------- /lib/server/session.ts: -------------------------------------------------------------------------------- 1 | import { db } from "./db"; 2 | import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from "@oslojs/encoding"; 3 | import { sha256 } from "@oslojs/crypto/sha2"; 4 | import { cookies } from "next/headers"; 5 | import { cache } from "react"; 6 | 7 | import type { User } from "./user"; 8 | 9 | export function validateSessionToken(token: string): SessionValidationResult { 10 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 11 | const row = db.queryOne( 12 | ` 13 | 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 14 | INNER JOIN user ON session.user_id = user.id 15 | LEFT JOIN totp_credential ON session.user_id = totp_credential.user_id 16 | LEFT JOIN passkey_credential ON user.id = passkey_credential.user_id 17 | LEFT JOIN security_key_credential ON user.id = security_key_credential.user_id 18 | WHERE session.id = ? 19 | `, 20 | [sessionId] 21 | ); 22 | 23 | if (row === null) { 24 | return { session: null, user: null }; 25 | } 26 | const session: Session = { 27 | id: row.string(0), 28 | userId: row.number(1), 29 | expiresAt: new Date(row.number(2) * 1000), 30 | twoFactorVerified: Boolean(row.number(3)) 31 | }; 32 | const user: User = { 33 | id: row.number(4), 34 | email: row.string(5), 35 | username: row.string(6), 36 | emailVerified: Boolean(row.number(7)), 37 | registeredTOTP: Boolean(row.number(8)), 38 | registeredPasskey: Boolean(row.number(9)), 39 | registeredSecurityKey: Boolean(row.number(10)), 40 | registered2FA: false 41 | }; 42 | if (user.registeredPasskey || user.registeredSecurityKey || user.registeredTOTP) { 43 | user.registered2FA = true; 44 | } 45 | if (Date.now() >= session.expiresAt.getTime()) { 46 | db.execute("DELETE FROM session WHERE id = ?", [sessionId]); 47 | return { session: null, user: null }; 48 | } 49 | if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { 50 | session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); 51 | db.execute("UPDATE session SET expires_at = ? WHERE session.id = ?", [ 52 | Math.floor(session.expiresAt.getTime() / 1000), 53 | sessionId 54 | ]); 55 | } 56 | return { session, user }; 57 | } 58 | 59 | export const getCurrentSession = cache((): SessionValidationResult => { 60 | const token = cookies().get("session")?.value ?? null; 61 | if (token === null) { 62 | return { session: null, user: null }; 63 | } 64 | const result = validateSessionToken(token); 65 | return result; 66 | }); 67 | 68 | export function invalidateSession(sessionId: string): void { 69 | db.execute("DELETE FROM session WHERE id = ?", [sessionId]); 70 | } 71 | 72 | export function invalidateUserSessions(userId: number): void { 73 | db.execute("DELETE FROM session WHERE user_id = ?", [userId]); 74 | } 75 | 76 | export function setSessionTokenCookie(token: string, expiresAt: Date): void { 77 | cookies().set("session", token, { 78 | httpOnly: true, 79 | path: "/", 80 | secure: process.env.NODE_ENV === "production", 81 | sameSite: "lax", 82 | expires: expiresAt 83 | }); 84 | } 85 | 86 | export function deleteSessionTokenCookie(): void { 87 | cookies().set("session", "", { 88 | httpOnly: true, 89 | path: "/", 90 | secure: process.env.NODE_ENV === "production", 91 | sameSite: "lax", 92 | maxAge: 0 93 | }); 94 | } 95 | 96 | export function generateSessionToken(): string { 97 | const tokenBytes = new Uint8Array(20); 98 | crypto.getRandomValues(tokenBytes); 99 | const token = encodeBase32LowerCaseNoPadding(tokenBytes); 100 | return token; 101 | } 102 | 103 | export function createSession(token: string, userId: number, flags: SessionFlags): Session { 104 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 105 | const session: Session = { 106 | id: sessionId, 107 | userId, 108 | expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), 109 | twoFactorVerified: flags.twoFactorVerified 110 | }; 111 | db.execute("INSERT INTO session (id, user_id, expires_at, two_factor_verified) VALUES (?, ?, ?, ?)", [ 112 | session.id, 113 | session.userId, 114 | Math.floor(session.expiresAt.getTime() / 1000), 115 | Number(session.twoFactorVerified) 116 | ]); 117 | return session; 118 | } 119 | 120 | export function setSessionAs2FAVerified(sessionId: string): void { 121 | db.execute("UPDATE session SET two_factor_verified = 1 WHERE id = ?", [sessionId]); 122 | } 123 | 124 | export interface SessionFlags { 125 | twoFactorVerified: boolean; 126 | } 127 | 128 | export interface Session extends SessionFlags { 129 | id: string; 130 | expiresAt: Date; 131 | userId: number; 132 | } 133 | 134 | type SessionValidationResult = { session: Session; user: User } | { session: null; user: null }; 135 | -------------------------------------------------------------------------------- /lib/server/totp.ts: -------------------------------------------------------------------------------- 1 | import { db } from "./db"; 2 | import { decrypt, encrypt } from "./encryption"; 3 | import { ExpiringTokenBucket, RefillingTokenBucket } from "./rate-limit"; 4 | 5 | export const totpBucket = new ExpiringTokenBucket(5, 60 * 30); 6 | export const totpUpdateBucket = new RefillingTokenBucket(3, 60 * 10); 7 | 8 | export function getUserTOTPKey(userId: number): Uint8Array | null { 9 | const row = db.queryOne("SELECT totp_credential.key FROM totp_credential WHERE user_id = ?", [userId]); 10 | if (row === null) { 11 | throw new Error("Invalid user ID"); 12 | } 13 | const encrypted = row.bytesNullable(0); 14 | if (encrypted === null) { 15 | return null; 16 | } 17 | return decrypt(encrypted); 18 | } 19 | 20 | export function updateUserTOTPKey(userId: number, key: Uint8Array): void { 21 | const encrypted = encrypt(key); 22 | try { 23 | db.execute("BEGIN TRANSACTION", []); 24 | db.execute("DELETE FROM totp_credential WHERE user_id = ?", [userId]); 25 | db.execute("INSERT INTO totp_credential (user_id, key) VALUES (?, ?)", [userId, encrypted]); 26 | db.execute("COMMIT", []); 27 | } catch (e) { 28 | if (db.inTransaction()) { 29 | db.execute("ROLLBACK", []); 30 | } 31 | throw e; 32 | } 33 | } 34 | 35 | export function deleteUserTOTPKey(userId: number): void { 36 | db.execute("DELETE FROM totp_credential WHERE user_id = ?", [userId]); 37 | } 38 | -------------------------------------------------------------------------------- /lib/server/user.ts: -------------------------------------------------------------------------------- 1 | import { db } from "./db"; 2 | import { decryptToString, encryptString } from "./encryption"; 3 | import { hashPassword } from "./password"; 4 | import { generateRandomRecoveryCode } from "./utils"; 5 | 6 | export function verifyUsernameInput(username: string): boolean { 7 | return username.length > 3 && username.length < 32 && username.trim() === username; 8 | } 9 | 10 | export async function createUser(email: string, username: string, password: string): Promise { 11 | const passwordHash = await hashPassword(password); 12 | const recoveryCode = generateRandomRecoveryCode(); 13 | const encryptedRecoveryCode = encryptString(recoveryCode); 14 | const row = db.queryOne( 15 | "INSERT INTO user (email, username, password_hash, recovery_code) VALUES (?, ?, ?, ?) RETURNING user.id", 16 | [email, username, passwordHash, encryptedRecoveryCode] 17 | ); 18 | if (row === null) { 19 | throw new Error("Unexpected error"); 20 | } 21 | const user: User = { 22 | id: row.number(0), 23 | username, 24 | email, 25 | emailVerified: false, 26 | registeredTOTP: false, 27 | registeredPasskey: false, 28 | registeredSecurityKey: false, 29 | registered2FA: false 30 | }; 31 | return user; 32 | } 33 | 34 | export async function updateUserPassword(userId: number, password: string): Promise { 35 | const passwordHash = await hashPassword(password); 36 | db.execute("UPDATE user SET password_hash = ? WHERE id = ?", [passwordHash, userId]); 37 | } 38 | 39 | export function updateUserEmailAndSetEmailAsVerified(userId: number, email: string): void { 40 | db.execute("UPDATE user SET email = ?, email_verified = 1 WHERE id = ?", [email, userId]); 41 | } 42 | 43 | export function setUserAsEmailVerifiedIfEmailMatches(userId: number, email: string): boolean { 44 | const result = db.execute("UPDATE user SET email_verified = 1 WHERE id = ? AND email = ?", [userId, email]); 45 | return result.changes > 0; 46 | } 47 | 48 | export function getUserPasswordHash(userId: number): string { 49 | const row = db.queryOne("SELECT password_hash FROM user WHERE id = ?", [userId]); 50 | if (row === null) { 51 | throw new Error("Invalid user ID"); 52 | } 53 | return row.string(0); 54 | } 55 | 56 | export function getUserRecoverCode(userId: number): string { 57 | const row = db.queryOne("SELECT recovery_code FROM user WHERE id = ?", [userId]); 58 | if (row === null) { 59 | throw new Error("Invalid user ID"); 60 | } 61 | return decryptToString(row.bytes(0)); 62 | } 63 | 64 | export function resetUserRecoveryCode(userId: number): string { 65 | const recoveryCode = generateRandomRecoveryCode(); 66 | const encrypted = encryptString(recoveryCode); 67 | db.execute("UPDATE user SET recovery_code = ? WHERE id = ?", [encrypted, userId]); 68 | return recoveryCode; 69 | } 70 | 71 | export function getUserFromEmail(email: string): User | null { 72 | const row = db.queryOne( 73 | `SELECT user.id, user.email, user.username, user.email_verified, IIF(totp_credential.id IS NOT NULL, 1, 0), IIF(passkey_credential.id IS NOT NULL, 1, 0), IIF(security_key_credential.id IS NOT NULL, 1, 0) FROM user 74 | LEFT JOIN totp_credential ON user.id = totp_credential.user_id 75 | LEFT JOIN passkey_credential ON user.id = passkey_credential.user_id 76 | LEFT JOIN security_key_credential ON user.id = security_key_credential.user_id 77 | WHERE user.email = ?`, 78 | [email] 79 | ); 80 | if (row === null) { 81 | return null; 82 | } 83 | const user: User = { 84 | id: row.number(0), 85 | email: row.string(1), 86 | username: row.string(2), 87 | emailVerified: Boolean(row.number(3)), 88 | registeredTOTP: Boolean(row.number(4)), 89 | registeredPasskey: Boolean(row.number(5)), 90 | registeredSecurityKey: Boolean(row.number(6)), 91 | registered2FA: false 92 | }; 93 | if (user.registeredPasskey || user.registeredSecurityKey || user.registeredTOTP) { 94 | user.registered2FA = true; 95 | } 96 | return user; 97 | } 98 | 99 | export interface User { 100 | id: number; 101 | email: string; 102 | username: string; 103 | emailVerified: boolean; 104 | registeredTOTP: boolean; 105 | registeredSecurityKey: boolean; 106 | registeredPasskey: boolean; 107 | registered2FA: boolean; 108 | } 109 | -------------------------------------------------------------------------------- /lib/server/utils.ts: -------------------------------------------------------------------------------- 1 | import { encodeBase32UpperCaseNoPadding } from "@oslojs/encoding"; 2 | 3 | export function generateRandomOTP(): string { 4 | const bytes = new Uint8Array(5); 5 | crypto.getRandomValues(bytes); 6 | const code = encodeBase32UpperCaseNoPadding(bytes); 7 | return code; 8 | } 9 | 10 | export function generateRandomRecoveryCode(): string { 11 | const recoveryCodeBytes = new Uint8Array(10); 12 | crypto.getRandomValues(recoveryCodeBytes); 13 | const recoveryCode = encodeBase32UpperCaseNoPadding(recoveryCodeBytes); 14 | return recoveryCode; 15 | } 16 | -------------------------------------------------------------------------------- /lib/server/webauthn.ts: -------------------------------------------------------------------------------- 1 | import { encodeHexLowerCase } from "@oslojs/encoding"; 2 | import { db } from "./db"; 3 | 4 | const challengeBucket = new Set(); 5 | 6 | export function createWebAuthnChallenge(): Uint8Array { 7 | const challenge = new Uint8Array(20); 8 | crypto.getRandomValues(challenge); 9 | const encoded = encodeHexLowerCase(challenge); 10 | challengeBucket.add(encoded); 11 | return challenge; 12 | } 13 | 14 | export function verifyWebAuthnChallenge(challenge: Uint8Array): boolean { 15 | const encoded = encodeHexLowerCase(challenge); 16 | return challengeBucket.delete(encoded); 17 | } 18 | 19 | export function getUserPasskeyCredentials(userId: number): WebAuthnUserCredential[] { 20 | const rows = db.query("SELECT id, user_id, name, algorithm, public_key FROM passkey_credential WHERE user_id = ?", [ 21 | userId 22 | ]); 23 | const credentials: WebAuthnUserCredential[] = []; 24 | for (const row of rows) { 25 | const credential: WebAuthnUserCredential = { 26 | id: row.bytes(0), 27 | userId: row.number(1), 28 | name: row.string(2), 29 | algorithmId: row.number(3), 30 | publicKey: row.bytes(4) 31 | }; 32 | credentials.push(credential); 33 | } 34 | return credentials; 35 | } 36 | 37 | export function getPasskeyCredential(credentialId: Uint8Array): WebAuthnUserCredential | null { 38 | const row = db.queryOne("SELECT id, user_id, name, algorithm, public_key FROM passkey_credential WHERE id = ?", [ 39 | credentialId 40 | ]); 41 | if (row === null) { 42 | return null; 43 | } 44 | const credential: WebAuthnUserCredential = { 45 | id: row.bytes(0), 46 | userId: row.number(1), 47 | name: row.string(2), 48 | algorithmId: row.number(3), 49 | publicKey: row.bytes(4) 50 | }; 51 | return credential; 52 | } 53 | 54 | export function getUserPasskeyCredential(userId: number, credentialId: Uint8Array): WebAuthnUserCredential | null { 55 | const row = db.queryOne( 56 | "SELECT id, user_id, name, algorithm, public_key FROM passkey_credential WHERE id = ? AND user_id = ?", 57 | [credentialId, userId] 58 | ); 59 | if (row === null) { 60 | return null; 61 | } 62 | const credential: WebAuthnUserCredential = { 63 | id: row.bytes(0), 64 | userId: row.number(1), 65 | name: row.string(2), 66 | algorithmId: row.number(3), 67 | publicKey: row.bytes(4) 68 | }; 69 | return credential; 70 | } 71 | 72 | export function createPasskeyCredential(credential: WebAuthnUserCredential): void { 73 | db.execute("INSERT INTO passkey_credential (id, user_id, name, algorithm, public_key) VALUES (?, ?, ?, ?, ?)", [ 74 | credential.id, 75 | credential.userId, 76 | credential.name, 77 | credential.algorithmId, 78 | credential.publicKey 79 | ]); 80 | } 81 | 82 | export function deleteUserPasskeyCredential(userId: number, credentialId: Uint8Array): boolean { 83 | const result = db.execute("DELETE FROM passkey_credential WHERE id = ? AND user_id = ?", [credentialId, userId]); 84 | return result.changes > 0; 85 | } 86 | 87 | export function getUserSecurityKeyCredentials(userId: number): WebAuthnUserCredential[] { 88 | const rows = db.query( 89 | "SELECT id, user_id, name, algorithm, public_key FROM security_key_credential WHERE user_id = ?", 90 | [userId] 91 | ); 92 | const credentials: WebAuthnUserCredential[] = []; 93 | for (const row of rows) { 94 | const credential: WebAuthnUserCredential = { 95 | id: row.bytes(0), 96 | userId: row.number(1), 97 | name: row.string(2), 98 | algorithmId: row.number(3), 99 | publicKey: row.bytes(4) 100 | }; 101 | credentials.push(credential); 102 | } 103 | return credentials; 104 | } 105 | 106 | export function getUserSecurityKeyCredential(userId: number, credentialId: Uint8Array): WebAuthnUserCredential | null { 107 | const row = db.queryOne( 108 | "SELECT id, user_id, name, algorithm, public_key FROM security_key_credential WHERE id = ? AND user_id = ?", 109 | [credentialId, userId] 110 | ); 111 | if (row === null) { 112 | return null; 113 | } 114 | const credential: WebAuthnUserCredential = { 115 | id: row.bytes(0), 116 | userId: row.number(1), 117 | name: row.string(2), 118 | algorithmId: row.number(3), 119 | publicKey: row.bytes(4) 120 | }; 121 | return credential; 122 | } 123 | 124 | export function createSecurityKeyCredential(credential: WebAuthnUserCredential): void { 125 | db.execute("INSERT INTO security_key_credential (id, user_id, name, algorithm, public_key) VALUES (?, ?, ?, ?, ?)", [ 126 | credential.id, 127 | credential.userId, 128 | credential.name, 129 | credential.algorithmId, 130 | credential.publicKey 131 | ]); 132 | } 133 | 134 | export function deleteUserSecurityKeyCredential(userId: number, credentialId: Uint8Array): boolean { 135 | const result = db.execute("DELETE FROM security_key_credential WHERE id = ? AND user_id = ?", [credentialId, userId]); 136 | return result.changes > 0; 137 | } 138 | 139 | export interface WebAuthnUserCredential { 140 | id: Uint8Array; 141 | userId: number; 142 | name: string; 143 | algorithmId: number; 144 | publicKey: Uint8Array; 145 | } 146 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | import type { NextRequest } from "next/server"; 4 | 5 | export async function middleware(request: NextRequest): Promise { 6 | if (request.method === "GET") { 7 | const response = NextResponse.next(); 8 | const token = request.cookies.get("session")?.value ?? null; 9 | if (token !== null) { 10 | // Only extend cookie expiration on GET requests since we can be sure 11 | // a new session wasn't set when handling the request. 12 | response.cookies.set("session", token, { 13 | path: "/", 14 | maxAge: 60 * 60 * 24 * 30, 15 | sameSite: "lax", 16 | httpOnly: true, 17 | secure: process.env.NODE_ENV === "production" 18 | }); 19 | } 20 | return response; 21 | } 22 | 23 | const originHeader = request.headers.get("Origin"); 24 | // NOTE: You may need to use `X-Forwarded-Host` instead 25 | const hostHeader = request.headers.get("Host"); 26 | if (originHeader === null || hostHeader === null) { 27 | return new NextResponse(null, { 28 | status: 403 29 | }); 30 | } 31 | let origin: URL; 32 | try { 33 | origin = new URL(originHeader); 34 | } catch { 35 | return new NextResponse(null, { 36 | status: 403 37 | }); 38 | } 39 | if (origin.host !== hostHeader) { 40 | return new NextResponse(null, { 41 | status: 403 42 | }); 43 | } 44 | return NextResponse.next(); 45 | } 46 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | serverComponentsExternalPackages: ["@node-rs/argon2"] 5 | } 6 | }; 7 | 8 | export default nextConfig; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-nextjs-email-password-webauthn", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "format": "prettier -w ." 11 | }, 12 | "dependencies": { 13 | "@node-rs/argon2": "^2.0.0", 14 | "@oslojs/binary": "^1.0.0", 15 | "@oslojs/crypto": "^1.0.1", 16 | "@oslojs/encoding": "^1.1.0", 17 | "@oslojs/otp": "^1.0.0", 18 | "@oslojs/webauthn": "^1.0.0", 19 | "@pilcrowjs/db-query": "^0.0.2", 20 | "@pilcrowjs/object-parser": "^0.0.4", 21 | "better-sqlite3": "^11.3.0", 22 | "next": "14.2.14", 23 | "react": "^18", 24 | "react-dom": "^18", 25 | "uqr": "^0.1.2" 26 | }, 27 | "devDependencies": { 28 | "@types/better-sqlite3": "^7.6.11", 29 | "@types/node": "^20", 30 | "@types/react": "^18", 31 | "@types/react-dom": "^18", 32 | "eslint": "^8", 33 | "eslint-config-next": "14.2.14", 34 | "eslint-config-prettier": "^9.1.0", 35 | "prettier": "^3.3.3", 36 | "typescript": "^5" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /setup.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE user ( 2 | id INTEGER NOT NULL PRIMARY KEY, 3 | email TEXT NOT NULL UNIQUE, 4 | username TEXT NOT NULL, 5 | password_hash TEXT NOT NULL, 6 | email_verified INTEGER NOT NULL DEFAULT 0, 7 | recovery_code BLOB NOT NULL 8 | ); 9 | 10 | CREATE INDEX email_index ON user(email); 11 | 12 | CREATE TABLE session ( 13 | id TEXT NOT NULL PRIMARY KEY, 14 | user_id INTEGER NOT NULL REFERENCES user(id), 15 | expires_at INTEGER NOT NULL, 16 | two_factor_verified INTEGER NOT NULL DEFAULT 0 17 | ); 18 | 19 | CREATE TABLE email_verification_request ( 20 | id TEXT NOT NULL PRIMARY KEY, 21 | user_id INTEGER NOT NULL REFERENCES user(id), 22 | email TEXT NOT NULL, 23 | code TEXT NOT NULL, 24 | expires_at INTEGER NOT NULL 25 | ); 26 | 27 | CREATE TABLE password_reset_session ( 28 | id TEXT NOT NULL PRIMARY KEY, 29 | user_id INTEGER NOT NULL REFERENCES user(id), 30 | email TEXT NOT NULL, 31 | code TEXT NOT NULL, 32 | expires_at INTEGER NOT NULL, 33 | email_verified INTEGER NOT NULL NOT NULL DEFAULT 0, 34 | two_factor_verified INTEGER NOT NULL DEFAULT 0 35 | ); 36 | 37 | CREATE TABLE totp_credential ( 38 | id INTEGER NOT NULL PRIMARY KEY, 39 | user_id INTEGER NOT NULL UNIQUE REFERENCES user(id), 40 | key BLOB NOT NULL 41 | ); 42 | 43 | CREATE TABLE passkey_credential ( 44 | id BLOB NOT NULL PRIMARY KEY, 45 | user_id INTEGER NOT NULL REFERENCES user(id), 46 | name TEXT NOT NULL, 47 | algorithm INTEGER NOT NULL, 48 | public_key BLOB NOT NULL 49 | ); 50 | 51 | CREATE TABLE security_key_credential ( 52 | id BLOB NOT NULL PRIMARY KEY, 53 | user_id INTEGER NOT NULL REFERENCES user(id), 54 | name TEXT NOT NULL, 55 | algorithm INTEGER NOT NULL, 56 | public_key BLOB NOT NULL 57 | ); 58 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | }, 23 | "target": "ES2020" 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | --------------------------------------------------------------------------------