//! The four sessionless auth pages, described. //! //! `/login`, `/forgot-password`, `/reset-password` and `/auth/2fa`: the screens //! a reader reaches without a session, where the site header would offer a //! Library and a Dashboard they cannot open. They replace //! `templates/pages/{login,forgot_password,reset_password,two_factor}.html` //! and the four Askama structs behind them. //! //! They are the first consumer of [`quasi_router::Screen::opens_at`], which is //! why they were converted together: each carried exactly one `autofocus`, and //! the caret landing in the one box a reader came here to type into is the //! whole of what these pages do. //! //! # Why these are not on a quasi mount //! //! Every other described document in this server is registered through //! [`super::public_document_mount`], and these are not. The mount hands a //! handler a [`Viewer`](super::Viewer), which carries the app state, the //! runtime and the CSRF token and does **not** carry the session. All four of //! these GETs need it: `/auth/2fa` reads a pending-2FA key and re-checks the //! tracking row against the database, `/reset-password` re-validates a signed //! HMAC link, and `/login` reads two query parameters and `Config::sso`. //! //! So the route stays an ordinary axum handler with the extractors it already //! had, and what is described is the *document*: this module owns the screens //! and the shell, and the handler renders one instead of a template. Moving the //! addresses onto the mount would mean moving four auth guards with them, which //! is a rewrite of four security-sensitive flows to change no pixel. //! //! The line that separates them: the mount owns *routing* -- the address, the //! viewer factory, the fragment protocol -- and these pages register no //! described route. Their POSTs are answered by the handlers that always //! answered them, into the region the description names. //! //! # Why the four screens each write the same three settings //! //! `measured`, `documented` and `indexed false` are the same on all four, and //! they used to be a `page(title, body)` helper. R2 makes a `-> Screen` shape //! its single `screen`, so a helper that takes a document and hands it back is //! the shape `custom_page::strip` was refused for, and the settings are written //! out. The one worth reading once is the third: none of the four wants to be //! indexed, because three of them answer only to a reader holding a link or a //! pending session, and a login form in a search result is a phishing lure with //! our name on it. //! //! # What each form's answer lands in //! //! A named region, through [`Action::replacing`], which is documented as the //! call for "a route the description layer does not serve". `/login` fills //! `login-errors` and the other three fill `form-feedback`, which is what the //! templates already targeted. //! //! `/auth/verify-2fa` is the one that changed. It targeted //! `closest .login-container` with `outerHTML` and answered with a whole //! rendered page, so a failed code swapped an entire document into a `div`. //! That is not sayable -- [`quasi_router::Replaces`] names a region, not a //! selector -- and it should not be: it now answers an alert into //! `form-feedback` like its two siblings. //! //! # The one thing that did not survive //! //! `reset_password.html` wrote `minlength="8"` on both password boxes. //! [`quasi_router::Field`] carries `required` and `max_length` and has no //! minimum, and adding one means a member on `makeover_layout::Field`, which is //! published and would pull the whole suite through a cascade for a browser //! hint. The rule is the server's and always was -- //! `crate::validation::limits::PASSWORD_MIN`, checked in //! `reset_password_handler` before the token is consumed -- so what is lost is //! a tooltip. A hint on the field says the same thing in words, before the //! reader submits rather than after. use makeover_layout as layout; use quasi_declare::declare; use quasi_router::{Action, Document, Node, RegionKind, Screen as Described, Slot}; use quasi_webview::Webview; /// The region every one of these pages is, and what the skip link points at. /// /// `login-container` because that is what the templates called the `div` and /// what `style.css` still styles. pub const PAGE_REGION: &str = "login-container"; /// Where `/login`'s answer lands. pub const LOGIN_FEEDBACK: &str = "login-errors"; /// Where the other three pages' answers land. pub const FEEDBACK: &str = "form-feedback"; /// The width these pages run at. Every one of them wrote it on the body. const MEASURE: layout::Measure = layout::Measure::Contained; /// The document any of these screens is drawn in. /// /// The wordmark rather than the site header, which is the whole reason these /// pages are their own family: [`crate::shell::wordmark`] says why. /// /// No `Chrome`, so no shortcuts binding and no overlay container. A reader who /// cannot sign in has nothing to reach with a key. #[must_use] pub fn renderer(csrf: &str, tail: &str) -> Webview { Webview::new().with_shell( crate::shell::described() .sending("X-CSRF-Token", csrf) .with_body_last(format!("{}{tail}", crate::shell::body_last())) .with_body_first(format!( "{}{}", crate::shell::skip_link(PAGE_REGION), crate::shell::wordmark() )) .with_head(format!( "", crate::helpers::escape_html(csrf) )), ) } /// The passkey offer, as markup, because none of it is describable. /// /// A `data-action` the classic dispatcher resolves, a container a script /// unhides once it has asked the browser whether it can do WebAuthn at all, and /// an element that script writes a failure into. A description can say a /// control calls a route; it cannot say a control calls a function in this /// page's own JavaScript, and it should not learn to. /// /// So the region is a [`RegionKind::Handover`]: the description says there is a /// place here and who owes the markup, and this is the host paying it. The id /// is the one `static/page-login-2.js` looks for. const PASSKEY: &str = concat!( "
or
", "", "
", ); /// The region the passkey offer is handed over in. pub const PASSKEY_REGION: &str = "passkey-login"; /// The two scripts the login page carried in `{% block scripts %}`. const LOGIN_SCRIPTS: &str = concat!( "", "", ); /// Render one of these screens as a whole document. #[must_use] pub fn document(csrf: Option<&str>, screen: &Described) -> String { use quasi_axum::Serves as _; // The login screen is the only one of the four that hands a region over, // and it is the only one that needs the two scripts that fill it. Asked of // the screen rather than passed in, so a caller cannot render the login // page without the thing that makes its passkey button work. let offers_passkey = screen.slot(PASSKEY_REGION).is_some(); let mut webview = renderer( csrf.unwrap_or_default(), if offers_passkey { LOGIN_SCRIPTS } else { "" }, ); if offers_passkey { webview = webview.with_fill(PASSKEY_REGION, PASSKEY); } webview.screen(screen) } /// The feedback region, filled, for a POST to answer an htmx submit with. /// /// The described forms use [`Action::replacing`], which is `hx-target="#"` /// plus `hx-swap="outerMorph"`: what the answer replaces is **the region /// itself**. So an answer that were a bare alert would replace the element the /// next attempt has to aim at, and a second wrong password would land nowhere. /// Answering with the region keeps its id, which is what makes a refused form /// retryable. /// /// That is also why these no longer answer `AlertTemplate`. The banner is the /// description's now, drawn by the same renderer that drew the page, so the two /// halves of one screen cannot come out of two hands. #[must_use] pub fn answered( id: &str, tone: layout::Tone, message: &str, onward: Option<(&str, &str)>, ) -> String { use quasi_axum::Serves as _; let mut slot = Slot::new(id, RegionKind::Pane).with(Node::banner(tone, message)); if let Some((route, label)) = onward { slot = slot.with(Node::act(label, Action::get(route).navigating())); } Webview::new().fragment(&Node::Region(slot)) } declare! { /// An empty region for a route's answer to land in. /// /// Empty, and that is the point: it is an address rather than content. The /// error a full-page POST re-renders goes in through `error` instead, /// because on that path there is no swap to land anything. /// /// An `Option` is an iterator of at most one, and `.into_iter()` is the /// method step that says so. shape feedback(id: &str, error: Option<&str>) -> Node; region id as Pane { for message in error.into_iter() { banner layout::Tone::Danger message; } } } declare! { /// The link back to the login form, which three of these four carry. shape back_to_login() -> Node; link "Back to login" to get "/login" navigating; } declare! { /// `/login`. /// /// `sso_enabled` is the testnot.work preview, where there is no local /// password at all and the whole form is replaced by one link. Two shapes /// of the same screen rather than two screens, because everything around /// them -- the wordmark, the measure, the notice -- is the same. Said as /// guards rather than as a dispatch, because the two branches offer a /// different *number* of things and a dispatch arm is one emission. #[must_use] pub shape login( prefill: &str, error: Option<&str>, notice: Option<&str>, sso_enabled: bool, ) -> Screen; screen single "Log In - Makenotwork" { measured MEASURE; documented Document::default().classed(crate::shell::body_class(MEASURE, &[])); indexed false; // The caret in the box the reader came here to fill. The password box // is the wrong answer even for somebody whose browser fills the first // one: a filled box is still where a correction is made. The preview // has no box at all, so it opens at nothing. opening_at "login" unless sso_enabled; region PAGE_REGION as Pane { for note in notice.into_iter() { banner layout::Tone::Info note; } include feedback(LOGIN_FEEDBACK, error); section "Log in"; text "testnot.work is a preview of makenot.work. Sign in with your \ makenot.work account to continue. Your password is only ever \ entered on makenot.work." when sso_enabled; act "Sign in with Makenot.work" to get "/sso/login" navigating when sso_enabled; form post "/login" replacing LOGIN_FEEDBACK unless sso_enabled { submit "Log In"; field Text "login" "Username or Email" { required; // What was typed, so a wrong password does not cost the // address as well. `login_handler`'s full-page error path // has always done this. value prefill; placeholder "username or you@example.com"; } field Secret "password" "Password" { required; placeholder "--------"; } field Checkbox "remember_me" "Remember me"; } link "Reset Password" to get "/forgot-password" navigating unless sso_enabled; link "Join now" to get "/join" navigating unless sso_enabled; // Hidden until `page-login-2.js` has asked the browser whether it // can do WebAuthn. See `PASSKEY`. region PASSKEY_REGION as RegionKind::handover("the passkey offer") unless sso_enabled {} } } } declare! { /// `/forgot-password`. #[must_use] pub shape forgot_password() -> Screen; screen single "Reset Password - Makenotwork" { measured MEASURE; documented Document::default().classed(crate::shell::body_class(MEASURE, &[])); indexed false; opening_at "email"; region PAGE_REGION as Pane { include feedback(FEEDBACK, None); section "Reset Password"; text "Enter your email address and we'll send you a link to reset your password."; form post "/forgot-password" replacing FEEDBACK { submit "Send Reset Link"; field Email "email" "Email" { required; placeholder "you@example.com"; } } include back_to_login(); } } } declare! { /// `/reset-password`. /// /// `valid` is whether the signed link still resolves. An expired one is a /// different screen rather than a disabled form: there is nothing to type, /// and the only useful control is the one that asks for a fresh link. /// /// The token rides on the action as a parameter rather than as a hidden /// field. A hidden input is markup standing in for a value the call already /// carries, and `with` is what the vocabulary has for it. #[must_use] pub shape reset_password(valid: bool, token: &str, error: Option<&str>) -> Screen; screen single "Set New Password - Makenotwork" { measured MEASURE; documented Document::default().classed(crate::shell::body_class(MEASURE, &[])); indexed false; // Nothing to type into on the expired screen, so nothing to open at. A // name no field carries would be honoured as nothing anyway; saying // nothing is the honest spelling of it. opening_at "password" when valid; region PAGE_REGION as Pane { include feedback(FEEDBACK, error); section "Set New Password" when valid; text "Enter your new password below." when valid; form post "/reset-password" with "token" token replacing FEEDBACK when valid { submit "Set Password"; field Secret "password" "New Password" { required; // The rule the server enforces, said before the reader // submits rather than after. See the module header for why // it is not `minlength`. hint "At least 8 characters"; placeholder "--------"; } field Secret "password_confirm" "Confirm Password" { required; placeholder "--------"; } } section "Link Expired" unless valid; text "This password reset link has expired or is invalid. Please request a \ new one." unless valid; act "Request New Link" to get "/forgot-password" navigating unless valid; include back_to_login(); } } } declare! { /// `/auth/2fa`. #[must_use] pub shape two_factor(error: Option<&str>) -> Screen; screen single "Two-Factor Authentication - Makenotwork" { measured MEASURE; documented Document::default().classed(crate::shell::body_class(MEASURE, &[])); indexed false; opening_at "code"; region PAGE_REGION as Pane { include feedback(FEEDBACK, error); section "Two-Factor Authentication"; text "Enter the 6-digit code from your authenticator app, or use a backup code."; form post "/auth/verify-2fa" replacing FEEDBACK { submit "Verify"; field Text "code" "Verification Code" { required; placeholder "000000"; // Six digits or an eight-character backup code, which is // what the template capped it at. A cap the box enforces, // unlike the password minimum above: `Field` carries a // maximum and no minimum. limited_to 8; } } include back_to_login(); } } } #[cfg(test)] mod tests { use super::*; fn html(screen: &Described) -> String { document(Some("tok&en"), screen) } /// The whole point of the pass: each of these pages carried exactly one /// `autofocus`, and the caret lands in the box the reader came to type in. #[test] fn every_page_opens_where_its_template_put_the_caret() { for (screen, name) in [ (login("", None, None, false), "login"), (forgot_password(), "email"), (reset_password(true, "t", None), "password"), (two_factor(None), "code"), ] { assert_eq!( screen.opens_at.as_deref(), Some(name), "{} opened somewhere else", screen.title ); let rendered = html(&screen); assert_eq!( rendered.matches("autofocus").count(), 1, "{} emitted more or less than one caret: {rendered}", screen.title ); } } /// A page with nothing to type into opens nowhere. Saying a name no field /// carries would be honoured as nothing anyway; saying nothing is the /// honest spelling. #[test] fn an_expired_link_has_no_caret_to_place() { let screen = reset_password(false, "", None); assert!(screen.opens_at.is_none()); assert!(!html(&screen).contains("autofocus")); } /// The token rides on the call rather than in a hidden input, and it is /// what the POST reads back as `token`. #[test] fn the_reset_token_travels_with_the_call() { let rendered = html(&reset_password(true, "a-real-token", None)); assert!(rendered.contains("a-real-token"), "{rendered}"); assert!( !rendered.contains("type=\"hidden\""), "the token is markup again: {rendered}" ); } /// Each form's answer aims at the region the template's `hx-target` named, /// which is what keeps the POST handlers untouched. #[test] fn every_form_aims_at_the_region_its_handler_answers_into() { assert!(html(&login("", None, None, false)).contains(LOGIN_FEEDBACK)); for screen in [ forgot_password(), reset_password(true, "t", None), two_factor(None), ] { assert!(html(&screen).contains(FEEDBACK), "{}", screen.title); } } /// A failed POST re-renders the page with the address intact, which the /// template did and a conversion that dropped it would cost a retype. #[test] fn a_refused_login_keeps_what_was_typed_and_says_why() { let rendered = html(&login( "areader", Some("Invalid username or password"), None, false, )); assert!(rendered.contains("areader"), "{rendered}"); assert!( rendered.contains("Invalid username or password"), "{rendered}" ); } /// The preview mirror has no local password at all, so the form is one /// link. The caret has nothing to go in either. #[test] fn the_sso_shape_offers_one_way_in_and_no_form() { let screen = login("", None, None, true); assert!(screen.opens_at.is_none()); let rendered = html(&screen); assert!(rendered.contains("/sso/login"), "{rendered}"); assert!(!rendered.contains("name=\"password\""), "{rendered}"); } /// The passkey offer is markup because none of it is describable, and the /// scripts that drive it ride with the page that has it. #[test] fn the_login_page_hands_over_the_passkey_offer_and_carries_its_scripts() { let rendered = html(&login("", None, None, false)); assert!(rendered.contains("loginWithPasskey"), "{rendered}"); assert!(rendered.contains("page-login-2.js"), "{rendered}"); assert!(rendered.contains("passkey.js"), "{rendered}"); // And no other page pays for them. let other = html(&forgot_password()); assert!(!other.contains("passkey"), "{other}"); } /// None of the four is a page a search result should offer. A login form /// in one is a phishing lure with our name on it. #[test] fn none_of_these_pages_is_indexable() { for screen in [ login("", None, None, false), forgot_password(), reset_password(true, "t", None), two_factor(None), ] { assert!(!screen.discovery.indexable, "{}", screen.title); assert!(html(&screen).contains("noindex"), "{}", screen.title); } } /// These pages carry the wordmark and no header: the nav would offer a /// Library and a Dashboard a reader who cannot sign in cannot open. #[test] fn these_pages_show_the_wordmark_and_no_site_header() { let rendered = html(&login("", None, None, false)); assert!(rendered.contains("brand-h1"), "{rendered}"); assert!(!rendered.contains("chrome-band"), "{rendered}"); } /// `736f45a5`: a described screen's markup carries none of the four /// spellings. All four templates wrote an `htmx-indicator` span. #[test] fn these_pages_spell_no_spinner() { for screen in [ login("", None, None, false), forgot_password(), reset_password(true, "t", None), two_factor(None), ] { let rendered = html(&screen); for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] { assert!( !rendered.contains(spelling), "{spelling} in {}", screen.title ); } } } }