├── .github └── workflows │ └── publish-docs.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs ├── .gitignore ├── favicon.ico ├── malta.config.json ├── og-logo.jpeg └── pages │ ├── 2fa │ ├── account-recovery.md │ ├── checklist.md │ ├── setup.md │ └── totp.md │ ├── email-password │ ├── checklist.md │ ├── email-verification.md │ ├── examples.md │ ├── login.md │ ├── password-reset.md │ ├── setup.md │ ├── signup.md │ ├── update-email.md │ └── update-password.md │ ├── faroe-server │ ├── database-backup.md │ └── getting-started.md │ ├── index.md │ └── reference │ ├── cli │ └── index.md │ ├── rest │ ├── endpoints │ │ ├── delete_email-update-requests_requestid.md │ │ ├── delete_password-reset-requests_requestid.md │ │ ├── delete_users_userid.md │ │ ├── delete_users_userid_email-update-requests.md │ │ ├── delete_users_userid_email-verification-request.md │ │ ├── delete_users_userid_password-reset-requests.md │ │ ├── delete_users_userid_totp-credential.md │ │ ├── get_email-update-requests_requestid.md │ │ ├── get_password-reset-requests_requestid.md │ │ ├── get_users.md │ │ ├── get_users_userid.md │ │ ├── get_users_userid_email-update-requests.md │ │ ├── get_users_userid_email-verification-request.md │ │ ├── get_users_userid_password-reset-requests.md │ │ ├── get_users_userid_totp-credential.md │ │ ├── post_password-reset-requests_requestid_verify-email.md │ │ ├── post_reset-password.md │ │ ├── post_users.md │ │ ├── post_users_userid_email-update-requests.md │ │ ├── post_users_userid_email-verification-request.md │ │ ├── post_users_userid_password-reset-requests.md │ │ ├── post_users_userid_regenerate-recovery-code.md │ │ ├── post_users_userid_register-totp.md │ │ ├── post_users_userid_reset-2fa.md │ │ ├── post_users_userid_update-password.md │ │ ├── post_users_userid_verify-2fa_totp.md │ │ ├── post_users_userid_verify-email.md │ │ ├── post_users_userid_verify-password.md │ │ └── post_verify-new-email.md │ ├── index.md │ └── models │ │ ├── email-update-request.md │ │ ├── password-reset-request.md │ │ ├── user-email-verification-request.md │ │ ├── user-totp-credential.md │ │ └── user.md │ └── sdk-js │ ├── index.md │ └── main │ ├── Faroe │ ├── createUser.md │ ├── createUserEmailUpdateRequest.md │ ├── createUserEmailVerificationRequest.md │ ├── createUserPasswordResetRequest.md │ ├── deleteEmailUpdateRequest.md │ ├── deletePasswordResetRequest.md │ ├── deleteUser.md │ ├── deleteUserEmailUpdateRequests.md │ ├── deleteUserEmailVerificationRequest.md │ ├── deleteUserPasswordResetRequests.md │ ├── deleteUserTOTPCredential.md │ ├── getEmailUpdateRequest.md │ ├── getPasswordResetRequest.md │ ├── getUser.md │ ├── getUserEmailUpdateRequests.md │ ├── getUserEmailVerificationRequest.md │ ├── getUserPasswordResetRequests.md │ ├── getUserTOTPCredential.md │ ├── getUsers.md │ ├── index.md │ ├── regenerateUserRecoveryCode.md │ ├── registerUserTOTPCredential.md │ ├── resetUser2FA.md │ ├── resetUserPassword.md │ ├── updateUserPassword.md │ ├── verifyNewUserEmail.md │ ├── verifyPasswordResetRequestEmail.md │ ├── verifyUser2FAWithTOTP.md │ ├── verifyUserEmail.md │ └── verifyUserPassword.md │ ├── FaroeEmailUpdateRequest.md │ ├── FaroeError.md │ ├── FaroeFetchError.md │ ├── FaroePasswordResetRequest.md │ ├── FaroeUser.md │ ├── FaroeUserEmailVerificationRequest.md │ ├── FaroeUserTOTPCredential.md │ ├── PaginationResult.md │ ├── SortOrder.md │ ├── UserSortBy.md │ ├── index.md │ ├── verifyEmailInput.md │ └── verifyPasswordInput.md ├── scripts └── build.sh └── src ├── .gitignore ├── argon2id ├── main.go └── main_test.go ├── auth.go ├── code.go ├── db.go ├── db_test.go ├── email.go ├── email_test.go ├── go.mod ├── go.sum ├── integration_test.go ├── main.go ├── main_test.go ├── otp ├── main.go └── main_test.go ├── password-reset.go ├── password-reset_test.go ├── ratelimit ├── counter.go └── token-bucket.go ├── request.go ├── request_test.go ├── schema.sql ├── strings.go ├── totp.go ├── totp_test.go ├── user.go └── user_test.go /.github/workflows/publish-docs.yaml: -------------------------------------------------------------------------------- 1 | name: "Publish docs" 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | env: 8 | CLOUDFLARE_API_TOKEN: ${{secrets.CLOUDFLARE_PAGES_API_TOKEN}} 9 | 10 | jobs: 11 | docs: 12 | name: "Build and deploy docs" 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: setup actions 16 | uses: actions/checkout@v3 17 | - name: setup node 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: 20.5.1 21 | registry-url: https://registry.npmjs.org 22 | - name: install malta 23 | run: | 24 | curl -o malta.tgz -L https://github.com/pilcrowonpaper/malta/releases/latest/download/linux-amd64.tgz 25 | tar -xvzf malta.tgz 26 | - name: build 27 | working-directory: docs 28 | run: ../linux-amd64/malta build 29 | - name: install wrangler 30 | run: npm i -g wrangler 31 | - name: deploy 32 | run: wrangler pages deploy docs/dist --project-name faroe --branch main -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | .DS_Store -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.2.1 4 | 5 | - Added `--dir` option `serve` command.˝ 6 | 7 | ## 0.2.0 8 | 9 | - Removed `email` field from user model. 10 | - Make sure to add a unique constraint to your user table's email field. 11 | - Replaced endpoints: 12 | - `POST /authenticate/password` with `POST /users/[user_id]/verify-password` 13 | - `POST /update-email` with `POST /verify-new-email` 14 | - `POST /password-reset-requests` with `POST /users/[user_id]/password-reset-requests` 15 | - Updated endpoint request parameters and response: 16 | - `GET /users` 17 | - `POST /users` -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 pilcrowOnPaper 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Faroe 2 | 3 | **Documentation: [faroe.dev](https://faroe.dev)** 4 | 5 | **[Discord server](https://discord.gg/EcwqgqSRt4)** 6 | 7 | *This software is not stable yet. Do not use it in production.* 8 | 9 | Faroe is an open source, self-hosted, and modular backend for email and password authentication. It exposes various API endpoints including: 10 | 11 | - Registering users with email and password 12 | - Authenticating users with email and password 13 | - Email verification 14 | - Password reset 15 | - 2FA with TOTP 16 | - 2FA recovery 17 | 18 | These work with your application's UI and backend to provide a complete authentication system. 19 | 20 | ```ts 21 | // Get user from your database. 22 | const user = await getUserFromEmail(email); 23 | if (user === null) { 24 | response.writeHeader(400); 25 | response.write("Account does not exist."); 26 | return; 27 | } 28 | 29 | let faroeUser: FaroeUser; 30 | try { 31 | faroeUser = await faroe.verifyUserPassword(user.faroeId, password, clientIP); 32 | } catch (e) { 33 | if (e instanceof FaroeError && e.code === "INCORRECT_PASSWORD") { 34 | response.writeHeader(400); 35 | response.write("Incorrect password."); 36 | return; 37 | } 38 | if (e instanceof FaroeError && e.code === "TOO_MANY_REQUESTS") { 39 | response.writeHeader(429); 40 | response.write("Please try again later."); 41 | return; 42 | } 43 | response.writeHeader(500); 44 | response.write("An unknown error occurred. Please try again later."); 45 | return; 46 | } 47 | 48 | // Create a new session in your application. 49 | const session = await createSession(user.id, null); 50 | ``` 51 | 52 | This is not a full authentication backend (Auth0, Supabase, etc) nor a full identity provider (KeyCloak, etc). It is specifically designed to handle the backend logic for email and password authentication. Faroe does not provide session management, frontend UI, or OAuth integration. 53 | 54 | Faroe is written in Go and uses SQLite as its database. 55 | 56 | Licensed under the MIT license. 57 | 58 | ## Features 59 | 60 | - Email login, email verification, 2FA with TOTP, 2FA recovery, and password reset 61 | - Rate limiting and brute force protection 62 | - Proper password strength checks 63 | - Everything included in a single binary 64 | 65 | ## Why? 66 | 67 | If you don't want to use a fullstack framework, implementing auth means paying for a third-party service, self-hosting an identity provider, or building one from scratch. JavaScript especially is yet to have a standard, default framework a built-in auth solution. A separate backend that handles everything is nice, but it can be frustrating to customize the overall login flow, data structure, and UI. Implementing from scratch gives you the most flexibility, but becomes time-consuming when you want to implement anything more than OAuth. 68 | 69 | Faroe is the middle ground between a dedicated auth backend and a custom implementation. You can let it handle the core logic and just build the UI and manage sessions. It's most of the hard part of auth compressed into a single binary file. 70 | 71 | ## Things to consider 72 | 73 | - Faroe does not include an email server. 74 | - Bot protection is not included. We highly recommend using Captchas or equivalent in registration and password reset forms. 75 | - Faroe uses SQLite in WAL mode as its database. This shouldn't cause issues unless you have 100,000+ users, and even then, the database will only handle a small part of your total requests. 76 | - Faroe uses in-memory storage for rate limiting. 77 | 78 | ## SDKs 79 | 80 | - [JavaScript SDK](https://github.com/faroedev/sdk-js) 81 | 82 | ## Examples 83 | 84 | - [Astro basic example](https://github.com/faroedev/example-astro-basic) 85 | - [Next.js basic example](https://github.com/faroedev/example-nextjs-basic) 86 | - [SvelteKit basic example](https://github.com/faroedev/example-sveltekit-basic) 87 | 88 | ## Security 89 | 90 | Please report security vulnerabilities to `pilcrowonpaper@gmail.com`. -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/faroe-archive/faroe/ab723b9cc529536914de3ce0cb6589ba5a1513fb/docs/favicon.ico -------------------------------------------------------------------------------- /docs/malta.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Faroe", 3 | "description": "Faroe is an open source, self-hosted, and modular identity provider for email and password authentication.", 4 | "domain": "https://faroe.dev", 5 | "twitter": "@pilcrowonpaper", 6 | "asset_hashing": true, 7 | "sidebar": [ 8 | { 9 | "title": "Faroe server", 10 | "pages": [ 11 | ["Getting started", "/faroe-server/getting-started"], 12 | ["Database backup", "/faroe-server/database-backup"] 13 | ] 14 | }, 15 | { 16 | "title": "Email and password", 17 | "pages": [ 18 | ["Example projects", "/email-password/examples"], 19 | ["Setting up your project", "/email-password/setup"], 20 | ["Sign up", "/email-password/signup"], 21 | ["Sign in", "/email-password/login"], 22 | ["Email verification", "/email-password/email-verification"], 23 | ["Update email address", "/email-password/update-email"], 24 | ["Update password", "/email-password/update-password"], 25 | ["Password reset", "/email-password/password-reset"], 26 | ["Implementation checklist", "/email-password/checklist"] 27 | ] 28 | }, 29 | { 30 | "title": "Two-factor authentication", 31 | "pages": [ 32 | ["Setting up your project", "/2fa/setup"], 33 | ["Authenticator apps", "/2fa/totp"], 34 | ["Account recovery", "/2fa/account-recovery"], 35 | ["Implementation checklist", "/2fa/checklist"] 36 | ] 37 | }, 38 | { 39 | "title": "API reference", 40 | "pages": [ 41 | ["CLI", "/reference/cli"], 42 | ["Rest API", "/reference/rest"], 43 | ["JavaScript SDK", "/reference/sdk-js"] 44 | ] 45 | }, 46 | { 47 | "title": "Link", 48 | "pages": [ 49 | ["GitHub", "https://github.com/faroedev/faroe"], 50 | ["Discord", "https://discord.gg/EcwqgqSRt4"] 51 | ] 52 | } 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /docs/og-logo.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/faroe-archive/faroe/ab723b9cc529536914de3ce0cb6589ba5a1513fb/docs/og-logo.jpeg -------------------------------------------------------------------------------- /docs/pages/2fa/account-recovery.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Account recovery" 3 | --- 4 | 5 | # Account recovery 6 | 7 | *This page uses the JavaScript SDK*. 8 | 9 | Faroe supports recovery codes, which can be used to reset a user's second factors. 10 | 11 | Use `Faroe.getUserRecoveryCode()` to get the user's recovery code. The code should displayed when the user first registers a second factor and should be accessible anytime after verifying their second factor. 12 | 13 | ```ts 14 | const recoveryCode = await faroe.getUserRecoveryCode(faroeUserId); 15 | ``` 16 | 17 | Use `Faroe.resetUser2FA()` to reset the user's second factors with a recovery code. This will delete the user's TOTP credential and generate a new recovery code. Set the `totp_registered` user attribute to `false`. 18 | 19 | ```ts 20 | import { FaroeError } from "@faroe/sdk"; 21 | 22 | import type { FaroeUser } from "@faroe/sdk"; 23 | 24 | async function handleReset2FARequest( 25 | request: HTTPRequest, 26 | response: HTTPResponse 27 | ): Promise { 28 | const clientIP = request.headers.get("X-Forwarded-For"); 29 | 30 | const { session, user } = await validateRequest(request); 31 | if (session === null) { 32 | response.writeHeader(401); 33 | response.write("Not authenticated."); 34 | return; 35 | } 36 | if (!user.totpRegistered) { 37 | response.writeHeader(403); 38 | response.write("Not allowed."); 39 | return; 40 | } 41 | 42 | let code: string; 43 | 44 | // ... 45 | 46 | try { 47 | await faroe.resetUser2FA(user.id, recoveryCode); 48 | } catch (e) { 49 | if (e instanceof FaroeError && e.code === "INCORRECT_CODE") { 50 | response.writeHeader(400); 51 | response.write("Incorrect code."); 52 | return; 53 | } 54 | if (e instanceof FaroeError && e.code === "TOO_MANY_REQUESTS") { 55 | response.writeHeader(429); 56 | response.write("Please try again later."); 57 | return; 58 | } 59 | response.writeHeader(500); 60 | response.write("An unknown error occurred. Please try again later."); 61 | return; 62 | } 63 | 64 | await setUserAsNot2FARegistered(user.id); 65 | await setSessionAsNot2FAVerified(session.id); 66 | 67 | // ... 68 | } 69 | ``` 70 | 71 | Use `Faroe.regenerateUserRecoveryCode()` to generate a new recovery code. Make sure that the user is 2FA-verified. 72 | 73 | ```ts 74 | async function handleReset2FARequest( 75 | request: HTTPRequest, 76 | response: HTTPResponse 77 | ): Promise { 78 | const clientIP = request.headers.get("X-Forwarded-For"); 79 | 80 | const { session, user } = await validateRequest(request); 81 | if (session === null) { 82 | response.writeHeader(401); 83 | response.write("Not authenticated."); 84 | return; 85 | } 86 | if (!session.twoFactorVerified) { 87 | response.writeHeader(403); 88 | response.write("Not allowed."); 89 | return; 90 | } 91 | 92 | let recoveryCode: string; 93 | try { 94 | recoveryCode = await faroe.regenerateUserRecoveryCode(user.id); 95 | } catch (e) { 96 | response.writeHeader(500); 97 | response.write("An unknown error occurred. Please try again later."); 98 | return; 99 | } 100 | 101 | // ... 102 | } 103 | ``` 104 | -------------------------------------------------------------------------------- /docs/pages/2fa/checklist.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Implementation checklist" 3 | --- 4 | 5 | # Implementation checklist 6 | 7 | - Do you check that the user has verified their second factor before they can reset their password? 8 | - Are users with a registered second factor that aren't 2FA-verified blocked from privledged actions, including changing passwords, viewing the recovery code, registering a new TOTP credential, and generating a new recovery code? 9 | -------------------------------------------------------------------------------- /docs/pages/2fa/setup.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Setting up your project" 3 | --- 4 | 5 | # Setting up your project 6 | 7 | This section is based on the email and password guides. 8 | 9 | ## Update your database 10 | 11 | Add a `two_factor_verified` attribute to your sessions. 12 | 13 | ```sql 14 | CREATE TABLE session ( 15 | id TEXT NOT NULL PRIMARY KEY, 16 | user_id INTEGER NOT NULL REFERENCES user(id), 17 | expires_at INTEGER NOT NULL, 18 | faroe_email_verification_id TEXT, 19 | two_factor_verified INTEGER NOT NULL DEFAULT 0 20 | ); 21 | ``` 22 | 23 | ## Update password reset flow 24 | 25 | Add a `two_factor_verified` attribute to your password reset sessions. 26 | 27 | ```sql 28 | CREATE TABLE password_reset_session ( 29 | id TEXT NOT NULL PRIMARY KEY, 30 | faroe_user_id TEXT NOT NULL, 31 | faroe_request_id TEXT NOT NULL, 32 | expires_at INTEGER NOT NULL, 33 | email_verified INTEGER NOT NULL DEFAULT 0, 34 | two_factor_verified INTGEGER NOT NULL DEFAULT 0 35 | ); 36 | ``` 37 | 38 | If the user has a second factor registered, users should be prompted for it before they can reset their password. 39 | 40 | ```ts 41 | const { session: passwordResetSession, user } = await validatePasswordResetRequest(request); 42 | if (passwordResetSession === null) { 43 | response.writeHeader(401); 44 | response.write("Not authenticated."); 45 | return; 46 | } 47 | 48 | if (!passwordResetSession.emailVerified) { 49 | response.writeHeader(403); 50 | response.write("Forbidden."); 51 | return; 52 | } 53 | if (user.registeredTOTP && !session.twoFactorVerified) { 54 | response.writeHeader(403); 55 | response.write("Forbidden."); 56 | return; 57 | } 58 | 59 | // ... 60 | 61 | try { 62 | await faroe.resetUserPassword(session.faroeRequestId, password, clientIP); 63 | } catch (e) { 64 | // ... 65 | } 66 | ``` 67 | -------------------------------------------------------------------------------- /docs/pages/2fa/totp.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Authenticator apps" 3 | --- 4 | 5 | # Authenticator apps 6 | 7 | *This page uses the JavaScript SDK*. 8 | 9 | Implement 2FA with TOTP credentials to allow users to user their authenticator apps as their second factor. 10 | 11 | ## Register TOTP credential 12 | 13 | We recommend install `@oslojs/otp` to create the key URI. 14 | 15 | ``` 16 | npm install @oslojs/otp 17 | ``` 18 | 19 | Generate a random 20 byte key and create a key URI with the interval set to 30 seconds and the digits to 6. 20 | 21 | ```ts 22 | import { createTOTPKeyURI } from "@oslojs/otp"; 23 | 24 | const key = new Uint8Array(20); 25 | crypto.getRandomValues(key); 26 | const keyURI = createTOTPKeyURI("My app", user.email, key, 30, 6); 27 | const qrcode = createQRCode(keyURI); 28 | ``` 29 | 30 | Ask the user to scan the QR code of the key with their authenticator app and enter the OTP code from the app. Send both the key (e.g. encode it with base64) and the code. 31 | 32 | ```ts 33 | // HTTPRequest and HTTPResponse are just generic interfaces 34 | async function handleRegisterTOTPRequest( 35 | request: HTTPRequest, 36 | response: HTTPResponse 37 | ): Promise { 38 | const clientIP = request.headers.get("X-Forwarded-For"); 39 | 40 | const { session, user } = await validateRequest(request); 41 | if (session === null) { 42 | response.writeHeader(401); 43 | response.write("Not authenticated."); 44 | return; 45 | } 46 | 47 | if (user.registeredTOTP && !session.twoFactorVerified) { 48 | response.writeHeader(403); 49 | response.write("Not allowed."); 50 | return; 51 | } 52 | 53 | let key: Uint8Array; 54 | let code: string; 55 | 56 | // ... 57 | 58 | try { 59 | await faroe.registerUserTOTPCredential(user.faroeId, key, code); 60 | } catch (e) { 61 | if (e instanceof FaroeError && e.code === "INCORRECT_CODE") { 62 | response.writeHeader(400); 63 | response.write("Incorrect code."); 64 | return; 65 | } 66 | if (e instanceof FaroeError && e.code === "TOO_MANY_REQUESTS") { 67 | response.writeHeader(429); 68 | response.write("Please try again later."); 69 | return; 70 | } 71 | response.writeHeader(500); 72 | response.write("An unknown error occurred. Please try again later."); 73 | return; 74 | } 75 | 76 | await setUserAsRegisteredTOTP(user.id); 77 | await setSessionAs2FAVerified(session.id); 78 | 79 | // ... 80 | 81 | } 82 | ``` 83 | 84 | ## Verify TOTP 85 | 86 | Use `Faroe.verifyUser2FAWithTOTP()` to verify TOTP codes. 87 | 88 | If successful, set the session as `two_factor_verified`. 89 | 90 | ```ts 91 | // HTTPRequest and HTTPResponse are just generic interfaces 92 | async function handleVerifyUserTOTP( 93 | request: HTTPRequest, 94 | response: HTTPResponse 95 | ): Promise { 96 | const clientIP = request.headers.get("X-Forwarded-For"); 97 | 98 | const { session, user } = await validateRequest(request); 99 | if (session === null) { 100 | response.writeHeader(401); 101 | response.write("Not authenticated."); 102 | return; 103 | } 104 | 105 | if (!user.registeredTOTP) { 106 | response.writeHeader(403); 107 | response.write("Not allowed."); 108 | return; 109 | } 110 | 111 | let code: string; 112 | 113 | try { 114 | await faroe.verifyUser2FAWithTOTP(user.faroeId, code); 115 | } catch (e) { 116 | if (e instanceof FaroeError && e.code === "INCORRECT_CODE") { 117 | response.writeHeader(400); 118 | response.write("Incorrect code."); 119 | return; 120 | } 121 | if (e instanceof FaroeError && e.code === "TOO_MANY_REQUESTS") { 122 | response.writeHeader(400); 123 | response.write("Please try again later."); 124 | return; 125 | } 126 | response.writeHeader(500); 127 | response.write("An unknown error occurred. Please try again later."); 128 | return; 129 | } 130 | 131 | await setSessionAs2FAVerified(session.id); 132 | 133 | // ... 134 | 135 | } 136 | ``` 137 | 138 | 2FA for password reset is nearly identical. 139 | 140 | ```ts 141 | async function handleVerifyPasswordResetUserTOTP( 142 | request: HTTPRequest, 143 | response: HTTPResponse 144 | ): Promise { 145 | const clientIP = request.headers.get("X-Forwarded-For"); 146 | 147 | const { session, user } = await validatePasswordResetRequest(request); 148 | if (session === null) { 149 | response.writeHeader(401); 150 | response.write("Not authenticated."); 151 | return; 152 | } 153 | 154 | if (!user.registeredTOTP) { 155 | response.writeHeader(403); 156 | response.write("Not allowed."); 157 | return; 158 | } 159 | 160 | let code: string; 161 | 162 | try { 163 | await faroe.verifyUser2FAWithTOTP(user.faroeId, code); 164 | } catch (e) { 165 | // ... 166 | } 167 | 168 | await setPasswordResetSessionAs2FAVerified(session.id); 169 | 170 | // ... 171 | 172 | } 173 | ``` 174 | 175 | ## Disconnect TOTP credential 176 | 177 | Use `Faroe.deleteUserTOTPCredential()` to delete a user's TOTP credential. Make sure that the user is 2FA-verified and to set `registered_totp` of the user to false afterwards. 178 | 179 | ```ts 180 | // HTTPRequest and HTTPResponse are just generic interfaces 181 | async function handleDisonnectTOTPCredential( 182 | request: HTTPRequest, 183 | response: HTTPResponse 184 | ): Promise { 185 | const clientIP = request.headers.get("X-Forwarded-For"); 186 | 187 | const { session, user } = await validateRequest(request); 188 | if (session === null) { 189 | response.writeHeader(401); 190 | response.write("Not authenticated."); 191 | return; 192 | } 193 | 194 | if (!user.registeredTOTP) { 195 | response.writeHeader(403); 196 | response.write("Not allowed."); 197 | return; 198 | } 199 | if (!session.twoFactorVerified) { 200 | response.writeHeader(403); 201 | response.write("Not allowed."); 202 | return; 203 | } 204 | 205 | await faroe.deleteUserTOTPCredential(user.faroeId); 206 | 207 | await setUserAsNotRegisteredTOTP(user.Id); 208 | 209 | // ... 210 | } 211 | ``` 212 | -------------------------------------------------------------------------------- /docs/pages/email-password/checklist.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Implementation checklist" 3 | --- 4 | 5 | # Implementation checklist 6 | 7 | - Do you normalize email addresses? 8 | - Do you pass the user's IP address to all Faroe methods that accept it? 9 | - Do you check that the password reset session has been email verified? 10 | - Can users without a verified email address manually request for a new verification code? 11 | - Are users without a verified email address blocked from actions that require a verified email address? 12 | - Do you invalidate all sessions belonging to a user after they update or reset their password? 13 | -------------------------------------------------------------------------------- /docs/pages/email-password/email-verification.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Email verification" 3 | --- 4 | 5 | # Email verification 6 | 7 | _This page uses the JavaScript SDK_. 8 | 9 | Ask the user for the email verification code sent to their inbox. 10 | 11 | Get the email verification request linked to the current session and use `Faroe.verifyUserEmail()` to verify the user's email. If successful, set the `email_verified` field of your application's user to `true` and unlink the verification request from the session. 12 | 13 | ```ts 14 | // Everything not imported is something you need to define yourself. 15 | import { FaroeError } from "@faroe/sdk"; 16 | 17 | async function handleVerifyEmailRequest( 18 | request: HTTPRequest, 19 | response: HTTPResponse 20 | ): Promise { 21 | const clientIP = request.headers.get("X-Forwarded-For"); 22 | 23 | const { session, user } = await validateRequest(request); 24 | if (session === null) { 25 | response.writeHeader(401); 26 | response.write("Not authenticated."); 27 | return; 28 | } 29 | 30 | if (user.emailVerified) { 31 | response.writeHeader(403); 32 | response.write("Not allowed."); 33 | return; 34 | } 35 | 36 | let code: string; 37 | 38 | // ... 39 | 40 | try { 41 | await faroe.verifyUserEmail(user.faroeId, code, clientIP); 42 | } catch (e) { 43 | if (e instanceof FaroeError && e.code === "INVALID_REQUEST") { 44 | const emailVerificationRequest = await faroe.createUserEmailVerificationRequest( 45 | faroeUser.id, 46 | clientIP 47 | ); 48 | const emailContent = `Your verification code is ${emailVerificationRequest.code}.`; 49 | await sendEmail(faroeUser.email, emailContent); 50 | 51 | response.writeHeader(400); 52 | response.write("Your verification code was expired. We sent a new one to your inbox."); 53 | return; 54 | } 55 | if (e instanceof FaroeError && e.code === "INCORRECT_CODE") { 56 | response.writeHeader(400); 57 | response.write("Incorrect code."); 58 | return; 59 | } 60 | if (e instanceof FaroeError && e.code === "TOO_MANY_REQUESTS") { 61 | response.writeHeader(429); 62 | response.write("Please try again later."); 63 | return; 64 | } 65 | response.writeHeader(500); 66 | response.write("An unknown error occurred. Please try again later."); 67 | return; 68 | } 69 | 70 | // Set email as verified. 71 | await setUserAsEmailVerified(session.userId); 72 | 73 | // ... 74 | } 75 | ``` 76 | 77 | Like in the sign up process, use `Faroe.createUserEmailVerificationRequest()` to create a new email verification request. This method has rate limiting built-in to prevent DoS attacks targeting your email servers. However, consider adding some kind of bot and spam protection. 78 | 79 | ```ts 80 | // Everything not imported is something you need to define yourself. 81 | import { FaroeError } from "@faroe/sdk"; 82 | 83 | import type { FaroeUserEmailVerificationRequest } from "@faroe/sdk"; 84 | 85 | async function handleResendEmailVerificationCodeRequest( 86 | request: HTTPRequest, 87 | response: HTTPResponse 88 | ): Promise { 89 | const clientIP = request.headers.get("X-Forwarded-For"); 90 | 91 | const { session, user } = await validateRequest(request); 92 | if (session === null) { 93 | response.writeHeader(401); 94 | response.write("Not authenticated."); 95 | return; 96 | } 97 | 98 | if (user.emailVerified) { 99 | response.writeHeader(403); 100 | response.write("Not allowed."); 101 | return; 102 | } 103 | 104 | let email: string; 105 | 106 | // ... 107 | 108 | if (!verifyEmailInput(email)) { 109 | response.writeHeader(400); 110 | response.write("Please enter a valid email address."); 111 | return; 112 | } 113 | 114 | let emailVerificationRequest: FaroeUserEmailVerificationRequest; 115 | try { 116 | emailVerificationRequest = await faroe.createUserEmailVerificationRequest( 117 | faroeUser.id, 118 | clientIP 119 | ); 120 | } catch (e) { 121 | if (e instanceof FaroeError && e.code === "TOO_MANY_REQUESTS") { 122 | response.writeHeader(429); 123 | response.write("Please try again later."); 124 | return; 125 | } 126 | response.writeHeader(500); 127 | response.write("An unknown error occurred. Please try again later."); 128 | return; 129 | } 130 | 131 | // Send verification code to user's inbox. 132 | const emailContent = `Your verification code is ${emailVerificationRequest.code}.`; 133 | await sendEmail(faroeUser.email, emailContent); 134 | 135 | // ... 136 | } 137 | ``` 138 | 139 | Faroe will lock out the user from creating a new verification request for a maximum of 15 minutes after their 5th failed attempt. If you plan to automatically create a new verification request if the current user doesn't have a valid request, ensure to expect rate-limiting errors. 140 | 141 | ```ts 142 | export async function createEmailVerificationRequestIfExpired( 143 | faroeUserId: string, 144 | email: string 145 | ): Promise { 146 | let verificationRequest = await faroe.getUserEmailVerificationRequest( 147 | faroeUserId 148 | ); 149 | if (verificationRequest == null) { 150 | verificationRequest = await faroe.createUserEmailVerificationRequest( 151 | faroeUserId 152 | ); 153 | // Send verification code to user's inbox. 154 | const emailContent = `Your verification code is ${verificationRequest.code}.`; 155 | await sendEmail(email, emailContent); 156 | } 157 | } 158 | ``` 159 | 160 | ```ts 161 | import { FaroeError } from "@faroe/sdk"; 162 | 163 | try { 164 | verificationRequest = await createEmailVerificationRequestIfExpired( 165 | user.faroeId, 166 | user.email 167 | ); 168 | } catch (e) { 169 | if (e instanceof FaroeError && e.code === "TOO_MANY_REQUESTS") { 170 | response.writeHeader(429); 171 | response.write("Please try again later."); 172 | return; 173 | } 174 | response.writeHeader(500); 175 | response.write("An unknown error occurred. Please try again later."); 176 | return; 177 | } 178 | ``` 179 | -------------------------------------------------------------------------------- /docs/pages/email-password/examples.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Example projects" 3 | --- 4 | 5 | # Example projects 6 | 7 | Example projects with email and password authentication, email verification, and password reset. 8 | 9 | - [Astro basic example](https://github.com/faroedev/example-astro-basic) 10 | - [Next.js basic example](https://github.com/faroedev/example-nextjs-basic) 11 | - [SvelteKit basic example](https://github.com/faroedev/example-sveltekit-basic) 12 | -------------------------------------------------------------------------------- /docs/pages/email-password/login.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Sign in" 3 | --- 4 | 5 | # Sign in 6 | 7 | *This page uses the JavaScript SDK*. 8 | 9 | Get the user from your database with the email and use `Faroe.verifyUserPassword()` to authenticate a user with email and password. We recommend doing some basic input validation with `verifyEmailInput()` and `verifyPasswordInput()`. Pass the user's client IP address to enable IP-based rate limiting. 10 | 11 | If successful, get the user from the Faroe user ID and create a new session. 12 | 13 | ```ts 14 | // Everything not imported is something you need to define yourself. 15 | import { verifyEmailInput, FaroeError } from "@faroe/sdk"; 16 | 17 | import type { FaroeUser } from "@faroe/sdk"; 18 | 19 | async function handleLoginRequest( 20 | request: HTTPRequest, 21 | response: HTTPResponse 22 | ): Promise { 23 | const clientIP = request.headers.get("X-Forwarded-For"); 24 | 25 | let email: string; 26 | let password: string; 27 | // ... 28 | 29 | // Normalize input. 30 | email = email.toLowerCase(); 31 | 32 | if (!verifyEmailInput(email)) { 33 | response.writeHeader(400); 34 | response.write("Please enter a valid email address."); 35 | return; 36 | } 37 | 38 | const user = await getUserFromEmail(email); 39 | if (user === null) { 40 | response.writeHeader(400); 41 | response.write("Account does not exist."); 42 | return; 43 | } 44 | 45 | let faroeUser: FaroeUser; 46 | try { 47 | faroeUser = await faroe.verifyUserPassword(user.faroeId, password, clientIP); 48 | } catch (e) { 49 | if (e instanceof FaroeError && e.code === "INCORRECT_PASSWORD") { 50 | response.writeHeader(400); 51 | response.write("Incorrect password."); 52 | return; 53 | } 54 | if (e instanceof FaroeError && e.code === "TOO_MANY_REQUESTS") { 55 | response.writeHeader(429); 56 | response.write("Please try again later."); 57 | return; 58 | } 59 | response.writeHeader(500); 60 | response.write("An unknown error occurred. Please try again later."); 61 | return; 62 | } 63 | 64 | const session = await createSession(user.id, null); 65 | 66 | // ... 67 | } 68 | ``` 69 | -------------------------------------------------------------------------------- /docs/pages/email-password/password-reset.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Password reset" 3 | --- 4 | 5 | # Password reset 6 | 7 | *This page uses the JavaScript SDK*. 8 | 9 | ## Send password reset email 10 | 11 | Create a "forgot password" form and ask for the user's email. Create a new password reset request with `Faroe.createUserPasswordResetRequest()`. We recommend doing some basic input validation with `verifyEmailInput()`. 12 | 13 | If successful, send the verification code to the user's inbox. Create a new password reset session and link the verification request to it. 14 | 15 | We highly recommend putting some kind of bot and spam protection in front of this method. 16 | 17 | ```ts 18 | // Everything not imported is something you need to define yourself. 19 | import { verifyEmailInput, FaroeError } from "@faroe/sdk"; 20 | 21 | import type { FaroePasswordResetRequest } from "@faroe/sdk"; 22 | 23 | async function handleForgotPasswordRequest( 24 | request: HTTPRequest, 25 | response: HTTPResponse 26 | ): Promise { 27 | const clientIP = request.headers.get("X-Forwarded-For"); 28 | 29 | let email: string; 30 | 31 | // ... 32 | 33 | // Normalize input. 34 | email = email.toLowerCase(); 35 | 36 | if (!verifyEmailInput(email)) { 37 | response.writeHeader(400); 38 | response.write("Please enter a valid email address."); 39 | return; 40 | } 41 | 42 | const user = await getUserFromEmail(email); 43 | if (user === null) { 44 | response.writeHeader(400); 45 | response.write("Account does not exist."); 46 | return; 47 | } 48 | 49 | let passwordResetRequest: FaroePasswordResetRequest; 50 | let verificationCode: string; 51 | try { 52 | [passwordResetRequest, verificationCode] = await faroe.createUserPasswordResetRequest(user.faroeId, clientIP); 53 | } catch (e) { 54 | if (e instanceof FaroeError && e.code === "TOO_MANY_REQUESTS") { 55 | response.writeHeader(429); 56 | response.write("Please try again later."); 57 | return; 58 | } 59 | response.writeHeader(500); 60 | response.write("An unknown error occurred. Please try again later."); 61 | return; 62 | } 63 | 64 | // Send verification code to user's inbox. 65 | const emailContent = `Your code is ${verificationCode}.`; 66 | await sendEmail(faroeUser.email, emailContent); 67 | 68 | const passwordResetSession = await createPasswordResetSession(user.id, passwordResetRequest.Id, { 69 | emailVerified: false 70 | }); 71 | 72 | // ... 73 | 74 | } 75 | ``` 76 | 77 | ## Verify password reset code 78 | 79 | Ask the user for the verification code and use `Faroe.verifyPasswordResetRequestEmail()` to verify it. After the 5th failed attempt, the password reset request will be invalidated. 80 | 81 | If successful, set the `email_verified` attribute of the session to `true`. 82 | 83 | ```ts 84 | // Everything not imported is something you need to define yourself. 85 | import { FaroeError } from "@faroe/sdk"; 86 | 87 | async function handleVerifyPasswordResetEmailRequest( 88 | request: HTTPRequest, 89 | response: HTTPResponse 90 | ): Promise { 91 | const clientIP = request.headers.get("X-Forwarded-For"); 92 | 93 | const session = await validatePasswordResetRequest(request); 94 | if (session === null) { 95 | response.writeHeader(401); 96 | response.write("Not authenticated."); 97 | return; 98 | } 99 | 100 | let code: string; 101 | 102 | // ... 103 | 104 | try { 105 | await faroe.verifyPasswordResetRequestEmail(session.faroeRequestId, code, clientIP); 106 | } catch (e) { 107 | if (e instanceof FaroeError && e.code === "INCORRECT_CODE") { 108 | response.writeHeader(400); 109 | response.write("Incorrect code."); 110 | return; 111 | } 112 | if (e instanceof FaroeError && e.code === "TOO_MANY_REQUESTS") { 113 | response.writeHeader(400); 114 | response.write("Please try again."); 115 | return; 116 | } 117 | if (e instanceof FaroeError && e.code === "NOT_FOUND") { 118 | await invalidatePasswordResetSession(session.id); 119 | response.writeHeader(400); 120 | response.write("Please restart the process."); 121 | return; 122 | } 123 | response.writeHeader(500); 124 | response.write("An unknown error occurred. Please try again later."); 125 | return; 126 | } 127 | 128 | await setPasswordResetSessionAsEmailVerified(session.id); 129 | 130 | // ... 131 | } 132 | ``` 133 | 134 | ## Reset password 135 | 136 | Use `Faroe.resetUserPassword()` to reset the user's password using the password reset request. We recommend doing some basic input validation with `verifyPasswordInput()`. 137 | 138 | **Ensure that the `email_verified` attribute of the password reset session is set to `true`.** 139 | 140 | If successful, set the user's email as verified and invalidate all sessions belonging to the user. 141 | 142 | ```ts 143 | // Everything not imported is something you need to define yourself. 144 | import { verifyPasswordInput, FaroeError } from "@faroe/sdk"; 145 | 146 | async function handleResetPasswordRequest( 147 | request: HTTPRequest, 148 | response: HTTPResponse 149 | ): Promise { 150 | const clientIP = request.headers.get("X-Forwarded-For"); 151 | 152 | const passwordResetSession = await validatePasswordResetRequest(request); 153 | if (passwordResetSession === null) { 154 | response.writeHeader(401); 155 | response.write("Not authenticated."); 156 | return; 157 | } 158 | // IMPORTANT! 159 | if (!passwordResetSession.emailVerified) { 160 | response.writeHeader(403); 161 | response.write("Forbidden."); 162 | return; 163 | } 164 | 165 | let password: string; 166 | 167 | // ... 168 | 169 | if (!verifyPasswordInput(password)) { 170 | response.writeHeader(400); 171 | response.write("Password must be 8 characters long."); 172 | return; 173 | } 174 | 175 | try { 176 | await faroe.resetUserPassword(session.faroeRequestId, password, clientIP); 177 | } catch (e) { 178 | if (e instanceof FaroeError && e.code === "INVALID_REQUEST") { 179 | response.writeHeader(400); 180 | response.write("Please restart the process."); 181 | return; 182 | } 183 | if (e instanceof FaroeError && e.code === "WEAK_PASSWORD") { 184 | response.writeHeader(400); 185 | response.write("Please use a stronger password."); 186 | return; 187 | } 188 | if (e instanceof FaroeError && e.code === "TOO_MANY_REQUESTS") { 189 | response.writeHeader(400); 190 | response.write("Please try again later."); 191 | return; 192 | } 193 | response.writeHeader(500); 194 | response.write("An unknown error occurred. Please try again."); 195 | return; 196 | } 197 | 198 | await setUserEmailAsVerified(passwordResetSession.userId); 199 | 200 | // Invalidate all sessions belonging to the user and create a new session. 201 | await invalidateUserSessions(passwordResetSession.userId); 202 | const session = await createSession(passwordResetSession.userId, null); 203 | 204 | // ... 205 | } 206 | ``` 207 | -------------------------------------------------------------------------------- /docs/pages/email-password/setup.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Setting up your project" 3 | --- 4 | 5 | # Setting up your project 6 | 7 | For JavaScript projects, install the SDK and initialize it with server url, and if defined, the secret 8 | 9 | ``` 10 | npm install @faroe/sdk 11 | ``` 12 | 13 | ```ts 14 | // Without a secret. 15 | export const faroe = new Faroe("http://localhost:4000", null); 16 | 17 | // With a secret. 18 | export const faroe = new Faroe("http://localhost:4000", secret); 19 | ``` 20 | 21 | In your application, you'll need to create a database table for your users, sessions, and password reset sessions. 22 | 23 | In the user table, create a field for the Faroe user ID, user email, and an `email_verified` flag. 24 | 25 | ```sql 26 | CREATE TABLE user ( 27 | id INTEGER NOT NULL PRIMARY KEY, 28 | faroe_id TEXT NOT NULL UNIQUE, 29 | email TEXT NOT NULL UNIQUE, 30 | email_verified INTEGER NOT NULL DEFAULT 0 31 | ); 32 | ``` 33 | 34 | Next, you'll need to implement sessions for managing the state of authenticated users. How you implement them is up to you but create an optional field for the Faroe email update request ID. For JavaScript projects, consider following the tutorial from [Lucia](https://lucia-auth.com). 35 | 36 | ```sql 37 | CREATE TABLE session ( 38 | id TEXT NOT NULL PRIMARY KEY, 39 | user_id INTEGER NOT NULL REFERENCES user(id), 40 | expires_at INTEGER NOT NULL, 41 | faroe_email_update_request_id TEXT 42 | ); 43 | ``` 44 | 45 | Same goes for password reset session. This will be used maintain state during the reset flow. Password reset sessions should have an *absolute* expiration of 10 minutes, and have a field for the Faroe user ID, Faroe password reset request ID, and an `email_verified` flag. Optionally store your application's user ID. 46 | 47 | ```sql 48 | CREATE TABLE password_reset_session ( 49 | id TEXT NOT NULL PRIMARY KEY, 50 | faroe_user_id TEXT NOT NULL, 51 | faroe_request_id TEXT NOT NULL, 52 | expires_at INTEGER NOT NULL, 53 | email_verified INTEGER NOT NULL DEFAULT 0 54 | ); 55 | ``` 56 | -------------------------------------------------------------------------------- /docs/pages/email-password/signup.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Sign up" 3 | --- 4 | 5 | # Sign up 6 | 7 | *This page uses the JavaScript SDK*. 8 | 9 | Use `Faroe.createUser()` to register a new user. We recommend doing some basic input validation with `verifyEmailInput()` and `verifyPasswordInput()`. Pass the user's client IP address to enable IP-based rate limiting. 10 | 11 | Next, create a new user for your application. Then, create a new email verification request with `Faroe.createUserEmailVerificationRequest()`. Send the verification code to the user's inbox and link the verification request to the current session. 12 | 13 | We highly recommend putting some kind of bot and spam protection in front of this method. 14 | 15 | ```ts 16 | // Everything not imported is something you need to define yourself. 17 | import { verifyEmailInput, verifyPasswordInput, FaroeError } from "@faroe/sdk"; 18 | 19 | import type { FaroeUser } from "@faroe/sdk"; 20 | 21 | // HTTPRequest and HTTPResponse are just generic interfaces 22 | async function handleSignUpRequest( 23 | request: HTTPRequest, 24 | response: HTTPResponse 25 | ): Promise { 26 | const clientIP = request.headers.get("X-Forwarded-For"); 27 | 28 | let email: string; 29 | let password: string; 30 | // ... 31 | 32 | // Normalize input. 33 | email = email.toLowerCase(); 34 | 35 | if (!verifyEmailInput(email)) { 36 | response.writeHeader(400); 37 | response.write("Please enter a valid email address."); 38 | return; 39 | } 40 | 41 | // Check if email is already used. 42 | const existingUser = await getUserFromEmail(email); 43 | if (existingUser !== null) { 44 | response.writeHeader(400); 45 | response.write("Email is already used."); 46 | return; 47 | } 48 | 49 | if (!verifyPasswordInput(password)) { 50 | response.writeHeader(400); 51 | response.write("Password must be 8 characters long."); 52 | return; 53 | } 54 | 55 | 56 | let faroeUser: FaroeUser; 57 | try { 58 | faroeUser = await faroe.createUser(password, clientIP); 59 | } catch (e) { 60 | if (e instanceof FaroeError && e.code === "WEAK_PASSWORD") { 61 | response.writeHeader(400); 62 | response.write("Please use a stronger password."); 63 | return; 64 | } 65 | if (e instanceof FaroeError && e.code === "TOO_MANY_REQUESTS") { 66 | response.writeHeader(429); 67 | response.write("Please try again later."); 68 | return; 69 | } 70 | response.writeHeader(500); 71 | response.write("An unknown error occurred. Please try again later."); 72 | return; 73 | } 74 | 75 | let user: User; 76 | try { 77 | user = await createUser(faroeUser.id, email, { 78 | emailVerified: false 79 | }); 80 | } catch { 81 | await faroe.deleteUser(faroeUser.id); 82 | response.writeHeader(500); 83 | response.write("An unknown error occurred. Please try again later."); 84 | return; 85 | } 86 | 87 | const emailVerificationRequest = await faroe.createUserEmailVerificationRequest( 88 | faroeUser.id, 89 | clientIP 90 | ); 91 | const emailContent = `Your verification code is ${emailVerificationRequest.code}.`; 92 | await sendEmail(faroeUser.email, emailContent); 93 | 94 | // Create a session and link the verification request 95 | const session = await createSession(user.id, FaroeUserEmailVerificationRequest.id); 96 | 97 | // ... 98 | } 99 | ``` 100 | -------------------------------------------------------------------------------- /docs/pages/email-password/update-email.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Update email address" 3 | --- 4 | 5 | # Update email address 6 | 7 | *This page uses the JavaScript SDK*. 8 | 9 | Create a new email update request, send the verification code to the user's inbox, and link the update request to the current session. 10 | 11 | ```ts 12 | // Everything not imported is something you need to define yourself. 13 | import { verifyEmailInput, FaroeError } from "@faroe/sdk"; 14 | 15 | import type { FaroeEmailUpdateRequest } from "@faroe/sdk"; 16 | 17 | async function handleSendEmailUpdateVerificationCodeRequest( 18 | request: HTTPRequest, 19 | response: HTTPResponse 20 | ): Promise { 21 | const clientIP = request.headers.get("X-Forwarded-For"); 22 | 23 | const { session, user } = await validateRequest(request); 24 | if (session === null) { 25 | response.writeHeader(401); 26 | response.write("Not authenticated."); 27 | return; 28 | } 29 | 30 | let email: string; 31 | 32 | // ... 33 | 34 | // Normalize input. 35 | email = email.toLowerCase(); 36 | 37 | if (!verifyEmailInput(email)) { 38 | response.writeHeader(400); 39 | response.write("Please enter a valid email address."); 40 | return; 41 | } 42 | 43 | const user = await getUserFromEmail(email); 44 | if (user !== null) { 45 | response.writeHeader(400); 46 | response.write("This email address is already used."); 47 | return; 48 | } 49 | 50 | let emailUpdateRequest: FaroeEmailUpdateRequest; 51 | try { 52 | emailUpdateRequest = await faroe.createUserEmailUpdateRequest( 53 | user.faroeId, 54 | faroeUser.email 55 | ); 56 | } catch (e) { 57 | if (e instanceof FaroeError && e.code === "TOO_MANY_REQUESTS") { 58 | response.writeHeader(429); 59 | response.write("Please try again later."); 60 | return; 61 | } 62 | response.writeHeader(500); 63 | response.write("An unknown error occurred. Please try again later."); 64 | return; 65 | } 66 | 67 | // Send verification code to user's inbox. 68 | const emailContent = `Your verification code is ${emailUpdateRequest.code}.`; 69 | await sendEmail(faroeUser.email, emailContent); 70 | 71 | // Link the verification request to the current session. 72 | await setSessionEmailUpdateRequestId(session.id, emailUpdateRequest.id); 73 | 74 | // ... 75 | 76 | } 77 | ``` 78 | 79 | Verify the code with `Faroe.verifyNewUserEmail()` and update your application's user's email address. 80 | 81 | ```ts 82 | // Everything not imported is something you need to define yourself. 83 | import { FaroeError } from "@faroe/sdk"; 84 | 85 | async function handleUpdateEmailRequest( 86 | request: HTTPRequest, 87 | response: HTTPResponse 88 | ): Promise { 89 | const clientIP = request.headers.get("X-Forwarded-For"); 90 | 91 | const { session, user } = await validateRequest(request); 92 | if (session === null) { 93 | response.writeHeader(401); 94 | response.write("Not authenticated."); 95 | return; 96 | } 97 | 98 | if (session.faroeEmailUpdateRequestId === null) { 99 | response.writeHeader(403); 100 | response.write("Not allowed."); 101 | return; 102 | } 103 | 104 | let code: string; 105 | 106 | // ... 107 | 108 | let newEmail: string 109 | try { 110 | newEmail = await faroe.verifyNewUserEmail( 111 | session.faroeEmailUpdateRequestId, 112 | code 113 | ); 114 | } catch (e) { 115 | if (e instanceof FaroeError && e.code === "INVALID_REQUEST") { 116 | response.writeHeader(400); 117 | response.write("Please restart the process."); 118 | return; 119 | } 120 | if (e instanceof FaroeError && e.code === "INCORRECT_CODE") { 121 | response.writeHeader(400); 122 | response.write("Incorrect code."); 123 | return; 124 | } 125 | if (e instanceof FaroeError && e.code === "TOO_MANY_REQUESTS") { 126 | response.writeHeader(400); 127 | response.write("Please try again later."); 128 | return; 129 | } 130 | response.writeHeader(500); 131 | response.write("An unknown error occurred. Please try again later."); 132 | return; 133 | } 134 | 135 | await updateUserEmailAndSetEmailAsVerified(session.userId, newEmail); 136 | 137 | await deleteSessionEmailUpdateRequestId(session.id); 138 | 139 | // ... 140 | 141 | } 142 | ``` 143 | -------------------------------------------------------------------------------- /docs/pages/email-password/update-password.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Update password" 3 | --- 4 | 5 | # Update password 6 | 7 | *This page uses the JavaScript SDK*. 8 | 9 | Use `Faroe.updateUserPassword()` to update the user's password using their current password. We recommend doing some basic input validation with `verifyPasswordInput()`. If successful, invalidate all existing sessions belonging to the user. 10 | 11 | ```ts 12 | // Everything not imported is something you need to define yourself. 13 | import { verifyPasswordInput, FaroeError } from "@faroe/sdk"; 14 | 15 | import type { FaroeUser } from "@faroe/sdk"; 16 | 17 | async function handleUpdatePasswordRequest( 18 | request: HTTPRequest, 19 | response: HTTPResponse 20 | ): Promise { 21 | const clientIP = request.headers.get("X-Forwarded-For"); 22 | 23 | const { session, user } = await validateRequest(request); 24 | if (session === null) { 25 | response.writeHeader(401); 26 | response.write("Not authenticated."); 27 | return; 28 | } 29 | 30 | let password: string; 31 | let newPassword: string; 32 | // ... 33 | 34 | if (!verifyPasswordInput(newPassword)) { 35 | response.writeHeader(400); 36 | response.write("Password must be 8 characters long."); 37 | return; 38 | } 39 | 40 | try { 41 | await faroe.updateUserPassword( 42 | user.faroeId, 43 | password, 44 | newPassword. 45 | clientIP 46 | ); 47 | } catch (e) { 48 | if (e instanceof FaroeError && e.code === "WEAK_PASSWORD") { 49 | response.writeHeader(400); 50 | response.write("Please use a stronger password."); 51 | return; 52 | } 53 | if (e instanceof FaroeError && e.code === "INCORRECT_PASSWORD") { 54 | response.writeHeader(400); 55 | response.write("Incorrect password."); 56 | return; 57 | } 58 | if (e instanceof FaroeError && e.code === "TOO_MANY_REQUESTS") { 59 | response.writeHeader(429); 60 | response.write("Please try again later."); 61 | return; 62 | } 63 | response.writeHeader(500); 64 | response.write("An unknown error occurred. Please try again later."); 65 | return; 66 | } 67 | 68 | // Invalidate all sessions belonging to the user and create a new session. 69 | await invalidateAllUserSessions(user.id); 70 | const session = await createSession(user.id, null); 71 | 72 | // ... 73 | 74 | } 75 | ``` 76 | -------------------------------------------------------------------------------- /docs/pages/faroe-server/database-backup.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Database backup" 3 | --- 4 | 5 | # Database backup 6 | 7 | Faroe does not backup its SQLite database. We recommend using [Litestream](https://litestream.io) to create database replicas locally or externally with services like S3. 8 | 9 | Use the `replicate` command to create a replica. Faroe should be ran as a child process using the `exec` option. 10 | 11 | ``` 12 | litestream replicate -exec="./faroe serve" faroe_data/sqlite.db file://backup 13 | ``` 14 | 15 | Use the `restore` command to restore the database from a replica. 16 | 17 | ``` 18 | litestream restore -o faroe_data/sqlite.db file://backup 19 | rm sqlite.db.tmp-shm 20 | sqlite.db.tmp-wal 21 | ``` -------------------------------------------------------------------------------- /docs/pages/faroe-server/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Getting started" 3 | --- 4 | 5 | # Getting started 6 | 7 | Install the latest version of Faroe: 8 | 9 | - [Download Faroe v0.2.1 for Linux (x64)](https://github.com/faroedev/faroe/releases/download/v0.2.1/linux-amd64.zip) 10 | - [Download Faroe v0.2.1 for Linux (ARM64)](https://github.com/faroedev/faroe/releases/download/v0.2.1/linux-arm64.zip) 11 | - [Download Faroe v0.2.1 for MacOS (x64)](https://github.com/faroedev/faroe/releases/download/v0.2.1/darwin-amd64.zip) 12 | - [Download Faroe v0.2.1 for MacOS (ARM64)](https://github.com/faroedev/faroe/releases/download/v0.2.1/darwin-arm64.zip) 13 | - [Download Faroe v0.2.1 for Windows (x64)](https://github.com/faroedev/faroe/releases/download/v0.2.1/windows-amd64.zip) 14 | - [Download Faroe v0.2.1 for Windows (ARM64)](https://github.com/faroedev/faroe/releases/download/v0.2.1/windows-arm64.zip) 15 | 16 | You can immediately start the server on port 4000 with `faroe server`: 17 | 18 | ``` 19 | ./faroe serve 20 | 21 | ./faroe serve --port=3000 22 | ``` 23 | 24 | This will create a `faroe_data` folder in the root that contains the SQLite database. Remember to add this to `.gitignore`. 25 | 26 | For production apps, generate a secret with the `generate-secret` command and pass it when starting the sever. 27 | 28 | ``` 29 | ./faroe generate-secret 30 | ``` 31 | 32 | ``` 33 | ./faroe serve --secret=SECRET 34 | ``` 35 | 36 | You can get a formatted list of users by sending a GET request to `/users` with the `Accept` header set to `text/plain`. 37 | 38 | ``` 39 | curl http://localhost:4000/users -H "Accept: text/plain" 40 | ``` -------------------------------------------------------------------------------- /docs/pages/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe" 3 | --- 4 | 5 | # Faroe 6 | 7 | *This software is not stable yet. Do not use it in production.* 8 | 9 | Faroe is an open source, self-hosted, and modular backend for email and password authentication. It exposes various API endpoints including: 10 | 11 | - Registering users with email and password 12 | - Authenticating users with email and password 13 | - Email verification 14 | - Password reset 15 | - 2FA with TOTP 16 | - 2FA recovery 17 | 18 | These work with your application's UI and backend to provide a complete authentication system. 19 | 20 | ```ts 21 | // Get user from your database. 22 | const user = await getUserFromEmail(email); 23 | if (user === null) { 24 | response.writeHeader(400); 25 | response.write("Account does not exist."); 26 | return; 27 | } 28 | 29 | let faroeUser: FaroeUser; 30 | try { 31 | faroeUser = await faroe.verifyUserPassword(user.faroeId, password, clientIP); 32 | } catch (e) { 33 | if (e instanceof FaroeError && e.code === "INCORRECT_PASSWORD") { 34 | response.writeHeader(400); 35 | response.write("Incorrect password."); 36 | return; 37 | } 38 | if (e instanceof FaroeError && e.code === "TOO_MANY_REQUESTS") { 39 | response.writeHeader(429); 40 | response.write("Please try again later."); 41 | return; 42 | } 43 | response.writeHeader(500); 44 | response.write("An unknown error occurred. Please try again later."); 45 | return; 46 | } 47 | 48 | // Create a new session in your application. 49 | const session = await createSession(user.id, null); 50 | ``` 51 | 52 | This is not a full authentication backend (Auth0, Supabase, etc) nor a full identity provider (KeyCloak, etc). It is specifically designed to handle the backend logic for email and password authentication. Faroe does not provide session management, frontend UI, or OAuth integration. 53 | 54 | Faroe is written in Go and uses SQLite as its database. 55 | 56 | Licensed under the MIT license. 57 | 58 | ## Features 59 | 60 | - Email login, email verification, 2FA with TOTP, 2FA recovery, and password reset 61 | - Rate limiting and brute force protection 62 | - Proper password strength checks 63 | - Everything included in a single binary 64 | 65 | ## Why? 66 | 67 | If you don't want to use a fullstack framework, implementing auth means paying for a third-party service, self-hosting an identity provider, or building one from scratch. JavaScript especially is yet to have a standard, default framework a built-in auth solution. A separate backend that handles everything is nice, but it can be frustrating to customize the overall login flow, data structure, and UI. Implementing from scratch gives you the most flexibility, but becomes time-consuming when you want to implement anything more than OAuth. 68 | 69 | Faroe is the middle ground between a dedicated auth backend and a custom implementation. You can let it handle the core logic and just build the UI and manage sessions. It's most of the hard part of auth compressed into a single binary file. 70 | 71 | ## Things to consider 72 | 73 | - Faroe does not include an email server. 74 | - Bot protection is not included. We highly recommend using Captchas or equivalent in registration and password reset forms. 75 | - Faroe uses SQLite in WAL mode as its database. This shouldn't cause issues unless you have 100,000+ users, and even then, the database will only handle a small part of your total requests. 76 | - Faroe uses in-memory storage for rate limiting. 77 | -------------------------------------------------------------------------------- /docs/pages/reference/cli/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "CLI reference" 3 | --- 4 | 5 | # CLI reference 6 | 7 | ## generate-secret 8 | 9 | Generates a random secret with 200 bits of entropy using a cryptographically secure source. 10 | 11 | ``` 12 | faroe generate-secret 13 | ``` 14 | 15 | ## serve 16 | 17 | Creates a `faroe_data` directory with an SQLite database file if it doesn't already exist and starts the server on port 4000. 18 | 19 | ``` 20 | faroe serve [...options] 21 | ``` 22 | 23 | ### Options 24 | 25 | - `--port`: The port number (default: 4000). 26 | - `--dir`: The path of the directory to store data (default: `faroe_data`). 27 | - `--secret`: A random secret. If provided, requires requests to the server to include the secret in the `Authorization` header. 28 | 29 | ### Example 30 | 31 | ``` 32 | faroe serve --port=3000 --dir="/data/faroe" --secret=SECURE_SECRET 33 | ``` -------------------------------------------------------------------------------- /docs/pages/reference/rest/endpoints/delete_email-update-requests_requestid.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "DELETE /email-update-requests/[request_id]" 3 | --- 4 | 5 | # DELETE /email-update-requests/[request_id] 6 | 7 | Deletes an email update request. 8 | 9 | ``` 10 | DELETE https://your-domain.com/email-update-requests/[request_id] 11 | ``` 12 | 13 | ## Successful response 14 | 15 | No response body (204). 16 | 17 | ## Error codes 18 | 19 | - [404] `NOT_FOUND`: The request does not exist or has expired. 20 | - [500] `UNKNOWN_ERROR` 21 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/endpoints/delete_password-reset-requests_requestid.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "DELETE /password-reset-requests/[request_id]" 3 | --- 4 | 5 | # DELETE /password-reset-requests/[request_id] 6 | 7 | Deletes a passwod reset request. 8 | 9 | ``` 10 | DELETE https://your-domain.com/password-reset-requests/REQUEST_ID 11 | ``` 12 | 13 | ## Successful response 14 | 15 | No response body (204). 16 | 17 | ## Error codes 18 | 19 | - [404] `NOT_FOUND`: The request does not exist or has expired. 20 | - [500] `UNKNOWN_ERROR` 21 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/endpoints/delete_users_userid.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "DELETE /users/[user_id]" 3 | --- 4 | 5 | # DELETE /users/[user_id] 6 | 7 | Deletes a user. 8 | 9 | ``` 10 | DELETE https://your-domain.com/users/USER_ID 11 | ``` 12 | 13 | ## Successful response 14 | 15 | No response body (204). 16 | 17 | ## Error codes 18 | 19 | - [404] `NOT_FOUND`: The user does not exist. 20 | - [500] `UNKNOWN_ERROR` 21 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/endpoints/delete_users_userid_email-update-requests.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "DELETE /users/[user_id]/email-update-request" 3 | --- 4 | 5 | # DELETE /users/[user_id]/email-update-request 6 | 7 | Deletes a user's email update requests. 8 | 9 | ``` 10 | DELETE https://your-domain.com/users/USER_ID/email-update-request" 11 | ``` 12 | 13 | ## Successful response 14 | 15 | No response body (204). 16 | 17 | ## Error codes 18 | 19 | - [404] `NOT_FOUND`: The user does not exist. 20 | - [500] `UNKNOWN_ERROR` 21 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/endpoints/delete_users_userid_email-verification-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "DELETE /users/[user_id]/email-verification-request" 3 | --- 4 | 5 | # DELETE /users/[user_id]/email-verification-request 6 | 7 | Deletes a user's email verification request. 8 | 9 | ``` 10 | DELETE https://your-domain.com/users/USER_ID/email-verification-request" 11 | ``` 12 | 13 | ## Successful response 14 | 15 | No response body (204). 16 | 17 | ## Error codes 18 | 19 | - [404] `NOT_FOUND`: The user doesn't exist, the user doesn't have a verification request, or their verification request has expired. 20 | - [500] `UNKNOWN_ERROR` 21 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/endpoints/delete_users_userid_password-reset-requests.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "DELETE /users/[user_id]/password-reset-request" 3 | --- 4 | 5 | # DELETE /users/[user_id]/password-reset-request 6 | 7 | Deletes a user's password reset requests. 8 | 9 | ``` 10 | DELETE https://your-domain.com/users/USER_ID/password-reset-request" 11 | ``` 12 | 13 | ## Successful response 14 | 15 | No response body (204). 16 | 17 | ## Error codes 18 | 19 | - [404] `NOT_FOUND`: The user does not exist. 20 | - [500] `UNKNOWN_ERROR` 21 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/endpoints/delete_users_userid_totp-credential.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "DELETE /users/[user_id]/totp-credential" 3 | --- 4 | 5 | # DELETE /users/[user_id]/totp-credential 6 | 7 | Deletes a user's TOTP credential. 8 | 9 | ``` 10 | DELETE https://your-domain.com/users/USER_ID/totp-credential 11 | ``` 12 | 13 | ## Successful response 14 | 15 | No response body (204). 16 | 17 | ## Error codes 18 | 19 | - [404] `NOT_FOUND`: The user does not exist or the user does not have a TOTP credential. 20 | - [500] `UNKNOWN_ERROR` 21 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/endpoints/get_email-update-requests_requestid.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "GET /email-update-requests/[request_id]" 3 | --- 4 | 5 | # GET /email-update-requests/[request_id] 6 | 7 | Gets an email update request. 8 | 9 | ``` 10 | GET https://your-domain.com/email-update-requests/[request_id] 11 | ``` 12 | 13 | ## Successful response 14 | 15 | Returns the [email update request model](/reference/rest/models/email-update-request) if the request exists and is valid. 16 | 17 | ## Error codes 18 | 19 | - [404] `NOT_FOUND`: The request does not exist or has expired. 20 | - [500] `UNKNOWN_ERROR` 21 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/endpoints/get_password-reset-requests_requestid.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "GET /password-reset-requests/[request_id]" 3 | --- 4 | 5 | # GET /password-reset-requests/[request_id] 6 | 7 | Gets a password reset request. 8 | 9 | ``` 10 | GET https://your-domain.com/password-reset-requests/REQUEST_ID 11 | ``` 12 | 13 | ## Successful response 14 | 15 | Returns the [password reset request model](/reference/rest/models/password-reset-request) if the request exists and is valid. 16 | 17 | ## Error codes 18 | 19 | - [404] `NOT_FOUND`: The request does not exist or has expired. 20 | - [500] `UNKNOWN_ERROR` 21 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/endpoints/get_users.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "GET /users" 3 | --- 4 | 5 | # GET /users 6 | 7 | Gets a list of users. 8 | 9 | ``` 10 | GET https://your-domain.com/users 11 | ``` 12 | 13 | ## Query parameters 14 | 15 | All parameters are optional. 16 | 17 | - `sort_by`: Field to sort the list by. One of: 18 | - `created_at` (default): Sort by when the user was created. 19 | - `id`: Sort by the user's ID. 20 | - `sort_order` Order of the list. One of: 21 | - `ascending` (default) 22 | - `descending` 23 | - `per_page`: A positive integer that specifies the number of items in a page (default: 20). 24 | - `page`: A positive integer that specifies the page number to be returned (default: 1). 25 | 26 | ### Example 27 | 28 | ``` 29 | /users?sort_by=created_at&sort_order=descending&per_page=50&page=2&email_query=%40example.com 30 | ``` 31 | 32 | ## Successful response 33 | 34 | Returns a JSON array of [user models](/reference/rest/models/user). If there are no users in the page, it will return an empty array. 35 | 36 | You can get the number of total pages from the `X-Pagination-Total-Pages` header and the total number of users with the `X-Pagination-Total` header.. 37 | 38 | ``` 39 | X-Pagination-Total-Pages: 6 40 | X-Pagination-Total: 113 41 | ``` 42 | 43 | ### Example 44 | 45 | ```json 46 | [ 47 | { 48 | "id": "eeidmqmvdtjhaddujv8twjug", 49 | "created_at": 1728783738, 50 | "email_verified": true, 51 | "registered_totp": false 52 | } 53 | ] 54 | ``` 55 | 56 | ## Error codes 57 | 58 | - [500] `UNKNOWN_ERROR` 59 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/endpoints/get_users_userid.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "GET /users/[user_id]" 3 | --- 4 | 5 | # GET /users/[user_id] 6 | 7 | Gets a user. 8 | 9 | ``` 10 | GET https://your-domain.com/users/USER_ID 11 | ``` 12 | 13 | ## Successful response 14 | 15 | Returns the [user model](/reference/rest/models/user) of the user if they exist. 16 | 17 | ## Error codes 18 | 19 | - [404] `NOT_FOUND`: The user does not exist. 20 | - [500] `UNKNOWN_ERROR` 21 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/endpoints/get_users_userid_email-update-requests.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "GET /users/[user_id]/email-update-requests" 3 | --- 4 | 5 | # GET /users/[user_id]/email-update-requests 6 | 7 | Gets a list of a user's email update requests. 8 | 9 | ``` 10 | GET https://your-domain.com/users/USER_ID/email-update-requests 11 | ``` 12 | 13 | ## Successful response 14 | 15 | Returns a JSON array of [email update request models](/reference/rest/models/email-update-request). If the user does not have any update requests, it will return an empty array. 16 | 17 | ### Example 18 | 19 | ```json 20 | [ 21 | { 22 | "id": "dvd742g6mpmaebbjxq72kwsr", 23 | "user_id": "7six6i2igxd5ct4dccjk4qtg", 24 | "created_at": 1728803704, 25 | "expires_at": 1728804304, 26 | "email": "cat@example.com", 27 | "code": "VQ9REYBU" 28 | } 29 | ] 30 | ``` 31 | 32 | ## Error codes 33 | 34 | - [404] `NOT_FOUND`: The user does not exist. 35 | - [500] `UNKNOWN_ERROR` 36 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/endpoints/get_users_userid_email-verification-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "GET /users/[user_id]/email-verification-request" 3 | --- 4 | 5 | # GET /users/[user_id]/email-verification-request 6 | 7 | Gets a user's valid email verification request. 8 | 9 | ``` 10 | GET https://your-domain.com/users/USER_ID/email-verification-reqes 11 | ``` 12 | 13 | ## Successful response 14 | 15 | Returns the [user email verification request model](/reference/rest/models/user-email-verification-request) if the request exists and is valid. 16 | 17 | ## Error codes 18 | 19 | - [404] `NOT_FOUND`: The user doesn't exist, the user doesn't have a verification request, or their verification request has expired. 20 | - [500] `UNKNOWN_ERROR` 21 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/endpoints/get_users_userid_password-reset-requests.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "GET /users/[user_id]/password-reset-requests" 3 | --- 4 | 5 | # GET /users/[user_id]/password-reset-requests 6 | 7 | Gets a list of a user's valid password reset requests. 8 | 9 | ``` 10 | GET https://your-domain.com/users/USER_ID/password-reset-requests 11 | ``` 12 | 13 | ## Successful response 14 | 15 | Returns a JSON array of [password reset request models](/reference/rest/models/password-reset-request). If the user does not have any update requests, it will return an empty array. 16 | 17 | ### Example 18 | 19 | ```json 20 | [ 21 | { 22 | "id": "cjjhw9ggvv7e9hfc3qjsiegv", 23 | "user_id": "wz2nyjz4ims4cyuw7eq6tnxy", 24 | "created_at": 1728804201, 25 | "expires_at": 1728804801 26 | } 27 | ] 28 | ``` 29 | 30 | ## Error codes 31 | 32 | - [404] `NOT_FOUND`: The user does not exist. 33 | - [500] `UNKNOWN_ERROR` 34 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/endpoints/get_users_userid_totp-credential.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "GET /users/[user_id]/totp-credential" 3 | --- 4 | 5 | # GET /users/[user_id]/totp-credential 6 | 7 | Gets a user's TOTP credential. 8 | 9 | ``` 10 | GET https://your-domain.com/users/USER_ID/totp-credential 11 | ``` 12 | 13 | ## Response body 14 | 15 | Returns the [user TOTP credential model](/reference/rest/models/user-totp-credential) of the credential if it exists. 16 | 17 | ## Error codes 18 | 19 | - [404] `NOT_FOUND`: The user or credential does not exist. 20 | - [500] `UNKNOWN_ERROR` 21 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/endpoints/post_password-reset-requests_requestid_verify-email.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "POST /password-reset-requests/[request_id]/verify-email" 3 | --- 4 | 5 | # POST /password-reset-requests/[request_id]/verify-email 6 | 7 | Verifies the email linked to a password reset request with a verification code. 8 | 9 | The reset request is immediately invalidated after the 5th failed attempt. 10 | 11 | ``` 12 | POST https://your-domain.com/password-reset-requests/REQUEST_ID/verify-email 13 | ``` 14 | 15 | ## Request body 16 | 17 | ```ts 18 | { 19 | "code": string, 20 | "client_ip": string 21 | } 22 | ``` 23 | 24 | - `code` (required): The email verification code for the password reset request. 25 | - `client_ip`: The client's IP address. If included, it will rate limit the endpoint based on it. 26 | 27 | ### Example 28 | 29 | ```json 30 | { 31 | "code": "9TW45AZU", 32 | "client_ip": "0.0.0.0" 33 | } 34 | ``` 35 | 36 | ## Successful response 37 | 38 | No response body (204). 39 | 40 | ## Error codes 41 | 42 | - [400] `INVALID_DATA`: Invalid request data. 43 | - [400] `INCORRECT_CODE`: The one-time code is incorrect. 44 | - [400] `TOO_MANY_REQUESTS`: Exceeded rate limit. 45 | - [404] `NOT_FOUND`: The password reset request does not exist or has expired. 46 | - [500] `UNKNOWN_ERROR` 47 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/endpoints/post_reset-password.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "POST /reset-password" 3 | --- 4 | 5 | # POST /reset-password 6 | 7 | Resets a user's password with a password reset request. On validation, it will mark the user's email as verified and invalidate all password reset requests linked to the user. 8 | 9 | ``` 10 | POST /reset-password 11 | ``` 12 | 13 | ## Request body 14 | 15 | ```ts 16 | { 17 | "request_id": string, 18 | "password": string, 19 | "client_ip": string 20 | } 21 | ``` 22 | 23 | - `request_id` (required): A valid password reset request ID. 24 | - `password` (required): A valid password. Password strength is determined by checking it aginst past data leaks using the [HaveIBeenPwned API](https://haveibeenpwned.com/API/v3#PwnedPasswords). 25 | - `client_ip`: The client's IP address. If included, it will rate limit the endpoint based on it. 26 | 27 | ## Successful response 28 | 29 | No response body (204). 30 | 31 | ## Error codes 32 | 33 | - [400] `INVALID_DATA`: Invalid request data. 34 | - [400] `WEAK_PASSWORD`: The password is too weak. 35 | - [400] `TOO_MANY_REQUESTS`: Exceeded rate limit. 36 | - [400] `INVALID_REQUEST`: Invalid reset request ID. 37 | - [500] `UNKNOWN_ERROR` 38 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/endpoints/post_users.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "POST /users" 3 | --- 4 | 5 | # POST /users 6 | 7 | Creates a new user. 8 | 9 | We highly recommend putting a Captcha or equivalent in front for spam and bot detection. 10 | 11 | ``` 12 | POST https://your-domain.com/users 13 | ``` 14 | 15 | ## Request body 16 | 17 | ```ts 18 | { 19 | "password": string, 20 | "client_ip": string 21 | } 22 | ``` 23 | 24 | - `password` (required): A valid password. Password strength is determined by checking it aginst past data leaks using the [HaveIBeenPwned API](https://haveibeenpwned.com/API/v3#PwnedPasswords). 25 | - `client_ip`: The client's IP address. If included, it will rate limit the endpoint based on it. 26 | 27 | ### Example 28 | 29 | ```json 30 | { 31 | "password": "48n2r3tnaqp", 32 | "client_ip": "0.0.0.0" 33 | } 34 | ``` 35 | 36 | ## Successful response 37 | 38 | Returns the [user model](/reference/rest/models/user) of the created user. 39 | 40 | ## Error codes 41 | 42 | - [400] `INVALID_DATA`: Malformed email address; invalid password length. 43 | - [400] `WEAK_PASSWORD`: The password is too weak. 44 | - [400] `TOO_MANY_REQUESTS`: Exceeded rate limit. 45 | - [500] `UNKNOWN_ERROR` 46 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/endpoints/post_users_userid_email-update-requests.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "POST /users/[user_id]/email-update-requests" 3 | --- 4 | 5 | # POST /users/[user_id]/email-update-requests 6 | 7 | Creates a new email update request for a user. This can only be called 3 times in a 15 minute window per user. 8 | 9 | Send the created update request's code to the email address. 10 | 11 | ``` 12 | POST https://your-domain.com/users/USER_ID/email-update-requests 13 | ``` 14 | 15 | ## Request body 16 | 17 | ```ts 18 | { 19 | "email": string 20 | } 21 | ``` 22 | 23 | - `email`: A valid email address. 24 | 25 | ## Successful response 26 | 27 | Returns the [email update request model](/reference/rest/models/email-verification-request) of the created request. 28 | 29 | ## Error codes 30 | 31 | - [400] `INVALID_DATA`: Invalid request data. 32 | - [400] `TOO_MANY_REQUESTS`: Exceeded rate limit. 33 | - [404] `NOT_FOUND`: The user does not exist. 34 | - [500] `UNKNOWN_ERROR` 35 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/endpoints/post_users_userid_email-verification-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "POST /users/[user_id]/email-verification-request" 3 | --- 4 | 5 | # POST /users/[user_id]/email-verification-request 6 | 7 | Creates a new email verification request for a user. This can only be called 3 times in a 15 minute window per user. 8 | 9 | ``` 10 | POST https://your-domain.com/users/USER_ID/email-verification-request 11 | ``` 12 | 13 | ## Successful response 14 | 15 | Returns the [user email verification request model](/reference/rest/models/user-email-verification-request) of the created request. 16 | 17 | ## Error codes 18 | 19 | - [400] `TOO_MANY_REQUESTS`: Exceeded rate limit. 20 | - [404] `NOT_FOUND`: The user does not exist. 21 | - [500] `UNKNOWN_ERROR` 22 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/endpoints/post_users_userid_password-reset-requests.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "POST /users/[user_id]/password-reset-requests" 3 | --- 4 | 5 | # POST /users/[user_id]/password-reset-requests 6 | 7 | Creates a new password reset request for a user. This can only be called 3 times in a 15 minute window per user. 8 | 9 | Send the created reset request's code to the email address. 10 | 11 | ``` 12 | POST https://your-domain.com/users/USER_ID/password-reset-requests 13 | ``` 14 | 15 | ## Request body 16 | 17 | ```ts 18 | { 19 | "client_ip": string 20 | } 21 | ``` 22 | 23 | - `client_ip`: The client's IP address. If included, it will rate limit the endpoint based on it. 24 | 25 | ### Example 26 | 27 | ```json 28 | {} 29 | ``` 30 | 31 | ## Successful response 32 | 33 | Returns the [password reset request model](/reference/rest/models/password-reset-requests-request) of the created request and a verification code. The code is only available here. 34 | 35 | ```ts 36 | { 37 | "id": string, 38 | "user_id": string, 39 | "created_at": number, 40 | "expires_at": number, 41 | "code": string 42 | } 43 | ``` 44 | 45 | - `code`: An 8-character alphanumeric email verification code. 46 | 47 | ### Example 48 | 49 | ```json 50 | { 51 | "id": "cjjhw9ggvv7e9hfc3qjsiegv", 52 | "user_id": "wz2nyjz4ims4cyuw7eq6tnxy", 53 | "created_at": 1728804201, 54 | "expires_at": 1728804801, 55 | "twoFactorVerified": false 56 | } 57 | ``` 58 | 59 | ## Error codes 60 | 61 | - [400] `INVALID_DATA`: Invalid request data. 62 | - [400] `TOO_MANY_REQUESTS`: Exceeded rate limit. 63 | - [404] `NOT_FOUND`: The user does not exist. 64 | - [500] `UNKNOWN_ERROR` 65 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/endpoints/post_users_userid_regenerate-recovery-code.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "POST /users/[user_id]/regenerate-recovery-code" 3 | --- 4 | 5 | # POST /users/[user_id]/regenerate-recovery-code 6 | 7 | Regenerates a user's recovery code. 8 | 9 | ``` 10 | POST https://your-domain.com/users/USER_ID/regenerate-recovery-code 11 | ``` 12 | 13 | ## Successful response 14 | 15 | Return the user's new recovery code if the user exists. 16 | 17 | ```ts 18 | { 19 | "recovery_code": string 20 | } 21 | ``` 22 | 23 | ### Example 24 | 25 | ```json 26 | { 27 | "recovery_code": "4UHZRTWP" 28 | } 29 | ``` 30 | 31 | ## Error codes 32 | 33 | - [404] `NOT_FOUND`: The user does not exist. 34 | - [500] `UNKNOWN_ERROR` 35 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/endpoints/post_users_userid_register-totp.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "POST /users/[user_id]/register-totp" 3 | --- 4 | 5 | # POST /users/[user_id]/register-totp 6 | 7 | Verifies and registers a TOTP (SHA-1, 6 digits, 30 seconds interval) credential to a user.. 8 | 9 | ``` 10 | POST https://your-domain.com/users/USER_ID/totp 11 | ``` 12 | 13 | ## Request body 14 | 15 | All fields are required. 16 | 17 | ```ts 18 | { 19 | "totp_key": string, 20 | "code": string 21 | } 22 | ``` 23 | 24 | - `totp_key`: A base64-encoded TOTP key. The encoded key must be 20 bytes. 25 | - `code`: The TOTP code from the key for verification. 26 | 27 | ## Response body 28 | 29 | Returns the [user TOTP credential model](/reference/rest/models/user-totp-credential) of the registered credential. 30 | 31 | ## Error codes 32 | 33 | - [400] `INVALID_DATA`: Invalid request data. 34 | - [400] `INCORRECT_CODE`: Incorrect TOTP code. 35 | - [404] `NOT_FOUND`: The user does not exist. 36 | - [500] `UNKNOWN_ERROR` 37 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/endpoints/post_users_userid_reset-2fa.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "POST /users/[user_id]/reset-2fa" 3 | --- 4 | 5 | # POST /users/[user_id]/reset-2fa 6 | 7 | Resets a user's second factors using a recovery code and generates a new recovery code. The user will be locked out from using their recovery code for 15 minutes after their 5th consecutive failed attempts. 8 | 9 | ``` 10 | POST https://your-domain.com/users/USER_ID/reset-2fa 11 | ``` 12 | 13 | ## Request body 14 | 15 | All fields are required. 16 | 17 | ```ts 18 | { 19 | "recovery_code": string 20 | } 21 | ``` 22 | 23 | - `recovery_code` 24 | 25 | ## Successful response 26 | 27 | Return the user's new recovery code. 28 | 29 | ```ts 30 | { 31 | "recovery_code": string 32 | } 33 | ``` 34 | 35 | ### Example 36 | 37 | ```json 38 | { 39 | "recovery_code": "4UHZRTWP" 40 | } 41 | ``` 42 | 43 | ## Error codes 44 | 45 | - [400] `INVALID_DATA`: Invalid request data. 46 | - [400] `TOO_MANY_REQUESTS`: Rate limit exceeded. 47 | - [400] `INCORRECT_CODE`: Incorrect recovery code. 48 | - [404] `NOT_FOUND`: The user does not exist. 49 | - [500] `UNKNOWN_ERROR` 50 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/endpoints/post_users_userid_update-password.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "POST /users/[user_id]/update-password" 3 | --- 4 | 5 | # POST /users/[user_id]/update-password 6 | 7 | Updates a user's password. 8 | 9 | ``` 10 | POST https://your-domain.com/users/USER_ID/update-password 11 | ``` 12 | 13 | ## Request body 14 | 15 | ```ts 16 | { 17 | "password": string, 18 | "new_password": string, 19 | "client_ip": string 20 | } 21 | ``` 22 | 23 | - `password` (required): The current password. 24 | - `new_password` (required): A valid password. Password strength is determined by checking it aginst past data leaks using the [HaveIBeenPwned API](https://haveibeenpwned.com/API/v3#PwnedPasswords). 25 | - `client_ip`: The client's IP address. If included, it will rate limit the endpoint based on it. 26 | 27 | ### Example 28 | 29 | ```json 30 | { 31 | "password": "48n2r3tnaqp", 32 | "new_password": "a83ri1lw2aw", 33 | "client_ip": "0.0.0.0" 34 | } 35 | ``` 36 | 37 | ## Successful response 38 | 39 | No response body (204). 40 | 41 | ## Error codes 42 | 43 | - [400] `INVALID_DATA`: Invalid request data. 44 | - [400] `WEAK_PASSWORD`: The password is too weak. 45 | - [400] `TOO_MANY_REQUESTS`: Exceeded rate limit. 46 | - [404] `NOT_FOUND`: The user does not exist. 47 | - [500] `UNKNOWN_ERROR` 48 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/endpoints/post_users_userid_verify-2fa_totp.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "POST /users/[user_id]/verify-2fa/totp" 3 | --- 4 | 5 | # POST /users/[user_id]/verify-2fa/totp 6 | 7 | Verifies a user's TOTP code. The user will be locked out from using TOTP as their second factor for 15 minutes after their 5th consecutive failed attempts. 8 | 9 | ``` 10 | POST https://your-domain.com/users/USER_ID/verify-2fa/totp 11 | ``` 12 | 13 | ## Request body 14 | 15 | All fields are required. 16 | 17 | ```ts 18 | { 19 | "code": string 20 | } 21 | ``` 22 | 23 | - `code`: The TOTP code. 24 | 25 | ## Successful response 26 | 27 | No response body (204). 28 | 29 | ## Error codes 30 | 31 | - [400] `INVALID_DATA`: Invalid request data. 32 | - [400] `NOT_ALLOWED`: The user does not have a TOTP credential registered. 33 | - [400] `TOO_MANY_REQUESTS`: Rate limit exceeded. 34 | - [400] `INCORRECT_CODE`: Incorrect TOTP code. 35 | - [404] `NOT_FOUND`: The user does not exist. 36 | - [500] `UNKNOWN_ERROR` 37 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/endpoints/post_users_userid_verify-email.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "POST /users/[user_id]/verify-email" 3 | --- 4 | 5 | # POST /users/[user_id]/verify-email 6 | 7 | Verifies and updates a user's email with the user's email verification request code. The user will be locked out from verifying their email for 15 minutes after their 5th consecutive failed attempts. 8 | 9 | ``` 10 | POST https://your-domain.com/users/USER_ID/verify-email 11 | ``` 12 | 13 | ## Request body 14 | 15 | All fields are required. 16 | 17 | ```ts 18 | { 19 | "code": string 20 | } 21 | ``` 22 | 23 | - `code`: The verification code of the user's email verification request. 24 | 25 | ### Example 26 | 27 | ```json 28 | { 29 | "code": "9TW45AZU" 30 | } 31 | ``` 32 | 33 | ## Successful response 34 | 35 | No response body (204). 36 | 37 | ## Error codes 38 | 39 | - [400] `INVALID_DATA`: Invalid request data. 40 | - [400] `NOT_ALLOWED`: The user does not have a request or has a expired request. 41 | - [400] `INCORRECT_CODE`: The one-time code is incorrect. 42 | - [400] `TOO_MANY_REQUESTS`: Exceeded rate limit. 43 | - [404] `NOT_FOUND`: The user does not exist. 44 | - [500] `UNKNOWN_ERROR` 45 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/endpoints/post_users_userid_verify-password.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "POST /users/[user_id]/verify-password" 3 | --- 4 | 5 | # POST /users/[user_id]/verify-password 6 | 7 | Verifies a user's password. It will temporary block the IP address if the client sends an incorrect password 5 times in a 15 minute window. 8 | 9 | ``` 10 | POST https://your-domain.com/users/USER_ID/verify-password 11 | ``` 12 | 13 | ## Request body 14 | 15 | ```ts 16 | { 17 | "password": string, 18 | "client_ip": string 19 | } 20 | ``` 21 | 22 | - `password` (required): A valid password. 23 | - `client_ip`: The client's IP address. If included, it will rate limit the endpoint based on it. 24 | 25 | ### Example 26 | 27 | ```json 28 | { 29 | "password": "48n2r3tnaqp" 30 | } 31 | ``` 32 | 33 | ## Successful response 34 | 35 | No response body (204). 36 | 37 | ## Error codes 38 | 39 | - [400] `INVALID_DATA`: Invalid request data. 40 | - [400] `INCORRECT_PASSWORD` 41 | - [400] `TOO_MANY_REQUESTS`: Exceeded rate limit. 42 | - [404] `NOT_FOUND`: The user does not exist. 43 | - [500] `UNKNOWN_ERROR` 44 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/endpoints/post_verify-new-email.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "POST /verify-new-email" 3 | --- 4 | 5 | # POST /verify-new-email 6 | 7 | Verifies an email update request's verification code. Upon a successful verification, all email update requests linked to the email address and password reset requests to the user are invalidated. 8 | 9 | The update request is immediately invalidated after the 5th failed attempt. 10 | 11 | ``` 12 | POST https://your-domain.com/verify-new-email 13 | ``` 14 | 15 | ## Request body 16 | 17 | ```ts 18 | { 19 | "request_id": string, 20 | "code": string 21 | } 22 | ``` 23 | 24 | - `request_id`: A valid email update request ID. 25 | - `code`: The verification code of the request. 26 | 27 | ## Response body 28 | 29 | The email address linked to the email update request. 30 | 31 | ```ts 32 | { 33 | "email": string 34 | } 35 | ``` 36 | 37 | ## Error codes 38 | 39 | - [400] `INVALID_DATA`: Invalid request data. 40 | - [400] `TOO_MANY_REQUESTS`: Rate limit exceeded. 41 | - [400] `INCORRECT_CODE`: Incorrect verification code. 42 | - [400] `INVALID_REQUEST`: Invalid update request ID. 43 | - [500] `UNKNOWN_ERROR` 44 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "REST API reference" 3 | --- 4 | 5 | # REST API reference 6 | 7 | All rest endpoints expects a JSON body and returns an JSON object. 8 | 9 | ## Authentication 10 | 11 | Set the `Authorization` header to the credential you provided when initializing your server. 12 | 13 | ``` 14 | Authorization: YOUR_CREDENTIAL 15 | ``` 16 | 17 | Faroe will return a 401 error response if the request has an invalid credential. 18 | 19 | ```json 20 | { 21 | "error": "NOT_AUTHENTICATED" 22 | } 23 | ``` 24 | 25 | ## Responses 26 | 27 | Successful responses will have a 200 status if it includes a response body or 204 status if not. 28 | 29 | All error responses have a 4xx or 5xx status and includes a JSON object with an `error` field. See each endpoint's page for a list of possible response statuses and error codes. 30 | 31 | ```json 32 | { 33 | "error": "INVALID_DATA" 34 | } 35 | ``` 36 | 37 | ## Data types 38 | 39 | - Email address: Must be less than 256 characters long, have a "@", and a "." in the domain part. Cannot start or end with a whitespace. 40 | - Password: Must be between 8 and 127 characters. 41 | 42 | ## Models 43 | 44 | - [User](/reference/rest/models/user) 45 | - [User email verification request](/reference/rest/models/user-email-verification-request) 46 | - [User TOTP credential](/reference/rest/models/user-totp-credential) 47 | - [Password reset request](/reference/rest/models/password-reset-request) 48 | 49 | ## Endpoints 50 | 51 | ### Authentication 52 | 53 | - [POST /authenticate/password](/reference/rest/endpoints/post_authenticate_password): Authenticate user with email and password. 54 | 55 | ### Users 56 | 57 | - [POST /users](/reference/rest/endpoints/post_users): Create a new user. 58 | - [GET /users](/reference/rest/endpoints/get_users): Get a list of users. 59 | - [GET /users/\[user_id\]](/reference/rest/endpoints/get_users_userid): Get a user. 60 | - [DELETE /users/\[user_id\]](/reference/rest/endpoints/delete_users_userid): Delete a user. 61 | - [POST /users/\[user_id\]/update-password](/reference/rest/endpoints/post_users_userid_update-password): Update a user's password. 62 | 63 | #### Email verification 64 | 65 | - [POST /users/\[user_id\]/email-verification-request](/reference/rest/endpoints/post_users_userid_email-verification-request): Create a new user email verification request. 66 | - [GET /users/\[user_id\]/email-verification-request](/reference/rest/endpoints/get_users_userid_email-verification-request): Get a user's email verification request. 67 | - [DELETE /users/\[user_id\]/email-verification-request](/reference/rest/endpoints/delete_users_userid_email-verification-request): Delete a user's email verification request. 68 | - [POST /users/\[user_id\]/verify-email](/reference/rest/endpoints/post_users_userid_verify-email): Verify their email verification request code. 69 | 70 | #### Email update 71 | 72 | - [POST /users/\[user_id\]/email-update-requests](/reference/rest/endpoints/post_users_userid_email-update-requests): Create a new user email update request. 73 | - [GET /users/\[user_id\]/email-update-requests](/reference/rest/endpoints/get_users_userid_email-update-requests): Gets a list of a user's email update requests. 74 | - [DELETE /users/\[user_id\]/email-update-requests](/reference/rest/endpoints/delete_users_userid_email-update-requests): Deletes a user's email update requests. 75 | - [GET /email-update-requests/\[request_id\]](/reference/rest/endpoints/get_email-update-requests_requestid): Get an email update request. 76 | - [DELETE /email-update-requests/\[request_id\]](/reference/rest/endpoints/delete_email-update-requests_requestid): Delete an email update request. 77 | - [POST /verify-new-email](/reference/rest/endpoints/post_verify-new-email): Update a user's email by verifying their email update request code. 78 | 79 | #### Two-factor authentication 80 | 81 | - [POST /users/\[user_id\/register-totp](/reference/rest/endpoints/post_users_userid_register-totp): Register a TOTP credential. 82 | - [GET /users/\[user_id\]/totp-credential](/reference/rest/endpoints/get_users_userid_totp-credential): Get a user's TOTP credential. 83 | - [DELETE /users/\[user_id\]/totp-credential](/reference/rest/endpoints/delete_users_userid_totp-credential): Delete a user's TOTP credential. 84 | - [POST /users/\[user_id\]/verify-2fa/totp](/reference/rest/endpoints/post_users_userid_verify-2fa_totp): Verify a user's TOTP code. 85 | - [POST /users/\[user_id\]/regenerate-recovery-code](/reference/rest/endpoints/post_users_userid_regenerate-recovery-code): Generate a new user recovery code. 86 | - [POST /users/\[user_id\]/reset-2fa](/reference/rest/endpoints/post_users_userid_reset-2fa): Reset a user's second factors with a recovery code. 87 | 88 | ### Password reset 89 | 90 | - [POST /users/\[user_id\]/password-reset-requests](/reference/rest/endpoints/post_users_userid_password-reset-requests): Create a new password reset request for a user. 91 | - [GET /password-reset-requests/\[request_id\]](/reference/rest/endpoints/get_password-reset-requests_requestid): Get a password reset request. 92 | - [DELETE /password-reset-requests/\[request_id\]](/reference/rest/endpoints/delete_password-reset-requests_requestid): Delete a password reset request. 93 | - [POST /password-reset-requests/\[request_id\]/verify-email](/reference/rest/endpoints/post_password-reset-requests_requestid_verify-email): Verify a reset request's email. 94 | - [POST /reset-password](/reference/rest/endpoints/post_reset-password): Reset the user's password with a verified reset request. 95 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/models/email-update-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Email update request model" 3 | --- 4 | 5 | # Email update request model 6 | 7 | ```ts 8 | { 9 | "id": string, 10 | "user_id": string, 11 | "created_at": number, 12 | "expires_at": number, 13 | "code": string 14 | } 15 | ``` 16 | 17 | - `id`: A 24-character long unique identifier with 120 bits of entropy. 18 | - `user_id`: A 24-character long user ID. 19 | - `created_at`: A 64-bit integer as an UNIX timestamp representing when the request was created. 20 | - `expires_at`: A 64-bit integer as an UNIX timestamp representing when the request will expire. 21 | - `email`: An email that is 255 or less characters. 22 | - `code`: An 8-character alphanumeric one-time code. 23 | 24 | ## Example 25 | 26 | ```json 27 | { 28 | "id": "dvd742g6mpmaebbjxq72kwsr", 29 | "user_id": "7six6i2igxd5ct4dccjk4qtg", 30 | "created_at": 1728803704, 31 | "expires_at": 1728804304, 32 | "email": "cat@example.com", 33 | "code": "VQ9REYBU" 34 | } 35 | ``` 36 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/models/password-reset-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Password reset request model" 3 | --- 4 | 5 | # Password reset request model 6 | 7 | ```ts 8 | { 9 | "id": string, 10 | "user_id": string, 11 | "created_at": number, 12 | "expires_at": number 13 | } 14 | ``` 15 | 16 | - `id`: A 24-character long unique identifier with 120 bits of entropy. 17 | - `user_id`: A 24-character long user ID. 18 | - `created_at`: A 64-bit integer as an UNIX timestamp representing when the request was created. 19 | - `expires_at`: A 64-bit integer as an UNIX timestamp representing when the request will expire. 20 | 21 | ## Example 22 | 23 | ```json 24 | { 25 | "id": "cjjhw9ggvv7e9hfc3qjsiegv", 26 | "user_id": "wz2nyjz4ims4cyuw7eq6tnxy", 27 | "created_at": 1728804201, 28 | "expires_at": 1728804801 29 | } 30 | ``` 31 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/models/user-email-verification-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "User email verification request model" 3 | --- 4 | 5 | # User email verification request model 6 | 7 | ```ts 8 | { 9 | "user_id": string, 10 | "created_at": number, 11 | "expires_at": number, 12 | "code": string 13 | } 14 | ``` 15 | 16 | - `user_id`: A 24-character long user ID. 17 | - `created_at`: A 64-bit integer as an UNIX timestamp representing when the request was created. 18 | - `expires_at`: A 64-bit integer as an UNIX timestamp representing when the request will expire. 19 | - `code`: An 8-character alphanumeric one-time code. 20 | 21 | ## Example 22 | 23 | ```json 24 | { 25 | "user_id": "da7qg28mnk98nbyzwij5hsh7", 26 | "created_at": 1728803704, 27 | "expires_at": 1728804304, 28 | "code": "9TW45AZU" 29 | } 30 | ``` 31 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/models/user-totp-credential.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "User TOTP credential model" 3 | --- 4 | 5 | # User TOTP credential model 6 | 7 | ```ts 8 | { 9 | "user_id": string, 10 | "created_at": number, 11 | "key": string 12 | } 13 | ``` 14 | 15 | - `user_id`: A 24-character long user ID. 16 | - `created_at`: A 64-bit integer as an UNIX timestamp representing when the credential was created. 17 | - `key`: A base64-encoded 20 byte key. 18 | 19 | ## Example 20 | 21 | ```json 22 | { 23 | "user_id": "vg6avv9dp7jvh36f8grjtpsj", 24 | "created_at": 1728783738, 25 | "key": "nHKsL9EFvdzuTWMzGCjZgZWojpU=" 26 | } 27 | ``` 28 | -------------------------------------------------------------------------------- /docs/pages/reference/rest/models/user.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "User model" 3 | --- 4 | 5 | # User model 6 | 7 | ```ts 8 | { 9 | "id": string, 10 | "created_at": number, 11 | "recovery_code": string, 12 | "registered_totp": boolean 13 | } 14 | ``` 15 | 16 | - `id`: A 24 character long unique identifier with 120 bits of entropy. 17 | - `created_at`: A 64-bit integer as an UNIX timestamp representing when the user was created. 18 | - `recovery_code`: A single-use code for resetting the user's second factors. 19 | - `registered_totp`: `true` if the user holds a TOTP credential. 20 | 21 | ## Example 22 | 23 | ```json 24 | { 25 | "id": "eeidmqmvdtjhaddujv8twjug", 26 | "created_at": 1728783738, 27 | "recovery_code": "12345678", 28 | "registered_totp": false 29 | } 30 | ``` 31 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "JavaScript SDK reference" 3 | --- 4 | 5 | # JavaScript SDK reference 6 | 7 | GitHub repository: [faroedev/sdk-js](https://github.com/faroedev/sdk-js) 8 | 9 | A JavaScript SDK built with the web standard [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API). Fully-typed and works with all major runtimes, including Node.js, Cloudflare Workers, Deno, and Bun. 10 | 11 | ``` 12 | npm install @faroe/sdk 13 | ``` 14 | 15 | ## Modules 16 | 17 | - [`@faroe/sdk`](/reference/sdk-js/main) 18 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/createUser.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe.createUser()" 3 | --- 4 | 5 | # Faroe.createUser() 6 | 7 | Mapped to [POST /users](/reference/rest/endpoints/post_users). 8 | 9 | Creates a new user. 10 | 11 | We highly recommend putting a Captcha or equivalent in front for spam and bot detection. 12 | 13 | ## Definition 14 | 15 | ```ts 16 | //$ FaroeUser=/reference/sdk-js/main/FaroeUser 17 | async function createUser( 18 | password: string, 19 | clientIP: string | null 20 | ): Promise<$$FaroeUser> 21 | ``` 22 | 23 | ### Parameters 24 | 25 | - `password`: A valid password. Password strength is determined by checking it aginst past data leaks using the [HaveIBeenPwned API](https://haveibeenpwned.com/API/v3#PwnedPasswords). 26 | - `clientIP` 27 | 28 | ## Error codes 29 | 30 | - `INVALID_DATA`: Malformed email address; invalid email address or password length. 31 | - `WEAK_PASSWORD`: The password is too weak. 32 | - `TOO_MANY_REQUESTS`: Exceeded rate limit. 33 | - `UNKNOWN_ERROR` 34 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/createUserEmailUpdateRequest.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe.createUserEmailUpdateRequest()" 3 | --- 4 | 5 | # Faroe.createUserEmailUpdateRequest() 6 | 7 | Mapped to [POST /users/\[user_id\]/email-update-requests](/reference/rest/endpoints/post_users_userid_email-update-requests). 8 | 9 | Creates a new email verification request for a user. This can only be called 3 times in a 15 minute window per user. 10 | 11 | ## Definition 12 | 13 | ```ts 14 | //$ $$FaroeEmailUpdateRequest=/reference/sdk-js/main/$$FaroeEmailUpdateRequest 15 | async function createUserEmailUpdateRequest( 16 | userId: string, 17 | email: string 18 | ): Promise<$$FaroeEmailUpdateRequest> 19 | ``` 20 | 21 | ### Parameters 22 | 23 | - `userId` 24 | - `email`: A valid email address. 25 | 26 | ## Error codes 27 | 28 | - `INVALID_DATA`: Invalid email address. 29 | - `TOO_MANY_REQUESTS`: Exceeded rate limit. 30 | - `NOT_FOUND`: The user does not exist. 31 | - `UNKNOWN_ERROR` 32 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/createUserEmailVerificationRequest.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe.createUserEmailVerificationRequest()" 3 | --- 4 | 5 | # Faroe.createUserEmailVerificationRequest() 6 | 7 | Mapped to [POST /users/\[user_id\]/email-verification-request](/reference/rest/endpoints/post_users_userid_email-verification-request). 8 | 9 | Creates a new email verification request for a user. This can only be called 3 times in a 15 minute window per user. 10 | 11 | ## Definition 12 | 13 | ```ts 14 | //$ FaroeUserEmailVerificationRequest=/reference/sdk-js/main/FaroeUserEmailVerificationRequest 15 | async function createUserEmailVerificationRequest( 16 | userId: string 17 | ): Promise<$$FaroeUserEmailVerificationRequest> 18 | ``` 19 | 20 | ### Parameters 21 | 22 | - `userId` 23 | 24 | ## Error codes 25 | 26 | - `INVALID_DATA`: Invalid email address. 27 | - `TOO_MANY_REQUESTS`: Exceeded rate limit. 28 | - `NOT_FOUND`: The user does not exist. 29 | - `UNKNOWN_ERROR` 30 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/createUserPasswordResetRequest.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe.createPasswordResetRequest()" 3 | --- 4 | 5 | # Faroe.createPasswordResetRequest() 6 | 7 | Mapped to [POST /password-reset-requests](/reference/rest/endpoints/post_password-reset-requests). 8 | 9 | Creates a new password reset request for a user. This can only be called 3 times in a 15 minute window per user. 10 | 11 | Send the created reset request's code to the email address. 12 | 13 | ## Definition 14 | 15 | ```ts 16 | //$ FaroePasswordResetRequest=/reference/sdk-js/main/$$FaroePasswordResetRequest 17 | async function createPasswordResetRequest( 18 | email: string, 19 | clientIP: string | null 20 | ): Promise<[request: $$FaroePasswordResetRequest, code: string]> 21 | ``` 22 | 23 | ### Parameters 24 | 25 | - `email`: A valid email address. 26 | - `clientIP` 27 | 28 | ## Error codes 29 | 30 | - `INVALID_DATA`: Malformed email address. 31 | - `USER_NOT_EXISTS`: A user linked to the email does not exist. 32 | - `TOO_MANY_REQUESTS`: Exceeded rate limit. 33 | - `UNKNOWN_ERROR` 34 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/deleteEmailUpdateRequest.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe.deleteEmailUpdateRequest()" 3 | --- 4 | 5 | # Faroe.deleteEmailUpdateRequest() 6 | 7 | Mapped to [DELETE /email-update-requests/\[request_id\]](/reference/rest/endpoints/delete_email-update-requests_requestid). 8 | 9 | Deletes an email update request. Deleting a non-existent request will not result in an error. 10 | 11 | ## Definition 12 | 13 | ```ts 14 | async function deleteEmailUpdateRequest(requestId: string): Promise 15 | ``` 16 | 17 | ### Parameters 18 | 19 | - `requestId` 20 | 21 | ## Error codes 22 | 23 | - `UNKNOWN_ERROR` 24 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/deletePasswordResetRequest.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe.deletePasswordResetRequest()" 3 | --- 4 | 5 | # Faroe.deletePasswordResetRequest() 6 | 7 | Mapped to [DELETE /password-reset-requests/\[request_id\]](/reference/rest/endpoints/delete_password-reset-requests_requestid). 8 | 9 | Deletes a passwod reset request. Deleting a non-existent request will not result in an error. 10 | 11 | ## Definition 12 | 13 | ```ts 14 | async function deletePasswordResetRequest(requestId: string): Promise 15 | ``` 16 | 17 | ### Parameters 18 | 19 | - `requestId` 20 | 21 | ## Error codes 22 | 23 | - `UNKNOWN_ERROR` 24 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/deleteUser.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe.deleteUser()" 3 | --- 4 | 5 | # Faroe.deleteUser() 6 | 7 | Mapped to [DELETE /users/\[user_id\]](/reference/rest/endpoints/delete_users_userid). 8 | 9 | Deletes a user. Deleting a non-existent user will not result in an error. 10 | 11 | ## Definition 12 | 13 | ```ts 14 | async function deleteUser(userId: string): Promise 15 | ``` 16 | 17 | ### Parameters 18 | 19 | - `userId` 20 | 21 | ## Error codes 22 | 23 | - `UNKNOWN_ERROR` 24 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/deleteUserEmailUpdateRequests.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe.deleteUserEmailUpdateRequests()" 3 | --- 4 | 5 | # Faroe.deleteUserEmailUpdateRequests() 6 | 7 | Mapped to [DELETE /users/\[user_id\]/email-update-requests](/reference/rest/endpoints/delete_users_userid_email-update-requests). 8 | 9 | Deletes a user's email update requests. Attempting to delete email update requests of a non-existent user will not result in an error. 10 | 11 | ## Definition 12 | 13 | ```ts 14 | async function deleteUserEmailUpdateRequests( 15 | userId: string 16 | ): Promise 17 | ``` 18 | ## Error codes 19 | 20 | - `UNKNOWN_ERROR` 21 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/deleteUserEmailVerificationRequest.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe.deleteUserEmailVerificationRequest()" 3 | --- 4 | 5 | # Faroe.deleteUserEmailVerificationRequest() 6 | 7 | Mapped to [DELETE /users/\[user_id\]/email-verification-request](/reference/rest/endpoints/delete_users_userid_email-verification-request). 8 | 9 | Deletes a user's email verification request. Deleting a non-existent request will not result in an error. 10 | 11 | ## Definition 12 | 13 | ```ts 14 | async function deleteUserEmailVerificationRequest(userId: string): Promise 15 | ``` 16 | 17 | ### Parameters 18 | 19 | - `userId` 20 | 21 | ## Error codes 22 | 23 | - `UNKNOWN_ERROR` 24 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/deleteUserPasswordResetRequests.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe.deleteUserPasswordResetRequests()" 3 | --- 4 | 5 | # Faroe.deleteUserPasswordResetRequests() 6 | 7 | Mapped to [DELETE /users/\[user_id\]/password-reset-requests](/reference/rest/endpoints/delete_users_userid_email-reset-requests). 8 | 9 | Deletes a user's password reset requests. Attempting to delete password reset requests of a non-existent user will not result in an error. 10 | 11 | ## Definition 12 | 13 | ```ts 14 | async function deleteUserPasswordResetRequests( 15 | userId: string 16 | ): Promise 17 | ``` 18 | ## Error codes 19 | 20 | - `UNKNOWN_ERROR` 21 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/deleteUserTOTPCredential.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe.deleteUserTOTPCredential()" 3 | --- 4 | 5 | # Faroe.deleteUserTOTPCredential() 6 | 7 | Mapped to [DELETE /users/\[user_id\]/totp-credential](/reference/rest/endpoints/delete_users_userid_totp-credential). 8 | 9 | Deletes a user's TOTP credential. Deleting a non-existent credential will not result in an error. 10 | 11 | ## Definition 12 | 13 | ```ts 14 | async function deleteUserTOTPCredential( 15 | userId: string 16 | ): Promise 17 | ``` 18 | 19 | ### Parameters 20 | 21 | - `userId` 22 | 23 | ## Error codes 24 | 25 | - `UNKNOWN_ERROR` 26 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/getEmailUpdateRequest.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe.getEmailUpdateRequest()" 3 | --- 4 | 5 | # Faroe.getEmailUpdateRequest() 6 | 7 | Mapped to [GET /email-update-requests/\[request_id\]](/reference/rest/endpoints/get_email-update-requests_requestid). 8 | 9 | Gets a email update request. Returns `null` if the request doesn't exist or has expired. 10 | 11 | ## Definition 12 | 13 | ```ts 14 | //$ FaroeEmailUpdateRequest=/reference/sdk-js/main/FaroeEmailUpdateRequest 15 | async function getEmailUpdateRequest( 16 | requestId: string 17 | ): Promise<$$FaroeEmailUpdateRequest | null> 18 | ``` 19 | 20 | ### Parameters 21 | 22 | - `requestId` 23 | 24 | ## Error codes 25 | 26 | - `UNKNOWN_ERROR` 27 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/getPasswordResetRequest.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe.getPasswordResetRequest()" 3 | --- 4 | 5 | # Faroe.getPasswordResetRequest() 6 | 7 | Mapped to [GET /password-reset-requests/\[request_id\]](/reference/rest/endpoints/get_password-reset-requests_requestid). 8 | 9 | Gets a password reset request. Returns `null` if the request doesn't exist. 10 | 11 | ## Definition 12 | 13 | ```ts 14 | //$ FaroePasswordResetRequest=/reference/sdk-js/main/FaroePasswordResetRequest 15 | async function getPasswordResetRequest( 16 | requestId: string 17 | ): Promise<$$FaroePasswordResetRequest | null> 18 | ``` 19 | 20 | ### Parameters 21 | 22 | - `requestId` 23 | 24 | ## Error codes 25 | 26 | - `UNKNOWN_ERROR` 27 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/getUser.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe.getUser()" 3 | --- 4 | 5 | # Faroe.getPasswordResetRequest() 6 | 7 | Mapped to [GET /users](/reference/rest/endpoints/get_users). 8 | 9 | Gets a user. Returns `null` if the user doesn't exist. 10 | 11 | ## Definition 12 | 13 | ```ts 14 | //$ FaroeUser=/reference/sdk-js/main/FaroeUser 15 | async function getUser(userId: string): Promise<$$FaroeUser | null> 16 | ``` 17 | 18 | ### Parameters 19 | 20 | - `userId` 21 | 22 | ## Error codes 23 | 24 | - `UNKNOWN_ERROR` 25 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/getUserEmailUpdateRequests.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe.getUserEmailUpdateRequests()" 3 | --- 4 | 5 | # Faroe.getUserEmailUpdateRequests() 6 | 7 | Mapped to [GET /users/\[user_id\]/email-update-requests](/reference/rest/endpoints/get_users_userid_email-update-requests). 8 | 9 | Gets an array of a user's valid email update requests. Returns an empty array if the user doesn't have any valid update requests or null if the user doesn't exist. 10 | 11 | ## Definition 12 | 13 | ```ts 14 | //$ FaroeEmailUpdateRequest=/reference/sdk-js/main/FaroeEmailUpdateRequest 15 | async function getUserEmailUpdateRequests( 16 | userId: string 17 | ): Promise<$$FaroeEmailUpdateRequest[] | null> 18 | ``` 19 | ## Error codes 20 | 21 | - `NOT_FOUND`: The user does not exist. 22 | - `UNKNOWN_ERROR` 23 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/getUserEmailVerificationRequest.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe.getUserEmailVerificationRequest()" 3 | --- 4 | 5 | # Faroe.getUserEmailVerificationRequest() 6 | 7 | Mapped to [GET /users/\[user_id\]/email-verification-request](/reference/rest/endpoints/get_users_userid_email-verification-request). 8 | 9 | Gets a user's email verification request. Returns `null` if the request doesn't exist or has expired. 10 | 11 | ## Definition 12 | 13 | ```ts 14 | //$ FaroeUserEmailVerificationRequest=/reference/sdk-js/main/FaroeUserEmailVerificationRequest 15 | async function getUserEmailVerificationRequest( 16 | userId: string 17 | ): Promise<$$FaroeUserEmailVerificationRequest | null> 18 | ``` 19 | 20 | ### Parameters 21 | 22 | - `userId` 23 | 24 | ## Error codes 25 | 26 | - `UNKNOWN_ERROR` 27 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/getUserPasswordResetRequests.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe.getUserPasswordResetRequests()" 3 | --- 4 | 5 | # Faroe.getUserPasswordResetRequests() 6 | 7 | Mapped to [GET /users/\[user_id\]/password-reset-requests](/reference/rest/endpoints/get_users_userid_password-reset-requests). 8 | 9 | Gets an array of a user's valid password reset requests. Returns an empty array if the user doesn't have any valid reset requests or null if the user doesn't exist. 10 | 11 | ## Definition 12 | 13 | ```ts 14 | //$ FaroePasswordResetRequest=/reference/sdk-js/main/FaroePasswordResetRequest 15 | async function getUserPasswordResetRequests( 16 | userId: string 17 | ): Promise<$$FaroePasswordResetRequest[] | null> 18 | ``` 19 | ## Error codes 20 | 21 | - `UNKNOWN_ERROR` 22 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/getUserTOTPCredential.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe.getUserTOTPCredential()" 3 | --- 4 | 5 | # Faroe.getUserTOTPCredential() 6 | 7 | Mapped to [GET /users/\[user_id\]/totp-credential](/reference/rest/endpoints/get_users_userid_totp-credential). 8 | 9 | Gets a user's TOTP credential. Returns `null` if the user doesn't exist or if the user doesn't have a TOTP credential. 10 | 11 | ## Definition 12 | 13 | ```ts 14 | //$ FaroeUserTOTPCredential=/reference/sdk-js/main/FaroeUserTOTPCredential 15 | async function getUserTOTPCredential(userId: string): Promise<$$FaroeUserTOTPCredential | null> 16 | ``` 17 | 18 | ### Parameters 19 | 20 | - `userId` 21 | 22 | ## Error codes 23 | 24 | - `UNKNOWN_ERROR` 25 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/getUsers.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe.getUsers()" 3 | --- 4 | 5 | # Faroe.getUsers() 6 | 7 | Mapped to [GET /users](/reference/rest/endpoints/get_users). 8 | 9 | Gets an array of users. Returns an empty array if there are no users. 10 | 11 | ## Definition 12 | 13 | ```ts 14 | //$ PaginationResult=/reference/sdk-js/main/PaginationResult 15 | //$ FaroeUser=/reference/sdk-js/main/FaroeUser 16 | async function getUsers(options?: { 17 | sortBy: UserSortBy = UserSortBy.CreatedAt, 18 | sortOrder: SortOrder = SortOrder.Ascending, 19 | perPage: number = 20, 20 | page: number = 1 21 | }): Promise<$$PaginationResult<$$FaroeUser>> 22 | ``` 23 | 24 | ### Parameters 25 | 26 | - `options.sortBy` 27 | - `options.sortOrder` 28 | - `options.perPage` 29 | - `options.page` 30 | 31 | ## Error codes 32 | 33 | - `UNKNOWN_ERROR` 34 | 35 | ## Examples 36 | 37 | ```ts 38 | import { Faroe, UserSortBy, SortOrder } from "@faroe/sdk"; 39 | 40 | const faroe = new Faroe(url, secret); 41 | 42 | const users = await faroe.getUsers({ 43 | sortBy: UserSortBy.CreatedAt, 44 | sortOrder: SortOrder.Ascending, 45 | perPage: 20, 46 | page: 2 47 | }); 48 | ``` 49 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe" 3 | --- 4 | 5 | # Faroe 6 | 7 | Represents a Faroe server client. 8 | 9 | Server errors are thrown as [`FaroeError`](/reference/sdk-js/main/FaroeError). The error code is available from `FaroeError.code`. See each method for a list of possible error codes. 10 | 11 | ```ts 12 | import { Faroe, FaroeError } from "@faroe/sdk" 13 | 14 | const faroe = new Faroe(url, secret); 15 | 16 | try { 17 | await faroe.createUser(password, clientIP); 18 | } catch (e) { 19 | if (e instanceof FaroeError) { 20 | const errorCode = e.code; 21 | } 22 | } 23 | ``` 24 | 25 | Errors caused by `fetch()` are wrapped as [`FaroeFetchError`](/reference/sdk-js/main/FaroeFetchError). 26 | 27 | ## Constructor 28 | 29 | ```ts 30 | function constructor(url: string, credential: string | null): this 31 | ``` 32 | 33 | ### Parameters 34 | 35 | - `url`: The base URL of the Faroe server (e.g. `https://your-domain.com`). 36 | - `credential`: The server credential. 37 | 38 | ## Methods 39 | 40 | - [`createUser()`](/reference/sdk-js/main/Faroe/createUser) 41 | - [`createUserEmailUpdateRequest()`](/reference/sdk-js/main/Faroe/createUserEmailUpdateRequest) 42 | - [`createUserEmailVerificationRequest()`](/reference/sdk-js/main/Faroe/createUserEmailVerificationRequest) 43 | - [`createUserPasswordResetRequest()`](/reference/sdk-js/main/Faroe/createUserPasswordResetRequest) 44 | - [`deleteEmailUpdateRequest()`](/reference/sdk-js/main/Faroe/deleteEmailUpdateRequest) 45 | - [`deletePasswordResetRequest()`](/reference/sdk-js/main/Faroe/deletePasswordResetRequest) 46 | - [`deleteUser()`](/reference/sdk-js/main/Faroe/deleteUser) 47 | - [`deleteUserEmailUpdateRequests()`](/reference/sdk-js/main/Faroe/deleteUserEmailUpdateRequests) 48 | - [`deleteUserEmailVerificationRequest()`](/reference/sdk-js/main/Faroe/deleteUserEmailVerificationRequest) 49 | - [`deleteUserPasswordResetRequests()`](/reference/sdk-js/main/Faroe/deleteUserPasswordResetRequests) 50 | - [`deleteUserTOTPCredential()`](/reference/sdk-js/main/Faroe/deleteUserTOTPCredential) 51 | - [`getEmailUpdateRequest()`](/reference/sdk-js/main/Faroe/getEmailUpdateRequest) 52 | - [`getPasswordResetRequest()`](/reference/sdk-js/main/Faroe/getPasswordResetRequest) 53 | - [`getUser()`](/reference/sdk-js/main/Faroe/getUser) 54 | - [`getUserEmailUpdateRequests()`](/reference/sdk-js/main/Faroe/getUserEmailUpdateRequests) 55 | - [`getUserEmailVerificationRequest()`](/reference/sdk-js/main/Faroe/getUserEmailVerificationRequest) 56 | - [`getUserPasswordResetRequests()`](/reference/sdk-js/main/Faroe/getUserPasswordResetRequests) 57 | - [`getUsers()`](/reference/sdk-js/main/Faroe/getUsers) 58 | - [`getUserTOTPCredential()`](/reference/sdk-js/main/Faroe/getUserTOTPCredential) 59 | - [`regenerateUserRecoveryCode()`](/reference/sdk-js/main/Faroe/regenerateUserRecoveryCode) 60 | - [`registerUserTOTPCredential()`](/reference/sdk-js/main/Faroe/registerUserTOTPCredential) 61 | - [`resetUser2FA()`](/reference/sdk-js/main/Faroe/resetUser2FA) 62 | - [`resetUserPassword()`](/reference/sdk-js/main/Faroe/resetUserPassword) 63 | - [`updateUserPassword()`](/reference/sdk-js/main/Faroe/updateUserPassword) 64 | - [`verifyNewUserEmail()`](/reference/sdk-js/main/Faroe/verifyNewUserEmail) 65 | - [`verifyPasswordResetRequestEmail()`](/reference/sdk-js/main/Faroe/verifyPasswordResetRequestEmail) 66 | - [`verifyUser2FAWithTOTP()`](/reference/sdk-js/main/Faroe/verifyUser2FAWithTOTP) 67 | - [`verifyUserEmail()`](/reference/sdk-js/main/Faroe/verifyUserEmail) 68 | - [`verifyUserPassword()`](/reference/sdk-js/main/Faroe/verifyUserPassword) 69 | 70 | ## Example 71 | 72 | ```ts 73 | import { Faroe } from "@faroe/sdk" 74 | 75 | const faroe = new Faroe("https://your-domain.com", process.env.FAROE_CREDENTIAL); 76 | ``` 77 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/regenerateUserRecoveryCode.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe.regenerateUserRecoveryCode()" 3 | --- 4 | 5 | # Faroe.regenerateUserRecoveryCode() 6 | 7 | Mapped to [POST /users/\[user_id\]/regenerate-recovery-code](/reference/rest/endpoints/post_users_userid_regenerate-recovery-code). 8 | 9 | Regenerates a user's recovery code and returns the new recovery code. 10 | 11 | ## Definition 12 | 13 | ```ts 14 | async function regenerateUserRecoveryCode( 15 | userId: string 16 | ): Promise 17 | ``` 18 | 19 | ### Parameters 20 | 21 | - `userId` 22 | 23 | ## Error codes 24 | 25 | - `NOT_FOUND` 26 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/registerUserTOTPCredential.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe.registerUserTOTPCredential()" 3 | --- 4 | 5 | # Faroe.registerUserTOTPCredential() 6 | 7 | Mapped to [POST /users/\[user_id\/register-totp](/reference/rest/endpoints/post_users_userid_register-totp). 8 | 9 | Registers a TOTP (SHA-1, 6 digits, 30 seconds interval) credential to a user. 10 | 11 | ## Definition 12 | 13 | ```ts 14 | //$ FaroeUserTOTPCredential=/reference/sdk-js/main/FaroeUserTOTPCredential 15 | async function registerUserTOTPCredential( 16 | userId: string, 17 | totpKey: Uint8Array, 18 | code: string 19 | ): Promise<$$FaroeUserTOTPCredential> 20 | ``` 21 | 22 | ### Parameters 23 | 24 | - `userId` 25 | - `totp_key`: A base64-encoded TOTP key. The encoded key must be 20 bytes. 26 | - `code`: The TOTP code from the key for verification. 27 | 28 | ## Error codes 29 | 30 | - `INVALID_DATA`: Invalid TOTP key length. 31 | - `INCORRECT_CODE` 32 | - `NOT_FOUND`: Invalid user ID. 33 | - `UNKNOWN_ERROR` 34 | 35 | ### Example 36 | 37 | ```ts 38 | import { Faroe, UserSortBy, SortOrder } from "@faroe/sdk"; 39 | 40 | const faroe = new Faroe(url, secret); 41 | 42 | const key = new Uint8Array(20); 43 | crypto.getRandomValues(key); 44 | 45 | // ... 46 | 47 | const credential = await faroe.registerUserTOTPCredential(userId, key, code); 48 | ``` 49 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/resetUser2FA.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe.resetUser2FA()" 3 | --- 4 | 5 | # Faroe.resetUser2FA() 6 | 7 | Mapped to [POST /users/\[user_id\]/reset-2fa](/reference/rest/endpoints/post_users_userid_reset-2fa). 8 | 9 | Resets a user's second factors using a recovery code and returns a new recovery code. The user will be locked out from using their recovery code for 15 minutes after their 5th consecutive failed attempts. 10 | 11 | 12 | ## Definition 13 | 14 | ```ts 15 | async function resetUser2FA( 16 | userId: string, 17 | recoveryCode: string 18 | ): Promise; 19 | ``` 20 | 21 | ## Parameters 22 | 23 | - `userId` 24 | - `recoveryCode` 25 | 26 | ## Error codes 27 | 28 | - `TOO_MANY_REQUESTS`: Rate limit exceeded. 29 | - `INCORRECT_CODE`: Incorrect recovery code. 30 | - `NOT_FOUND`: The user does not exist. 31 | - `UNKNOWN_ERROR` 32 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/resetUserPassword.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe.resetUserPassword()" 3 | --- 4 | 5 | # Faroe.resetUserPassword() 6 | 7 | Mapped to [POST /reset-password](/reference/rest/endpoints/post_reset-password). 8 | 9 | Resets a user's password with a password reset request.On validation, it will mark the user's email as verified and invalidate all password reset requests linked to the user. 10 | 11 | ## Definition 12 | 13 | ```ts 14 | async function resetUserPassword( 15 | requestId: string, 16 | password: string, 17 | clientIP: string | null 18 | ): Promise 19 | ``` 20 | 21 | ### Parameters 22 | 23 | - `request_id`: A valid password reset request ID. 24 | - `password`: A new valid password. A valid password. Password strength is determined by checking it aginst past data leaks using the [HaveIBeenPwned API](https://haveibeenpwned.com/API/v3#PwnedPasswords). 25 | - `clientIP` 26 | 27 | ## Error codes 28 | 29 | - `SECOND_FACTOR_NOT_VERIFIED`: 2FA required. 30 | - `INVALID_DATA`: Invalid password length. 31 | - `WEAK_PASSWORD`: The password is too weak. 32 | - `TOO_MANY_REQUESTS`: Exceeded rate limit. 33 | - `INVALID_REQUEST`: Invalid reset request ID. 34 | - `UNKNOWN_ERROR` 35 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/updateUserPassword.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe.updateUserPassword()" 3 | --- 4 | 5 | # Faroe.updateUserPassword() 6 | 7 | Mapped to [POST /users/\[user_id\]/password](/reference/rest/endpoints/post_users_userid_password). 8 | 9 | Updates a user's password. 10 | 11 | ## Definition 12 | 13 | ```ts 14 | async function updateUserPassword( 15 | userId: string, 16 | password: string, 17 | newPassword: string, 18 | clientIP: string | null 19 | ): Promise 20 | ``` 21 | 22 | ### Parameters 23 | 24 | - `userId` 25 | - `password`: Current password. 26 | - `new Password`: A valid password. Password strength is determined by checking it aginst past data leaks using the [HaveIBeenPwned API](https://haveibeenpwned.com/API/v3#PwnedPasswords). 27 | - `clientIP` 28 | 29 | ## Error codes 30 | 31 | - `INVALID_DATA`: Invalid password length. 32 | - `WEAK_PASSWORD`: The password is too weak. 33 | - `TOO_MANY_REQUESTS`: Exceeded rate limit. 34 | - `NOT_FOUND`: The user does not exist. 35 | - `UNKNOWN_ERROR` 36 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/verifyNewUserEmail.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe.verifyNewUserEmail()" 3 | --- 4 | 5 | # Faroe.verifyNewUserEmail() 6 | 7 | Mapped to [POST /verify-new-email](/reference/rest/endpoints/post_verify-new-email). 8 | 9 | Verifies an email update request's verification code. Upon a successful verification, all email update requests linked to the email address and password reset requests to the user are invalidated. 10 | 11 | The update request is immediately invalidated after the 5th failed attempt. 12 | 13 | ## Definition 14 | 15 | ```ts 16 | function verifyNewUserEmail(requestId: string, code: string): Promise; 17 | ``` 18 | 19 | ## Parameters 20 | 21 | - `requestId`: A valid email update request ID. 22 | - `code`: The verification code of the request. 23 | 24 | ## Error codes 25 | 26 | - `TOO_MANY_REQUESTS`: Rate limit exceeded. 27 | - `INCORRECT_CODE`: Incorrect verification code. 28 | - `INVALID_REQUEST`: Invalid update request ID. 29 | - `UNKNOWN_ERROR` 30 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/verifyPasswordResetRequestEmail.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe.verifyPasswordResetRequestEmail()" 3 | --- 4 | 5 | # Faroe.verifyPasswordResetRequestEmail() 6 | 7 | Mapped to [POST /password-reset-requests/\[request_id\]/verify-email](/reference/rest/endpoints/post_password-reset-requests_requestid_verify-email). 8 | 9 | Verifies the email linked to a password reset request with a verification code. 10 | 11 | The reset request is immediately invalidated after the 5th failed attempt. 12 | 13 | ## Definition 14 | 15 | ```ts 16 | async function verifyPasswordResetRequestEmail( 17 | requestId: string, 18 | code: string, 19 | clientIP: string 20 | ): Promise 21 | ``` 22 | 23 | ### Parameters 24 | 25 | - `requestId` 26 | - `code` 27 | - `clientIP` 28 | 29 | ## Error codes 30 | 31 | - `INCORRECT_CODE`: The one-time code is incorrect. 32 | - `TOO_MANY_REQUESTS`: Exceeded rate limit. 33 | - `NOT_FOUND`: The password reset request does not exist or has expired. 34 | - `UNKNOWN_ERROR` 35 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/verifyUser2FAWithTOTP.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe.verifyUser2FAWithTOTP()" 3 | --- 4 | 5 | # Faroe.verifyUser2FAWithTOTP() 6 | 7 | Mapped to [POST /users/\[user_id\]/verify-2fa/totp](/reference/rest/endpoints/post_users_userid_verify-2fa_totp). 8 | 9 | Verifies a user's TOTP code. The user will be locked out from using TOTP as their second factor for 15 minutes after their 5th consecutive failed attempts. 10 | 11 | ## Definition 12 | 13 | ```ts 14 | async function verifyUser2FAWithTOTP(userId: string, code: string): Promise 15 | ``` 16 | 17 | ### Parameters 18 | 19 | - `userId` 20 | - `code`: The TOTP code. 21 | 22 | ## Error codes 23 | 24 | - `NOT_ALLOWED`: The user does not have a TOTP credential registered. 25 | - `TOO_MANY_REQUESTS`: Rate limit exceeded. 26 | - `INCORRECT_CODE`: Incorrect TOTP code. 27 | - `NOT_FOUND`: The user does not exist. 28 | - `UNKNOWN_ERROR` 29 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/verifyUserEmail.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe.verifyUserEmail()" 3 | --- 4 | 5 | # Faroe.verifyUserEmail() 6 | 7 | Mapped to [POST /users/\[user_id\]/verify-email](/reference/rest/endpoints/post_users_userid_verify-email). 8 | 9 | Verifies and updates a user's email with the user's email verification request code. The user will be locked out from verifying their email for 15 minutes after their 5th consecutive failed attempts. 10 | 11 | ## Definition 12 | 13 | ```ts 14 | async function verifyUserEmail(userId: string, code: string): Promise 15 | ``` 16 | 17 | ### Parameters 18 | 19 | - `userId` 20 | - `code`: The one-time code of the email verification request. 21 | 22 | ## Error codes 23 | 24 | - `NOT_ALLOWED`: The user does not have a request or has a expired request. 25 | - `INCORRECT_CODE`: The one-time code is incorrect. 26 | - `TOO_MANY_REQUESTS`: Exceeded rate limit. 27 | - `NOT_FOUND`: The user does not exist. 28 | - `UNKNOWN_ERROR` 29 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/Faroe/verifyUserPassword.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Faroe.verifyUserPassword()" 3 | --- 4 | 5 | # Faroe.verifyUserPassword() 6 | 7 | Mapped to [POST /users/\[user_id\]/verify-password](/reference/rest/endpoints/post_users_userid_verify-password). 8 | 9 | Verifies a user's password. It will temporary block the IP address if the client sends an incorrect password 5 times in a 15 minute window. 10 | 11 | ## Definition 12 | 13 | ```ts 14 | async function verifyUserPassword( 15 | userId: string, 16 | password: string, 17 | clientIP: string | null 18 | ): Promise 19 | ``` 20 | 21 | ### Parameters 22 | 23 | - `userId` 24 | - `password` 25 | - `clientIP` 26 | 27 | ## Error codes 28 | 29 | - `INCORRECT_PASSWORD` 30 | - `TOO_MANY_REQUESTS`: Exceeded rate limit. 31 | - `UNKNOWN_ERROR` 32 | - `NOT_FOUND`: User does not exist. 33 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/FaroeEmailUpdateRequest.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "FaroeEmailUpdateRequest" 3 | --- 4 | 5 | # FaroeEmailUpdateRequest 6 | 7 | Mapped to the [email update request model](/reference/rest/models/email-update-request). 8 | 9 | Represents an email update request. 10 | 11 | ## Definition 12 | 13 | ```ts 14 | interface FaroeEmailUpdateRequest { 15 | id: string; 16 | userId: string; 17 | createdAt: Date; 18 | expiresAt: Date; 19 | email: string; 20 | code: string; 21 | } 22 | ``` 23 | 24 | ### Properties 25 | 26 | - `id`: A 24-character long unique identifier with 120 bits of entropy. 27 | - `userId`: A 24-character long user ID. 28 | - `created`: A timestamp representing when the request was created. 29 | - `expiresAt`: A timestamp representing when the request will expire. 30 | - `email`: An email that is 255 or less characters. 31 | - `code`: An 8-character alphanumeric one-time code. 32 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/FaroeError.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "FaroeError" 3 | --- 4 | 5 | # FaroeError 6 | 7 | Extends `Error`. 8 | 9 | An error indicating a server error response. 10 | 11 | ## Properties 12 | 13 | ```ts 14 | interface Properties { 15 | status: number; 16 | code: string; 17 | } 18 | ``` 19 | 20 | - `status`: HTTP response status. 21 | - `code`: Faroe error code. 22 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/FaroeFetchError.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "FaroeFetchError" 3 | --- 4 | 5 | # FaroeFetchError 6 | 7 | Extends `Error`. 8 | 9 | An error indicating that a `fetch()` request failed. The root error is available from the `cause` property. 10 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/FaroePasswordResetRequest.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "FaroePasswordResetRequest" 3 | --- 4 | 5 | # FaroePasswordResetRequest 6 | 7 | Mapped to the [password reset request model](/reference/rest/models/password-reset-request). 8 | 9 | Represents an user. 10 | 11 | ## Definition 12 | 13 | ```ts 14 | interface FaroePasswordResetRequest { 15 | id: string; 16 | userId: string; 17 | createdAt: Date; 18 | expiresAt: Date; 19 | } 20 | ``` 21 | 22 | ### Properties 23 | 24 | - `id`: A 24-character long unique identifier with 120 bits of entropy. 25 | - `userId`: A 24-character long user ID. 26 | - `createdAt`: A timestamp representing when the request was created. 27 | - `expiresAt`: A timestamp representing when the request will expire. 28 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/FaroeUser.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "FaroeUser" 3 | --- 4 | 5 | # FaroeUser 6 | 7 | Represents an user. Mapped to the [user model](/reference/rest/models/user). 8 | 9 | ## Definition 10 | 11 | ```ts 12 | interface FaroeUser { 13 | id: string; 14 | createdAt: Date; 15 | recoveryCode: string; 16 | registeredTOTP: boolean; 17 | } 18 | ``` 19 | 20 | ### Properties 21 | 22 | - `id`: A 24 character long unique identifier with 120 bits of entropy. 23 | - `createdAt`: A timestamp representing when the user was created. 24 | - `recoveryCode`: A single-use code for resetting the user's second factors. 25 | - `registeredTOTP`: `true` if the user holds a TOTP credential. 26 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/FaroeUserEmailVerificationRequest.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "FaroeUserEmailVerificationRequest" 3 | --- 4 | 5 | # FaroeUserEmailVerificationRequest 6 | 7 | Mapped to the [user email verification request model](/reference/rest/models/user-email-verification-request). 8 | 9 | Represents an email verification request. 10 | 11 | ## Definition 12 | 13 | ```ts 14 | interface FaroeUserEmailVerificationRequest { 15 | userId: string; 16 | createdAt: Date; 17 | expiresAt: Date; 18 | code: string; 19 | } 20 | ``` 21 | 22 | ### Properties 23 | 24 | - `userId`: A 24-character long user ID. 25 | - `created`: A timestamp representing when the request was created. 26 | - `expiresAt`: A timestamp representing when the request will expire. 27 | - `code`: An 8-character alphanumeric one-time code. 28 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/FaroeUserTOTPCredential.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "FaroeUserTOTPCredential" 3 | --- 4 | 5 | # FaroeUserTOTPCredential 6 | 7 | Mapped to the [user totp credential model](/reference/rest/models/user-totp-credential). 8 | 9 | Represents a user's totp credential. 10 | 11 | ## Definition 12 | 13 | ```ts 14 | interface FaroeUserTOTPCredential { 15 | userId: string; 16 | createdAt: Date; 17 | key: Uint8Array; 18 | } 19 | ``` 20 | 21 | ### Properties 22 | 23 | - `userId`: A 24-character long user ID. 24 | - `createdAt`: A timestamp representing when the credential was created. 25 | - `key`: A 20 byte key. 26 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/PaginationResult.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "PaginationResult" 3 | --- 4 | 5 | # PaginationResult 6 | 7 | Represents a paginated result. 8 | 9 | ## Definition 10 | 11 | ```ts 12 | interface PaginationResult { 13 | total: number; 14 | totalPages: number; 15 | items: T[] 16 | } 17 | ``` 18 | 19 | ### Type parameters 20 | 21 | - `T` 22 | 23 | ### Properties 24 | 25 | - `total`: Total number of records. 26 | - `totalPages` 27 | - `items`: Items in the page. -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/SortOrder.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "SortOrder" 3 | --- 4 | 5 | # SortOrder 6 | 7 | ## Definition 8 | 9 | ```ts 10 | enum SortOrder { 11 | Ascending, 12 | Descending 13 | } 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/UserSortBy.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "UserSortBy" 3 | --- 4 | 5 | # UserSortBy 6 | 7 | ## Definition 8 | 9 | ```ts 10 | enum UserSortBy { 11 | CreatedAt, 12 | Id 13 | } 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "@faroe/sdk" 3 | --- 4 | 5 | # @faroe/sdk 6 | 7 | ## Functions 8 | 9 | - [`verifyEmailInput()`](/reference/sdk-js/main/verifyEmailInput) 10 | - [`verifyPasswordInput()`](/reference/sdk-js/main/verifyPasswordInput) 11 | 12 | ## Classes 13 | 14 | - [`Faroe`](/reference/sdk-js/main/Faroe) 15 | - [`FaroeError`](/reference/sdk-js/main/FaroeError) 16 | - [`FaroeFetchError`](/reference/sdk-js/main/FaroeFetchError) 17 | 18 | ## Interfaces 19 | 20 | - [`FaroeEmailUpdateRequest`](/reference/sdk-js/main/FaroeEmailUpdateRequest) 21 | - [`FaroePasswordResetRequest`](/reference/sdk-js/main/FaroePasswordResetRequest) 22 | - [`FaroeUserEmailVerificationRequest`](/reference/sdk-js/main/FaroeUserEmailVerificationRequest) 23 | - [`FaroeUserTOTPCredential`](/reference/sdk-js/main/FaroeUserTOTPCredential) 24 | - [`FaroeUser`](/reference/sdk-js/main/FaroeUser) 25 | - [`PaginationResult`](/reference/sdk-js/main/PaginationResult) 26 | 27 | ## Enums 28 | 29 | - [`UserSortBy`](/reference/sdk-js/main/UserSortBy) 30 | - [`SortOrder`](/reference/sdk-js/main/SortOrder) 31 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/verifyEmailInput.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "verifyEmailInput()" 3 | --- 4 | 5 | # verifyEmailInput() 6 | 7 | Verifies that the string is a valid Faroe email address. 8 | 9 | ## Definition 10 | 11 | ```ts 12 | function verifyEmailInput(email: string): boolean 13 | ``` 14 | 15 | ### Parameters 16 | 17 | - `email` 18 | -------------------------------------------------------------------------------- /docs/pages/reference/sdk-js/main/verifyPasswordInput.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "verifyPasswordInput()" 3 | --- 4 | 5 | # verifyPasswordInput() 6 | 7 | Verifies that the string is a valid Faroe password. 8 | 9 | ## Definition 10 | 11 | ```ts 12 | function verifyPasswordInput(password: string): boolean 13 | ``` 14 | 15 | ### Parameters 16 | 17 | - `password` 18 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | set -e 2 | 3 | rm -rf bin 4 | cd src 5 | 6 | echo 'building darwin-amd64...' 7 | GOOS=darwin GOARCH=amd64 go build -o ../bin/darwin-amd64/faroe 8 | echo 'building darwin-arm64...' 9 | GOOS=darwin GOARCH=arm64 go build -o ../bin/darwin-arm64/faroe 10 | 11 | echo 'building linux-amd64...' 12 | GOOS=linux GOARCH=amd64 go build -o ../bin/linux-amd64/faroe 13 | echo 'building linux-arm64...' 14 | GOOS=linux GOARCH=arm64 go build -o ../bin/linux-arm64/faroe 15 | 16 | echo 'building windows-amd64...' 17 | GOOS=windows GOARCH=amd64 go build -o ../bin/windows-amd64/faroe.exe 18 | echo 'building windows-arm64...' 19 | GOOS=windows GOARCH=arm64 go build -o ../bin/windows-arm64/faroe.exe 20 | 21 | cd .. 22 | cd bin 23 | for dir in $(ls -d *); do 24 | cp ../LICENSE "$dir"/LICENSE 25 | zip -r "$dir".zip $dir 26 | rm -rf $dir 27 | done 28 | cd .. 29 | 30 | echo 'done!' -------------------------------------------------------------------------------- /src/.gitignore: -------------------------------------------------------------------------------- 1 | faroe_data/ 2 | faroe 3 | -------------------------------------------------------------------------------- /src/argon2id/main.go: -------------------------------------------------------------------------------- 1 | package argon2id 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/subtle" 6 | "encoding/base64" 7 | "errors" 8 | "fmt" 9 | "strings" 10 | 11 | "golang.org/x/crypto/argon2" 12 | ) 13 | 14 | func Hash(password string) (string, error) { 15 | salt := make([]byte, 16) 16 | _, err := rand.Read(salt) 17 | if err != nil { 18 | return "", err 19 | } 20 | key := argon2.IDKey([]byte(password), salt, 2, 19456, 1, 32) 21 | hash := fmt.Sprintf("$argon2id$v=19$m=19456,t=2,p=1$%s$%s", base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)) 22 | return hash, nil 23 | } 24 | 25 | func Verify(hash string, password string) (bool, error) { 26 | parts := strings.Split(hash, "$") 27 | if len(parts) != 6 { 28 | return false, errors.New("invalid hash") 29 | } 30 | if parts[0] != "" { 31 | return false, errors.New("invalid hash") 32 | } 33 | if parts[1] != "argon2id" { 34 | return false, errors.New("invalid algorithm") 35 | } 36 | if parts[2] != "v=19" { 37 | return false, errors.New("unsupported hash") 38 | } 39 | var m, t, p int32 40 | _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p) 41 | if err != nil { 42 | return false, errors.New("invalid hash") 43 | } 44 | salt, err := base64.RawStdEncoding.DecodeString(parts[4]) 45 | if err != nil { 46 | return false, errors.New("invalid hash") 47 | } 48 | key1, err := base64.RawStdEncoding.DecodeString(parts[5]) 49 | if err != nil { 50 | return false, errors.New("invalid hash") 51 | } 52 | key2 := argon2.IDKey([]byte(password), salt, 2, 19456, 1, uint32(len(key1))) 53 | valid := subtle.ConstantTimeCompare(key1, key2) 54 | return valid == 1, nil 55 | } 56 | -------------------------------------------------------------------------------- /src/argon2id/main_test.go: -------------------------------------------------------------------------------- 1 | package argon2id 2 | 3 | import "testing" 4 | 5 | func Test(t *testing.T) { 6 | hash, err := Hash("123456") 7 | if err != nil { 8 | t.Fatal(err) 9 | } 10 | valid, err := Verify(hash, "123456") 11 | if err != nil { 12 | t.Fatal(err) 13 | } 14 | if !valid { 15 | t.Fatalf("Expected hash to match") 16 | } 17 | valid, err = Verify(hash, "12345") 18 | if err != nil { 19 | t.Fatal(err) 20 | } 21 | if valid { 22 | t.Fatalf("Expected hash to not match") 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/auth.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "faroe/argon2id" 7 | "io" 8 | "log" 9 | "net/http" 10 | 11 | "github.com/julienschmidt/httprouter" 12 | ) 13 | 14 | func handleVerifyUserPasswordRequest(env *Environment, w http.ResponseWriter, r *http.Request, params httprouter.Params) { 15 | if !verifyRequestSecret(env.secret, r) { 16 | writeNotAuthenticatedErrorResponse(w) 17 | return 18 | } 19 | if !verifyJSONContentTypeHeader(r) { 20 | writeUnsupportedMediaTypeErrorResponse(w) 21 | return 22 | } 23 | if !verifyJSONAcceptHeader(r) { 24 | writeNotAcceptableErrorResponse(w) 25 | return 26 | } 27 | 28 | userId := params.ByName("user_id") 29 | user, err := getUser(env.db, r.Context(), userId) 30 | if errors.Is(err, ErrRecordNotFound) { 31 | writeNotFoundErrorResponse(w) 32 | return 33 | } 34 | if err != nil { 35 | log.Println(err) 36 | writeUnexpectedErrorResponse(w) 37 | return 38 | } 39 | 40 | body, err := io.ReadAll(r.Body) 41 | if err != nil { 42 | log.Println(err) 43 | writeUnexpectedErrorResponse(w) 44 | return 45 | } 46 | var data struct { 47 | Password *string `json:"password"` 48 | ClientIP string `json:"client_ip"` 49 | } 50 | err = json.Unmarshal(body, &data) 51 | if err != nil { 52 | log.Println(err) 53 | writeExpectedErrorResponse(w, ExpectedErrorInvalidData) 54 | return 55 | } 56 | 57 | if data.Password == nil { 58 | writeExpectedErrorResponse(w, ExpectedErrorInvalidData) 59 | return 60 | } 61 | if data.ClientIP != "" && !env.passwordHashingIPRateLimit.Consume(data.ClientIP) { 62 | writeExpectedErrorResponse(w, ExpectedErrorTooManyRequests) 63 | return 64 | } 65 | if data.ClientIP != "" && !env.loginIPRateLimit.Consume(data.ClientIP) { 66 | writeExpectedErrorResponse(w, ExpectedErrorTooManyRequests) 67 | return 68 | } 69 | validPassword, err := argon2id.Verify(user.PasswordHash, *data.Password) 70 | if err != nil { 71 | log.Println(err) 72 | writeUnexpectedErrorResponse(w) 73 | return 74 | } 75 | if !validPassword { 76 | writeExpectedErrorResponse(w, ExpectedErrorIncorrectPassword) 77 | return 78 | } 79 | if data.ClientIP != "" { 80 | env.loginIPRateLimit.AddTokenIfEmpty(data.ClientIP) 81 | } 82 | w.WriteHeader(204) 83 | } 84 | -------------------------------------------------------------------------------- /src/code.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/base32" 6 | ) 7 | 8 | func generateSecureCode() (string, error) { 9 | bytes := make([]byte, 5) 10 | _, err := rand.Read(bytes) 11 | if err != nil { 12 | return "", err 13 | } 14 | // Remove 0, O, 1, I to remove ambiguity 15 | code := base32.NewEncoding("ABCDEFGHJKLMNPQRSTUVWXYZ23456789").EncodeToString(bytes) 16 | return code, nil 17 | } 18 | -------------------------------------------------------------------------------- /src/db.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | ) 7 | 8 | func cleanUpDatabase(db *sql.DB) error { 9 | _, err := db.Exec("DELETE FROM user_email_verification_request WHERE expires_at <= ?", time.Now().Unix()) 10 | if err != nil { 11 | return err 12 | } 13 | _, err = db.Exec("DELETE FROM password_reset_request WHERE expires_at <= ?", time.Now().Unix()) 14 | if err != nil { 15 | return err 16 | } 17 | return err 18 | } 19 | -------------------------------------------------------------------------------- /src/db_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestCleanUpDatabase(t *testing.T) { 12 | db := initializeTestDB(t) 13 | defer db.Close() 14 | 15 | now := time.Unix(time.Now().Unix(), 0) 16 | 17 | user1 := User{ 18 | Id: "1", 19 | CreatedAt: now, 20 | PasswordHash: "HASH", 21 | RecoveryCode: "12345678", 22 | TOTPRegistered: false, 23 | } 24 | err := insertUser(db, context.Background(), &user1) 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | 29 | resetRequest1 := PasswordResetRequest{ 30 | Id: "1", 31 | UserId: user1.Id, 32 | CreatedAt: now, 33 | ExpiresAt: now.Add(-10 * time.Minute), 34 | CodeHash: "HASH", 35 | } 36 | err = insertPasswordResetRequest(db, context.Background(), &resetRequest1) 37 | if err != nil { 38 | t.Fatal(err) 39 | } 40 | 41 | resetRequest2 := PasswordResetRequest{ 42 | Id: "2", 43 | UserId: user1.Id, 44 | CreatedAt: now, 45 | ExpiresAt: now.Add(10 * time.Minute), 46 | CodeHash: "HASH", 47 | } 48 | err = insertPasswordResetRequest(db, context.Background(), &resetRequest2) 49 | if err != nil { 50 | t.Fatal(err) 51 | } 52 | 53 | resetRequest3 := PasswordResetRequest{ 54 | Id: "3", 55 | UserId: user1.Id, 56 | CreatedAt: now, 57 | ExpiresAt: now.Add(10 * time.Minute), 58 | CodeHash: "HASH", 59 | } 60 | err = insertPasswordResetRequest(db, context.Background(), &resetRequest3) 61 | if err != nil { 62 | t.Fatal(err) 63 | } 64 | 65 | user2 := User{ 66 | Id: "2", 67 | CreatedAt: now, 68 | PasswordHash: "$argon2id$v=19$m=19456,t=2,p=1$enc5MDZrSElTSVE0ODdTSw$CS/AV+PQs08MhdeIrHhfmQ", 69 | RecoveryCode: "12345678", 70 | TOTPRegistered: false, 71 | } 72 | err = insertUser(db, context.Background(), &user2) 73 | if err != nil { 74 | t.Fatal(err) 75 | } 76 | 77 | user3 := User{ 78 | Id: "3", 79 | CreatedAt: now, 80 | PasswordHash: "$argon2id$v=19$m=19456,t=2,p=1$enc5MDZrSElTSVE0ODdTSw$CS/AV+PQs08MhdeIrHhfmQ", 81 | RecoveryCode: "12345678", 82 | TOTPRegistered: false, 83 | } 84 | err = insertUser(db, context.Background(), &user3) 85 | if err != nil { 86 | t.Fatal(err) 87 | } 88 | 89 | verificationRequest1 := UserEmailVerificationRequest{ 90 | UserId: user1.Id, 91 | CreatedAt: now, 92 | Code: "12345678", 93 | ExpiresAt: now.Add(10 * time.Minute), 94 | } 95 | err = insertUserEmailVerificationRequest(db, &verificationRequest1) 96 | if err != nil { 97 | t.Fatal(err) 98 | } 99 | 100 | verificationRequest2 := UserEmailVerificationRequest{ 101 | UserId: user2.Id, 102 | CreatedAt: now, 103 | Code: "12345678", 104 | ExpiresAt: now.Add(-10 * time.Minute), 105 | } 106 | err = insertUserEmailVerificationRequest(db, &verificationRequest2) 107 | if err != nil { 108 | t.Fatal(err) 109 | } 110 | 111 | verificationRequest3 := UserEmailVerificationRequest{ 112 | UserId: user3.Id, 113 | CreatedAt: now, 114 | Code: "12345678", 115 | ExpiresAt: now.Add(-10 * time.Minute), 116 | } 117 | err = insertUserEmailVerificationRequest(db, &verificationRequest3) 118 | if err != nil { 119 | t.Fatal(err) 120 | } 121 | 122 | err = cleanUpDatabase(db) 123 | if err != nil { 124 | t.Fatal(err) 125 | } 126 | 127 | var passwordResetRequestCount int 128 | err = db.QueryRow("SELECT count(*) FROM password_reset_request").Scan(&passwordResetRequestCount) 129 | if err != nil { 130 | t.Fatal(err) 131 | } 132 | assert.Equal(t, 2, passwordResetRequestCount) 133 | 134 | var emailVerificationRequestCount int 135 | err = db.QueryRow("SELECT count(*) FROM user_email_verification_request").Scan(&emailVerificationRequestCount) 136 | if err != nil { 137 | t.Fatal(err) 138 | } 139 | assert.Equal(t, 1, emailVerificationRequestCount) 140 | } 141 | -------------------------------------------------------------------------------- /src/email_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/json" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func insertUserEmailVerificationRequest(db *sql.DB, request *UserEmailVerificationRequest) error { 13 | _, err := db.Exec("INSERT INTO user_email_verification_request (user_id, created_at, expires_at, code) VALUES (?, ?, ?, ?)", request.UserId, request.CreatedAt.Unix(), request.ExpiresAt.Unix(), request.Code, request.CreatedAt.Unix(), request.Code, request.UserId) 14 | return err 15 | } 16 | func TestEncodeEmailToJSON(t *testing.T) { 17 | t.Parallel() 18 | 19 | email := "user@example.com" 20 | 21 | expected := EmailJSON{ 22 | Email: email, 23 | } 24 | 25 | var result EmailJSON 26 | 27 | json.Unmarshal([]byte(encodeEmailToJSON(email)), &result) 28 | 29 | assert.Equal(t, expected, result) 30 | } 31 | 32 | func TestEmailUpdateRequestEncodeToJSON(t *testing.T) { 33 | t.Parallel() 34 | 35 | now := time.Unix(time.Now().Unix(), 0) 36 | 37 | request := EmailUpdateRequest{ 38 | Id: "1", 39 | UserId: "1", 40 | Email: "user@example.com", 41 | CreatedAt: now, 42 | ExpiresAt: now.Add(10 * time.Minute), 43 | Code: "12345678", 44 | } 45 | 46 | expected := EmailUpdateRequestJSON{ 47 | Id: request.Id, 48 | UserId: request.UserId, 49 | Email: request.Email, 50 | CreatedAtUnix: request.CreatedAt.Unix(), 51 | ExpiresAtUnix: request.ExpiresAt.Unix(), 52 | Code: request.Code, 53 | } 54 | 55 | var result EmailUpdateRequestJSON 56 | 57 | json.Unmarshal([]byte(request.EncodeToJSON()), &result) 58 | 59 | assert.Equal(t, expected, result) 60 | } 61 | 62 | func TestUserEmailVerificationRequestEncodeToJSON(t *testing.T) { 63 | t.Parallel() 64 | 65 | now := time.Unix(time.Now().Unix(), 0) 66 | 67 | request := UserEmailVerificationRequest{ 68 | UserId: "1", 69 | CreatedAt: now, 70 | ExpiresAt: now.Add(10 * time.Minute), 71 | Code: "12345678", 72 | } 73 | 74 | expected := UserEmailVerificationRequestJSON{ 75 | UserId: request.UserId, 76 | CreatedAtUnix: request.CreatedAt.Unix(), 77 | ExpiresAtUnix: request.ExpiresAt.Unix(), 78 | Code: request.Code, 79 | } 80 | 81 | var result UserEmailVerificationRequestJSON 82 | 83 | json.Unmarshal([]byte(request.EncodeToJSON()), &result) 84 | 85 | assert.Equal(t, expected, result) 86 | } 87 | 88 | type EmailJSON struct { 89 | Email string `json:"email"` 90 | } 91 | 92 | type EmailUpdateRequestJSON struct { 93 | Id string `json:"id"` 94 | UserId string `json:"user_id"` 95 | Email string `json:"email"` 96 | CreatedAtUnix int64 `json:"created_at"` 97 | ExpiresAtUnix int64 `json:"expires_at"` 98 | Code string `json:"code"` 99 | } 100 | 101 | type UserEmailVerificationRequestJSON struct { 102 | UserId string `json:"user_id"` 103 | CreatedAtUnix int64 `json:"created_at"` 104 | ExpiresAtUnix int64 `json:"expires_at"` 105 | Code string `json:"code"` 106 | } 107 | -------------------------------------------------------------------------------- /src/go.mod: -------------------------------------------------------------------------------- 1 | module faroe 2 | 3 | go 1.22.1 4 | 5 | require ( 6 | github.com/julienschmidt/httprouter v1.3.0 7 | github.com/stretchr/testify v1.9.0 8 | golang.org/x/crypto v0.28.0 9 | modernc.org/sqlite v1.33.1 10 | ) 11 | 12 | require ( 13 | github.com/davecgh/go-spew v1.1.1 // indirect 14 | github.com/dustin/go-humanize v1.0.1 // indirect 15 | github.com/google/uuid v1.6.0 // indirect 16 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 17 | github.com/mattn/go-isatty v0.0.20 // indirect 18 | github.com/ncruces/go-strftime v0.1.9 // indirect 19 | github.com/pmezard/go-difflib v1.0.0 // indirect 20 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 21 | golang.org/x/sys v0.26.0 // indirect 22 | gopkg.in/yaml.v3 v3.0.1 // indirect 23 | modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect 24 | modernc.org/libc v1.55.3 // indirect 25 | modernc.org/mathutil v1.6.0 // indirect 26 | modernc.org/memory v1.8.0 // indirect 27 | modernc.org/strutil v1.2.0 // indirect 28 | modernc.org/token v1.1.0 // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /src/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 4 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 5 | github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= 6 | github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= 7 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 8 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 9 | github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 10 | github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 11 | github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= 12 | github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= 13 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 14 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 15 | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= 16 | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 17 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 18 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 19 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 20 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 21 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 22 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 23 | golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= 24 | golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= 25 | golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= 26 | golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 27 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 28 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= 29 | golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 30 | golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= 31 | golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= 32 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 33 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 34 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 35 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 36 | modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= 37 | modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= 38 | modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= 39 | modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= 40 | modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= 41 | modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= 42 | modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= 43 | modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= 44 | modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= 45 | modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= 46 | modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= 47 | modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= 48 | modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= 49 | modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= 50 | modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= 51 | modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= 52 | modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= 53 | modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= 54 | modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= 55 | modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= 56 | modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM= 57 | modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= 58 | modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= 59 | modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= 60 | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 61 | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 62 | -------------------------------------------------------------------------------- /src/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/rand" 5 | "database/sql" 6 | "encoding/base32" 7 | "errors" 8 | "faroe/ratelimit" 9 | "flag" 10 | "fmt" 11 | "log" 12 | "net/http" 13 | "os" 14 | "path" 15 | "strings" 16 | "time" 17 | 18 | _ "embed" 19 | 20 | "github.com/julienschmidt/httprouter" 21 | _ "modernc.org/sqlite" 22 | ) 23 | 24 | const version = "0.1.0" 25 | 26 | //go:embed schema.sql 27 | var schema string 28 | 29 | func main() { 30 | for _, arg := range os.Args { 31 | if arg == "-v" || arg == "--version" { 32 | fmt.Printf("Faroe version %s\n", version) 33 | return 34 | } 35 | } 36 | 37 | if len(os.Args) < 2 { 38 | fmt.Print(`Usage: 39 | 40 | faroe serve - Start the Faroe server 41 | faroe generate-secret - Generate a secure secret 42 | 43 | `) 44 | return 45 | } 46 | 47 | if os.Args[1] == "serve" { 48 | serveCommand() 49 | return 50 | } 51 | if os.Args[1] == "generate-secret" { 52 | generateSecretCommand() 53 | return 54 | } 55 | 56 | flag.Parse() 57 | fmt.Println("Unknown command") 58 | } 59 | 60 | func generateSecretCommand() { 61 | // Remove "server" command since Go's flag package stops parsing at first non-flag argument. 62 | os.Args = os.Args[1:] 63 | flag.Parse() 64 | 65 | bytes := make([]byte, 25) 66 | _, err := rand.Read(bytes) 67 | if err != nil { 68 | log.Fatal("Failed to generate secret\n") 69 | } 70 | secret := base32.NewEncoding("abcdefghijkmnpqrstuvwxyz23456789").EncodeToString(bytes) 71 | fmt.Println(secret) 72 | } 73 | 74 | func serveCommand() { 75 | // Remove "server" command since Go's flag package stops parsing at first non-flag argument. 76 | os.Args = os.Args[1:] 77 | 78 | var port int 79 | var dataDir string 80 | var secretString string 81 | flag.IntVar(&port, "port", 4000, "Port number") 82 | flag.StringVar(&dataDir, "dir", "faroe_data", "Data directory name") 83 | flag.StringVar(&secretString, "secret", "", "Server secret") 84 | flag.Parse() 85 | 86 | secret := []byte(secretString) 87 | 88 | err := os.MkdirAll(dataDir, os.ModePerm) 89 | if err != nil { 90 | log.Fatal(err) 91 | } 92 | 93 | db, err := sql.Open("sqlite", path.Join(dataDir, "sqlite.db")) 94 | if err != nil { 95 | log.Fatal(err) 96 | } 97 | _, err = db.Exec("PRAGMA journal_mode=WAL;") 98 | if err != nil { 99 | log.Fatal(err) 100 | } 101 | _, err = db.Exec("PRAGMA busy_timeout=5000;") 102 | if err != nil { 103 | log.Fatal(err) 104 | } 105 | 106 | _, err = db.Exec(schema) 107 | if err != nil { 108 | log.Fatal(err) 109 | } 110 | 111 | go func() { 112 | for range time.Tick(10 * 24 * time.Hour) { 113 | err := cleanUpDatabase(db) 114 | if err != nil { 115 | log.Println(err) 116 | } 117 | } 118 | }() 119 | 120 | go func() { 121 | // for range time.Tick(1 * time.Hour) { 122 | // err = backupDatabase() 123 | // if err != nil { 124 | // log.Printf("SYSTEM DATABASE_BACKUP %e\n", err) 125 | // } else { 126 | // log.Println("SYSTEM DATABASE_BACKUP SUCCESS") 127 | // } 128 | // } 129 | }() 130 | 131 | passwordHashingIPRateLimit := ratelimit.NewTokenBucketRateLimit(5, 10*time.Second) 132 | loginIPRateLimit := ratelimit.NewExpiringTokenBucketRateLimit(5, 15*time.Minute) 133 | createEmailRequestUserRateLimit := ratelimit.NewTokenBucketRateLimit(3, 5*time.Minute) 134 | verifyUserEmailRateLimit := ratelimit.NewExpiringTokenBucketRateLimit(5, 15*time.Minute) 135 | verifyEmailUpdateVerificationCodeLimitCounter := ratelimit.NewLimitCounter(5) 136 | createPasswordResetIPRateLimit := ratelimit.NewTokenBucketRateLimit(3, 5*time.Minute) 137 | verifyPasswordResetCodeLimitCounter := ratelimit.NewLimitCounter(5) 138 | totpUserRateLimit := ratelimit.NewExpiringTokenBucketRateLimit(5, 15*time.Minute) 139 | recoveryCodeUserRateLimit := ratelimit.NewExpiringTokenBucketRateLimit(5, 15*time.Minute) 140 | 141 | go func() { 142 | for range time.Tick(10 * 24 * time.Hour) { 143 | passwordHashingIPRateLimit.Clear() 144 | loginIPRateLimit.Clear() 145 | createEmailRequestUserRateLimit.Clear() 146 | verifyUserEmailRateLimit.Clear() 147 | verifyEmailUpdateVerificationCodeLimitCounter.Clear() 148 | createPasswordResetIPRateLimit.Clear() 149 | verifyPasswordResetCodeLimitCounter.Clear() 150 | totpUserRateLimit.Clear() 151 | recoveryCodeUserRateLimit.Clear() 152 | log.Println("SYSTEM RESET_MEMORY_STORAGE") 153 | } 154 | }() 155 | 156 | env := &Environment{ 157 | db: db, 158 | secret: secret, 159 | passwordHashingIPRateLimit: passwordHashingIPRateLimit, 160 | loginIPRateLimit: loginIPRateLimit, 161 | createEmailRequestUserRateLimit: createEmailRequestUserRateLimit, 162 | verifyUserEmailRateLimit: verifyUserEmailRateLimit, 163 | verifyEmailUpdateVerificationCodeLimitCounter: verifyEmailUpdateVerificationCodeLimitCounter, 164 | createPasswordResetIPRateLimit: createPasswordResetIPRateLimit, 165 | verifyPasswordResetCodeLimitCounter: verifyPasswordResetCodeLimitCounter, 166 | totpUserRateLimit: totpUserRateLimit, 167 | recoveryCodeUserRateLimit: recoveryCodeUserRateLimit, 168 | } 169 | 170 | app := CreateApp(env) 171 | fmt.Printf("Starting server in port %d...\n", port) 172 | err = http.ListenAndServe(fmt.Sprintf(":%d", port), app) 173 | 174 | log.Println(err) 175 | } 176 | 177 | func writeExpectedErrorResponse(w http.ResponseWriter, message string) { 178 | w.Header().Set("Content-Type", "application/json") 179 | w.WriteHeader(400) 180 | escapedMessage := strings.ReplaceAll(string(message), "\"", "\\\"") 181 | w.Write([]byte(fmt.Sprintf("{\"error\":\"%s\"}", escapedMessage))) 182 | } 183 | 184 | func writeUnexpectedErrorResponse(w http.ResponseWriter) { 185 | w.Header().Set("Content-Type", "application/json") 186 | w.WriteHeader(500) 187 | w.Write([]byte("{\"error\":\"UNEXPECTED_ERROR\"}")) 188 | } 189 | 190 | func writeNotFoundErrorResponse(w http.ResponseWriter) { 191 | w.Header().Set("Content-Type", "application/json") 192 | w.WriteHeader(404) 193 | w.Write([]byte("{\"error\":\"NOT_FOUND\"}")) 194 | } 195 | 196 | func writeNotAcceptableErrorResponse(w http.ResponseWriter) { 197 | w.Header().Set("Content-Type", "application/json") 198 | w.WriteHeader(406) 199 | w.Write([]byte("{\"error\":\"NOT_ACCEPTABLE\"}")) 200 | } 201 | 202 | func writeUnsupportedMediaTypeErrorResponse(w http.ResponseWriter) { 203 | w.Header().Set("Content-Type", "application/json") 204 | w.WriteHeader(415) 205 | w.Write([]byte("{\"error\":\"UNSUPPORTED_MEDIA_TYPE\"}")) 206 | } 207 | 208 | func writeNotAuthenticatedErrorResponse(w http.ResponseWriter) { 209 | w.Header().Set("Content-Type", "application/json") 210 | w.WriteHeader(401) 211 | w.Write([]byte("{\"error\":\"NOT_AUTHENTICATED\"}")) 212 | } 213 | 214 | func generateId() (string, error) { 215 | bytes := make([]byte, 15) 216 | _, err := rand.Read(bytes) 217 | if err != nil { 218 | return "", err 219 | } 220 | id := base32.NewEncoding("abcdefghijkmnpqrstuvwxyz23456789").EncodeToString(bytes) 221 | return id, nil 222 | } 223 | 224 | type SortOrder int 225 | 226 | const ( 227 | SortOrderAscending SortOrder = iota 228 | SortOrderDescending 229 | ) 230 | 231 | const ( 232 | ExpectedErrorInvalidData = "INVALID_DATA" 233 | ExpectedErrorTooManyRequests = "TOO_MANY_REQUESTS" 234 | ExpectedErrorWeakPassword = "WEAK_PASSWORD" 235 | ExpectedErrorUserNotExists = "USER_NOT_EXISTS" 236 | ExpectedErrorIncorrectPassword = "INCORRECT_PASSWORD" 237 | ExpectedErrorIncorrectCode = "INCORRECT_CODE" 238 | ExpectedErrorSecondFactorNotVerified = "SECOND_FACTOR_NOT_VERIFIED" 239 | ExpectedErrorInvalidRequest = "INVALID_REQUEST" 240 | ExpectedErrorNotAllowed = "NOT_ALLOWED" 241 | ) 242 | 243 | var ErrRecordNotFound = errors.New("record not found") 244 | 245 | func NewRouter(env *Environment, defaultHandle RouteHandle) Router { 246 | router := Router{ 247 | r: &httprouter.Router{ 248 | NotFound: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 249 | defaultHandle(env, w, r, httprouter.Params{}) 250 | }), 251 | }, 252 | env: env, 253 | } 254 | return router 255 | } 256 | 257 | type Router struct { 258 | r *httprouter.Router 259 | env *Environment 260 | } 261 | 262 | func (router *Router) Handle(method string, path string, handle RouteHandle) { 263 | router.r.Handle(method, path, func(w http.ResponseWriter, r *http.Request, params httprouter.Params) { 264 | handle(router.env, w, r, params) 265 | }) 266 | } 267 | 268 | func (router *Router) Handler() http.Handler { 269 | return router.r 270 | } 271 | 272 | type RouteHandle = func(env *Environment, w http.ResponseWriter, r *http.Request, params httprouter.Params) 273 | 274 | type Environment struct { 275 | db *sql.DB 276 | secret []byte 277 | passwordHashingIPRateLimit ratelimit.TokenBucketRateLimit 278 | loginIPRateLimit ratelimit.ExpiringTokenBucketRateLimit 279 | createEmailRequestUserRateLimit ratelimit.TokenBucketRateLimit 280 | verifyUserEmailRateLimit ratelimit.ExpiringTokenBucketRateLimit 281 | verifyEmailUpdateVerificationCodeLimitCounter ratelimit.LimitCounter 282 | createPasswordResetIPRateLimit ratelimit.TokenBucketRateLimit 283 | verifyPasswordResetCodeLimitCounter ratelimit.LimitCounter 284 | totpUserRateLimit ratelimit.ExpiringTokenBucketRateLimit 285 | recoveryCodeUserRateLimit ratelimit.ExpiringTokenBucketRateLimit 286 | } 287 | 288 | func CreateApp(env *Environment) http.Handler { 289 | router := NewRouter(env, func(env *Environment, w http.ResponseWriter, r *http.Request, _ httprouter.Params) { 290 | if !verifyRequestSecret(env.secret, r) { 291 | writeNotAuthenticatedErrorResponse(w) 292 | } else { 293 | writeNotFoundErrorResponse(w) 294 | } 295 | }) 296 | 297 | router.Handle("GET", "/", func(env *Environment, w http.ResponseWriter, r *http.Request, _ httprouter.Params) { 298 | if !verifyRequestSecret(env.secret, r) { 299 | writeNotAuthenticatedErrorResponse(w) 300 | return 301 | } 302 | w.Write([]byte(fmt.Sprintf("Faroe version %s\n\nRead the documentation: https://faroe.dev\n", version))) 303 | }) 304 | 305 | router.Handle("POST", "/users", handleCreateUserRequest) 306 | router.Handle("GET", "/users", handleGetUsersRequest) 307 | router.Handle("DELETE", "/users", handleDeleteUsersRequest) 308 | router.Handle("GET", "/users/:user_id", handleGetUserRequest) 309 | router.Handle("DELETE", "/users/:user_id", handleDeleteUserRequest) 310 | router.Handle("POST", "/users/:user_id/verify-password", handleVerifyUserPasswordRequest) 311 | router.Handle("POST", "/users/:user_id/update-password", handleUpdateUserPasswordRequest) 312 | router.Handle("POST", "/users/:user_id/register-totp", handleRegisterTOTPRequest) 313 | router.Handle("GET", "/users/:user_id/totp-credential", handleGetUserTOTPCredentialRequest) 314 | router.Handle("DELETE", "/users/:user_id/totp-credential", handleDeleteUserTOTPCredentialRequest) 315 | router.Handle("POST", "/users/:user_id/verify-2fa/totp", handleVerifyTOTPRequest) 316 | router.Handle("POST", "/users/:user_id/reset-2fa", handleResetUser2FARequest) 317 | router.Handle("POST", "/users/:user_id/regenerate-recovery-code", handleRegenerateUserRecoveryCodeRequest) 318 | router.Handle("POST", "/users/:user_id/email-verification-request", handleCreateUserEmailVerificationRequestRequest) 319 | router.Handle("GET", "/users/:user_id/email-verification-request", handleGetUserEmailVerificationRequestRequest) 320 | router.Handle("DELETE", "/users/:user_id/email-verification-request", handleDeleteUserEmailVerificationRequestRequest) 321 | router.Handle("POST", "/users/:user_id/verify-email", handleVerifyUserEmailRequest) 322 | router.Handle("POST", "/users/:user_id/email-update-requests", handleCreateUserEmailUpdateRequestRequest) 323 | router.Handle("GET", "/users/:user_id/email-update-requests", handleGetUserEmailUpdateRequestsRequest) 324 | router.Handle("DELETE", "/users/:user_id/email-update-requests", handleDeleteUserEmailUpdateRequestsRequest) 325 | router.Handle("GET", "/email-update-requests/:request_id", handleGetEmailUpdateRequestRequest) 326 | router.Handle("DELETE", "/email-update-requests/:request_id", handleDeleteEmailUpdateRequestRequest) 327 | router.Handle("POST", "/verify-new-email", handleUpdateEmailRequest) 328 | router.Handle("POST", "/users/:user_id/password-reset-requests", handleCreateUserPasswordResetRequestRequest) 329 | router.Handle("GET", "/password-reset-requests/:request_id", handleGetPasswordResetRequestRequest) 330 | router.Handle("DELETE", "/password-reset-requests/:request_id", handleDeletePasswordResetRequestRequest) 331 | router.Handle("POST", "/password-reset-requests/:request_id/verify-email", handleVerifyPasswordResetRequestEmailRequest) 332 | router.Handle("GET", "/users/:user_id/password-reset-requests", handleGetUserPasswordResetRequestsRequest) 333 | router.Handle("DELETE", "/users/:user_id/password-reset-requests", handleDeleteUserPasswordResetRequestsRequest) 334 | router.Handle("POST", "/reset-password", handleResetPasswordRequest) 335 | 336 | return router.Handler() 337 | 338 | } 339 | -------------------------------------------------------------------------------- /src/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "faroe/ratelimit" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func initializeTestDB(t *testing.T) *sql.DB { 11 | db, err := sql.Open("sqlite", ":memory:") 12 | if err != nil { 13 | t.Fatal(err) 14 | } 15 | _, err = db.Exec(schema) 16 | if err != nil { 17 | db.Close() 18 | t.Fatal(err) 19 | } 20 | return db 21 | } 22 | 23 | func createEnvironment(db *sql.DB, secret []byte) *Environment { 24 | env := &Environment{ 25 | db: db, 26 | secret: secret, 27 | passwordHashingIPRateLimit: ratelimit.NewTokenBucketRateLimit(5, 10*time.Second), 28 | loginIPRateLimit: ratelimit.NewExpiringTokenBucketRateLimit(5, 15*time.Minute), 29 | createEmailRequestUserRateLimit: ratelimit.NewTokenBucketRateLimit(3, 5*time.Minute), 30 | verifyUserEmailRateLimit: ratelimit.NewExpiringTokenBucketRateLimit(5, 15*time.Minute), 31 | verifyEmailUpdateVerificationCodeLimitCounter: ratelimit.NewLimitCounter(5), 32 | createPasswordResetIPRateLimit: ratelimit.NewTokenBucketRateLimit(3, 5*time.Minute), 33 | verifyPasswordResetCodeLimitCounter: ratelimit.NewLimitCounter(5), 34 | totpUserRateLimit: ratelimit.NewExpiringTokenBucketRateLimit(5, 15*time.Minute), 35 | recoveryCodeUserRateLimit: ratelimit.NewExpiringTokenBucketRateLimit(5, 15*time.Minute), 36 | } 37 | return env 38 | } 39 | 40 | type ErrorJSON struct { 41 | Error string `json:"error"` 42 | } 43 | -------------------------------------------------------------------------------- /src/otp/main.go: -------------------------------------------------------------------------------- 1 | package otp 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/sha1" 6 | "crypto/subtle" 7 | "encoding/binary" 8 | "math" 9 | "strconv" 10 | "time" 11 | ) 12 | 13 | func GenerateTOTP(now time.Time, key []byte, interval time.Duration, digits int) string { 14 | counter := uint64(now.Unix()) / uint64(interval.Seconds()) 15 | return GenerateHOTP(key, counter, digits) 16 | } 17 | 18 | func VerifyTOTP(now time.Time, key []byte, interval time.Duration, digits int, otp string) bool { 19 | if len(otp) != digits { 20 | return false 21 | } 22 | generated := GenerateTOTP(now, key, interval, digits) 23 | valid := subtle.ConstantTimeCompare([]byte(generated), []byte(otp)) == 1 24 | return valid 25 | } 26 | 27 | func VerifyTOTPWithGracePeriod(now time.Time, key []byte, interval time.Duration, digits int, otp string, gracePeriod time.Duration) bool { 28 | counter1 := uint64(now.Add(-1*gracePeriod).Unix()) / uint64(interval.Seconds()) 29 | generated := GenerateHOTP(key, counter1, digits) 30 | valid := subtle.ConstantTimeCompare([]byte(generated), []byte(otp)) == 1 31 | if valid { 32 | return true 33 | } 34 | counter2 := uint64(now.Unix()) / uint64(interval.Seconds()) 35 | if counter2 != counter1 { 36 | generated = GenerateHOTP(key, counter2, digits) 37 | valid = subtle.ConstantTimeCompare([]byte(generated), []byte(otp)) == 1 38 | if valid { 39 | return true 40 | } 41 | } 42 | counter3 := uint64(now.Add(gracePeriod).Unix()) / uint64(interval.Seconds()) 43 | if counter3 != counter1 && counter3 != counter2 { 44 | generated = GenerateHOTP(key, counter3, digits) 45 | valid = subtle.ConstantTimeCompare([]byte(generated), []byte(otp)) == 1 46 | if valid { 47 | return true 48 | } 49 | } 50 | return false 51 | } 52 | 53 | func GenerateHOTP(key []byte, counter uint64, digits int) string { 54 | if digits < 6 || digits > 8 { 55 | panic("invalid totp digits") 56 | } 57 | counterBytes := make([]byte, 8) 58 | binary.BigEndian.PutUint64(counterBytes, counter) 59 | mac := hmac.New(sha1.New, key) 60 | mac.Write(counterBytes) 61 | hs := mac.Sum(nil) 62 | offset := hs[len(hs)-1] & 0x0f 63 | truncated := hs[offset : offset+4] 64 | truncated[0] &= 0x7f 65 | snum := binary.BigEndian.Uint32(truncated) 66 | d := snum % (uint32(math.Pow10(digits))) 67 | otp := strconv.Itoa(int(d)) 68 | for len(otp) < digits { 69 | otp = "0" + otp 70 | } 71 | return otp 72 | } 73 | 74 | func VerifyHOTP(key []byte, counter uint64, digits int, otp string) bool { 75 | return GenerateHOTP(key, counter, digits) == otp 76 | } 77 | -------------------------------------------------------------------------------- /src/otp/main_test.go: -------------------------------------------------------------------------------- 1 | package otp 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestGenerateHOTP(t *testing.T) { 9 | key := make([]byte, 20) 10 | for i := 0; i < len(key); i++ { 11 | key[i] = 0xff 12 | } 13 | tests := []struct { 14 | counter uint64 15 | expected string 16 | }{ 17 | { 18 | 0, "103905", 19 | }, 20 | { 21 | 1, "463444", 22 | }, 23 | { 24 | 10, "413510", 25 | }, 26 | { 27 | 100, "632126", 28 | }, 29 | { 30 | 10000, "529078", 31 | }, 32 | { 33 | 100000000, "818472", 34 | }, 35 | } 36 | 37 | for _, test := range tests { 38 | t.Run(fmt.Sprintf("Counter: %d", test.counter), func(t *testing.T) { 39 | result := GenerateHOTP(key, test.counter, 6) 40 | if result != test.expected { 41 | t.Errorf("got %s, expected %s", result, test.expected) 42 | } 43 | }) 44 | } 45 | } 46 | 47 | func TestVerifyHOTP(t *testing.T) { 48 | key := make([]byte, 20) 49 | for i := 0; i < len(key); i++ { 50 | key[i] = 0xff 51 | } 52 | validTests := []struct { 53 | counter uint64 54 | otp string 55 | }{ 56 | { 57 | 0, "103905", 58 | }, 59 | { 60 | 1, "463444", 61 | }, 62 | { 63 | 10, "413510", 64 | }, 65 | { 66 | 100, "632126", 67 | }, 68 | { 69 | 10000, "529078", 70 | }, 71 | { 72 | 100000000, "818472", 73 | }, 74 | } 75 | invlaidTests := []struct { 76 | counter uint64 77 | otp string 78 | }{ 79 | { 80 | 0, "103906", 81 | }, 82 | } 83 | 84 | for _, test := range validTests { 85 | t.Run(fmt.Sprintf("Counter: %d", test.counter), func(t *testing.T) { 86 | result := VerifyHOTP(key, test.counter, 6, test.otp) 87 | if !result { 88 | t.Error("got false, expected true") 89 | } 90 | }) 91 | } 92 | for _, test := range invlaidTests { 93 | t.Run(fmt.Sprintf("Counter: %d", test.counter), func(t *testing.T) { 94 | result := VerifyHOTP(key, test.counter, 6, test.otp) 95 | if result { 96 | t.Error("got true, expected false") 97 | } 98 | }) 99 | } 100 | 101 | } 102 | -------------------------------------------------------------------------------- /src/password-reset.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "encoding/json" 7 | "errors" 8 | "faroe/argon2id" 9 | "fmt" 10 | "io" 11 | "log" 12 | "net/http" 13 | "time" 14 | 15 | "github.com/julienschmidt/httprouter" 16 | ) 17 | 18 | func handleCreateUserPasswordResetRequestRequest(env *Environment, w http.ResponseWriter, r *http.Request, params httprouter.Params) { 19 | if !verifyRequestSecret(env.secret, r) { 20 | writeNotAuthenticatedErrorResponse(w) 21 | return 22 | } 23 | if !verifyJSONContentTypeHeader(r) { 24 | writeUnsupportedMediaTypeErrorResponse(w) 25 | return 26 | } 27 | if !verifyJSONAcceptHeader(r) { 28 | writeNotAcceptableErrorResponse(w) 29 | return 30 | } 31 | 32 | userId := params.ByName("user_id") 33 | userExists, err := checkUserExists(env.db, r.Context(), userId) 34 | if err != nil { 35 | log.Println(err) 36 | writeUnexpectedErrorResponse(w) 37 | return 38 | } 39 | if !userExists { 40 | writeNotFoundErrorResponse(w) 41 | return 42 | } 43 | 44 | body, err := io.ReadAll(r.Body) 45 | 46 | if err != nil { 47 | writeExpectedErrorResponse(w, ExpectedErrorInvalidData) 48 | return 49 | } 50 | if len(body) > 0 { 51 | var data struct { 52 | ClientIP string `json:"client_ip"` 53 | } 54 | 55 | err = json.Unmarshal(body, &data) 56 | if err != nil { 57 | writeExpectedErrorResponse(w, ExpectedErrorInvalidData) 58 | return 59 | } 60 | 61 | if data.ClientIP != "" && !env.passwordHashingIPRateLimit.Consume(data.ClientIP) { 62 | writeExpectedErrorResponse(w, ExpectedErrorTooManyRequests) 63 | return 64 | } 65 | if data.ClientIP != "" && !env.createPasswordResetIPRateLimit.Consume(data.ClientIP) { 66 | writeExpectedErrorResponse(w, ExpectedErrorTooManyRequests) 67 | return 68 | } 69 | } 70 | 71 | err = deleteExpiredUserPasswordResetRequests(env.db, r.Context(), userId) 72 | if err != nil { 73 | log.Println(err) 74 | writeUnexpectedErrorResponse(w) 75 | return 76 | } 77 | 78 | code, err := generateSecureCode() 79 | if err != nil { 80 | log.Println(err) 81 | writeUnexpectedErrorResponse(w) 82 | return 83 | } 84 | 85 | codeHash, err := argon2id.Hash(code) 86 | if err != nil { 87 | log.Println(err) 88 | writeUnexpectedErrorResponse(w) 89 | return 90 | } 91 | 92 | resetRequest, err := createPasswordResetRequest(env.db, r.Context(), userId, codeHash) 93 | if err != nil { 94 | log.Println(err) 95 | writeUnexpectedErrorResponse(w) 96 | return 97 | } 98 | 99 | w.Header().Set("Content-Type", "application/json") 100 | w.WriteHeader(200) 101 | w.Write([]byte(resetRequest.EncodeToJSONWithCode(code))) 102 | } 103 | 104 | func handleGetPasswordResetRequestRequest(env *Environment, w http.ResponseWriter, r *http.Request, params httprouter.Params) { 105 | if !verifyRequestSecret(env.secret, r) { 106 | writeNotAuthenticatedErrorResponse(w) 107 | return 108 | } 109 | if !verifyJSONAcceptHeader(r) { 110 | writeNotAcceptableErrorResponse(w) 111 | return 112 | } 113 | 114 | resetRequestId := params.ByName("request_id") 115 | resetRequest, err := getPasswordResetRequest(env.db, r.Context(), resetRequestId) 116 | if errors.Is(err, ErrRecordNotFound) { 117 | writeNotFoundErrorResponse(w) 118 | return 119 | } 120 | if err != nil { 121 | log.Println(err) 122 | writeUnexpectedErrorResponse(w) 123 | return 124 | } 125 | // If now is or after expiration 126 | if time.Now().Compare(resetRequest.ExpiresAt) >= 0 { 127 | err = deletePasswordResetRequest(env.db, r.Context(), resetRequest.Id) 128 | if err != nil { 129 | log.Println(err) 130 | writeUnexpectedErrorResponse(w) 131 | return 132 | } 133 | writeNotFoundErrorResponse(w) 134 | return 135 | } 136 | w.Header().Set("Content-Type", "application/json") 137 | w.WriteHeader(200) 138 | w.Write([]byte(resetRequest.EncodeToJSON())) 139 | } 140 | 141 | func handleVerifyPasswordResetRequestEmailRequest(env *Environment, w http.ResponseWriter, r *http.Request, params httprouter.Params) { 142 | if !verifyRequestSecret(env.secret, r) { 143 | writeNotAuthenticatedErrorResponse(w) 144 | return 145 | } 146 | if !verifyJSONContentTypeHeader(r) { 147 | writeUnsupportedMediaTypeErrorResponse(w) 148 | return 149 | } 150 | 151 | resetRequestId := params.ByName("request_id") 152 | resetRequest, err := getPasswordResetRequest(env.db, r.Context(), resetRequestId) 153 | if errors.Is(err, ErrRecordNotFound) { 154 | writeNotFoundErrorResponse(w) 155 | return 156 | } 157 | if err != nil { 158 | log.Println(err) 159 | writeUnexpectedErrorResponse(w) 160 | return 161 | } 162 | // If now is or after expiration 163 | if time.Now().Compare(resetRequest.ExpiresAt) >= 0 { 164 | err = deletePasswordResetRequest(env.db, r.Context(), resetRequest.Id) 165 | if err != nil { 166 | log.Println(err) 167 | writeUnexpectedErrorResponse(w) 168 | return 169 | } 170 | writeNotFoundErrorResponse(w) 171 | return 172 | } 173 | 174 | body, err := io.ReadAll(r.Body) 175 | if err != nil { 176 | writeExpectedErrorResponse(w, ExpectedErrorInvalidData) 177 | return 178 | } 179 | var data struct { 180 | Code *string `json:"code"` 181 | ClientIP string `json:"client_ip"` 182 | } 183 | err = json.Unmarshal(body, &data) 184 | if err != nil { 185 | writeExpectedErrorResponse(w, ExpectedErrorInvalidData) 186 | return 187 | } 188 | if data.Code == nil || *data.Code == "" { 189 | writeExpectedErrorResponse(w, ExpectedErrorInvalidData) 190 | return 191 | } 192 | if data.ClientIP != "" && !env.passwordHashingIPRateLimit.Consume(data.ClientIP) { 193 | writeExpectedErrorResponse(w, ExpectedErrorTooManyRequests) 194 | return 195 | } 196 | if !env.verifyPasswordResetCodeLimitCounter.Consume(resetRequest.Id) { 197 | err = deletePasswordResetRequest(env.db, r.Context(), resetRequest.Id) 198 | if err != nil { 199 | log.Println(err) 200 | writeUnexpectedErrorResponse(w) 201 | return 202 | } 203 | writeExpectedErrorResponse(w, ExpectedErrorTooManyRequests) 204 | return 205 | } 206 | validCode, err := argon2id.Verify(resetRequest.CodeHash, *data.Code) 207 | if err != nil { 208 | log.Println(err) 209 | writeUnexpectedErrorResponse(w) 210 | return 211 | } 212 | if !validCode { 213 | writeExpectedErrorResponse(w, ExpectedErrorIncorrectCode) 214 | return 215 | } 216 | 217 | w.WriteHeader(204) 218 | } 219 | 220 | func handleResetPasswordRequest(env *Environment, w http.ResponseWriter, r *http.Request, _ httprouter.Params) { 221 | if !verifyRequestSecret(env.secret, r) { 222 | writeNotAuthenticatedErrorResponse(w) 223 | return 224 | } 225 | if !verifyJSONContentTypeHeader(r) { 226 | writeUnsupportedMediaTypeErrorResponse(w) 227 | return 228 | } 229 | 230 | body, err := io.ReadAll(r.Body) 231 | if err != nil { 232 | log.Println(err) 233 | writeUnexpectedErrorResponse(w) 234 | return 235 | } 236 | var data struct { 237 | RequestId *string `json:"request_id"` 238 | Password *string `json:"password"` 239 | ClientIP string `json:"client_ip"` 240 | } 241 | err = json.Unmarshal(body, &data) 242 | if err != nil { 243 | writeExpectedErrorResponse(w, ExpectedErrorInvalidData) 244 | return 245 | } 246 | 247 | if data.RequestId == nil { 248 | writeExpectedErrorResponse(w, ExpectedErrorInvalidData) 249 | return 250 | } 251 | if data.Password == nil { 252 | writeExpectedErrorResponse(w, ExpectedErrorInvalidData) 253 | return 254 | } 255 | 256 | resetRequest, err := getPasswordResetRequest(env.db, r.Context(), *data.RequestId) 257 | if errors.Is(err, ErrRecordNotFound) { 258 | writeExpectedErrorResponse(w, ExpectedErrorInvalidRequest) 259 | return 260 | } 261 | if err != nil { 262 | writeUnexpectedErrorResponse(w) 263 | return 264 | } 265 | // If now is or after expiration 266 | if time.Now().Compare(resetRequest.ExpiresAt) >= 0 { 267 | err = deletePasswordResetRequest(env.db, r.Context(), resetRequest.Id) 268 | if err != nil { 269 | log.Println(err) 270 | writeUnexpectedErrorResponse(w) 271 | return 272 | } 273 | writeExpectedErrorResponse(w, ExpectedErrorInvalidRequest) 274 | return 275 | } 276 | 277 | password := *data.Password 278 | if len(password) > 127 { 279 | writeExpectedErrorResponse(w, ExpectedErrorInvalidData) 280 | return 281 | } 282 | strongPassword, err := verifyPasswordStrength(password) 283 | if err != nil { 284 | log.Println(err) 285 | writeUnexpectedErrorResponse(w) 286 | return 287 | } 288 | if !strongPassword { 289 | writeExpectedErrorResponse(w, ExpectedErrorWeakPassword) 290 | return 291 | } 292 | 293 | if data.ClientIP != "" && !env.passwordHashingIPRateLimit.Consume(data.ClientIP) { 294 | writeExpectedErrorResponse(w, ExpectedErrorTooManyRequests) 295 | return 296 | } 297 | passwordHash, err := argon2id.Hash(password) 298 | if err != nil { 299 | log.Println(err) 300 | writeUnexpectedErrorResponse(w) 301 | return 302 | } 303 | 304 | validResetRequest, err := resetUserPasswordWithPasswordResetRequest(env.db, r.Context(), resetRequest.Id, passwordHash) 305 | if err != nil { 306 | log.Println(err) 307 | writeUnexpectedErrorResponse(w) 308 | return 309 | } 310 | if !validResetRequest { 311 | writeExpectedErrorResponse(w, ExpectedErrorInvalidRequest) 312 | return 313 | } 314 | 315 | w.WriteHeader(204) 316 | } 317 | 318 | func handleDeletePasswordResetRequestRequest(env *Environment, w http.ResponseWriter, r *http.Request, params httprouter.Params) { 319 | if !verifyRequestSecret(env.secret, r) { 320 | writeNotAuthenticatedErrorResponse(w) 321 | return 322 | } 323 | if !verifyJSONAcceptHeader(r) { 324 | writeNotAcceptableErrorResponse(w) 325 | return 326 | } 327 | 328 | resetRequestId := params.ByName("request_id") 329 | resetRequest, err := getPasswordResetRequest(env.db, r.Context(), resetRequestId) 330 | if errors.Is(err, ErrRecordNotFound) { 331 | writeNotFoundErrorResponse(w) 332 | return 333 | } 334 | if err != nil { 335 | log.Println(err) 336 | writeUnexpectedErrorResponse(w) 337 | return 338 | } 339 | // If now is or after expiration 340 | if time.Now().Compare(resetRequest.ExpiresAt) >= 0 { 341 | err = deletePasswordResetRequest(env.db, r.Context(), resetRequest.Id) 342 | if err != nil { 343 | log.Println(err) 344 | writeUnexpectedErrorResponse(w) 345 | return 346 | } 347 | writeNotFoundErrorResponse(w) 348 | return 349 | } 350 | 351 | err = deletePasswordResetRequest(env.db, r.Context(), resetRequest.Id) 352 | if err != nil { 353 | log.Println(err) 354 | writeUnexpectedErrorResponse(w) 355 | return 356 | } 357 | 358 | w.WriteHeader(204) 359 | } 360 | 361 | func handleGetUserPasswordResetRequestsRequest(env *Environment, w http.ResponseWriter, r *http.Request, params httprouter.Params) { 362 | if !verifyRequestSecret(env.secret, r) { 363 | writeNotAuthenticatedErrorResponse(w) 364 | return 365 | } 366 | if !verifyJSONAcceptHeader(r) { 367 | writeNotAcceptableErrorResponse(w) 368 | return 369 | } 370 | 371 | userId := params.ByName("user_id") 372 | userExists, err := checkUserExists(env.db, r.Context(), userId) 373 | if err != nil { 374 | log.Println(err) 375 | writeUnexpectedErrorResponse(w) 376 | return 377 | } 378 | if !userExists { 379 | writeNotFoundErrorResponse(w) 380 | return 381 | } 382 | 383 | err = deleteExpiredUserPasswordResetRequests(env.db, r.Context(), userId) 384 | if err != nil { 385 | log.Println(err) 386 | writeUnexpectedErrorResponse(w) 387 | return 388 | } 389 | 390 | resetRequest, err := getUserPasswordResetRequests(env.db, r.Context(), userId) 391 | if err != nil { 392 | log.Println(err) 393 | writeUnexpectedErrorResponse(w) 394 | return 395 | } 396 | 397 | w.Header().Set("Content-Type", "application/json") 398 | w.WriteHeader(200) 399 | if len(resetRequest) == 0 { 400 | w.Write([]byte("[]")) 401 | return 402 | } 403 | w.Write([]byte("[")) 404 | for i, user := range resetRequest { 405 | w.Write([]byte(user.EncodeToJSON())) 406 | if i != len(resetRequest)-1 { 407 | w.Write([]byte(",")) 408 | } 409 | } 410 | w.Write([]byte("]")) 411 | } 412 | 413 | func handleDeleteUserPasswordResetRequestsRequest(env *Environment, w http.ResponseWriter, r *http.Request, params httprouter.Params) { 414 | if !verifyRequestSecret(env.secret, r) { 415 | writeNotAuthenticatedErrorResponse(w) 416 | return 417 | } 418 | if !verifyJSONAcceptHeader(r) { 419 | writeNotAcceptableErrorResponse(w) 420 | return 421 | } 422 | 423 | userId := params.ByName("user_id") 424 | userExists, err := checkUserExists(env.db, r.Context(), userId) 425 | if err != nil { 426 | log.Println(err) 427 | writeUnexpectedErrorResponse(w) 428 | return 429 | } 430 | if !userExists { 431 | writeNotFoundErrorResponse(w) 432 | return 433 | } 434 | 435 | err = deleteUserPasswordResetRequests(env.db, r.Context(), userId) 436 | if err != nil { 437 | log.Println(err) 438 | writeUnexpectedErrorResponse(w) 439 | return 440 | } 441 | w.WriteHeader(204) 442 | } 443 | 444 | func createPasswordResetRequest(db *sql.DB, ctx context.Context, userId string, codeHash string) (PasswordResetRequest, error) { 445 | now := time.Now() 446 | id, err := generateId() 447 | if err != nil { 448 | return PasswordResetRequest{}, nil 449 | } 450 | 451 | request := PasswordResetRequest{ 452 | Id: id, 453 | UserId: userId, 454 | CreatedAt: now, 455 | ExpiresAt: now.Add(10 * time.Minute), 456 | CodeHash: codeHash, 457 | } 458 | err = insertPasswordResetRequest(db, ctx, &request) 459 | if err != nil { 460 | return PasswordResetRequest{}, err 461 | } 462 | return request, nil 463 | } 464 | 465 | func insertPasswordResetRequest(db *sql.DB, ctx context.Context, request *PasswordResetRequest) error { 466 | _, err := db.ExecContext(ctx, "INSERT INTO password_reset_request (id, user_id, created_at, expires_at, code_hash) VALUES (?, ?, ?, ?, ?)", request.Id, request.UserId, request.CreatedAt.Unix(), request.ExpiresAt.Unix(), request.CodeHash) 467 | return err 468 | } 469 | 470 | func getPasswordResetRequest(db *sql.DB, ctx context.Context, requestId string) (PasswordResetRequest, error) { 471 | var request PasswordResetRequest 472 | var createdAtUnix, expiresAtUnix int64 473 | row := db.QueryRowContext(ctx, "SELECT id, user_id, created_at, code_hash, expires_at FROM password_reset_request WHERE id = ?", requestId) 474 | err := row.Scan(&request.Id, &request.UserId, &createdAtUnix, &request.CodeHash, &expiresAtUnix) 475 | if errors.Is(err, sql.ErrNoRows) { 476 | return PasswordResetRequest{}, ErrRecordNotFound 477 | } 478 | if err != nil { 479 | return PasswordResetRequest{}, err 480 | } 481 | request.CreatedAt = time.Unix(createdAtUnix, 0) 482 | request.ExpiresAt = time.Unix(expiresAtUnix, 0) 483 | return request, nil 484 | } 485 | 486 | func getUserPasswordResetRequests(db *sql.DB, ctx context.Context, requestId string) ([]PasswordResetRequest, error) { 487 | rows, err := db.QueryContext(ctx, "SELECT id, user_id, created_at, code_hash, expires_at FROM password_reset_request WHERE id = ?", requestId) 488 | if err != nil { 489 | return nil, err 490 | } 491 | var requests []PasswordResetRequest 492 | defer rows.Close() 493 | for rows.Next() { 494 | var request PasswordResetRequest 495 | var createdAtUnix, expiresAtUnix int64 496 | err := rows.Scan(&request.Id, &request.UserId, &createdAtUnix, &request.CodeHash, &expiresAtUnix) 497 | if err != nil { 498 | return nil, err 499 | } 500 | request.CreatedAt = time.Unix(createdAtUnix, 0) 501 | request.ExpiresAt = time.Unix(expiresAtUnix, 0) 502 | requests = append(requests, request) 503 | } 504 | return requests, nil 505 | } 506 | 507 | func resetUserPasswordWithPasswordResetRequest(db *sql.DB, ctx context.Context, requestId string, passwordHash string) (bool, error) { 508 | tx, err := db.BeginTx(ctx, nil) 509 | if err != nil { 510 | return false, err 511 | } 512 | var userId string 513 | err = tx.QueryRow("DELETE FROM password_reset_request WHERE id = ? AND expires_at > ? RETURNING user_id", requestId, time.Now().Unix()).Scan(&userId) 514 | if errors.Is(err, sql.ErrNoRows) { 515 | err = tx.Commit() 516 | if err != nil { 517 | tx.Rollback() 518 | return false, err 519 | } 520 | return false, nil 521 | } 522 | if err != nil { 523 | tx.Rollback() 524 | return false, err 525 | } 526 | _, err = tx.Exec("DELETE FROM password_reset_request WHERE user_id = ?", userId) 527 | if err != nil { 528 | tx.Rollback() 529 | return false, err 530 | } 531 | _, err = tx.Exec("UPDATE user SET password_hash = ? WHERE id = ?", passwordHash, userId) 532 | if err != nil { 533 | tx.Rollback() 534 | return false, err 535 | } 536 | tx.Commit() 537 | return true, nil 538 | } 539 | 540 | func deletePasswordResetRequest(db *sql.DB, ctx context.Context, requestId string) error { 541 | _, err := db.ExecContext(ctx, "DELETE FROM password_reset_request WHERE id = ?", requestId) 542 | return err 543 | } 544 | 545 | func deleteExpiredUserPasswordResetRequests(db *sql.DB, ctx context.Context, userId string) error { 546 | _, err := db.ExecContext(ctx, "DELETE FROM password_reset_request WHERE user_id = ? AND expires_at <= ?", userId, time.Now().Unix()) 547 | return err 548 | } 549 | 550 | func deleteUserPasswordResetRequests(db *sql.DB, ctx context.Context, userId string) error { 551 | _, err := db.ExecContext(ctx, "DELETE FROM password_reset_request WHERE user_id = ?", userId) 552 | return err 553 | } 554 | 555 | type PasswordResetRequest struct { 556 | Id string 557 | UserId string 558 | CreatedAt time.Time 559 | ExpiresAt time.Time 560 | CodeHash string 561 | } 562 | 563 | func (r *PasswordResetRequest) EncodeToJSON() string { 564 | encoded := fmt.Sprintf("{\"id\":\"%s\",\"user_id\":\"%s\",\"created_at\":%d,\"expires_at\":%d}", r.Id, r.UserId, r.CreatedAt.Unix(), r.ExpiresAt.Unix()) 565 | return encoded 566 | } 567 | 568 | func (r *PasswordResetRequest) EncodeToJSONWithCode(code string) string { 569 | encoded := fmt.Sprintf("{\"id\":\"%s\",\"user_id\":\"%s\",\"created_at\":%d,\"expires_at\":%d,\"code\":\"%s\"}", r.Id, r.UserId, r.CreatedAt.Unix(), r.ExpiresAt.Unix(), code) 570 | return encoded 571 | } 572 | -------------------------------------------------------------------------------- /src/password-reset_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestPasswordResetRequestEncodeToJSON(t *testing.T) { 12 | t.Parallel() 13 | 14 | now := time.Unix(time.Now().Unix(), 0) 15 | 16 | request := PasswordResetRequest{ 17 | Id: "1", 18 | UserId: "1", 19 | CreatedAt: now, 20 | ExpiresAt: now.Add(10 * time.Minute), 21 | CodeHash: "HASH1", 22 | } 23 | 24 | expected := PasswordResetRequestJSON{ 25 | Id: request.Id, 26 | UserId: request.UserId, 27 | CreatedAtUnix: request.CreatedAt.Unix(), 28 | ExpiresAtUnix: request.ExpiresAt.Unix(), 29 | } 30 | 31 | var result PasswordResetRequestJSON 32 | 33 | json.Unmarshal([]byte(request.EncodeToJSON()), &result) 34 | 35 | assert.Equal(t, expected, result) 36 | } 37 | 38 | func TestPasswordResetRequestEncodeToJSONWithCode(t *testing.T) { 39 | t.Parallel() 40 | 41 | now := time.Unix(time.Now().Unix(), 0) 42 | 43 | code := "12345678" 44 | request := PasswordResetRequest{ 45 | Id: "1", 46 | UserId: "1", 47 | CreatedAt: now, 48 | ExpiresAt: now.Add(10 * time.Minute), 49 | CodeHash: "HASH1", 50 | } 51 | 52 | expected := PasswordResetRequestWithCodeJSON{ 53 | Id: request.Id, 54 | UserId: request.UserId, 55 | CreatedAtUnix: request.CreatedAt.Unix(), 56 | ExpiresAtUnix: request.ExpiresAt.Unix(), 57 | Code: code, 58 | } 59 | 60 | var result PasswordResetRequestWithCodeJSON 61 | 62 | json.Unmarshal([]byte(request.EncodeToJSONWithCode(code)), &result) 63 | 64 | assert.Equal(t, expected, result) 65 | } 66 | 67 | type PasswordResetRequestJSON struct { 68 | Id string `json:"id"` 69 | UserId string `json:"user_id"` 70 | CreatedAtUnix int64 `json:"created_at"` 71 | ExpiresAtUnix int64 `json:"expires_at"` 72 | } 73 | 74 | type PasswordResetRequestWithCodeJSON struct { 75 | Id string `json:"id"` 76 | UserId string `json:"user_id"` 77 | CreatedAtUnix int64 `json:"created_at"` 78 | ExpiresAtUnix int64 `json:"expires_at"` 79 | Code string `json:"code"` 80 | } 81 | -------------------------------------------------------------------------------- /src/ratelimit/counter.go: -------------------------------------------------------------------------------- 1 | package ratelimit 2 | 3 | import "sync" 4 | 5 | func NewLimitCounter(max int) LimitCounter { 6 | counter := LimitCounter{ 7 | mu: &sync.Mutex{}, 8 | storage: map[string]int{}, 9 | max: max, 10 | } 11 | return counter 12 | } 13 | 14 | type LimitCounter struct { 15 | mu *sync.Mutex 16 | storage map[string]int 17 | max int 18 | } 19 | 20 | func (lc *LimitCounter) Consume(key string) bool { 21 | lc.mu.Lock() 22 | defer lc.mu.Unlock() 23 | if lc.storage[key] < lc.max { 24 | lc.storage[key]++ 25 | return true 26 | } 27 | delete(lc.storage, key) 28 | return false 29 | } 30 | 31 | func (lc *LimitCounter) Delete(key string) { 32 | lc.mu.Lock() 33 | delete(lc.storage, key) 34 | lc.mu.Unlock() 35 | } 36 | 37 | func (lc *LimitCounter) Clear() { 38 | lc.mu.Lock() 39 | size := len(lc.storage) 40 | lc.storage = make(map[string]int, size/2) 41 | lc.mu.Unlock() 42 | } 43 | -------------------------------------------------------------------------------- /src/ratelimit/token-bucket.go: -------------------------------------------------------------------------------- 1 | package ratelimit 2 | 3 | import ( 4 | "math" 5 | "sync" 6 | "time" 7 | ) 8 | 9 | func NewTokenBucketRateLimit(max int, refillInterval time.Duration) TokenBucketRateLimit { 10 | ratelimit := TokenBucketRateLimit{ 11 | mu: &sync.Mutex{}, 12 | storage: map[string]refillingTokenBucket{}, 13 | max: max, 14 | refillIntervalMilliseconds: refillInterval.Milliseconds(), 15 | } 16 | return ratelimit 17 | } 18 | 19 | type TokenBucketRateLimit struct { 20 | mu *sync.Mutex 21 | storage map[string]refillingTokenBucket 22 | max int 23 | refillIntervalMilliseconds int64 24 | } 25 | 26 | func (rl *TokenBucketRateLimit) Check(key string) bool { 27 | rl.mu.Lock() 28 | defer rl.mu.Unlock() 29 | if _, ok := rl.storage[key]; !ok { 30 | return true 31 | } 32 | now := time.Now() 33 | refill := int((now.UnixMilli() - rl.storage[key].refilledAtUnixMilliseconds) / rl.refillIntervalMilliseconds) 34 | count := int(math.Min(float64(rl.storage[key].count+refill), float64(rl.max))) 35 | return count > 0 36 | } 37 | 38 | func (rl *TokenBucketRateLimit) Consume(key string) bool { 39 | rl.mu.Lock() 40 | defer rl.mu.Unlock() 41 | now := time.Now() 42 | if _, ok := rl.storage[key]; !ok { 43 | rl.storage[key] = refillingTokenBucket{rl.max - 1, now.UnixMilli()} 44 | return true 45 | } 46 | refill := int((now.UnixMilli() - rl.storage[key].refilledAtUnixMilliseconds) / rl.refillIntervalMilliseconds) 47 | count := int(math.Min(float64(rl.storage[key].count+refill), float64(rl.max))) 48 | if count < 1 { 49 | return false 50 | } 51 | rl.storage[key] = refillingTokenBucket{count - 1, now.UnixMilli()} 52 | return true 53 | } 54 | 55 | func (rl *TokenBucketRateLimit) AddTokenIfEmpty(key string) { 56 | rl.mu.Lock() 57 | defer rl.mu.Unlock() 58 | bucket, ok := rl.storage[key] 59 | if !ok { 60 | return 61 | } 62 | now := time.Now() 63 | refill := int((now.UnixMilli() - bucket.refilledAtUnixMilliseconds) / rl.refillIntervalMilliseconds) 64 | count := int(math.Min(float64(bucket.count+refill), float64(rl.max))) 65 | if count < 1 { 66 | rl.storage[key] = refillingTokenBucket{1, now.UnixMilli()} 67 | } 68 | } 69 | 70 | func (rl *TokenBucketRateLimit) Reset(key string) { 71 | rl.mu.Lock() 72 | delete(rl.storage, key) 73 | rl.mu.Unlock() 74 | } 75 | 76 | func (rl *TokenBucketRateLimit) Clear() { 77 | rl.mu.Lock() 78 | size := len(rl.storage) 79 | rl.storage = make(map[string]refillingTokenBucket, size/2) 80 | rl.mu.Unlock() 81 | } 82 | 83 | type refillingTokenBucket struct { 84 | count int 85 | refilledAtUnixMilliseconds int64 86 | } 87 | 88 | func NewExpiringTokenBucketRateLimit(max int, expiresIn time.Duration) ExpiringTokenBucketRateLimit { 89 | ratelimit := ExpiringTokenBucketRateLimit{ 90 | mu: &sync.Mutex{}, 91 | storage: map[string]expiringTokenBucket{}, 92 | max: max, 93 | expiresInMilliseconds: expiresIn.Milliseconds(), 94 | } 95 | return ratelimit 96 | } 97 | 98 | type ExpiringTokenBucketRateLimit struct { 99 | mu *sync.Mutex 100 | storage map[string]expiringTokenBucket 101 | max int 102 | expiresInMilliseconds int64 103 | } 104 | 105 | func (rl *ExpiringTokenBucketRateLimit) Check(key string) bool { 106 | rl.mu.Lock() 107 | defer rl.mu.Unlock() 108 | now := time.Now() 109 | if _, ok := rl.storage[key]; !ok { 110 | return true 111 | } 112 | expiresAtMilliseconds := rl.storage[key].createdAtUnixMilliseconds + rl.expiresInMilliseconds 113 | if now.UnixMilli() >= expiresAtMilliseconds { 114 | return true 115 | } 116 | return rl.storage[key].count > 0 117 | } 118 | 119 | func (rl *ExpiringTokenBucketRateLimit) Consume(key string) bool { 120 | rl.mu.Lock() 121 | defer rl.mu.Unlock() 122 | now := time.Now() 123 | if _, ok := rl.storage[key]; !ok { 124 | rl.storage[key] = expiringTokenBucket{rl.max - 1, now.UnixMilli()} 125 | return true 126 | } 127 | expiresAtMilliseconds := rl.storage[key].createdAtUnixMilliseconds + rl.expiresInMilliseconds 128 | if now.UnixMilli() >= expiresAtMilliseconds { 129 | rl.storage[key] = expiringTokenBucket{rl.max - 1, now.UnixMilli()} 130 | return true 131 | } 132 | if rl.storage[key].count < 1 { 133 | return false 134 | } 135 | rl.storage[key] = expiringTokenBucket{rl.storage[key].count - 1, now.UnixMilli()} 136 | return true 137 | } 138 | 139 | func (rl *ExpiringTokenBucketRateLimit) AddTokenIfEmpty(key string) { 140 | rl.mu.Lock() 141 | defer rl.mu.Unlock() 142 | 143 | bucket, ok := rl.storage[key] 144 | if !ok { 145 | return 146 | } 147 | count := int(math.Max(float64(bucket.count), 1)) 148 | rl.storage[key] = expiringTokenBucket{count, bucket.createdAtUnixMilliseconds} 149 | } 150 | 151 | func (rl *ExpiringTokenBucketRateLimit) Reset(key string) { 152 | rl.mu.Lock() 153 | delete(rl.storage, key) 154 | rl.mu.Unlock() 155 | } 156 | 157 | func (rl *ExpiringTokenBucketRateLimit) Clear() { 158 | rl.mu.Lock() 159 | size := len(rl.storage) 160 | rl.storage = make(map[string]expiringTokenBucket, size/2) 161 | rl.mu.Unlock() 162 | } 163 | 164 | type expiringTokenBucket struct { 165 | count int 166 | createdAtUnixMilliseconds int64 167 | } 168 | -------------------------------------------------------------------------------- /src/request.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/subtle" 5 | "mime" 6 | "net/http" 7 | "strings" 8 | ) 9 | 10 | func verifyRequestSecret(secret []byte, r *http.Request) bool { 11 | if len(secret) == 0 { 12 | return true 13 | } 14 | authorizationHeader, ok := r.Header["Authorization"] 15 | if !ok { 16 | return false 17 | } 18 | return subtle.ConstantTimeCompare(secret, []byte(authorizationHeader[0])) == 1 19 | } 20 | 21 | func verifyJSONContentTypeHeader(r *http.Request) bool { 22 | contentType, ok := r.Header["Content-Type"] 23 | if !ok { 24 | return true 25 | } 26 | mediatype, _, err := mime.ParseMediaType(contentType[0]) 27 | if err != nil { 28 | return false 29 | } 30 | return mediatype == "application/json" || mediatype == "text/plain" 31 | } 32 | 33 | func verifyJSONAcceptHeader(r *http.Request) bool { 34 | accept, ok := r.Header["Accept"] 35 | if !ok { 36 | return true 37 | } 38 | entries := strings.Split(accept[0], ",") 39 | for _, entry := range entries { 40 | entry = strings.TrimSpace(entry) 41 | parts := strings.Split(entry, ";") 42 | mediaType := strings.TrimSpace(parts[0]) 43 | if mediaType == "*/*" || mediaType == "application/*" || mediaType == "application/json" { 44 | return true 45 | } 46 | } 47 | return false 48 | } 49 | 50 | func parseJSONOrTextAcceptHeader(r *http.Request) (ContentType, bool) { 51 | accept, ok := r.Header["Accept"] 52 | if !ok { 53 | return ContentTypeJSON, true 54 | } 55 | entries := strings.Split(accept[0], ",") 56 | for _, entry := range entries { 57 | entry = strings.TrimSpace(entry) 58 | parts := strings.Split(entry, ";") 59 | mediaType := strings.TrimSpace(parts[0]) 60 | if mediaType == "*/*" || mediaType == "application/*" || mediaType == "application/json" { 61 | return ContentTypeJSON, true 62 | } 63 | if mediaType == "text/plain" { 64 | return ContentTypePlainText, true 65 | } 66 | } 67 | return ContentTypeJSON, false 68 | } 69 | 70 | type ContentType = int 71 | 72 | const ( 73 | ContentTypeJSON ContentType = iota 74 | ContentTypePlainText 75 | ) 76 | -------------------------------------------------------------------------------- /src/request_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http/httptest" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestVerifyRequestSecret(t *testing.T) { 11 | r := httptest.NewRequest("GET", "/", nil) 12 | r.Header.Set("Authorization", "abc") 13 | assert.Equal(t, true, verifyRequestSecret([]byte{}, r)) 14 | 15 | r = httptest.NewRequest("GET", "/", nil) 16 | r.Header.Set("Authorization", "") 17 | assert.Equal(t, true, verifyRequestSecret([]byte{}, r)) 18 | 19 | r = httptest.NewRequest("GET", "/", nil) 20 | r.Header.Set("Authorization", "abc") 21 | assert.Equal(t, true, verifyRequestSecret([]byte("abc"), r)) 22 | 23 | r = httptest.NewRequest("GET", "/", nil) 24 | r.Header.Set("Authorization", "") 25 | assert.Equal(t, false, verifyRequestSecret([]byte("abc"), r)) 26 | 27 | r = httptest.NewRequest("GET", "/", nil) 28 | assert.Equal(t, false, verifyRequestSecret([]byte("abc"), r)) 29 | } 30 | -------------------------------------------------------------------------------- /src/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS user ( 2 | id TEXT NOT NULL PRIMARY KEY, 3 | created_at INTEGER NOT NULL, 4 | password_hash TEXT NOT NULL, 5 | recovery_code TEXT NOT NULL 6 | ) STRICT; 7 | 8 | CREATE TABLE IF NOT EXISTS user_email_verification_request ( 9 | user_id TEXT NOT NULL UNIQUE PRIMARY KEY REFERENCES user(id), 10 | created_at INTEGER NOT NULL, 11 | expires_at INTEGER NOT NULL, 12 | code TEXT NOT NULL 13 | ) STRICT; 14 | 15 | CREATE TABLE IF NOT EXISTS email_update_request ( 16 | id TEXT NOT NULL PRIMARY KEY, 17 | user_id TEXT NOT NULL REFERENCES user(id), 18 | created_at INTEGER NOT NULL, 19 | expires_at INTEGER NOT NULL, 20 | email TEXT NOT NULL, 21 | code TEXT NOT NULL 22 | ) STRICT; 23 | 24 | CREATE INDEX IF NOT EXISTS email_update_request_user_id_index ON email_update_request(user_id); 25 | 26 | CREATE TABLE IF NOT EXISTS password_reset_request ( 27 | id TEXT NOT NULL PRIMARY KEY, 28 | user_id TEXT NOT NULL REFERENCES user(id), 29 | created_at INTEGER NOT NULL, 30 | expires_at INTEGER NOT NULL, 31 | code_hash TEXT NOT NULL 32 | ) STRICT; 33 | 34 | CREATE INDEX IF NOT EXISTS password_reset_request_user_id_index ON password_reset_request(user_id); 35 | 36 | CREATE TABLE IF NOT EXISTS user_totp_credential ( 37 | user_id TEXT NOT NULL PRIMARY KEY REFERENCES user(id), 38 | created_at INTEGER NOT NULL, 39 | key BLOB NULL 40 | ) STRICT; 41 | 42 | CREATE TABLE IF NOT EXISTS passkey_credential ( 43 | id TEXT NOT NULL, 44 | user_id TEXT NOT NULL REFERENCES user(id), 45 | name TEXT NOT NULL, 46 | created_at INTEGER NOT NULL, 47 | cose_algorithm_id INTEGER NOT NULL, 48 | public_key BLOB NULL 49 | ) STRICT; 50 | 51 | CREATE INDEX IF NOT EXISTS passkey_credential_user_id_index ON passkey_credential(user_id); 52 | 53 | CREATE TABLE IF NOT EXISTS security_key ( 54 | id TEXT NOT NULL, 55 | user_id TEXT NOT NULL REFERENCES user(id), 56 | name TEXT NOT NULL, 57 | created_at INTEGER NOT NULL, 58 | cose_algorithm_id INTEGER NOT NULL, 59 | public_key BLOB NULL 60 | ) STRICT; 61 | 62 | CREATE INDEX IF NOT EXISTS security_key_user_id_index ON security_key(user_id); 63 | -------------------------------------------------------------------------------- /src/strings.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func padEnd(s string, n int) string { 4 | for len(s) < n { 5 | s += " " 6 | } 7 | return s 8 | } 9 | -------------------------------------------------------------------------------- /src/totp.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "encoding/base64" 7 | "encoding/json" 8 | "errors" 9 | "faroe/otp" 10 | "fmt" 11 | "io" 12 | "log" 13 | "net/http" 14 | "time" 15 | 16 | "github.com/julienschmidt/httprouter" 17 | ) 18 | 19 | func handleRegisterTOTPRequest(env *Environment, w http.ResponseWriter, r *http.Request, params httprouter.Params) { 20 | if !verifyRequestSecret(env.secret, r) { 21 | writeNotAuthenticatedErrorResponse(w) 22 | return 23 | } 24 | if !verifyJSONContentTypeHeader(r) { 25 | writeUnsupportedMediaTypeErrorResponse(w) 26 | return 27 | } 28 | 29 | userId := params.ByName("user_id") 30 | userExists, err := checkUserExists(env.db, r.Context(), userId) 31 | if err != nil { 32 | log.Println(err) 33 | writeUnexpectedErrorResponse(w) 34 | return 35 | } 36 | if !userExists { 37 | writeNotFoundErrorResponse(w) 38 | return 39 | } 40 | 41 | body, err := io.ReadAll(r.Body) 42 | if err != nil { 43 | log.Println(err) 44 | writeUnexpectedErrorResponse(w) 45 | return 46 | } 47 | var data struct { 48 | Key *string `json:"key"` 49 | Code *string `json:"code"` 50 | } 51 | err = json.Unmarshal(body, &data) 52 | if err != nil { 53 | writeExpectedErrorResponse(w, ExpectedErrorInvalidData) 54 | return 55 | } 56 | if data.Key == nil { 57 | writeExpectedErrorResponse(w, ExpectedErrorInvalidData) 58 | return 59 | } 60 | key, err := base64.StdEncoding.DecodeString(*data.Key) 61 | if err != nil { 62 | writeExpectedErrorResponse(w, ExpectedErrorInvalidData) 63 | return 64 | } 65 | if len(key) != 20 { 66 | writeExpectedErrorResponse(w, ExpectedErrorInvalidData) 67 | return 68 | } 69 | 70 | if data.Code == nil || *data.Code == "" { 71 | writeExpectedErrorResponse(w, ExpectedErrorInvalidData) 72 | return 73 | } 74 | validCode := otp.VerifyTOTPWithGracePeriod(time.Now(), key, 30*time.Second, 6, *data.Code, 10*time.Second) 75 | if !validCode { 76 | writeExpectedErrorResponse(w, ExpectedErrorIncorrectCode) 77 | return 78 | } 79 | 80 | credential, err := registerUserTOTPCredential(env.db, r.Context(), userId, key) 81 | if errors.Is(err, ErrRecordNotFound) { 82 | writeNotFoundErrorResponse(w) 83 | return 84 | } 85 | if err != nil { 86 | log.Println(err) 87 | writeUnexpectedErrorResponse(w) 88 | return 89 | } 90 | 91 | w.Header().Set("Content-Type", "application/json") 92 | w.WriteHeader(200) 93 | w.Write([]byte(credential.EncodeToJSON())) 94 | } 95 | 96 | func handleVerifyTOTPRequest(env *Environment, w http.ResponseWriter, r *http.Request, params httprouter.Params) { 97 | if !verifyRequestSecret(env.secret, r) { 98 | writeNotAuthenticatedErrorResponse(w) 99 | return 100 | } 101 | if !verifyJSONContentTypeHeader(r) { 102 | writeUnsupportedMediaTypeErrorResponse(w) 103 | return 104 | } 105 | 106 | userId := params.ByName("user_id") 107 | userExists, err := checkUserExists(env.db, r.Context(), userId) 108 | if err != nil { 109 | log.Println(err) 110 | writeUnexpectedErrorResponse(w) 111 | return 112 | } 113 | if !userExists { 114 | writeNotFoundErrorResponse(w) 115 | return 116 | } 117 | 118 | credential, err := getUserTOTPCredential(env.db, r.Context(), userId) 119 | if errors.Is(err, ErrRecordNotFound) { 120 | writeExpectedErrorResponse(w, ExpectedErrorNotAllowed) 121 | return 122 | } 123 | if err != nil { 124 | log.Println(err) 125 | writeUnexpectedErrorResponse(w) 126 | return 127 | } 128 | 129 | body, err := io.ReadAll(r.Body) 130 | if err != nil { 131 | log.Println(err) 132 | writeUnexpectedErrorResponse(w) 133 | return 134 | } 135 | var data struct { 136 | Code *string `json:"code"` 137 | } 138 | err = json.Unmarshal(body, &data) 139 | if err != nil { 140 | writeExpectedErrorResponse(w, ExpectedErrorInvalidData) 141 | return 142 | } 143 | if data.Code == nil || *data.Code == "" { 144 | writeExpectedErrorResponse(w, ExpectedErrorInvalidData) 145 | return 146 | } 147 | if !env.totpUserRateLimit.Consume(userId) { 148 | writeExpectedErrorResponse(w, ExpectedErrorTooManyRequests) 149 | return 150 | } 151 | valid := otp.VerifyTOTPWithGracePeriod(time.Now(), credential.Key, 30*time.Second, 6, *data.Code, 10*time.Second) 152 | if !valid { 153 | writeExpectedErrorResponse(w, ExpectedErrorIncorrectCode) 154 | return 155 | } 156 | env.totpUserRateLimit.Reset(userId) 157 | 158 | w.WriteHeader(204) 159 | } 160 | 161 | func handleDeleteUserTOTPCredentialRequest(env *Environment, w http.ResponseWriter, r *http.Request, params httprouter.Params) { 162 | if !verifyRequestSecret(env.secret, r) { 163 | writeNotAuthenticatedErrorResponse(w) 164 | return 165 | } 166 | 167 | userId := params.ByName("user_id") 168 | _, err := getUserTOTPCredential(env.db, r.Context(), userId) 169 | if errors.Is(err, ErrRecordNotFound) { 170 | writeNotFoundErrorResponse(w) 171 | return 172 | } 173 | if err != nil { 174 | log.Println(err) 175 | writeUnexpectedErrorResponse(w) 176 | return 177 | } 178 | 179 | err = deleteUserTOTPCredential(env.db, r.Context(), userId) 180 | if err != nil { 181 | log.Println(err) 182 | writeUnexpectedErrorResponse(w) 183 | return 184 | } 185 | 186 | w.WriteHeader(204) 187 | } 188 | 189 | func handleGetUserTOTPCredentialRequest(env *Environment, w http.ResponseWriter, r *http.Request, params httprouter.Params) { 190 | if !verifyRequestSecret(env.secret, r) { 191 | writeNotAuthenticatedErrorResponse(w) 192 | return 193 | } 194 | if !verifyJSONAcceptHeader(r) { 195 | writeUnsupportedMediaTypeErrorResponse(w) 196 | return 197 | } 198 | userId := params.ByName("user_id") 199 | credential, err := getUserTOTPCredential(env.db, r.Context(), userId) 200 | if errors.Is(err, ErrRecordNotFound) { 201 | writeNotFoundErrorResponse(w) 202 | return 203 | } 204 | w.Header().Set("Content-Type", "application/json") 205 | w.WriteHeader(200) 206 | w.Write([]byte(credential.EncodeToJSON())) 207 | } 208 | 209 | func getUserTOTPCredential(db *sql.DB, ctx context.Context, userId string) (UserTOTPCredential, error) { 210 | var credential UserTOTPCredential 211 | var createdAtUnix int64 212 | row := db.QueryRowContext(ctx, "SELECT user_id, created_at, key FROM user_totp_credential WHERE user_id = ?", userId) 213 | err := row.Scan(&credential.UserId, &createdAtUnix, &credential.Key) 214 | if errors.Is(err, sql.ErrNoRows) { 215 | return UserTOTPCredential{}, ErrRecordNotFound 216 | } 217 | credential.CreatedAt = time.Unix(createdAtUnix, 0) 218 | return credential, nil 219 | } 220 | 221 | func registerUserTOTPCredential(db *sql.DB, ctx context.Context, userId string, key []byte) (UserTOTPCredential, error) { 222 | credential := UserTOTPCredential{ 223 | UserId: userId, 224 | CreatedAt: time.Unix(time.Now().Unix(), 0), 225 | Key: key, 226 | } 227 | _, err := db.ExecContext(ctx, `INSERT INTO user_totp_credential (user_id, created_at, key) VALUES (?, ?, ?) 228 | ON CONFLICT (user_id) DO UPDATE SET created_at = ?, key = ? WHERE user_id = ?`, credential.UserId, credential.CreatedAt.Unix(), credential.Key, credential.CreatedAt.Unix(), credential.Key, credential.UserId) 229 | if err != nil { 230 | return UserTOTPCredential{}, err 231 | } 232 | return credential, nil 233 | } 234 | 235 | func deleteUserTOTPCredential(db *sql.DB, ctx context.Context, userId string) error { 236 | _, err := db.ExecContext(ctx, "DELETE FROM user_totp_credential WHERE user_id = ?", userId) 237 | return err 238 | } 239 | 240 | type UserTOTPCredential struct { 241 | UserId string 242 | CreatedAt time.Time 243 | Key []byte 244 | } 245 | 246 | func (c *UserTOTPCredential) EncodeToJSON() string { 247 | encoded := fmt.Sprintf("{\"user_id\":\"%s\",\"created_at\":%d,\"key\":\"%s\"}", c.UserId, c.CreatedAt.Unix(), base64.StdEncoding.EncodeToString(c.Key)) 248 | return encoded 249 | } 250 | -------------------------------------------------------------------------------- /src/totp_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/base64" 6 | "encoding/json" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func insertUserTOTPCredential(db *sql.DB, credential *UserTOTPCredential) error { 14 | _, err := db.Exec("INSERT INTO user_totp_credential (user_id, created_at, key) VALUES (?, ?, ?)", credential.UserId, credential.CreatedAt.Unix(), credential.Key) 15 | return err 16 | } 17 | 18 | func TestUserTOTPCredentialEncodeToJSON(t *testing.T) { 19 | t.Parallel() 20 | 21 | now := time.Unix(time.Now().Unix(), 0) 22 | 23 | credential := UserTOTPCredential{ 24 | UserId: "1", 25 | CreatedAt: now, 26 | Key: []byte{0x01, 0x02, 0x03}, 27 | } 28 | 29 | expected := UserTOTPCredentialJSON{ 30 | UserId: credential.UserId, 31 | CreatedAtUnix: credential.CreatedAt.Unix(), 32 | EncodedKey: base64.StdEncoding.EncodeToString(credential.Key), 33 | } 34 | 35 | var result UserTOTPCredentialJSON 36 | 37 | json.Unmarshal([]byte(credential.EncodeToJSON()), &result) 38 | 39 | assert.Equal(t, expected, result) 40 | } 41 | 42 | type UserTOTPCredentialJSON struct { 43 | UserId string `json:"user_id"` 44 | CreatedAtUnix int64 `json:"created_at"` 45 | EncodedKey string `json:"key"` 46 | } 47 | -------------------------------------------------------------------------------- /src/user_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestUserEncodeToJSON(t *testing.T) { 12 | t.Parallel() 13 | 14 | now := time.Unix(time.Now().Unix(), 0) 15 | 16 | user := User{ 17 | Id: "1", 18 | CreatedAt: now, 19 | PasswordHash: "HASH1", 20 | RecoveryCode: "12345678", 21 | TOTPRegistered: false, 22 | } 23 | 24 | expected := UserJSON{ 25 | Id: user.Id, 26 | CreatedAtUnix: user.CreatedAt.Unix(), 27 | TOTPRegistered: user.TOTPRegistered, 28 | RecoveryCode: user.RecoveryCode, 29 | } 30 | 31 | var result UserJSON 32 | 33 | json.Unmarshal([]byte(user.EncodeToJSON()), &result) 34 | 35 | assert.Equal(t, expected, result) 36 | } 37 | 38 | func TestEncodeRecoveryCodeToJSON(t *testing.T) { 39 | t.Parallel() 40 | 41 | recoveryCode := "12345678" 42 | 43 | expected := RecoveryCodeJSON{ 44 | RecoveryCode: recoveryCode, 45 | } 46 | 47 | var result RecoveryCodeJSON 48 | 49 | json.Unmarshal([]byte(encodeRecoveryCodeToJSON(recoveryCode)), &result) 50 | 51 | assert.Equal(t, expected, result) 52 | } 53 | 54 | type UserJSON struct { 55 | Id string `json:"id"` 56 | CreatedAtUnix int64 `json:"created_at"` 57 | RecoveryCode string `json:"recovery_code"` 58 | TOTPRegistered bool `json:"totp_registered"` 59 | } 60 | 61 | type RecoveryCodeJSON struct { 62 | RecoveryCode string `json:"recovery_code"` 63 | } 64 | --------------------------------------------------------------------------------