Public: anyone can read this; the crew can change it.
Sign up with a username and a password; email optional #2951
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2923-sign-up-with-a-username-and-a-password-e → main 4 commits fork 5 threads · 5 unresolved
README.md +1 −1
| @@ -343,7 +343,7 @@ to `/recover`: give the email on your account and, if it is verified, a link | ||
| 343 | 343 | that lasts 30 minutes and works once registers a new passkey and signs you in. |
| 344 | 344 | The relying party is the address the browser used (or |
| 345 | 345 | `FORGE_URL`), so a passkey does not follow a forge to another hostname. |
| 346 | Sign-up is a username, an email address and a passkey (`/signup`, `src/web/pages/signup-passkey.ts`); the running server refuses a password-only sign-up on the form, `POST /api/v1/users` and the terminal (`passkeyOnlySignup`, set in `main.ts`). Accounts that already have a password still sign in. The owner claim of a spawned forge (`/claim`, `web/pages/claim.ts`) and an admin's everyday account (`web/pages/everyday.ts`) make their account with a passkey too (`web/passkey-ceremony.ts`, challenges from `POST /claim/options` and `POST /settings/everyday/options`); neither reads a password, and without JavaScript the page says a passkey needs it. A claim post with no passkey is refused and does not spend the link. | |
| 346 | Sign-up is a username and a password of at least 12 characters (`/signup`, `POST /api/v1/users` with `{username, password, email?}`, the terminal), hashed with argon2id; the email is optional and, when given, gets the verify link (an account with none has nothing to prove). A passkey (`src/web/pages/signup-passkey.ts`, which does take an email) is offered beside the password. Nothing in the path touches an edge, parent or Forgejo account. An operator can still refuse password sign-ups with `passkeyOnlySignup`. The owner claim of a spawned forge (`/claim`, `web/pages/claim.ts`) and an admin's everyday account (`web/pages/everyday.ts`) make their account with a passkey too (`web/passkey-ceremony.ts`, challenges from `POST /claim/options` and `POST /settings/everyday/options`); neither reads a password, and without JavaScript the page says a passkey needs it. A claim post with no passkey is refused and does not spend the link. | |
| 347 | 347 | |
| 348 | 348 | A forge with a parent (`FORGE_PARENT_URL`, or the `upstream` in a spawned |
| 349 | 349 | forge's `parent.json`) offers "Continue with <parent>" on `/signin`. The forge |
src/actions/users.ts +33 −8
| @@ -34,6 +34,7 @@ import { | ||
| 34 | 34 | type ProfilePatch, |
| 35 | 35 | type UpdateProfileError, |
| 36 | 36 | type User, |
| 37 | isStandInEmail, | |
| 37 | 38 | } from "../core/users.ts"; |
| 38 | 39 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 39 | 40 | import { requireUser } from "./access.ts"; |
| @@ -41,7 +42,8 @@ import type { SignupSlot } from "./signup-window.ts"; | ||
| 41 | 42 | |
| 42 | 43 | export interface SignUpInput { |
| 43 | 44 | username: string; |
| 44 | email: string; | |
| 45 | /** Optional: absent or blank, the account has none and nothing is mailed. */ | |
| 46 | email?: string; | |
| 45 | 47 | password: string; |
| 46 | 48 | /** The Turnstile token the browser's widget gave; read only when a secret is set. */ |
| 47 | 49 | humanToken?: string; |
| @@ -50,6 +52,7 @@ export interface SignUpInput { | ||
| 50 | 52 | export type SignUpError = |
| 51 | 53 | | CreateUserError |
| 52 | 54 | | "human_check" |
| 55 | | "invalid_email" | |
| 53 | 56 | | "passkey_required" |
| 54 | 57 | | "signup_closed"; |
| 55 | 58 | |
| @@ -79,6 +82,19 @@ export function plausibleEmail(email: string): boolean { | ||
| 79 | 82 | return email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); |
| 80 | 83 | } |
| 81 | 84 | |
| 85 | /** The shortest password a sign-up takes. */ | |
| 86 | export const MIN_SIGNUP_PASSWORD = 12; | |
| 87 | ||
| 88 | /** | |
| 89 | * Stands in for the address of an account that gave none: the column is | |
| 90 | * unique and never empty, and `.invalid` is a name no mail can reach | |
| 91 | * (the same device the crew's addresses use). | |
| 92 | */ | |
| 93 | const noEmail = (username: string): string => `${username.toLowerCase()}@no-email.invalid`; | |
| 94 | ||
| 95 | /** Whether an address is one a person could have given: plausible and not a stand-in. */ | |
| 96 | const givenEmail = (email: string): boolean => plausibleEmail(email) && !isStandInEmail(email); | |
| 97 | ||
| 82 | 98 | /** What a person is told when the check fails. */ |
| 83 | 99 | export const HUMAN_CHECK_MESSAGE = |
| 84 | 100 | "We could not tell you are a person. Reload the page and try again."; |
| @@ -101,12 +117,15 @@ export async function signUp( | ||
| 101 | 117 | address?: string | undefined; |
| 102 | 118 | }, |
| 103 | 119 | ): Promise<Result<User, SignUpError>> { |
| 104 | // Password sign-in stays for the accounts that have a password. It can go | |
| 105 | // when every such account has a passkey (Settings → Passkeys shows who is | |
| 106 | // left) and recovery without a password exists (#1618): then delete this | |
| 107 | // action's password path, `verifyPasswordThrottled`'s doors and the password column. | |
| 120 | // A username and a password is a way in that stays, beside the passkey | |
| 121 | // (#2923; it once was to go, #1618). Email is optional: given, the verify | |
| 122 | // link goes to it. Either way the account starts unproven: adding and | |
| 123 | // proving an address in settings is what lets it create and cost. | |
| 108 | 124 | if (signupClosed(app, from.invite)) return err("signup_closed"); |
| 109 | 125 | if (app.config.passkeyOnlySignup === true) return err("passkey_required"); |
| 126 | const email = (input.email ?? "").trim(); | |
| 127 | if (email !== "" && !givenEmail(email)) return err("invalid_email"); | |
| 128 | if (input.password.length < MIN_SIGNUP_PASSWORD) return err("weak_password"); | |
| 110 | 129 | const secret = app.config.turnstileSecret; |
| 111 | 130 | // The terminal cannot render a widget, so with a secret set it brings no |
| 112 | 131 | // token and is refused, the forge's first account too: sign up on the web page. |
| @@ -116,8 +135,14 @@ export async function signUp( | ||
| 116 | 135 | if (!(await verify(input.humanToken ?? "", from.address))) |
| 117 | 136 | return err("human_check"); |
| 118 | 137 | } |
| 119 | const { humanToken: _token, ...account } = input; | |
| 120 | const created = await createUser(app.db, { ...account, origin: "signup", slot: from.slot, invite: from.invite }); | |
| 138 | const created = await createUser(app.db, { | |
| 139 | username: input.username, | |
| 140 | password: input.password, | |
| 141 | email: email === "" ? noEmail(input.username) : email, | |
outdated
High: no email means no verification needed, so leaving out one field skips the verify gate ( |
||
| 142 | origin: "signup", | |
| 143 | slot: from.slot, | |
| 144 | invite: from.invite, | |
| 145 | }); | |
| 121 | 146 | if (!created.ok) return created; |
| 122 | 147 | app.bus.append({ |
| 123 | 148 | kind: "user.created", |
| @@ -130,7 +155,7 @@ export async function signUp( | ||
| 130 | 155 | auditInvite(app, created.value.id); |
| 131 | 156 | // The address is proven by the link it is sent; until then nothing it |
| 132 | 157 | // does may create or cost. |
| 133 | await welcome(app, created.value); | |
| 158 | if (email !== "") await welcome(app, created.value); | |
| 134 | 159 | return created; |
| 135 | 160 | } |
| 136 | 161 |
src/app.ts +2 −3
| @@ -137,9 +137,8 @@ export interface ForgeConfig { | ||
| 137 | 137 | /** |
| 138 | 138 | * Refuse every sign-up that would make an account with only a password: |
| 139 | 139 | * the web form, `POST /api/v1/users` and the terminal answer in words, |
| 140 | * and `signUpWithPasskey` is the one way in. `main.ts` sets it; the | |
| 141 | * library default is off so a test can still seed accounts with a | |
| 142 | * password. | |
| 140 | * and `signUpWithPasskey` is the one way in. An operator's choice, off | |
| 141 | * by default: a username and a password sign up (#2923). | |
| 143 | 142 | */ |
| 144 | 143 | passkeyOnlySignup?: boolean; |
| 145 | 144 | /** |
src/core/users.ts +8 −1
| @@ -388,6 +388,9 @@ export type UpdateProfileError = | ||
| 388 | 388 | | "invalid_commit_email" |
| 389 | 389 | | "no_such_user"; |
| 390 | 390 | |
| 391 | /** Whether an address is a stand-in (`.invalid`): no mail reaches it, no person gave it. */ | |
| 392 | export const isStandInEmail = (email: string): boolean => /\.invalid$/i.test(email.trim()); | |
| 393 | ||
| 391 | 394 | /** Change the mutable profile fields. Absent keys are left untouched. */ |
| 392 | 395 | export function updateProfile( |
| 393 | 396 | db: Database, |
| @@ -396,10 +399,14 @@ export function updateProfile( | ||
| 396 | 399 | ): Result<User, UpdateProfileError> { |
| 397 | 400 | const current = getUserById(db, userId); |
| 398 | 401 | if (!current || current.kind === "org") return err("no_such_user"); |
| 399 | const email = patch.email?.trim() ?? current.email; | |
| 402 | let email = patch.email?.trim() ?? current.email; | |
| 403 | // The form shows a stand-in as empty; leaving it so keeps it. | |
| 404 | if (email === "" && isStandInEmail(current.email)) email = current.email; | |
| 400 | 405 | if (email === "" || email.length > MAX_EMAIL || !email.includes("@")) |
| 401 | 406 | return err("invalid_email"); |
| 402 | 407 | if (email.toLowerCase() !== current.email.toLowerCase()) { |
| 408 | // Stand-ins belong to the accounts that gave no address; nobody may take one. | |
| 409 | if (isStandInEmail(email)) return err("invalid_email"); | |
| 403 | 410 | const taken = db |
| 404 | 411 | .query("SELECT 1 FROM user WHERE email = ?1 COLLATE NOCASE AND id != ?2") |
| 405 | 412 | .get(email, userId); |
src/http/api.ts +2
| @@ -1215,6 +1215,8 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1215 | 1215 | // ── accounts ───────────────────────────────────────────────────────── |
| 1216 | 1216 | |
| 1217 | 1217 | router.add("POST", "/api/v1/users", async (ctx) => { |
| 1218 | // A page on another site cannot sign a visitor up from here. | |
| 1219 | if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden(); | |
| 1218 | 1220 | const slot = signupSlot(ctx); |
| 1219 | 1221 | if (typeof slot === "number") |
| 1220 | 1222 | return json( |
src/main.ts −2
| @@ -219,8 +219,6 @@ const app = createApp({ | ||
| 219 | 219 | Number(process.env.FORGE_MAIL_DAILY) > 0 |
| 220 | 220 | ? { mailDailyCap: Number(process.env.FORGE_MAIL_DAILY) } |
| 221 | 221 | : {}), |
| 222 | // No door makes a password-only account (#1996). | |
| 223 | passkeyOnlySignup: true, | |
| 224 | 222 | // Signup asks Cloudflare Turnstile when its secret is set. |
| 225 | 223 | ...(turnstileSecret === "" |
| 226 | 224 | ? {} |
src/tui/screens/common.ts +1 −1
| @@ -840,7 +840,7 @@ const MESSAGES: Record<string, string> = { | ||
| 840 | 840 | email_taken: "that email address is taken", |
| 841 | 841 | key_exists: "that key is already registered", |
| 842 | 842 | taken: "that name is taken", |
| 843 | weak_password: "the password must be at least 10 characters", | |
| 843 | weak_password: "the password is too short (10 characters at least)", | |
outdated
Low: password change in the terminal shows this message too, but its minimum is still 10. Give the sign-up refusal its own message. |
||
| 844 | 844 | bad_credentials: "wrong username or password", |
| 845 | 845 | throttled: "too many attempts: try again in a minute", |
| 846 | 846 | already_forked: "you already have a fork of this repo", |
src/tui/screens/signin.ts +8 −3
| @@ -35,10 +35,13 @@ const SIGN_IN: Field[] = [ | ||
| 35 | 35 | |
| 36 | 36 | const CREATE: Field[] = [ |
| 37 | 37 | { label: "Username" }, |
| 38 | { label: "Email" }, | |
| 39 | { label: "Password", secret: true, hint: "At least 10 characters." }, | |
| 38 | { label: "Email", hint: "Optional." }, | |
| 39 | { label: "Password", secret: true, hint: "At least 12 characters." }, | |
| 40 | 40 | ]; |
| 41 | 41 | |
| 42 | /** What sign-up says to a short password; the password change form keeps its own wording. */ | |
| 43 | export const SIGNUP_WEAK_MESSAGE = "the password is too short (sign-up needs 12 characters)"; | |
| 44 | ||
| 42 | 45 | /** While signup is `invite` the create form also asks for the link's token. */ |
| 43 | 46 | const INVITE_BLANK_MESSAGE = "Paste the token from your invite link."; |
| 44 | 47 | const INVITE: Field = { label: "Invite", secret: true, hint: "The token from your invite link." }; |
| @@ -109,7 +112,9 @@ export function signInScreen(f: Session, ctx: Ctx, onDone: () => void): Screen { | ||
| 109 | 112 | ); |
outdated
This nested ternary with a string literal inline is hard to read. Lift it into a named constant beside |
||
| 110 | 113 | if (!created.ok) { |
| 111 | 114 | // With signup = invite a refusal means the token did not open the door. |
| 112 | error = needsInvite && created.error === "signup_closed" ? INVITE_SPENT_MESSAGE : errorText(created.error); | |
| 115 | if (needsInvite && created.error === "signup_closed") error = INVITE_SPENT_MESSAGE; | |
| 116 | else if (created.error === "weak_password") error = SIGNUP_WEAK_MESSAGE; | |
| 117 | else error = errorText(created.error); | |
| 113 | 118 | return; |
| 114 | 119 | } |
| 115 | 120 | const adopted = f.adopt(created.value); |
src/web/pages/account.ts +10 −5
| @@ -36,6 +36,7 @@ import { endAccountSession } from "../../actions/signins.ts"; | ||
| 36 | 36 | import { listSessions, type SessionInfo } from "../../core/sessions.ts"; |
| 37 | 37 | import { ADMIN_TOKEN_WORDS, listTokens, type TokenScope } from "../../core/tokens.ts"; |
| 38 | 38 | import type { CommitEmail, User } from "../../core/users.ts"; |
| 39 | import { isStandInEmail } from "../../core/users.ts"; | |
| 39 | 40 | import { cookieValue, type Ctx } from "../../http/context.ts"; |
| 40 | 41 | import { sshCommand } from "../../ssh/command.ts"; |
| 41 | 42 | import { |
| @@ -100,7 +101,7 @@ interface ProfileForm { | ||
| 100 | 101 | /** The profile form as the account stands now. */ |
| 101 | 102 | const currentProfile = (user: User): ProfileForm => ({ |
| 102 | 103 | displayName: user.displayName, |
| 103 | email: user.email, | |
| 104 | email: isStandInEmail(user.email) ? "" : user.email, | |
| 104 | 105 | commitEmail: user.commitEmail, |
| 105 | 106 | }); |
| 106 | 107 | |
| @@ -224,12 +225,16 @@ function unverifiedNotice(ctx: Ctx): Html { | ||
| 224 | 225 | return card( |
| 225 | 226 | html`<p> |
| 226 | 227 | Your email is not verified yet. Until it is, you can read and talk, |
| 227 | but not make repositories, apps or runs. Follow the link we sent to | |
| 228 | <strong>${user.email}</strong>, or ask for another. | |
| 228 | but not make repositories, apps or runs. | |
| 229 | ${isStandInEmail(user.email) | |
| 230 | ? html`Add your email below and follow the link we send to it.` | |
| 231 | : html`Follow the link we sent to <strong>${user.email}</strong>, or ask for another.`} | |
| 229 | 232 | </p> |
| 230 | <form method="post" action="/settings/email/verify"> | |
| 233 | ${isStandInEmail(user.email) | |
| 234 | ? html`` | |
| 235 | : html`<form method="post" action="/settings/email/verify"> | |
| 231 | 236 | ${btn("Send another link", { type: "submit" })} |
| 232 | </form>`, | |
| 237 | </form>`}`, | |
| 233 | 238 | { tone: "attn" }, |
| 234 | 239 | ); |
| 235 | 240 | } |
src/web/pages/site.ts +20 −8
| @@ -278,8 +278,8 @@ function authForm( | ||
| 278 | 278 | </div>`, |
| 279 | 279 | ); |
| 280 | 280 | |
| 281 | // No door makes a password-only account: sign-up is a name, an address and | |
| 282 | // a passkey, made in the browser (`signup-passkey.ts`). | |
| 281 | // Only an operator who set `passkeyOnlySignup` gets a form with no | |
| 282 | // password: a name, an address and a passkey (`signup-passkey.ts`). | |
| 283 | 283 | const byPasskey = kind === "signup" && ctx.app.config.passkeyOnlySignup === true; |
| 284 | 284 | |
| 285 | 285 | // Sign-in leads with the passkey: one press, nothing typed; the password |
| @@ -326,7 +326,7 @@ function authForm( | ||
| 326 | 326 | type: "email", |
| 327 | 327 | value: values.email ?? "", |
| 328 | 328 | autocomplete: "email", |
| 329 | required: true, | |
| 329 | hint: "Optional. Given, we send a link to prove it.", | |
| 330 | 330 | // A pinned name is not editable, so the keyboard goes to the email. |
| 331 | 331 | ...(vouched !== undefined ? { autofocus: true } : {}), |
| 332 | 332 | ...(errors.email ? { error: errors.email } : {}), |
| @@ -340,7 +340,7 @@ function authForm( | ||
| 340 | 340 | autocomplete: kind === "signin" ? "current-password" : "new-password", |
| 341 | 341 | required: true, |
| 342 | 342 | ...(kind === "signup" |
| 343 | ? { minlength: 10, hint: "At least 10 characters." } | |
| 343 | ? { minlength: 12, hint: "At least 12 characters." } | |
| 344 | 344 | : {}), |
| 345 | 345 | ...(errors.password ? { error: errors.password } : {}), |
| 346 | 346 | }), |
| @@ -359,6 +359,17 @@ function authForm( | ||
| 359 | 359 | <p class="error-note mb" id="passkey-signup-note" role="alert">${errors.password ?? ""}</p> |
| 360 | 360 | <noscript><p class="muted small">Signing up needs JavaScript for the passkey.</p></noscript>`, |
| 361 | 361 | )} |
| 362 | ${when( | |
| 363 | kind === "signup" && !byPasskey, | |
| 364 | () => html`<div class="passkey-on"> | |
| 365 | <p class="muted small mb"> | |
| 366 | Or skip the password: a passkey signs you in with a fingerprint, | |
| 367 | face or device PIN. It needs an email address above. | |
| 368 | </p> | |
| 369 | <p class="error-note mb" id="passkey-signup-note" role="alert"></p> | |
| 370 | <button type="button" id="passkey-signup" class="btn" data-variant="outline">Create account with a passkey</button> | |
Low: this button is hand-rolled markup rather than the named Non-blocking: this is a hand-rolled button. It matches the existing passkey button, but both should move onto |
||
| 371 | </div>`, | |
| 372 | )} | |
| 362 | 373 | ${when( |
| 363 | 374 | errors.human, |
| 364 | 375 | (message) => html`<p class="error mb" role="alert">${message}</p>`, |
| @@ -469,7 +480,7 @@ export function signupPage(ctx: Ctx): Response { | ||
| 469 | 480 | title: "Sign up", |
| 470 | 481 | body: authForm(ctx, "signup"), |
| 471 | 482 | turnstile: siteKey(ctx) !== undefined, |
| 472 | ...(ctx.app.config.passkeyOnlySignup === true ? { scriptJs: passkeyJs } : {}), | |
| 483 | scriptJs: passkeyJs, | |
| 473 | 484 | }); |
| 474 | 485 | } |
| 475 | 486 | |
| @@ -627,7 +638,7 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> { | ||
| 627 | 638 | }, |
| 628 | 639 | }), |
| 629 | 640 | turnstile: siteKey(ctx) !== undefined, |
| 630 | ...(ctx.app.config.passkeyOnlySignup === true ? { scriptJs: passkeyJs } : {}), | |
| 641 | scriptJs: passkeyJs, | |
| 631 | 642 | }, |
| 632 | 643 | 429, |
| 633 | 644 | ); |
| @@ -653,7 +664,8 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> { | ||
| 653 | 664 | username_taken: { username: "That username is taken." }, |
| 654 | 665 | username_reserved: { username: "That name is reserved." }, |
| 655 | 666 | email_taken: { email: "That email already has an account." }, |
| 656 | weak_password: { password: "Passwords need at least 10 characters." }, | |
| 667 | weak_password: { password: "Passwords need at least 12 characters." }, | |
| 668 | invalid_email: { email: "That does not look like an email address." }, | |
| 657 | 669 | human_check: { human: HUMAN_CHECK_MESSAGE }, |
| 658 | 670 | passkey_required: { human: PASSKEY_REQUIRED_MESSAGE }, |
| 659 | 671 | }[created.error] ?? { username: "Something went wrong." }; |
| @@ -663,7 +675,7 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> { | ||
| 663 | 675 | title: "Sign up", |
| 664 | 676 | body: authForm(ctx, "signup", { values: { username, email }, errors }), |
| 665 | 677 | turnstile: siteKey(ctx) !== undefined, |
| 666 | ...(ctx.app.config.passkeyOnlySignup === true ? { scriptJs: passkeyJs } : {}), | |
| 678 | scriptJs: passkeyJs, | |
| 667 | 679 | }, |
| 668 | 680 | 422, |
| 669 | 681 | ); |
test/signup-password.test.ts added +141
| @@ -0,0 +1,141 @@ | ||
| 1 | /** | |
| 2 | * Sign-up with a username and a password, email optional (#2923): the | |
| 3 | * account is made with neither passkey nor email and signs in; a short | |
| 4 | * password, a closed forge and a cross-origin post are refused; none of it | |
| 5 | * needs an edge or origin configured. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { requireVerified } from "../src/actions/verify.ts"; | |
| 13 | import { updateProfile } from "../src/core/users.ts"; | |
| 14 | import { emailVerified } from "../src/core/verify.ts"; | |
| 15 | import { getUserByName, verifyPasswordThrottled } from "../src/core/users.ts"; | |
| 16 | import { setLimit } from "../src/settings/limits.ts"; | |
| 17 | import { setSignup } from "../src/settings/signup.ts"; | |
| 18 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 19 | ||
| 20 | describe("password sign-up", () => { | |
| 21 | let dataDir: string; | |
| 22 | let app: App; | |
| 23 | let servers: Servers; | |
| 24 | let base: string; | |
| 25 | const mails: { to: string }[] = []; | |
| 26 | ||
| 27 | const post = (body: unknown, headers: Record<string, string> = {}) => | |
| 28 | fetch(`${base}/api/v1/users`, { | |
| 29 | method: "POST", | |
| 30 | headers: { "content-type": "application/json", ...headers }, | |
| 31 | body: JSON.stringify(body), | |
| 32 | }); | |
| 33 | ||
| 34 | beforeAll(async () => { | |
| 35 | dataDir = await mkdtemp(join(tmpdir(), "forge-signup-password-")); | |
| 36 | delete process.env.FORGE_EDGE_TOKEN; | |
| 37 | app = createApp({ | |
| 38 | dataDir, | |
| 39 | port: 0, | |
| 40 | mailer: async (m) => { | |
| 41 | mails.push({ to: m.to }); | |
| 42 | return true; | |
| 43 | }, | |
| 44 | }); | |
| 45 | servers = await serve(app); | |
| 46 | base = servers.url; | |
| 47 | setLimit(app.db, "signup.per_source", 100, "system"); | |
| 48 | }); | |
| 49 | afterAll(async () => { | |
| 50 | servers.stop(); | |
| 51 | app.close(); | |
| 52 | await rm(dataDir, { recursive: true, force: true }); | |
| 53 | }); | |
| 54 | ||
| 55 | test("an account with neither passkey nor email signs up and signs in", async () => { | |
| 56 | const res = await post({ username: "quinn", password: "a-long-passphrase" }); | |
| 57 | expect(res.status).toBe(201); | |
| 58 | const user = getUserByName(app.db, "quinn")!; | |
| 59 | expect(user.email.endsWith(".invalid")).toBe(true); | |
| 60 | expect(emailVerified(app.db, user.id)).toBe(false); | |
| 61 | expect(requireVerified(app, user)).toEqual({ ok: false, error: "unverified" }); | |
| 62 | expect(mails.length).toBe(0); | |
| 63 | const hash = app.db.query<{ password_hash: string }, [string]>("SELECT password_hash FROM user WHERE id = ?1").get(user.id)!; | |
| 64 | expect(hash.password_hash.startsWith("$argon2id$")).toBe(true); | |
| 65 | const verified = await verifyPasswordThrottled(app.db, { username: "quinn", password: "a-long-passphrase", source: "test" }); | |
| 66 | expect(verified.ok).toBe(true); | |
| 67 | const basic = await fetch(`${base}/api/v1/user`, { | |
| 68 | headers: { authorization: `Basic ${btoa("quinn:a-long-passphrase")}` }, | |
| 69 | }); | |
| 70 | expect(basic.status).toBe(200); | |
| 71 | }); | |
| 72 | ||
| 73 | test("a second account with no email does not collide with the first", async () => { | |
| 74 | expect((await post({ username: "rae", password: "another-passphrase" })).status).toBe(201); | |
| 75 | }); | |
| 76 | ||
| 77 | test("a given email gets the verify mail and starts unproven", async () => { | |
| 78 | const res = await post({ username: "sol", password: "a-long-passphrase", email: "[email protected]" }); | |
| 79 | expect(res.status).toBe(201); | |
| 80 | expect(mails.map((m) => m.to)).toEqual(["[email protected]"]); | |
| 81 | expect(emailVerified(app.db, getUserByName(app.db, "sol")!.id)).toBe(false); | |
| 82 | }); | |
| 83 | ||
| 84 | test("a stand-in address cannot be taken in settings, and shows empty", () => { | |
| 85 | const rae = getUserByName(app.db, "quinn")!; | |
| 86 | const other = getUserByName(app.db, "sol") ?? rae; | |
| 87 | expect(updateProfile(app.db, other.id, { email: rae.email }).ok).toBe(false); | |
| 88 | expect(updateProfile(app.db, other.id, { email: "[email protected]" }).ok).toBe(false); | |
| 89 | const kept = updateProfile(app.db, rae.id, { email: "", displayName: "Quinn" }); | |
| 90 | expect(kept.ok && kept.value.email).toBe(rae.email); | |
| 91 | }); | |
| 92 | ||
| 93 | test("a stand-in address cannot be given, and a malformed one is refused", async () => { | |
| 94 | expect((await post({ username: "tam", password: "a-long-passphrase", email: "[email protected]" })).status).toBe(422); | |
| 95 | expect((await post({ username: "tam", password: "a-long-passphrase", email: "nope" })).status).toBe(422); | |
| 96 | }); | |
| 97 | ||
| 98 | test("a password under 12 characters is refused", async () => { | |
| 99 | const res = await post({ username: "uma", password: "elevenchars" }); | |
| 100 | expect(res.status).toBe(422); | |
| 101 | expect(((await res.json()) as { error: string }).error).toBe("weak_password"); | |
| 102 | expect(getUserByName(app.db, "uma")).toBeUndefined(); | |
| 103 | }); | |
| 104 | ||
| 105 | test("the form signs up with no email and sets an HttpOnly session", async () => { | |
| 106 | const page = await fetch(`${base}/signup`); | |
| 107 | const text = await page.text(); | |
| 108 | expect(text).toContain('id="passkey-signup"'); | |
| 109 | const res = await fetch(`${base}/signup`, { | |
| 110 | method: "POST", | |
| 111 | redirect: "manual", | |
| 112 | headers: { "content-type": "application/x-www-form-urlencoded", origin: base }, | |
| 113 | body: new URLSearchParams({ username: "vic", email: "", password: "a-long-passphrase" }), | |
| 114 | }); | |
| 115 | expect(res.status).toBe(303); | |
| 116 | expect(res.headers.get("set-cookie")).toContain("HttpOnly"); | |
| 117 | expect(getUserByName(app.db, "vic")).toBeDefined(); | |
| 118 | }); | |
| 119 | ||
| 120 | test("a cross-origin post is refused", async () => { | |
| 121 | const api = await post({ username: "wes", password: "a-long-passphrase" }, { origin: "https://evil.example" }); | |
| 122 | expect(api.status).toBe(403); | |
| 123 | const form = await fetch(`${base}/signup`, { | |
| 124 | method: "POST", | |
| 125 | redirect: "manual", | |
| 126 | headers: { "content-type": "application/x-www-form-urlencoded", origin: "https://evil.example" }, | |
| 127 | body: new URLSearchParams({ username: "wes", password: "a-long-passphrase" }), | |
| 128 | }); | |
| 129 | expect(form.status).toBe(403); | |
| 130 | expect(getUserByName(app.db, "wes")).toBeUndefined(); | |
| 131 | }); | |
| 132 | ||
| 133 | test("a closed forge refuses", async () => { | |
| 134 | setSignup(app.db, "closed", "system"); | |
| 135 | const res = await post({ username: "xan", password: "a-long-passphrase" }); | |
| 136 | expect(res.status).toBe(403); | |
| 137 | expect(((await res.json()) as { error: string }).error).toBe("signup_closed"); | |
| 138 | expect(getUserByName(app.db, "xan")).toBeUndefined(); | |
| 139 | setSignup(app.db, "open", "system"); | |
| 140 | }); | |
| 141 | }); |
test/tui-signup-edge.test.ts +37
| @@ -8,6 +8,8 @@ import { tmpdir } from "node:os"; | ||
| 8 | 8 | import { join } from "node:path"; |
| 9 | 9 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 10 | 10 | import { getUserByName } from "../src/core/users.ts"; |
| 11 | import { emailVerified } from "../src/core/verify.ts"; | |
| 12 | import { SIGNUP_WEAK_MESSAGE } from "../src/tui/screens/signin.ts"; | |
| 11 | 13 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 12 | 14 | import type { TuiIdentity } from "../src/tui/session.ts"; |
| 13 | 15 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| @@ -57,3 +59,38 @@ test("with an edge token the terminal sign-up makes no row", async () => { | ||
| 57 | 59 | app.stop(); |
| 58 | 60 | } |
| 59 | 61 | }); |
| 62 | ||
| 63 | test("without an edge the terminal signs up with a blank email, unverified, and refuses 11 characters", async () => { | |
| 64 | const plain = createApp({ dataDir: await mkdtemp(join(tmpdir(), "forge-tui-signup-")), port: 0, url: "https://forge.example", disk: () => ({ freeBytes: 95, totalBytes: 100 }) }); | |
| 65 | const identity: TuiIdentity = { sshUsername: "git", remoteAddress: "198.51.100.8", fingerprint: "SHA256:y", publicKey: KEY }; | |
| 66 | const { app } = createForgeApp(plain, identity, { terminal: headlessTerminal(120, 32), exitHooks: false }); | |
| 67 | app.start(); | |
| 68 | const key = (k: string, ctrl = false): void => | |
| 69 | app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 70 | const type = (t: string): void => [...t].forEach((c) => key(c)); | |
| 71 | try { | |
| 72 | await settleApp(app); | |
| 73 | key("s"); | |
| 74 | await settleApp(app); | |
| 75 | // The forge has no accounts, so the form opens in create mode. | |
| 76 | expect(app.screen.toText()).toContain("Email"); | |
| 77 | type("nora"); | |
| 78 | key("tab"); | |
| 79 | key("tab"); // email left blank | |
| 80 | type("elevenchars"); | |
| 81 | key("enter"); | |
| 82 | await settleApp(app); | |
| 83 | expect(app.screen.toText()).toContain(SIGNUP_WEAK_MESSAGE); | |
| 84 | expect(getUserByName(plain.db, "nora")).toBeFalsy(); | |
| 85 | type("1"); // twelve now | |
| 86 | key("enter"); | |
| 87 | await settleApp(app); | |
| 88 | const user = getUserByName(plain.db, "nora"); | |
| 89 | expect(user).toBeTruthy(); | |
| 90 | expect(emailVerified(plain.db, user!.id)).toBe(false); | |
| 91 | expect(app.screen.toText()).toContain("signed in as nora"); | |
| 92 | } finally { | |
| 93 | app.stop(); | |
| 94 | plain.close(); | |
| 95 | } | |
| 96 | }); |
test/turnstile.test.ts +2 −1
| @@ -192,7 +192,8 @@ describe("turnstile doors", () => { | ||
| 192 | 192 | test("off, the page has no widget and the policy is unchanged", async () => { |
| 193 | 193 | const { base } = await boot(); |
| 194 | 194 | const response = await fetch(`${base}/signup`); |
| 195 | expect(await response.text()).not.toContain("cf-turnstile"); | |
| 195 | // The passkey script always names the token field; the widget is the div. | |
| 196 | expect(await response.text()).not.toContain("class=\"cf-turnstile"); | |
| 196 | 197 | expect(response.headers.get("content-security-policy")).not.toContain("cloudflare"); |
| 197 | 198 | }); |
| 198 | 199 |
Medium:
updateProfile(core/users.ts) still accepts[email protected]. Another account can take this stand-in, and so can an old one after a rename, and the settings page shows it as the person's email. Refuse.invalidinupdateProfileand render a stand-in as empty.