Skip to content
753 linesCodeBlameRaw
1//! A person's email addresses and the security of their account: identity's
2//! methods for them, and the rules they follow, kept pure so every caller
3//! applies the same ones.
4//!
5//! An account has up to [`MAX_EMAILS`] addresses. One is primary: account
6//! mail and password resets go there. A confirmed address belongs to one
7//! account; until someone confirms it, any account may have added it, and
8//! the first to confirm it keeps it. Sensitive changes need the person to
9//! have signed in within [`RECENT_AUTH_SECONDS`], or to give their password
10//! again ([`Reauth`]); a refusal for that is `FailureCode::ReauthRequired`.
11//!
12//! An account can turn on two-factor authentication: a code from an
13//! authenticator app (TOTP, RFC 6238), with recovery codes for when the app
14//! is lost. Signing in with a password then asks for a code as well.
15//!
16//! Workspaces can ask more of their members. [`WorkspacePolicy`] is where
17//! that goes: identity evaluates it wherever someone gains or uses access
18//! to a workspace. Today an owner can require two-factor authentication;
19//! the email rules are there for later.
20
21use serde::{Deserialize, Serialize};
22
23use crate::User;
24
25/// The most addresses one account may have, confirmed or not.
26pub const MAX_EMAILS: usize = 10;
27
28/// How long after signing in (or confirming the password) a person may make
29/// sensitive changes without being asked to prove it is them again.
30pub const RECENT_AUTH_SECONDS: u64 = 10 * 60;
31
32/// The least time between two confirmation emails to one address.
33pub const RESEND_SECONDS: u64 = 60;
34
35/// Where each person's private commit address lives:
36/// `<id suffix>+<username>@users.noreply.g1t.sh`.
37pub const NOREPLY_DOMAIN: &str = "users.noreply.g1t.sh";
38
39/// How many characters of the account id the noreply address carries. The
40/// end of an id is its random part, so a username alone never resolves.
41pub const NOREPLY_ID_CHARS: usize = 8;
42
43/// An address trimmed and lowercased, if it looks like one: something, an
44/// `@`, and a domain with a dot, at most 254 characters, no spaces.
45pub fn normalize_email(text: &str) -> Option<String> {
46 let email = text.trim().to_lowercase();
47 let well_formed = email.len() <= 254
48 && email.split_once('@').is_some_and(|(local, domain)| {
49 !local.is_empty() && !domain.contains('@') && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.')
50 })
51 && !email.contains(char::is_whitespace);
52 well_formed.then_some(email)
53}
54
55/// The person's noreply address, used for commits g1t makes for them when
56/// they keep their address private.
57pub fn noreply_address(user_id: &str, username: &str) -> String {
58 format!("{}+{}@{NOREPLY_DOMAIN}", id_suffix(user_id), username.to_lowercase())
59}
60
61/// The last [`NOREPLY_ID_CHARS`] characters of an account id, lowercased.
62pub fn id_suffix(user_id: &str) -> String {
63 let chars: Vec<char> = user_id.chars().collect();
64 let start = chars.len().saturating_sub(NOREPLY_ID_CHARS);
65 chars[start..].iter().collect::<String>().to_lowercase()
66}
67
68/// The id suffix and username a noreply address names, or `None` for any
69/// other address.
70pub fn parse_noreply(email: &str) -> Option<(String, String)> {
71 let email = email.trim().to_lowercase();
72 let local = email.strip_suffix(&format!("@{NOREPLY_DOMAIN}"))?;
73 let (suffix, username) = local.split_once('+')?;
74 (suffix.chars().count() == NOREPLY_ID_CHARS && !username.is_empty()).then(|| (suffix.to_owned(), username.to_owned()))
75}
76
77/// Whether a sign-in at `authenticated_at` (RFC 3339) is recent at `now`
78/// (RFC 3339), within `window_seconds`. Both are g1t's fixed format, which
79/// compares as text.
80pub fn is_recent(authenticated_at: Option<&str>, now_ms: u64, window_seconds: u64) -> bool {
81 let since = crate::time::rfc3339(now_ms.saturating_sub(window_seconds * 1000));
82 authenticated_at.is_some_and(|at| at >= since.as_str())
83}
84
85/// One address, as the rules about removing and choosing addresses see it.
86#[derive(Clone, Debug, PartialEq, Eq)]
87pub struct EmailState {
88 pub email: String,
89 pub verified: bool,
90 pub primary: bool,
91}
92
93/// Why `email` cannot be removed from an account with `all`, or `None`.
94pub fn removal_refusal(all: &[EmailState], email: &str) -> Option<&'static str> {
95 let Some(target) = all.iter().find(|state| state.email == email) else {
96 return Some("That address is not on your account.");
97 };
98 if target.primary {
99 return Some("That is your primary address. Make another confirmed address primary first.");
100 }
101 let confirmed = all.iter().filter(|state| state.verified).count();
102 if target.verified && confirmed <= 1 {
103 return Some("That is your only confirmed address. Add and confirm another first.");
104 }
105 None
106}
107
108/// Why `email` cannot be made primary, or `None`.
109pub fn primary_refusal(all: &[EmailState], email: &str) -> Option<&'static str> {
110 match all.iter().find(|state| state.email == email) {
111 None => Some("That address is not on your account."),
112 Some(state) if !state.verified => Some("Confirm that address before making it primary."),
113 Some(_) => None,
114 }
115}
116
117/// Proof that the person making a sensitive change is the account's owner,
118/// now. Either is enough: the session they are using, if they signed in to
119/// it within [`RECENT_AUTH_SECONDS`], or their password. A correct password
120/// also renews the session's sign-in time, so they are not asked again
121/// straight away.
122#[derive(Clone, Debug, Default, Serialize, Deserialize)]
123#[serde(rename_all = "camelCase")]
124pub struct Reauth {
125 #[serde(default)]
126 pub session_token: Option<String>,
127 #[serde(default)]
128 pub password: Option<String>,
129 /// Who is asking, such as the visitor's IP address, so wrong passwords
130 /// are counted against it too.
131 #[serde(default)]
132 pub client: Option<String>,
133}
134
135/// One of a person's addresses, as they see it.
136#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "camelCase")]
138pub struct AccountEmail {
139 /// As typed when it was added.
140 pub email: String,
141 pub verified: bool,
142 pub primary: bool,
143 /// Gets security notices as well as the primary.
144 pub backup: bool,
145 /// RFC 3339.
146 pub created_at: String,
147 /// RFC 3339.
148 pub verified_at: Option<String>,
149}
150
151/// A person's addresses and what they do with them. `list_emails` (takes
152/// `UserArgs`) returns `Outcome<AccountEmails>`, and so does every change.
153#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "camelCase")]
155pub struct AccountEmails {
156 /// The primary first, then confirmed addresses, then the rest, oldest
157 /// first within each.
158 pub emails: Vec<AccountEmail>,
159 /// Commits g1t makes for the person use `noreply`, not the primary.
160 pub private_email: bool,
161 /// Pushes of commits that carry one of the person's addresses are
162 /// refused while `private_email` is on.
163 pub block_private_pushes: bool,
164 /// `<id suffix>+<username>@users.noreply.g1t.sh`.
165 pub noreply: String,
166 /// The address commits g1t makes for the person carry now.
167 pub commit_email: String,
168 /// [`MAX_EMAILS`].
169 pub limit: u32,
170}
171
172/// `add_email`: adds an address and emails it a confirmation link; adding
173/// one already on the account and unconfirmed sends the link again.
174/// `remove_email`: removes one, never the primary nor the last confirmed
175/// address. Both need [`Reauth`] and tell every confirmed address.
176/// `resend_email_verification` sends the link again, at most once every
177/// [`RESEND_SECONDS`], and needs no reauth. People only: never an agent's
178/// or a workspace's token.
179#[derive(Debug, Serialize, Deserialize)]
180pub struct AccountEmailArgs {
181 pub user: User,
182 pub email: String,
183 #[serde(default)]
184 pub reauth: Reauth,
185}
186
187// --- Confirming an address ---
188
189/// How many digits the code in a confirmation email has.
190pub const CONFIRM_CODE_DIGITS: usize = 6;
191
192/// How long the code and the link in a confirmation email work. Sending
193/// another email ends both at once.
194pub const CONFIRM_TTL_SECONDS: u64 = 60 * 60;
195
196/// What an account that has not confirmed its address hears from anything
197/// other than the pages that confirm it: the API, MCP and git. `site` is
198/// where the confirmation page is, such as `https://g1t.sh`.
199pub fn confirm_email_first(site: &str) -> String {
200 format!(
201 "Confirm your email address first: enter the code from the email g1t sent you at {}/confirm-email, or follow the link in it.",
202 site.trim_end_matches('/')
203 )
204}
205
206/// A confirmation code as typed or pasted, with spaces and hyphens taken
207/// out; None unless that leaves exactly [`CONFIRM_CODE_DIGITS`] digits.
208pub fn tidy_confirm_code(code: &str) -> Option<String> {
209 let digits: String = code.chars().filter(|c| !c.is_whitespace() && *c != '-').collect();
210 (digits.len() == CONFIRM_CODE_DIGITS && digits.chars().all(|c| c.is_ascii_digit())).then_some(digits)
211}
212
213/// `confirm_email_code`: the code from a confirmation email, typed by the
214/// signed-in person it was sent to. It confirms the address it was sent
215/// to. Wrong codes are counted against the account and `client`; past a
216/// limit nothing is checked for a while. Returns `Outcome<EmailConfirmed>`.
217#[derive(Debug, Serialize, Deserialize)]
218pub struct ConfirmEmailCodeArgs {
219 pub user: User,
220 pub code: String,
221 /// Who is asking, such as the visitor's IP address, for rate limits.
222 #[serde(default)]
223 pub client: Option<String>,
224}
225
226/// `change_pending_email`: for an account that has not confirmed any
227/// address, replaces the address it signed up with and sends a new code
228/// and link there. Returns `Outcome<AccountEmails>`.
229#[derive(Debug, Serialize, Deserialize)]
230pub struct PendingEmailArgs {
231 pub user: User,
232 pub email: String,
233}
234
235/// What confirming an address did. `verify_email` (the link) and
236/// `confirm_email_code` (the code) return it.
237#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
238#[serde(rename_all = "camelCase")]
239pub struct EmailConfirmed {
240 pub username: String,
241 /// The address confirmed, as typed when it was added.
242 pub email: String,
243 /// Whether the account is confirmed now: whether its primary is.
244 pub verified: bool,
245 /// The workspace the invite the account signed up with joined it to,
246 /// by slug, now that the account is confirmed.
247 #[serde(default)]
248 pub joined: Option<String>,
249 /// Why the invite the account signed up with no longer applies, when it
250 /// was revoked, expired or its workspace deleted while the account
251 /// waited. The address is confirmed all the same.
252 #[serde(default)]
253 pub invite_lapsed: Option<String>,
254}
255
256/// `update_email_settings`: each field given is changed. `primary` must be
257/// a confirmed address. `backup` is a confirmed address to get security
258/// notices too, or empty for the primary only. Changing either needs
259/// [`Reauth`]; the privacy switches do not. Returns `Outcome<AccountEmails>`.
260#[derive(Debug, Default, Serialize, Deserialize)]
261#[serde(rename_all = "camelCase")]
262pub struct EmailSettingsArgs {
263 pub user: User,
264 #[serde(default)]
265 pub primary: Option<String>,
266 #[serde(default)]
267 pub backup: Option<String>,
268 #[serde(default)]
269 pub private_email: Option<bool>,
270 #[serde(default)]
271 pub block_private_pushes: Option<bool>,
272 #[serde(default)]
273 pub reauth: Reauth,
274}
275
276/// `reauthenticate`: the person typed their password again for the session
277/// they are using; sensitive changes need no more proof for
278/// [`RECENT_AUTH_SECONDS`]. Returns `Outcome<bool>`.
279#[derive(Debug, Serialize, Deserialize)]
280#[serde(rename_all = "camelCase")]
281pub struct ReauthenticateArgs {
282 pub session_token: String,
283 pub password: String,
284 #[serde(default)]
285 pub client: Option<String>,
286}
287
288/// `email_owners`: who wrote commits, by their author addresses. Matches
289/// confirmed addresses and noreply addresses only, never an unconfirmed
290/// one. At most 200 addresses. Returns a map from each address that
291/// matched, lowercased, to its owner.
292#[derive(Debug, Serialize, Deserialize)]
293pub struct EmailOwnersArgs {
294 pub emails: Vec<String>,
295}
296
297/// The account an address belongs to.
298#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
299pub struct EmailOwner {
300 pub id: String,
301 pub username: String,
302 pub avatar: Option<String>,
303}
304
305/// `commit_identity`: the name and address to put on a commit g1t makes for
306/// a person (a merge, a web edit, catching a branch up). Their noreply
307/// address while they keep their address private, otherwise their primary.
308/// Returns `Option<CommitIdentity>`; null for an unknown account.
309#[derive(Debug, Serialize, Deserialize)]
310#[serde(rename_all = "camelCase")]
311pub struct CommitIdentityArgs {
312 pub user_id: String,
313}
314
315#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
316pub struct CommitIdentity {
317 pub name: String,
318 pub email: String,
319}
320
321/// `push_email_guard` (takes `CommitIdentityArgs`): what a push by this
322/// person must not publish. Returns `Option<PushEmailGuard>`: null unless
323/// they keep their address private and block pushes that expose it.
324#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
325pub struct PushEmailGuard {
326 /// Their confirmed addresses, lowercased.
327 pub emails: Vec<String>,
328 /// The address to commit with instead.
329 pub noreply: String,
330}
331
332impl PushEmailGuard {
333 /// Whether a commit carrying `email` would publish one of the
334 /// person's addresses.
335 pub fn exposes(&self, email: &str) -> bool {
336 let email = email.trim().to_lowercase();
337 !email.is_empty() && self.emails.contains(&email)
338 }
339}
340
341/// An address with all but the first letter of its local part hidden:
342/// `s***@gmail.com`.
343pub fn mask_email(email: &str) -> String {
344 match email.split_once('@') {
345 Some((local, domain)) => {
346 let first: String = local.chars().take(1).collect();
347 format!("{first}***@{domain}")
348 }
349 None => "***".to_owned(),
350 }
351}
352
353/// Something that happened to an account's security. `security_log` (takes
354/// `UserArgs`) returns the newest [`SECURITY_LOG_LIMIT`], newest first.
355#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
356#[serde(rename_all = "camelCase")]
357pub struct SecurityEvent {
358 /// `email_added`, `email_verified`, `email_removed`,
359 /// `primary_email_changed`, `backup_email_changed`,
360 /// `email_privacy_changed`, `password_changed`, `two_factor_enabled`,
361 /// `two_factor_disabled`, `recovery_codes_regenerated`,
362 /// `recovery_code_used`, `token_created`, `token_deleted`,
363 /// `token_rescoped`, `ssh_key_added`, `ssh_key_removed`,
364 /// `oauth_grant_created`, `oauth_grant_revoked` or
365 /// `oauth_grant_rescoped`.
366 pub kind: String,
367 /// The address concerned, or what changed.
368 pub detail: Option<String>,
369 /// Whether g1t staff made the change.
370 pub by_staff: bool,
371 /// Why staff made it.
372 pub reason: Option<String>,
373 /// The staff member, by email. Only in staff views.
374 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub staff: Option<String>,
376 /// RFC 3339.
377 pub created_at: String,
378}
379
380/// How many entries `security_log` returns.
381pub const SECURITY_LOG_LIMIT: usize = 50;
382
383// --- Two-factor authentication ---
384
385/// How long one TOTP code lasts, in seconds (RFC 6238's default).
386pub const TOTP_STEP_SECONDS: u64 = 30;
387/// How many digits a code has.
388pub const TOTP_DIGITS: u32 = 6;
389/// How many steps either side of now a code is accepted from, for clocks
390/// that are a little off: one, so a code works for up to 90 seconds.
391pub const TOTP_SKEW_STEPS: u64 = 1;
392/// How many recovery codes an account gets.
393pub const RECOVERY_CODES: usize = 10;
394/// How long a sign-in waits for its code, in seconds.
395pub const TWO_FACTOR_CHALLENGE_SECONDS: u64 = 10 * 60;
396/// How many wrong codes one sign-in may have before it must start again.
397pub const TWO_FACTOR_ATTEMPTS: u32 = 5;
398/// The issuer authenticator apps show beside the account.
399pub const TOTP_ISSUER: &str = "g1t";
400
401/// Where an account's two-factor authentication stands.
402/// `two_factor_status` (takes `UserArgs`) returns `Outcome<TwoFactorStatus>`.
403#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
404pub struct TwoFactorStatus {
405 pub enabled: bool,
406 /// RFC 3339.
407 pub enabled_at: Option<String>,
408 /// Recovery codes not used yet.
409 pub recovery_codes_left: u32,
410 /// The workspaces the person belongs to that require it.
411 pub required_by: Vec<String>,
412}
413
414/// What an authenticator app needs: the secret in base32, and the same as
415/// an `otpauth://` address for a QR code.
416#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
417pub struct TwoFactorSetup {
418 pub secret: String,
419 pub uri: String,
420}
421
422/// Single-use recovery codes, shown once.
423#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
424pub struct RecoveryCodes {
425 pub codes: Vec<String>,
426}
427
428/// `two_factor_start`: begins turning it on, replacing any enrolment in
429/// progress. Needs [`Reauth`]. Refused while it is on. Returns
430/// `Outcome<TwoFactorSetup>`.
431///
432/// `two_factor_recovery_codes`: makes new recovery codes, replacing the
433/// old ones. Needs [`Reauth`] and two-factor on. Returns
434/// `Outcome<RecoveryCodes>`.
435#[derive(Debug, Serialize, Deserialize)]
436pub struct TwoFactorArgs {
437 pub user: User,
438 #[serde(default)]
439 pub reauth: Reauth,
440}
441
442/// `two_factor_enable`: a code from the app confirms the enrolment, and
443/// two-factor is on; returns the recovery codes, shown once.
444/// `two_factor_disable`: turns it off; needs a code (or a recovery code)
445/// as well as [`Reauth`]. Refused for an owner of a workspace that
446/// requires it. Both return `Outcome<...>`: `RecoveryCodes` and `bool`.
447#[derive(Debug, Serialize, Deserialize)]
448pub struct TwoFactorCodeArgs {
449 pub user: User,
450 pub code: String,
451 #[serde(default)]
452 pub reauth: Reauth,
453}
454
455/// `two_factor_sign_in`: the second step of signing in. `challenge` is
456/// what `sign_in` returned as `SignedIn::two_factor_challenge`; `code` is a
457/// code from the app or a recovery code. Returns `Outcome<SignedIn>`, with
458/// a session.
459#[derive(Debug, Serialize, Deserialize)]
460pub struct TwoFactorSignInArgs {
461 pub challenge: String,
462 pub code: String,
463 #[serde(default)]
464 pub client: Option<String>,
465}
466
467/// The `otpauth://` address for a secret, as authenticator apps read it
468/// from a QR code.
469pub fn otpauth_uri(secret_base32: &str, username: &str) -> String {
470 let label: String = format!("{TOTP_ISSUER}:{username}")
471 .chars()
472 .map(|c| if c.is_ascii_alphanumeric() || "-._~:".contains(c) { c.to_string() } else { format!("%{:02X}", c as u32) })
473 .collect();
474 format!(
475 "otpauth://totp/{label}?secret={secret_base32}&issuer={TOTP_ISSUER}&algorithm=SHA1&digits={TOTP_DIGITS}&period={TOTP_STEP_SECONDS}"
476 )
477}
478
479/// A code as typed, tidied: spaces and hyphens taken out, lowercased.
480pub fn tidy_code(code: &str) -> String {
481 code.chars().filter(|c| !c.is_whitespace() && *c != '-').collect::<String>().to_lowercase()
482}
483
484/// Whether a tidied code is shaped like an app's: six digits.
485pub fn is_totp_shaped(code: &str) -> bool {
486 code.len() == TOTP_DIGITS as usize && code.chars().all(|c| c.is_ascii_digit())
487}
488
489// --- Staff ---
490
491/// `admin_user` (takes `UsernameArgs`): one account's addresses and
492/// security log, for staff. Returns `Option<AdminUser>`.
493#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
494#[serde(rename_all = "camelCase")]
495pub struct AdminUser {
496 pub id: String,
497 pub username: String,
498 /// RFC 3339.
499 pub created_at: String,
500 pub emails: Vec<AccountEmail>,
501 pub private_email: bool,
502 pub log: Vec<SecurityEvent>,
503}
504
505/// `admin_remove_email`: staff remove an address from an account, such as
506/// an unconfirmed one someone else needs or a compromised one. Never the
507/// last confirmed address; removing the primary makes the oldest other
508/// confirmed address primary. Recorded in the person's security log with
509/// the reason, and the person is told. Returns `Outcome<AdminUser>`.
510#[derive(Debug, Serialize, Deserialize)]
511pub struct AdminRemoveEmailArgs {
512 pub username: String,
513 pub email: String,
514 pub reason: String,
515 /// The staff member, by email.
516 pub staff: String,
517}
518
519// --- Workspace policy ---
520
521/// What a workspace asks of its members' accounts. Nothing, today, for
522/// every workspace ([`WorkspacePolicy::default`]); identity already checks
523/// it wherever someone joins a workspace or uses access to one, so asking
524/// for more is a matter of storing it.
525#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
526#[serde(rename_all = "camelCase")]
527pub struct WorkspacePolicy {
528 /// Members need a confirmed address at one of these domains. Empty:
529 /// any domain.
530 #[serde(default)]
531 pub allowed_email_domains: Vec<String>,
532 /// Members need a confirmed address at all.
533 #[serde(default)]
534 pub require_verified_email: bool,
535 /// Members need a second factor on their account.
536 #[serde(default)]
537 pub require_two_factor: bool,
538}
539
540/// What a policy can ask about an account.
541#[derive(Clone, Debug, Default, PartialEq, Eq)]
542pub struct SecurityFacts {
543 /// Lowercased.
544 pub verified_emails: Vec<String>,
545 pub two_factor: bool,
546}
547
548/// What an account lacks to meet a workspace's policy.
549#[derive(Clone, Debug, PartialEq, Eq)]
550pub enum PolicyGap {
551 VerifiedEmail,
552 EmailDomain(Vec<String>),
553 TwoFactor,
554}
555
556impl PolicyGap {
557 /// Its name: `verified_email`, `email_domain` or `two_factor`.
558 pub fn as_str(&self) -> &'static str {
559 match self {
560 PolicyGap::VerifiedEmail => "verified_email",
561 PolicyGap::EmailDomain(_) => "email_domain",
562 PolicyGap::TwoFactor => "two_factor",
563 }
564 }
565
566 /// What to tell the person, for a workspace named `slug`.
567 pub fn message(&self, slug: &str) -> String {
568 match self {
569 PolicyGap::VerifiedEmail => format!("{slug} needs members to have a confirmed email address."),
570 PolicyGap::EmailDomain(domains) => format!(
571 "{slug} needs members to have a confirmed address at {}. Add one in your account settings.",
572 domains.join(" or ")
573 ),
574 PolicyGap::TwoFactor => format!("{slug} requires two-factor authentication. Turn it on in your account's security settings to use it again."),
575 }
576 }
577}
578
579impl WorkspacePolicy {
580 /// Whether the policy asks for anything, so callers can skip gathering
581 /// [`SecurityFacts`] when it does not.
582 pub fn asks_nothing(&self) -> bool {
583 self.allowed_email_domains.is_empty() && !self.require_verified_email && !self.require_two_factor
584 }
585
586 /// Everything the account lacks, or an empty list when it meets the
587 /// policy.
588 pub fn gaps(&self, facts: &SecurityFacts) -> Vec<PolicyGap> {
589 let mut gaps = Vec::new();
590 if self.require_verified_email && facts.verified_emails.is_empty() {
591 gaps.push(PolicyGap::VerifiedEmail);
592 }
593 if !self.allowed_email_domains.is_empty() {
594 let allowed: Vec<String> = self.allowed_email_domains.iter().map(|domain| domain.trim().trim_start_matches('@').to_lowercase()).collect();
595 let has = facts.verified_emails.iter().any(|email| {
596 email
597 .rsplit_once('@')
598 .is_some_and(|(_, domain)| allowed.iter().any(|allowed| domain == allowed))
599 });
600 if !has {
601 gaps.push(PolicyGap::EmailDomain(allowed));
602 }
603 }
604 if self.require_two_factor && !facts.two_factor {
605 gaps.push(PolicyGap::TwoFactor);
606 }
607 gaps
608 }
609}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614
615 #[test]
616 fn a_confirmation_code_is_six_digits_however_it_is_typed() {
617 assert_eq!(tidy_confirm_code("482913").as_deref(), Some("482913"));
618 assert_eq!(tidy_confirm_code(" 482 913 ").as_deref(), Some("482913"));
619 assert_eq!(tidy_confirm_code("482-913").as_deref(), Some("482913"));
620 assert_eq!(tidy_confirm_code("48291"), None);
621 assert_eq!(tidy_confirm_code("4829134"), None);
622 assert_eq!(tidy_confirm_code("48291a"), None);
623 assert_eq!(tidy_confirm_code(""), None);
624 }
625
626 #[test]
627 fn a_pending_account_is_a_person_without_a_confirmed_address() {
628 let person = User { id: "usr_1".into(), username: "ada".into(), ..User::default() };
629 assert!(person.awaits_confirmation());
630 assert!(!User { verified: true, ..person.clone() }.awaits_confirmation());
631 // A workspace's token, an agent and g1t itself are never pending.
632 assert!(!User { kind: crate::PrincipalKind::Workspace, ..person.clone() }.awaits_confirmation());
633 assert!(!User { kind: crate::PrincipalKind::Agent, ..person.clone() }.awaits_confirmation());
634 assert!(!User::system("acme").awaits_confirmation());
635 let said = confirm_email_first("https://git.example.com/");
636 assert!(said.contains("https://git.example.com/confirm-email"));
637 assert!(said.starts_with("Confirm your email address first"));
638 }
639
640 #[test]
641 fn a_push_guard_matches_the_persons_own_addresses_and_masks_them() {
642 let guard = PushEmailGuard { emails: vec!["sam@gmail.com".into()], noreply: "abc+sam@users.noreply.g1t.sh".into() };
643 assert!(guard.exposes(" Sam@Gmail.com"));
644 assert!(!guard.exposes("abc+sam@users.noreply.g1t.sh"));
645 assert!(!guard.exposes("someone@gmail.com"));
646 assert!(!guard.exposes(""));
647 assert_eq!(mask_email("sam@gmail.com"), "s***@gmail.com");
648 assert_eq!(mask_email("nope"), "***");
649 }
650
651 fn state(email: &str, verified: bool, primary: bool) -> EmailState {
652 EmailState { email: email.into(), verified, primary }
653 }
654
655 #[test]
656 fn addresses_are_trimmed_lowercased_and_checked() {
657 assert_eq!(normalize_email(" Ada@Example.COM "), Some("ada@example.com".into()));
658 assert_eq!(normalize_email("ada+g1t@mail.example.co.uk"), Some("ada+g1t@mail.example.co.uk".into()));
659 for bad in ["", "ada", "@example.com", "ada@example", "ada@@example.com", "a da@example.com", "ada@.com", "ada@example."] {
660 assert_eq!(normalize_email(bad), None, "{bad}");
661 }
662 assert_eq!(normalize_email(&format!("{}@example.com", "a".repeat(250))), None);
663 }
664
665 #[test]
666 fn the_noreply_address_carries_the_end_of_the_id_and_the_username() {
667 let address = noreply_address("usr_01j9zq4m8x7k2v5n3b6c1d0efg", "Ada");
668 assert_eq!(address, "6c1d0efg+ada@users.noreply.g1t.sh");
669 assert_eq!(parse_noreply(&address), Some(("6c1d0efg".into(), "ada".into())));
670 assert_eq!(parse_noreply("6C1D0EFG+Ada@Users.Noreply.G1T.sh"), Some(("6c1d0efg".into(), "ada".into())));
671 assert_eq!(parse_noreply("ada@example.com"), None);
672 assert_eq!(parse_noreply("short+ada@users.noreply.g1t.sh"), None);
673 assert_eq!(parse_noreply("6c1d0efg@users.noreply.g1t.sh"), None);
674 assert_eq!(parse_noreply("6c1d0efg+@users.noreply.g1t.sh"), None);
675 assert_eq!(id_suffix("usr_x"), "usr_x");
676 }
677
678 #[test]
679 fn a_sign_in_is_recent_for_ten_minutes() {
680 let now = 1_800_000_000_000;
681 let at = |ms_ago: u64| crate::time::rfc3339(now - ms_ago);
682 assert!(is_recent(Some(&at(0)), now, RECENT_AUTH_SECONDS));
683 assert!(is_recent(Some(&at(9 * 60 * 1000)), now, RECENT_AUTH_SECONDS));
684 assert!(is_recent(Some(&at(10 * 60 * 1000)), now, RECENT_AUTH_SECONDS));
685 assert!(!is_recent(Some(&at(10 * 60 * 1000 + 1)), now, RECENT_AUTH_SECONDS));
686 assert!(!is_recent(None, now, RECENT_AUTH_SECONDS));
687 }
688
689 #[test]
690 fn neither_the_primary_nor_the_last_confirmed_address_can_be_removed() {
691 let all = [state("a@x.io", true, true), state("b@x.io", true, false), state("c@x.io", false, false)];
692 assert!(removal_refusal(&all, "a@x.io").unwrap().contains("primary"));
693 assert_eq!(removal_refusal(&all, "b@x.io"), None);
694 assert_eq!(removal_refusal(&all, "c@x.io"), None);
695 assert!(removal_refusal(&all, "d@x.io").is_some());
696 // An unconfirmed primary (a new account) stays; so does the only
697 // confirmed address, primary or not.
698 let lone = [state("a@x.io", false, true), state("b@x.io", true, false)];
699 assert!(removal_refusal(&lone, "b@x.io").unwrap().contains("only confirmed"));
700 }
701
702 #[test]
703 fn only_a_confirmed_address_can_be_primary() {
704 let all = [state("a@x.io", true, true), state("b@x.io", false, false)];
705 assert_eq!(primary_refusal(&all, "a@x.io"), None);
706 assert!(primary_refusal(&all, "b@x.io").unwrap().contains("Confirm"));
707 assert!(primary_refusal(&all, "z@x.io").is_some());
708 }
709
710 #[test]
711 fn the_otpauth_address_names_the_account_and_issuer() {
712 let uri = otpauth_uri("JBSWY3DPEHPK3PXP", "ada lovelace");
713 assert_eq!(
714 uri,
715 "otpauth://totp/g1t:ada%20lovelace?secret=JBSWY3DPEHPK3PXP&issuer=g1t&algorithm=SHA1&digits=6&period=30"
716 );
717 }
718
719 #[test]
720 fn codes_are_tidied_before_they_are_checked() {
721 assert_eq!(tidy_code(" 123 456 "), "123456");
722 assert!(is_totp_shaped(&tidy_code("123-456")));
723 assert!(!is_totp_shaped("12345"));
724 assert!(!is_totp_shaped("abcdef"));
725 assert_eq!(tidy_code("ABCD-EFGH-IJ"), "abcdefghij");
726 }
727
728 #[test]
729 fn the_default_policy_asks_nothing_and_any_account_meets_it() {
730 let policy = WorkspacePolicy::default();
731 assert!(policy.asks_nothing());
732 assert!(policy.gaps(&SecurityFacts::default()).is_empty());
733 }
734
735 #[test]
736 fn a_policy_names_everything_an_account_lacks() {
737 let policy = WorkspacePolicy {
738 allowed_email_domains: vec!["@Acme.com".into()],
739 require_verified_email: true,
740 require_two_factor: true,
741 };
742 assert!(!policy.asks_nothing());
743 assert_eq!(
744 policy.gaps(&SecurityFacts::default()),
745 vec![PolicyGap::VerifiedEmail, PolicyGap::EmailDomain(vec!["acme.com".into()]), PolicyGap::TwoFactor]
746 );
747 let member = SecurityFacts { verified_emails: vec!["ada@gmail.com".into(), "ada@acme.com".into()], two_factor: true };
748 assert!(policy.gaps(&member).is_empty());
749 let lookalike = SecurityFacts { verified_emails: vec!["ada@notacme.com".into()], two_factor: true };
750 assert_eq!(policy.gaps(&lookalike), vec![PolicyGap::EmailDomain(vec!["acme.com".into()])]);
751 assert!(PolicyGap::EmailDomain(vec!["acme.com".into()]).message("acme").contains("acme.com"));
752 }
753}