Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm
16 files+419−460/16 viewed
| 12 | 12 | hyphens, up to 39 characters. | |
| 13 | 13 | ||
| 14 | 14 | Before you can do anything else, you [confirm your email | |
| 15 | − | address](#confirming-your-email-address) with the code g1t emails you. | |
| 15 | + | address](#confirming-your-email-address) with the code g1t emails you, | |
| 16 | + | unless you signed up from the link in an invite g1t emailed to that address | |
| 17 | + | (see [invites from your inbox](#invites-from-your-inbox)). | |
| 16 | 18 | ||
| 17 | 19 | Accounts can only be created in a browser. There is no API for it, by | |
| 18 | 20 | design: it keeps passwords out of scripts and agents, and lets g1t protect | |
| ⋯ | |||
| 130 | 132 | ||
| 131 | 133 | 1. **No account yet**: sign up on the page. When the invite was sent to | |
| 132 | 134 | your address, the email field is filled in and locked. Choose a | |
| 133 | − | username (one is suggested from your address) and a password, then | |
| 135 | + | username (one is suggested from your address) and a password. If you | |
| 136 | + | opened the page from the invite email itself, the address is already | |
| 137 | + | confirmed and you go straight in (see | |
| 138 | + | [invites from your inbox](#invites-from-your-inbox)); otherwise | |
| 134 | 139 | [confirm the address](#confirming-your-email-address) with the code g1t | |
| 135 | − | emails it, even though the invite came there: an invite link can be | |
| 136 | − | forwarded, so it does not prove the inbox is yours. Or select | |
| 137 | − | **Continue with GitHub**: the invite rides along, and the account uses | |
| 138 | − | the invited address when GitHub has verified it too, in which case no | |
| 139 | − | confirmation is needed. | |
| 140 | + | emails it. Or select **Continue with GitHub**: the invite rides along, | |
| 141 | + | and the account uses the invited address when GitHub has verified it | |
| 142 | + | too, in which case no confirmation is needed. | |
| 140 | 143 | 2. **The address already has an account**: select **Sign in to accept**. | |
| 141 | 144 | After you sign in, the invite is accepted for you. | |
| 142 | 145 | 3. **Signed in as someone else**: an invite sent to one address works only | |
| ⋯ | |||
| 161 | 164 | An expired, revoked or used invite says which, and who sent it, so you | |
| 162 | 165 | can ask them for a new one; or ask for access from the same page. | |
| 163 | 166 | ||
| 167 | + | ### Invites from your inbox | |
| 168 | + | ||
| 169 | + | When g1t emails an invite to an address (an invite you make for someone, | |
| 170 | + | an owner's invite into a workspace or a repository, or an approved | |
| 171 | + | [request for access](#asking-for-access)), the link in that email carries a | |
| 172 | + | `proof` that only the email has: `g1t.sh/invite/<code>?proof=…`. Opening | |
| 173 | + | the link shows that you can read that inbox, so: | |
| 174 | + | ||
| 175 | + | - the invite page says the address is confirmed because you came from the | |
| 176 | + | invite email, and the email field stays locked to it; | |
| 177 | + | - your new account starts with the address confirmed: no code is sent, and | |
| 178 | + | you land in the workspace or repository the invite was for straight | |
| 179 | + | away. | |
| 180 | + | ||
| 181 | + | Anything else confirms the address the usual way, after you sign up: the | |
| 182 | + | code typed at [g1t.sh/register](https://g1t.sh/register), an invite link | |
| 183 | + | copied from **Settings → Invites** (whoever made the invite sees the code, | |
| 184 | + | never the proof), an invite made for anyone with the link, or an invite | |
| 185 | + | email sent before this existed. The proof is tied to one invite and its | |
| 186 | + | address, and stops working when the invite is used, revoked or expires. | |
| 187 | + | ||
| 164 | 188 | ### Invite links for a group | |
| 165 | 189 | ||
| 166 | 190 | g1t sometimes hands one link to a group: an event's judges, readers of a | |
| ⋯ | |||
| 209 | 233 | An owner can invite an email address straight into a workspace from its | |
| 210 | 234 | People page; see [members and roles](/guides/workspaces/#members-and-roles). | |
| 211 | 235 | When the address has no g1t account, the invite makes the account, which | |
| 212 | − | joins the workspace once it confirms its email address, and it uses one | |
| 213 | − | invite. Inviting someone who is | |
| 236 | + | joins the workspace once it confirms its email address (at once when it | |
| 237 | + | was made from the invite email's link), and it uses one invite. Inviting someone who is | |
| 214 | 238 | already on g1t costs nothing. | |
| 215 | 239 | ||
| 216 | 240 | ### Need more invites? | |
| ⋯ | |||
| 245 | 269 | ## Confirming your email address | |
| 246 | 270 | ||
| 247 | 271 | A new account confirms its email address before it can do anything else on | |
| 248 | − | g1t. Right after you sign up, g1t emails the address from `noreply@g1t.sh` | |
| 272 | + | g1t, unless it already has (it was made with GitHub, or from the link in | |
| 273 | + | its invite email). Right after you sign up, g1t emails the address from `noreply@g1t.sh` | |
| 249 | 274 | with two ways to confirm it, either one enough: | |
| 250 | 275 | ||
| 251 | 276 | - a **six-digit code**, shown large in the email (and in its subject, so a | |
| ⋯ | |||
| 294 | 319 | is one GitHub has verified, so GitHub has already proved the inbox is | |
| 295 | 320 | yours, and no code is sent. | |
| 296 | 321 | ||
| 322 | + | ### Addresses an invite email has confirmed | |
| 323 | + | ||
| 324 | + | An account made from the link in the invite g1t emailed to its address | |
| 325 | + | starts confirmed the same way: following that link proved the inbox is | |
| 326 | + | yours. It works only for the address the invite was sent to, and only from | |
| 327 | + | the email's own link; see [invites from your inbox](#invites-from-your-inbox). | |
| 328 | + | ||
| 297 | 329 | ### Accounts that never confirmed | |
| 298 | 330 | ||
| 299 | 331 | Accounts are confirmed once and stay confirmed. An account made before this | |
| 333 | 333 | ||
| 334 | 334 | The email names you and the workspace and links to the invite's page. | |
| 335 | 335 | Someone new signs up right there, with the invited address filled in, and | |
| 336 | − | joins once they confirm it with the code g1t emails them; someone with an | |
| 337 | − | account signs in. Either way they land in the workspace as a member, with | |
| 336 | + | joins at once when they opened the page from that email (it proves the | |
| 337 | + | address is theirs), or otherwise once they confirm it with the code g1t | |
| 338 | + | emails them; someone with an account signs in. Either way they land in the workspace as a member, with | |
| 338 | 339 | a one-time welcome. Until a new account confirms its address, its invite | |
| 339 | 340 | shows as **confirming their email** under the members, and you can still | |
| 340 | 341 | revoke it. See |
| 6 | 6 | HAVE_AN_INVITE, | |
| 7 | 7 | INVITES_CONTACT, | |
| 8 | 8 | cleanCode, | |
| 9 | + | cleanProof, | |
| 10 | + | invitePath, | |
| 11 | + | inviteSignUpCopy, | |
| 9 | 12 | inviteFor, | |
| 10 | 13 | inviteLink, | |
| 11 | 14 | inviteState, | |
| ⋯ | |||
| 45 | 48 | assert.equal(inviteLink(CODE, "http://localhost:8787/"), `http://localhost:8787/invite/${CODE}`); | |
| 46 | 49 | }); | |
| 47 | 50 | ||
| 51 | + | const PROOF = "4f9c2a7e0b13d5c84f9c2a7e0b13d5c84f9c2a7e0b13d5c84f9c2a7e0b13d5c8"; | |
| 52 | + | ||
| 53 | + | test("an invite email's proof is kept only when it looks like one, and goes along to the invite's page", () => { | |
| 54 | + | assert.equal(cleanProof(PROOF), PROOF); | |
| 55 | + | assert.equal(cleanProof(` ${PROOF.toUpperCase()} `), PROOF); | |
| 56 | + | assert.equal(cleanProof("not-a-proof"), null); | |
| 57 | + | assert.equal(cleanProof("abc"), null); | |
| 58 | + | assert.equal(cleanProof("a".repeat(500)), null); | |
| 59 | + | assert.equal(cleanProof(null), null); | |
| 60 | + | assert.equal(invitePath(CODE, PROOF), `/invite/${CODE}?proof=${PROOF}`); | |
| 61 | + | assert.equal(invitePath(CODE, null), `/invite/${CODE}`); | |
| 62 | + | assert.equal(invitePath(CODE), `/invite/${CODE}`); | |
| 63 | + | }); | |
| 64 | + | ||
| 65 | + | test("signing up from the invite email says the address is confirmed already; otherwise the code step applies", () => { | |
| 66 | + | const base = { address: "ada@example.com", emailProven: false, workspace: { name: "Flagon, Inc." }, repository: null }; | |
| 67 | + | const proven = inviteSignUpCopy({ ...base, emailProven: true }); | |
| 68 | + | assert.equal(proven.intro, "You join Flagon, Inc. as soon as you create it."); | |
| 69 | + | assert.match(proven.confirmed ?? "", /^ada@example\.com is confirmed: you came here from the invite we emailed to it/); | |
| 70 | + | assert.match(proven.hint, /confirmed already/); | |
| 71 | + | assert.doesNotMatch(proven.hint, /code/); | |
| 72 | + | ||
| 73 | + | // No proof (a code typed in, or a link passed on): nothing new is said. | |
| 74 | + | const plain = inviteSignUpCopy(base); | |
| 75 | + | assert.equal(plain.intro, "You join Flagon, Inc. as soon as you confirm your email."); | |
| 76 | + | assert.equal(plain.confirmed, null); | |
| 77 | + | assert.equal(plain.hint, "Your invite was sent here. We email it a code to confirm it before you start."); | |
| 78 | + | ||
| 79 | + | // An invite for anyone with the code has no address to prove. | |
| 80 | + | const open = inviteSignUpCopy({ ...base, address: null, emailProven: true, workspace: null }); | |
| 81 | + | assert.equal(open.confirmed, null); | |
| 82 | + | assert.equal(open.intro, "It takes a minute."); | |
| 83 | + | assert.equal(open.hint, "We email it a code to confirm it before you start."); | |
| 84 | + | ||
| 85 | + | const repo = inviteSignUpCopy({ ...base, workspace: null, repository: { name: "flagon-io/g1t" }, emailProven: true }); | |
| 86 | + | assert.equal(repo.intro, "You get flagon-io/g1t as soon as you create it."); | |
| 87 | + | }); | |
| 88 | + | ||
| 48 | 89 | test("a pasted link or code is tidied to the code", () => { | |
| 49 | 90 | assert.equal(cleanCode(CODE), CODE); | |
| 50 | 91 | assert.equal(cleanCode(` ${CODE} `), CODE); | |
| 65 | 65 | return text.replace(/\s+/g, "").slice(0, 80); | |
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | + | /** | |
| 69 | + | * The `proof` an invite email's link carries, tidied: hex, or null for | |
| 70 | + | * anything else. Identity decides whether it is the invite's own; this only | |
| 71 | + | * keeps junk out of what is passed on and put back into a form. | |
| 72 | + | */ | |
| 73 | + | export function cleanProof(raw: string | null | undefined): string | null { | |
| 74 | + | const text = (raw ?? "").trim().toLowerCase(); | |
| 75 | + | return /^[0-9a-f]{16,128}$/.test(text) ? text : null; | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /** An invite's page, keeping the email's proof when there is one. */ | |
| 79 | + | export function invitePath(code: string, proof?: string | null): string { | |
| 80 | + | const path = `/invite/${encodeURIComponent(code)}`; | |
| 81 | + | return proof ? `${path}?proof=${encodeURIComponent(proof)}` : path; | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | type Proven = { | |
| 85 | + | /** The bound address in full, or null for an invite to anyone with the code. */ | |
| 86 | + | address: string | null; | |
| 87 | + | emailProven: boolean; | |
| 88 | + | workspace: { name: string } | null; | |
| 89 | + | repository: { name: string } | null; | |
| 90 | + | }; | |
| 91 | + | ||
| 92 | + | /** | |
| 93 | + | * What signing up on an invite's page says about the email address. Opened | |
| 94 | + | * from the invite's own email (`emailProven`), the address is confirmed | |
| 95 | + | * already, so there is no code to enter; otherwise the address is confirmed | |
| 96 | + | * after sign-up, as it always is. | |
| 97 | + | */ | |
| 98 | + | export function inviteSignUpCopy(invite: Proven): { | |
| 99 | + | /** Under "Create your account". */ | |
| 100 | + | intro: string; | |
| 101 | + | /** Under the email field. */ | |
| 102 | + | hint: string; | |
| 103 | + | /** Said plainly above the form when the address is confirmed already; null otherwise. */ | |
| 104 | + | confirmed: string | null; | |
| 105 | + | } { | |
| 106 | + | const proven = invite.emailProven && invite.address !== null; | |
| 107 | + | const when = proven ? "as soon as you create it" : "as soon as you confirm your email"; | |
| 108 | + | const intro = invite.workspace | |
| 109 | + | ? `You join ${invite.workspace.name} ${when}.` | |
| 110 | + | : invite.repository | |
| 111 | + | ? `You get ${invite.repository.name} ${when}.` | |
| 112 | + | : "It takes a minute."; | |
| 113 | + | if (proven) { | |
| 114 | + | return { | |
| 115 | + | intro, | |
| 116 | + | hint: "Your invite was sent here, and you opened it from that email, so this address is confirmed already.", | |
| 117 | + | confirmed: `${invite.address} is confirmed: you came here from the invite we emailed to it, so there is no code to enter after you sign up.`, | |
| 118 | + | }; | |
| 119 | + | } | |
| 120 | + | return { | |
| 121 | + | intro, | |
| 122 | + | hint: invite.address | |
| 123 | + | ? "Your invite was sent here. We email it a code to confirm it before you start." | |
| 124 | + | : "We email it a code to confirm it before you start.", | |
| 125 | + | confirmed: null, | |
| 126 | + | }; | |
| 127 | + | } | |
| 128 | + | ||
| 68 | 129 | type Listed = { | |
| 69 | 130 | status: "pending" | "awaiting_confirmation" | "redeemed" | "expired" | "revoked"; | |
| 70 | 131 | redeemedBy: string | null; |
| 1 | − | import { CircleAlert, Lock, Ticket } from "lucide-react"; | |
| 1 | + | import { CircleAlert, Lock, MailCheck, Ticket } from "lucide-react"; | |
| 2 | 2 | import { Form, Link, data, redirect } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { InvitePreview, User } from "@g1t/contracts"; | |
| ⋯ | |||
| 11 | 11 | import { Avatar, ButtonLink, ErrorText, Field, Input, SubmitButton } from "../components/ui"; | |
| 12 | 12 | import { githubSignInEnabled } from "../lib/github.server"; | |
| 13 | 13 | import { identity } from "../lib/services.server"; | |
| 14 | − | import { cleanCode, landingFor, looksAutomated, suggestUsername, welcomeCookie } from "../lib/invites"; | |
| 14 | + | import { cleanCode, cleanProof, inviteSignUpCopy, landingFor, looksAutomated, suggestUsername, welcomeCookie } from "../lib/invites"; | |
| 15 | 15 | import { clientKey } from "../lib/registration.server"; | |
| 16 | 16 | import { assertSameOrigin, getViewer, requireUser, roleIn, startSession } from "../lib/session.server"; | |
| 17 | 17 | import { rememberWorkspace } from "../lib/workspace-choice"; | |
| ⋯ | |||
| 29 | 29 | * an address that has an account), and the workspace or repository it | |
| 30 | 30 | * gives. Signing in or up elsewhere (GitHub, /login) comes back here with | |
| 31 | 31 | * `?accept=1`, which finishes the job. | |
| 32 | + | * | |
| 33 | + | * The link in the invite's own email also carries `?proof=`, which only | |
| 34 | + | * that email has: signing up from it makes the account with the address | |
| 35 | + | * confirmed already. The code alone (typed in, or a link passed on) does | |
| 36 | + | * not, and the address is confirmed after sign-up as usual. | |
| 32 | 37 | */ | |
| 33 | 38 | export async function loader({ request, context, params }: Route.LoaderArgs) { | |
| 34 | 39 | const code = cleanCode(params.code); | |
| 35 | 40 | const viewer = getViewer(context); | |
| 36 | − | const accepting = new URL(request.url).searchParams.get("accept") === "1"; | |
| 37 | − | const checked = await identity.checkInvite(code, clientKey(request), { viewer, anyStatus: true }); | |
| 41 | + | const search = new URL(request.url).searchParams; | |
| 42 | + | const accepting = search.get("accept") === "1"; | |
| 43 | + | const emailProof = cleanProof(search.get("proof")); | |
| 44 | + | const checked = await identity.checkInvite(code, clientKey(request), { viewer, anyStatus: true, emailProof }); | |
| 38 | 45 | const invite = checked.ok ? checked.value : null; | |
| 39 | 46 | // A shared link for a group signs up on /register, which names the group. | |
| 40 | 47 | if (invite?.sharedLabel) throw redirect(`/register?invite=${encodeURIComponent(code)}`); | |
| ⋯ | |||
| 61 | 68 | alreadyIn: viewer && invite ? alreadyIn(viewer, invite) : false, | |
| 62 | 69 | github: false, | |
| 63 | 70 | suggestion: suggestUsername(invite?.address), | |
| 71 | + | // Only a proof identity accepted goes back into the form. | |
| 72 | + | proof: invite?.emailProven ? emailProof : null, | |
| 64 | 73 | started: Date.now(), | |
| 65 | 74 | acceptError: null as string | null, | |
| 66 | 75 | }; | |
| ⋯ | |||
| 95 | 104 | const code = cleanCode(params.code); | |
| 96 | 105 | const form = await request.formData(); | |
| 97 | 106 | const client = clientKey(request); | |
| 98 | − | const checked = await identity.checkInvite(code, client, { viewer: getViewer(context) }); | |
| 107 | + | const emailProof = cleanProof(String(form.get("proof") ?? "")); | |
| 108 | + | const checked = await identity.checkInvite(code, client, { viewer: getViewer(context), emailProof }); | |
| 99 | 109 | if (!checked.ok) return data({ error: checked.error.message }, { status: 422 }); | |
| 100 | 110 | const invite = checked.value; | |
| 101 | 111 | ||
| ⋯ | |||
| 110 | 120 | String(form.get("password") ?? ""), | |
| 111 | 121 | code, | |
| 112 | 122 | client, | |
| 123 | + | // Identity checks it again, against this invite and this address. | |
| 124 | + | emailProof, | |
| 113 | 125 | ); | |
| 114 | 126 | if (!result.ok) return data({ error: result.error.message }, { status: 422 }); | |
| 115 | 127 | throw landIn(request, invite, [startSession(result.value.sessionToken)], true); | |
| ⋯ | |||
| 202 | 214 | const here = `/invite/${loaded.code}`; | |
| 203 | 215 | const back = `${here}?accept=1`; | |
| 204 | 216 | const github = `/auth/github?${new URLSearchParams({ invite: loaded.code, next: back })}`; | |
| 217 | + | const copy = inviteSignUpCopy(invite); | |
| 205 | 218 | return ( | |
| 206 | 219 | <section aria-labelledby="sign-up" className="rounded-xl border border-line bg-surface/60 p-5 sm:p-6"> | |
| 207 | 220 | <h2 id="sign-up" className="text-base font-semibold"> | |
| 208 | 221 | Create your account | |
| 209 | 222 | </h2> | |
| 210 | − | <p className="mt-1 text-sm text-muted"> | |
| 211 | − | {invite.workspace | |
| 212 | − | ? `You join ${invite.workspace.name} as soon as you confirm your email.` | |
| 213 | − | : invite.repository | |
| 214 | − | ? `You get ${invite.repository.name} as soon as you confirm your email.` | |
| 215 | − | : "It takes a minute."} | |
| 216 | − | </p> | |
| 223 | + | <p className="mt-1 text-sm text-muted">{copy.intro}</p> | |
| 224 | + | {copy.confirmed && ( | |
| 225 | + | <p className="mt-4 flex items-start gap-2 rounded-md border border-success/40 bg-success/5 p-3 text-sm" role="status"> | |
| 226 | + | <MailCheck size={16} aria-hidden="true" className="mt-0.5 shrink-0 text-success" /> | |
| 227 | + | <span>{copy.confirmed}</span> | |
| 228 | + | </p> | |
| 229 | + | )} | |
| 217 | 230 | {loaded.github && ( | |
| 218 | 231 | <div className="mt-5"> | |
| 219 | 232 | <ContinueWithGithub href={github} /> | |
| ⋯ | |||
| 223 | 236 | <Form method="post" className={`relative space-y-4 ${loaded.github ? "" : "mt-5"}`}> | |
| 224 | 237 | <input type="hidden" name="intent" value="register" /> | |
| 225 | 238 | <Honeypot started={loaded.started} /> | |
| 239 | + | {loaded.proof && <input type="hidden" name="proof" value={loaded.proof} />} | |
| 226 | 240 | {invite.address ? ( | |
| 227 | − | <Field label="Email" hint="Your invite was sent here. We email it a code to confirm it before you start."> | |
| 241 | + | <Field label="Email" hint={copy.hint}> | |
| 228 | 242 | <span className="relative block"> | |
| 229 | 243 | <Input name="email" type="email" value={invite.address} readOnly aria-readonly="true" autoComplete="email" /> | |
| 230 | − | <Lock size={14} aria-hidden="true" className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-faint" /> | |
| 244 | + | {copy.confirmed ? ( | |
| 245 | + | <MailCheck size={14} aria-hidden="true" className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-success" /> | |
| 246 | + | ) : ( | |
| 247 | + | <Lock size={14} aria-hidden="true" className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-faint" /> | |
| 248 | + | )} | |
| 231 | 249 | </span> | |
| 232 | 250 | </Field> | |
| 233 | 251 | ) : ( | |
| 234 | − | <Field label="Email" hint="We email it a code to confirm it before you start."> | |
| 252 | + | <Field label="Email" hint={copy.hint}> | |
| 235 | 253 | <Input name="email" type="email" autoComplete="email" required maxLength={254} /> | |
| 236 | 254 | </Field> | |
| 237 | 255 | )} | |
| 11 | 11 | import { githubSignInEnabled } from "../lib/github.server"; | |
| 12 | 12 | import { Avatar, Button, ErrorText, Field, Input, SubmitButton } from "../components/ui"; | |
| 13 | 13 | import { identity } from "../lib/services.server"; | |
| 14 | − | import { cleanCode, looksAutomated, sharedDomainsHint, sharedInviteLine } from "../lib/invites"; | |
| 14 | + | import { cleanCode, cleanProof, invitePath, looksAutomated, sharedDomainsHint, sharedInviteLine } from "../lib/invites"; | |
| 15 | 15 | import { clientKey, registrationMode } from "../lib/registration.server"; | |
| 16 | 16 | import { | |
| 17 | 17 | assertSameOrigin, | |
| ⋯ | |||
| 36 | 36 | // where it leads; it signs up, joins and lands in one go. A shared link | |
| 37 | 37 | // for a group signs up here: it joins nothing, and the form says which | |
| 38 | 38 | // group it is for. | |
| 39 | − | if (invite && code && !invite.sharedLabel) throw redirect(`/invite/${encodeURIComponent(code)}`); | |
| 39 | + | // The invite email's proof goes with it, so the address it proves stays | |
| 40 | + | // confirmed there. | |
| 41 | + | if (invite && code && !invite.sharedLabel) { | |
| 42 | + | throw redirect(invitePath(code, cleanProof(new URL(request.url).searchParams.get("proof")))); | |
| 43 | + | } | |
| 40 | 44 | // Signing up with GitHub carries the invite code and `next` through it. | |
| 41 | 45 | const params = new URLSearchParams(); | |
| 42 | 46 | if (code) params.set("invite", code); | |
| 245 | 245 | /// registration is open. | |
| 246 | 246 | #[serde(default)] | |
| 247 | 247 | pub invite_code: Option<String>, | |
| 248 | + | /// The `proof` from the invite email's link. When it is the invite's | |
| 249 | + | /// own and `email` is the address the invite was sent to, the account | |
| 250 | + | /// starts with that address confirmed; otherwise it is ignored. | |
| 251 | + | #[serde(default)] | |
| 252 | + | pub email_proof: Option<String>, | |
| 248 | 253 | /// Who is asking, such as the visitor's IP address, for rate limits. | |
| 249 | 254 | #[serde(default)] | |
| 250 | 255 | pub client: Option<String>, | |
| ⋯ | |||
| 1278 | 1283 | pub viewer: Option<User>, | |
| 1279 | 1284 | #[serde(default)] | |
| 1280 | 1285 | pub any_status: bool, | |
| 1286 | + | /// The `proof` from the invite email's link, if the page was opened | |
| 1287 | + | /// from it: sets `InvitePreview::email_proven`. | |
| 1288 | + | #[serde(default)] | |
| 1289 | + | pub email_proof: Option<String>, | |
| 1281 | 1290 | } | |
| 1282 | 1291 | ||
| 1283 | 1292 | /// Someone shown on an invite. | |
| ⋯ | |||
| 1333 | 1342 | /// domains, such as `["cloudflare.com"]`. Empty for any address. | |
| 1334 | 1343 | #[serde(default)] | |
| 1335 | 1344 | pub shared_domains: Vec<String>, | |
| 1345 | + | /// Whether `email_proof` was this pending invite's own, from the email | |
| 1346 | + | /// it was sent in: the account made with it starts with `address` | |
| 1347 | + | /// confirmed. False without a proof, with a wrong one, and for an | |
| 1348 | + | /// invite bound to no address. | |
| 1349 | + | #[serde(default)] | |
| 1350 | + | pub email_proven: bool, | |
| 1336 | 1351 | } | |
| 1337 | 1352 | ||
| 1338 | 1353 | /// `accept_invite`: a signed-in person uses a workspace invite made for | |
| 41 | 41 | export function identityClient(service: ServiceBinding): IdentityApi { | |
| 42 | 42 | const call = <T>(method: string, args: object) => rpc<T>(service, method, args); | |
| 43 | 43 | return { | |
| 44 | − | register: (username, email, password, inviteCode, client) => | |
| 45 | − | call("register", { username, email, password, invite_code: inviteCode ?? null, client: client ?? null }), | |
| 44 | + | register: (username, email, password, inviteCode, client, emailProof) => | |
| 45 | + | call("register", { | |
| 46 | + | username, | |
| 47 | + | email, | |
| 48 | + | password, | |
| 49 | + | invite_code: inviteCode ?? null, | |
| 50 | + | email_proof: emailProof ?? null, | |
| 51 | + | client: client ?? null, | |
| 52 | + | }), | |
| 46 | 53 | signIn: (username, password, client) => call("sign_in", { username, password, client: client ?? null }), | |
| 47 | 54 | twoFactorSignIn: (challenge, code, client) => call("two_factor_sign_in", { challenge, code, client: client ?? null }), | |
| 48 | 55 | signOut: (sessionToken) => call("sign_out", { sessionToken }), | |
| ⋯ | |||
| 153 | 160 | client: client ?? null, | |
| 154 | 161 | viewer: options.viewer ?? null, | |
| 155 | 162 | any_status: options.anyStatus ?? false, | |
| 163 | + | email_proof: options.emailProof ?? null, | |
| 156 | 164 | }), | |
| 157 | 165 | acceptInvite: (user, code) => call("accept_invite", { user, code }), | |
| 158 | 166 | inviteMember: (actor, slug, email) => call("invite_member", { actor, slug, email }), | |
| 281 | 281 | sharedLabel: string | null; | |
| 282 | 282 | /** The email domains a shared invite link is limited to; empty for any address. */ | |
| 283 | 283 | sharedDomains: string[]; | |
| 284 | + | /** | |
| 285 | + | * Whether the page was opened from this pending invite's own email (its | |
| 286 | + | * `proof` checked out): the account made with it starts with `address` | |
| 287 | + | * confirmed. False without a proof, with a wrong one, or for an invite | |
| 288 | + | * bound to no address. | |
| 289 | + | */ | |
| 290 | + | emailProven: boolean; | |
| 284 | 291 | }; | |
| 285 | 292 | ||
| 286 | 293 | export type WaitlistStatus = "waiting" | "invited" | "dismissed"; | |
| ⋯ | |||
| 702 | 709 | inviteCode?: string | null, | |
| 703 | 710 | /** Who is asking, such as the visitor's IP address, for rate limits. */ | |
| 704 | 711 | client?: string | null, | |
| 712 | + | /** | |
| 713 | + | * The `proof` from the invite email's link. When it is the invite's own | |
| 714 | + | * and `email` is the address it was sent to, the account starts with that | |
| 715 | + | * address confirmed; otherwise it is ignored. | |
| 716 | + | */ | |
| 717 | + | emailProof?: string | null, | |
| 705 | 718 | ): Promise<Result<{ user: User; sessionToken: string }>>; | |
| 706 | 719 | /** Verifies a username and password for website sign-in. */ | |
| 707 | 720 | /** | |
| ⋯ | |||
| 890 | 903 | /** | |
| 891 | 904 | * What a code is for. Unknown, used, revoked and expired codes all get the | |
| 892 | 905 | * same answer, unless `anyStatus`: then a real code that is spent is | |
| 893 | − | * described, with its `status`. `viewer` sets `forViewer`. | |
| 906 | + | * described, with its `status`. `viewer` sets `forViewer`; `emailProof`, | |
| 907 | + | * the `proof` from the invite email's link, sets `emailProven`. | |
| 894 | 908 | */ | |
| 895 | 909 | checkInvite( | |
| 896 | 910 | code: string, | |
| 897 | 911 | client?: string | null, | |
| 898 | − | options?: { viewer?: User | null; anyStatus?: boolean }, | |
| 912 | + | options?: { viewer?: User | null; anyStatus?: boolean; emailProof?: string | null }, | |
| 899 | 913 | ): Promise<Result<InvitePreview>>; | |
| 900 | 914 | /** | |
| 901 | 915 | * A signed-in person uses a workspace invite sent to their address, or one | |
| 759 | 759 | &full_name(&repo), | |
| 760 | 760 | a.role.label(), | |
| 761 | 761 | None, | |
| 762 | + | None, | |
| 762 | 763 | INVITATION_DAYS, | |
| 763 | 764 | ) | |
| 764 | 765 | .await | |
| ⋯ | |||
| 804 | 805 | let id = self | |
| 805 | 806 | .insert_invitation(repo, workspace_id, None, Some(email), Some(&invite.id), role, &actor.id, days) | |
| 806 | 807 | .await?; | |
| 808 | + | // The email's link proves the address, as any invite email's does. | |
| 809 | + | let proof = self.email_proof_for(&invite.id, email); | |
| 807 | 810 | if let Some(code) = &invite.code | |
| 808 | 811 | && let Err(error) = crate::email::send_repo_invite( | |
| 809 | 812 | &self.env, | |
| ⋯ | |||
| 812 | 815 | &full_name(repo), | |
| 813 | 816 | role.label(), | |
| 814 | 817 | Some(code), | |
| 818 | + | proof.as_deref(), | |
| 815 | 819 | days, | |
| 816 | 820 | ) | |
| 817 | 821 | .await | |
| 46 | 46 | hex::encode(mac.finalize().into_bytes()) | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | + | /// The proof an invite email's link carries that whoever follows it reads | |
| 50 | + | /// that inbox: an HMAC-SHA256 under `key` (IDENTITY_KEY) of the invite's id | |
| 51 | + | /// and the address it is bound to, trimmed and lowercased. Only the email | |
| 52 | + | /// has it: the inviter sees the code, never this, and nobody can make one | |
| 53 | + | /// without the key. | |
| 54 | + | pub fn invite_proof(key: &[u8], invite_id: &str, email: &str) -> String { | |
| 55 | + | use hmac::{Hmac, Mac}; | |
| 56 | + | let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key).expect("HMAC takes any key length"); | |
| 57 | + | mac.update(b"g1t invite email proof\0"); | |
| 58 | + | mac.update(invite_id.as_bytes()); | |
| 59 | + | mac.update(b"\0"); | |
| 60 | + | mac.update(email.trim().to_lowercase().as_bytes()); | |
| 61 | + | hex::encode(mac.finalize().into_bytes()) | |
| 62 | + | } | |
| 63 | + | ||
| 49 | 64 | /// Whether two strings are equal, in time that depends on their length only. | |
| 50 | 65 | pub fn same(a: &str, b: &str) -> bool { | |
| 51 | 66 | a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |diff, (x, y)| diff | (x ^ y)) == 0 |
| 288 | 288 | pub workspace: Option<&'a str>, | |
| 289 | 289 | pub joins_existing_account: bool, | |
| 290 | 290 | pub code: &'a str, | |
| 291 | + | /// The proof that whoever follows the link reads this inbox | |
| 292 | + | /// (invites.rs, `email_proof`): the account made from it starts with | |
| 293 | + | /// the address confirmed. None for an invite to an existing account, | |
| 294 | + | /// or without IDENTITY_KEY. | |
| 295 | + | pub proof: Option<&'a str>, | |
| 291 | 296 | pub days: u64, | |
| 292 | 297 | /// A line from whoever sent it, such as staff approving a request. | |
| 293 | 298 | pub note: Option<&'a str>, | |
| 294 | 299 | } | |
| 295 | 300 | ||
| 301 | + | /// An invite's page, as its email links to it: with the email's proof | |
| 302 | + | /// when it has one, so following it confirms the address (invites.rs). | |
| 303 | + | /// The code alone is what the inviter can see and share. | |
| 304 | + | pub fn invite_link(site: &str, code: &str, proof: Option<&str>) -> String { | |
| 305 | + | match proof { | |
| 306 | + | Some(proof) => format!("{site}/invite/{code}?proof={proof}"), | |
| 307 | + | None => format!("{site}/invite/{code}"), | |
| 308 | + | } | |
| 309 | + | } | |
| 310 | + | ||
| 296 | 311 | /// The subject and letter of an invite email. | |
| 297 | 312 | pub fn invite_letter(invite: &InviteEmail, site: &str) -> (String, Letter) { | |
| 298 | 313 | let (subject, intro) = invite_wording(invite.from, invite.workspace, invite.joins_existing_account); | |
| ⋯ | |||
| 314 | 329 | paragraphs: vec![intro], | |
| 315 | 330 | quotes, | |
| 316 | 331 | code: None, | |
| 317 | − | action: Some((action, format!("{site}/invite/{}", invite.code))), | |
| 332 | + | action: Some((action, invite_link(site, invite.code, invite.proof))), | |
| 318 | 333 | footer: format!( | |
| 319 | 334 | "This invite works for {} days, only for this address. If you were not expecting it, you can ignore this message.", | |
| 320 | 335 | invite.days | |
| ⋯ | |||
| 413 | 428 | } | |
| 414 | 429 | ||
| 415 | 430 | /// An invitation to collaborate on one repository. `code` is set when the | |
| 416 | − | /// address has no account yet: the link then makes one and accepts; without | |
| 417 | − | /// it, the link opens the invitation to accept or decline. | |
| 431 | + | /// address has no account yet: the link then makes one and accepts, with | |
| 432 | + | /// `proof` (see [`invite_link`]); without it, the link opens the | |
| 433 | + | /// invitation to accept or decline. | |
| 418 | 434 | pub async fn send_repo_invite( | |
| 419 | 435 | env: &Env, | |
| 420 | 436 | to: &str, | |
| ⋯ | |||
| 422 | 438 | repo: &str, | |
| 423 | 439 | role: &str, | |
| 424 | 440 | code: Option<&str>, | |
| 441 | + | proof: Option<&str>, | |
| 425 | 442 | days: u64, | |
| 426 | 443 | ) -> Result<()> { | |
| 427 | 444 | let (subject, intro) = repo_invite_wording(from, repo, role, code.is_some()); | |
| 428 | 445 | let link = match code { | |
| 429 | − | Some(code) => format!("{}/invite/{code}", site(env)), | |
| 446 | + | Some(code) => invite_link(&site(env), code, proof), | |
| 430 | 447 | None => format!("{}/{repo}/invitations", site(env)), | |
| 431 | 448 | }; | |
| 432 | 449 | send_link( | |
| ⋯ | |||
| 574 | 591 | workspace: Some("Flagon, Inc."), | |
| 575 | 592 | joins_existing_account: false, | |
| 576 | 593 | code: "g1t-abcd", | |
| 594 | + | proof: None, | |
| 577 | 595 | days: 30, | |
| 578 | 596 | note, | |
| 579 | 597 | } | |
| ⋯ | |||
| 596 | 614 | } | |
| 597 | 615 | ||
| 598 | 616 | #[test] | |
| 617 | + | fn an_invite_link_carries_the_emails_proof_when_it_has_one() { | |
| 618 | + | let proven = InviteEmail { proof: Some("4f9c2a"), ..invite(None, None) }; | |
| 619 | + | let (_, letter) = invite_letter(&proven, SITE); | |
| 620 | + | assert_eq!(letter.action.as_ref().unwrap().1, "https://g1t.sh/invite/g1t-abcd?proof=4f9c2a"); | |
| 621 | + | // An invite sent before proofs, or for an existing account: the code alone. | |
| 622 | + | assert_eq!(invite_link(SITE, "g1t-abcd", None), "https://g1t.sh/invite/g1t-abcd"); | |
| 623 | + | } | |
| 624 | + | ||
| 625 | + | #[test] | |
| 599 | 626 | fn links_point_at_the_site_they_are_given() { | |
| 600 | 627 | let (_, letter) = invite_letter(&invite(None, None), "http://localhost:8787"); | |
| 601 | 628 | assert_eq!(letter.action.as_ref().unwrap().1, "http://localhost:8787/invite/g1t-abcd"); | |
| ⋯ | |||
| 688 | 715 | workspace: Some("Flagon, Inc."), | |
| 689 | 716 | joins_existing_account: false, | |
| 690 | 717 | code: "g1t-k7m2-q9xd-4hpw-abcd-0123-4567-89ef-ghjk", | |
| 718 | + | proof: None, | |
| 691 | 719 | days: 30, | |
| 692 | 720 | note: None, | |
| 693 | 721 | }, SITE); | |
| ⋯ | |||
| 698 | 726 | workspace: None, | |
| 699 | 727 | joins_existing_account: false, | |
| 700 | 728 | code: "g1t-k7m2-q9xd-4hpw-abcd-0123-4567-89ef-ghjk", | |
| 729 | + | proof: None, | |
| 701 | 730 | days: 30, | |
| 702 | 731 | note: Some("Thanks for waiting. We would love to see the compiler."), | |
| 703 | 732 | }, SITE); | |
| 584 | 584 | password_hash: "", | |
| 585 | 585 | verified: true, | |
| 586 | 586 | invite_code, | |
| 587 | + | // GitHub has confirmed the address already. | |
| 588 | + | email_proof: None, | |
| 587 | 589 | client: None, | |
| 588 | 590 | }) | |
| 589 | 591 | .await? |
| 233 | 233 | } | |
| 234 | 234 | } | |
| 235 | 235 | ||
| 236 | + | /// The proof for an invite's email link, or None when there is none to | |
| 237 | + | /// make: no key (a development setup), or no address the invite is bound | |
| 238 | + | /// to. See [`crypto::invite_proof`]. | |
| 239 | + | pub fn email_proof(key: &[u8], invite_id: &str, bound: Option<&str>) -> Option<String> { | |
| 240 | + | let bound = bound.map(str::trim).filter(|bound| !bound.is_empty())?; | |
| 241 | + | (!key.is_empty()).then(|| crypto::invite_proof(key, invite_id, bound)) | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | /// Whether `proof` shows that whoever brings it followed the invite's own | |
| 245 | + | /// email: it is the proof for this invite and the address it is bound to, | |
| 246 | + | /// and `email`, the address the account is made with, is that address. | |
| 247 | + | /// Anything else (no proof, a wrong or altered one, another invite's, an | |
| 248 | + | /// invite bound to no address, a different address) proves nothing, and | |
| 249 | + | /// the address is confirmed as any other is. | |
| 250 | + | pub fn proves_email(key: &[u8], invite_id: &str, bound: Option<&str>, email: &str, proof: Option<&str>) -> bool { | |
| 251 | + | let (Some(expected), Some(proof)) = (email_proof(key, invite_id, bound), proof.map(str::trim)) else { | |
| 252 | + | return false; | |
| 253 | + | }; | |
| 254 | + | let same_address = bound.is_some_and(|bound| bound.trim().to_lowercase() == email.trim().to_lowercase()); | |
| 255 | + | same_address && crypto::same(&expected, &proof.to_ascii_lowercase()) | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | /// Whether a new account starts with its address confirmed: GitHub | |
| 259 | + | /// confirmed it (`verified`), or `invite`, the one-person invite that | |
| 260 | + | /// admitted it, was followed from its own email with `proof` and `email` is | |
| 261 | + | /// the address it was sent to. A shared link, a code typed in or passed on, | |
| 262 | + | /// or an invite bound to no address: confirmed as any other is. | |
| 263 | + | pub fn starts_confirmed(key: &[u8], verified: bool, invite: Option<&InviteRow>, email: &str, proof: Option<&str>) -> bool { | |
| 264 | + | verified | |
| 265 | + | || invite.is_some_and(|row| row.kind == "account" && proves_email(key, &row.id, row.email.as_deref(), email, proof)) | |
| 266 | + | } | |
| 267 | + | ||
| 236 | 268 | /// What an invite used to sign up does once its account confirms its | |
| 237 | 269 | /// address. | |
| 238 | 270 | #[derive(Clone, Debug, PartialEq, Eq)] | |
| ⋯ | |||
| 439 | 471 | /// Whether the address is confirmed already (GitHub's verified email). | |
| 440 | 472 | pub verified: bool, | |
| 441 | 473 | pub invite_code: Option<&'a str>, | |
| 474 | + | /// The proof from the invite email's link ([`proves_email`]): when it | |
| 475 | + | /// is the invite's and `email` is the address the invite was sent to, | |
| 476 | + | /// the account starts with that address confirmed. | |
| 477 | + | pub email_proof: Option<&'a str>, | |
| 442 | 478 | /// Who is asking, for rate limits. | |
| 443 | 479 | pub client: Option<&'a str>, | |
| 444 | 480 | } | |
| ⋯ | |||
| 498 | 534 | Sealer::new(&self.env.secret("IDENTITY_KEY").ok()?.to_string()) | |
| 499 | 535 | } | |
| 500 | 536 | ||
| 537 | + | /// The key invite email proofs are made under: IDENTITY_KEY, or none | |
| 538 | + | /// in a development setup without one (then no proof is made, and none | |
| 539 | + | /// is accepted). | |
| 540 | + | fn proof_key(&self) -> Vec<u8> { | |
| 541 | + | self.env.secret("IDENTITY_KEY").map(|key| key.to_string().into_bytes()).unwrap_or_default() | |
| 542 | + | } | |
| 543 | + | ||
| 544 | + | /// The proof for the link of an invite emailed to `to`, the address it | |
| 545 | + | /// is bound to; never shown anywhere but in that email. | |
| 546 | + | pub(crate) fn email_proof_for(&self, invite_id: &str, to: &str) -> Option<String> { | |
| 547 | + | email_proof(&self.proof_key(), invite_id, Some(to)) | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | /// Whether `proof` shows the invite in `row` was followed from its own | |
| 551 | + | /// email, by someone making an account with `email`. | |
| 552 | + | fn proven(&self, row: &InviteRow, email: &str, proof: Option<&str>) -> bool { | |
| 553 | + | starts_confirmed(&self.proof_key(), false, Some(row), email, proof) | |
| 554 | + | } | |
| 555 | + | ||
| 501 | 556 | // --- Rate limits --- | |
| 502 | 557 | ||
| 503 | 558 | /// Counts one more hit on `key` this hour; false once past `limit`. | |
| ⋯ | |||
| 693 | 748 | /// invite-only, `invite_code` must admit `email`; the code is spent in | |
| 694 | 749 | /// the same transaction as the account is made. What the invite gives | |
| 695 | 750 | /// (a workspace, repository invitations) is applied once the address | |
| 696 | − | /// is confirmed: at once for an address GitHub has confirmed, otherwise | |
| 751 | + | /// is confirmed: at once for an address GitHub has confirmed or one | |
| 752 | + | /// proven by the invite email's link ([`proves_email`]), otherwise | |
| 697 | 753 | /// in the transaction that confirms it (emails.rs, `confirm_address`). | |
| 698 | 754 | /// In open mode a code is used if it is good and otherwise ignored. | |
| 699 | 755 | pub async fn create_account(&self, new: NewAccount<'_>) -> Result<Outcome<User>> { | |
| ⋯ | |||
| 739 | 795 | } | |
| 740 | 796 | } | |
| 741 | 797 | ||
| 742 | − | // Only an address GitHub has confirmed starts confirmed. An invite | |
| 743 | − | // bound to the address proves nothing: its link can be forwarded, | |
| 744 | − | // so the new account confirms the address like any other. | |
| 745 | − | let verified = new.verified; | |
| 798 | + | // An address GitHub has confirmed starts confirmed, and so does the | |
| 799 | + | // address an invite was emailed to, when the link followed was the | |
| 800 | + | // email's own: its proof is in no code the inviter sees or shares. | |
| 801 | + | // The code alone proves nothing (it can be passed on), so without | |
| 802 | + | // the proof the new account confirms the address like any other. | |
| 803 | + | let verified = starts_confirmed(&self.proof_key(), new.verified, invite.as_ref(), new.email, new.email_proof); | |
| 746 | 804 | let user = User { | |
| 747 | 805 | id: new_id("usr", now_ms()), | |
| 748 | 806 | username: new.username.to_owned(), | |
| ⋯ | |||
| 823 | 881 | self.count_failure(new.client).await?; | |
| 824 | 882 | return Ok(Outcome::fail(FailureCode::Forbidden, INVALID)); | |
| 825 | 883 | } | |
| 826 | − | // Confirmed already (GitHub): what the invite gives, now. Otherwise | |
| 884 | + | // Confirmed already (GitHub, or the invite email): what the invite gives, now. Otherwise | |
| 827 | 885 | // it waits, spent, for the address to be confirmed. | |
| 828 | 886 | if let Some(row) = invite | |
| 829 | 887 | && user.verified | |
| ⋯ | |||
| 1169 | 1227 | }; | |
| 1170 | 1228 | if let (Some(email), Some(code)) = (&email, &invite.code) { | |
| 1171 | 1229 | let from = self.display_name(&a.user).await; | |
| 1172 | − | self.send_invite_email(email, Some(&from), None, false, code, None).await; | |
| 1230 | + | self.send_invite_email(email, Some(&from), None, false, code, &invite.id, None).await; | |
| 1173 | 1231 | } | |
| 1174 | 1232 | let logs: Vec<String> = a.user.workspaces.iter().map(|membership| membership.slug.clone()).collect(); | |
| 1175 | 1233 | self.audit_invites(&a.user, "invite.created", logs, a.surface.unwrap_or(Surface::Web), format!("Created invite {}", invite.hint)) | |
| ⋯ | |||
| 1184 | 1242 | workspace: Option<&str>, | |
| 1185 | 1243 | existing: bool, | |
| 1186 | 1244 | code: &str, | |
| 1245 | + | invite_id: &str, | |
| 1187 | 1246 | note: Option<&str>, | |
| 1188 | 1247 | ) { | |
| 1248 | + | // An invite that makes an account carries the proof that the link | |
| 1249 | + | // came from this email; one for an existing account has nothing | |
| 1250 | + | // to prove. | |
| 1251 | + | let proof = if existing { None } else { self.email_proof_for(invite_id, to) }; | |
| 1189 | 1252 | let invite = crate::email::InviteEmail { | |
| 1190 | 1253 | to, | |
| 1191 | 1254 | from, | |
| 1192 | 1255 | workspace, | |
| 1193 | 1256 | joins_existing_account: existing, | |
| 1194 | 1257 | code, | |
| 1258 | + | proof: proof.as_deref(), | |
| 1195 | 1259 | days: self.invite_ttl_days(), | |
| 1196 | 1260 | note, | |
| 1197 | 1261 | }; | |
| ⋯ | |||
| 1349 | 1413 | _ => false, | |
| 1350 | 1414 | }; | |
| 1351 | 1415 | let repository = self.repository_of_code(&row.id).await?; | |
| 1416 | + | // Opened from the invite's own email: the account it makes starts | |
| 1417 | + | // with the address confirmed. Said only while it can make one. | |
| 1418 | + | let email_proven = pending | |
| 1419 | + | && !has_account | |
| 1420 | + | && row.email.as_deref().is_some_and(|bound| self.proven(&row, bound, a.email_proof.as_deref())); | |
| 1352 | 1421 | #[derive(Deserialize)] | |
| 1353 | 1422 | struct From { | |
| 1354 | 1423 | username: String, | |
| ⋯ | |||
| 1391 | 1460 | expires_at: row.expires_at, | |
| 1392 | 1461 | shared_label: None, | |
| 1393 | 1462 | shared_domains: Vec::new(), | |
| 1463 | + | email_proven, | |
| 1394 | 1464 | })) | |
| 1395 | 1465 | } | |
| 1396 | 1466 | ||
| ⋯ | |||
| 1550 | 1620 | if let Some(code) = &invite.code { | |
| 1551 | 1621 | let from = self.display_name(&a.actor).await; | |
| 1552 | 1622 | let workspace = self.workspace_name(&workspace_id, &slug).await; | |
| 1553 | − | self.send_invite_email(&email, Some(&from), Some(&workspace), has_account, code, None).await; | |
| 1623 | + | self.send_invite_email(&email, Some(&from), Some(&workspace), has_account, code, &invite.id, None).await; | |
| 1554 | 1624 | } | |
| 1555 | 1625 | self.audit_invites( | |
| 1556 | 1626 | &a.actor, | |
| ⋯ | |||
| 1953 | 2023 | return Ok(Outcome::fail(FailureCode::Conflict, "The invite could not be made. Try again.")); | |
| 1954 | 2024 | }; | |
| 1955 | 2025 | if let (Some(email), Some(code)) = (&email, &invite.code) { | |
| 1956 | − | self.send_invite_email(email, None, None, false, code, note).await; | |
| 2026 | + | self.send_invite_email(email, None, None, false, code, &invite.id, note).await; | |
| 1957 | 2027 | } | |
| 1958 | 2028 | invite.staff = Some(staff.to_owned()); | |
| 1959 | 2029 | Ok(Outcome::Ok(invite)) | |
| ⋯ | |||
| 2396 | 2466 | assert_eq!(admits(Some(&account), "ada@example.com", false), Ok(())); | |
| 2397 | 2467 | } | |
| 2398 | 2468 | ||
| 2469 | + | const KEY: &[u8] = b"identity key"; | |
| 2470 | + | ||
| 2471 | + | #[test] | |
| 2472 | + | fn an_invite_emails_proof_is_for_its_invite_and_address_only() { | |
| 2473 | + | let proof = email_proof(KEY, "inv_1", Some("ada@example.com")).unwrap(); | |
| 2474 | + | assert_eq!(proof.len(), 64); | |
| 2475 | + | let proves = |id: &str, bound: Option<&str>, email: &str, proof: Option<&str>| proves_email(KEY, id, bound, email, proof); | |
| 2476 | + | // The right invite and address, however the address is written. | |
| 2477 | + | assert!(proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&proof))); | |
| 2478 | + | assert!(proves("inv_1", Some("Ada@Example.com"), " ADA@example.com ", Some(&proof))); | |
| 2479 | + | assert!(proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&proof.to_uppercase()))); | |
| 2480 | + | // Another address: the account confirms that one itself. | |
| 2481 | + | assert!(!proves("inv_1", Some("ada@example.com"), "eve@example.com", Some(&proof))); | |
| 2482 | + | // Another invite's proof, even for the same address. | |
| 2483 | + | assert!(!proves("inv_2", Some("ada@example.com"), "ada@example.com", Some(&proof))); | |
| 2484 | + | // Tampered, cut short, empty or missing. | |
| 2485 | + | let mut tampered = proof.clone().into_bytes(); | |
| 2486 | + | tampered[10] = if tampered[10] == b'0' { b'1' } else { b'0' }; | |
| 2487 | + | let tampered = String::from_utf8(tampered).unwrap(); | |
| 2488 | + | assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&tampered))); | |
| 2489 | + | assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&proof[..32]))); | |
| 2490 | + | assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(""))); | |
| 2491 | + | assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", None)); | |
| 2492 | + | // An invite bound to no address has no proof to give. | |
| 2493 | + | assert_eq!(email_proof(KEY, "inv_1", None), None); | |
| 2494 | + | assert!(!proves("inv_1", None, "ada@example.com", Some(&proof))); | |
| 2495 | + | // Made under another key: not ours. | |
| 2496 | + | let foreign = email_proof(b"another key", "inv_1", Some("ada@example.com")).unwrap(); | |
| 2497 | + | assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&foreign))); | |
| 2498 | + | // Without a key (development) none is made, and none is taken. | |
| 2499 | + | assert_eq!(email_proof(b"", "inv_1", Some("ada@example.com")), None); | |
| 2500 | + | let unkeyed = crypto::invite_proof(b"", "inv_1", "ada@example.com"); | |
| 2501 | + | assert!(!proves_email(b"", "inv_1", Some("ada@example.com"), "ada@example.com", Some(&unkeyed))); | |
| 2502 | + | } | |
| 2503 | + | ||
| 2504 | + | #[test] | |
| 2505 | + | fn an_account_starts_confirmed_only_from_the_invite_email_to_its_address() { | |
| 2506 | + | let invite = row(None, false, LATER); | |
| 2507 | + | let proof = email_proof(KEY, &invite.id, invite.email.as_deref()).unwrap(); | |
| 2508 | + | // From the invite email, with the address it was sent to. | |
| 2509 | + | assert!(starts_confirmed(KEY, false, Some(&invite), "ada@example.com", Some(&proof))); | |
| 2510 | + | // The code alone (typed in, or a link passed on), or a bad proof. | |
| 2511 | + | assert!(!starts_confirmed(KEY, false, Some(&invite), "ada@example.com", None)); | |
| 2512 | + | assert!(!starts_confirmed(KEY, false, Some(&invite), "ada@example.com", Some("0123"))); | |
| 2513 | + | // A different address than the invite's. | |
| 2514 | + | assert!(!starts_confirmed(KEY, false, Some(&invite), "eve@example.com", Some(&proof))); | |
| 2515 | + | // No invite (open registration, or a shared link), or one bound to no address. | |
| 2516 | + | assert!(!starts_confirmed(KEY, false, None, "ada@example.com", Some(&proof))); | |
| 2517 | + | let unbound = InviteRow { email: None, ..row(None, false, LATER) }; | |
| 2518 | + | assert!(!starts_confirmed(KEY, false, Some(&unbound), "ada@example.com", Some(&proof))); | |
| 2519 | + | // A workspace invite makes no account. | |
| 2520 | + | let join = InviteRow { kind: "workspace".into(), ..row(None, false, LATER) }; | |
| 2521 | + | assert!(!starts_confirmed(KEY, false, Some(&join), "ada@example.com", Some(&proof))); | |
| 2522 | + | // GitHub's confirmed address, whatever else. | |
| 2523 | + | assert!(starts_confirmed(KEY, true, None, "ada@example.com", None)); | |
| 2524 | + | } | |
| 2525 | + | ||
| 2399 | 2526 | #[test] | |
| 2400 | 2527 | fn addresses_are_checked_and_masked() { | |
| 2401 | 2528 | assert_eq!(normalize_email(" Ada@Example.COM ").as_deref(), Some("ada@example.com")); | |
| 427 | 427 | password_hash: &password_hash, | |
| 428 | 428 | verified: false, | |
| 429 | 429 | invite_code, | |
| 430 | + | email_proof: a.email_proof.as_deref(), | |
| 430 | 431 | client: a.client.as_deref(), | |
| 431 | 432 | }) | |
| 432 | 433 | .await? |
| 379 | 379 | expires_at: link.expires_at.clone(), | |
| 380 | 380 | shared_label: Some(link.label.clone()), | |
| 381 | 381 | shared_domains: link.domains(), | |
| 382 | + | email_proven: false, | |
| 382 | 383 | } | |
| 383 | 384 | } | |
| 384 | 385 |