//! 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. //! //! # 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_router::screen::Field; 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)) } /// The page body every one of these four shares: one region, at the measure /// they all run at, carrying whatever the page put in it. fn page(title: &str, body: Slot) -> Described { Described::single(title) .measured(MEASURE) .documented(Document::default().classed(crate::shell::body_class(MEASURE, &[]))) // None of the four wants to be indexed: 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. .indexed(false) .with(body) } /// 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. fn feedback(id: &str, error: Option<&str>) -> Node { let mut slot = Slot::new(id, RegionKind::Pane); if let Some(message) = error { slot = slot.with(Node::banner(layout::Tone::Danger, message)); } Node::Region(slot) } /// The link back to the login form, which three of these four carry. fn back_to_login() -> Node { Node::Link { text: "Back to login".to_owned(), action: Action::get("/login").navigating(), } } /// `/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. #[must_use] pub fn login( prefill: &str, error: Option<&str>, notice: Option<&str>, sso_enabled: bool, ) -> Described { let mut body = Slot::new(PAGE_REGION, RegionKind::Pane); if let Some(note) = notice { body = body.with(Node::banner(layout::Tone::Info, note)); } if sso_enabled { return page( "Log In - Makenotwork", body.with(feedback(LOGIN_FEEDBACK, error)) .with(Node::section("Log in")) .with(Node::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.", )) .with(Node::act( "Sign in with Makenot.work", Action::get("/sso/login").navigating(), )), ); } let mut login_field = Field::new(layout::FieldKind::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); login_field.placeholder = Some("username or you@example.com".to_owned()); let mut password = Field::new(layout::FieldKind::Secret, "password", "Password").required(); password.placeholder = Some("--------".to_owned()); page( "Log In - Makenotwork", body.with(feedback(LOGIN_FEEDBACK, error)) .with(Node::section("Log in")) .with(Node::Form { action: Action::post("/login").replacing(LOGIN_FEEDBACK), submit: "Log In".to_owned(), fields: vec![ login_field, password, Field::new(layout::FieldKind::Checkbox, "remember_me", "Remember me"), ], }) .with(Node::Link { text: "Reset Password".to_owned(), action: Action::get("/forgot-password").navigating(), }) .with(Node::Link { text: "Join now".to_owned(), action: Action::get("/join").navigating(), }) // Hidden until `page-login-2.js` has asked the browser whether it // can do WebAuthn. See `PASSKEY`. .with(Node::Region(Slot::handover( PASSKEY_REGION, "the passkey offer", ))), ) // 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. .opening_at("login") } /// `/forgot-password`. #[must_use] pub fn forgot_password() -> Described { let mut email = Field::new(layout::FieldKind::Email, "email", "Email").required(); email.placeholder = Some("you@example.com".to_owned()); page( "Reset Password - Makenotwork", Slot::new(PAGE_REGION, RegionKind::Pane) .with(feedback(FEEDBACK, None)) .with(Node::section("Reset Password")) .with(Node::text( "Enter your email address and we'll send you a link to reset your password.", )) .with(Node::Form { action: Action::post("/forgot-password").replacing(FEEDBACK), submit: "Send Reset Link".to_owned(), fields: vec![email], }) .with(back_to_login()), ) .opening_at("email") } /// `/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 [`Action::with`] is what the vocabulary has for "send this along". #[must_use] pub fn reset_password(valid: bool, token: &str, error: Option<&str>) -> Described { let mut body = Slot::new(PAGE_REGION, RegionKind::Pane).with(feedback(FEEDBACK, error)); if valid { let mut password = Field::new(layout::FieldKind::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"); password.placeholder = Some("--------".to_owned()); let mut confirm = Field::new( layout::FieldKind::Secret, "password_confirm", "Confirm Password", ) .required(); confirm.placeholder = Some("--------".to_owned()); body = body .with(Node::section("Set New Password")) .with(Node::text("Enter your new password below.")) .with(Node::Form { action: Action::post("/reset-password") .with("token", token) .replacing(FEEDBACK), submit: "Set Password".to_owned(), fields: vec![password, confirm], }); } else { body = body .with(Node::section("Link Expired")) .with(Node::text( "This password reset link has expired or is invalid. Please request a new one.", )) .with(Node::act( "Request New Link", Action::get("/forgot-password").navigating(), )); } let screen = page("Set New Password - Makenotwork", body.with(back_to_login())); if valid { screen.opening_at("password") } else { // Nothing to type into, so nothing to open at. A name no field carries // would be honoured as nothing anyway; saying nothing is the honest // spelling of it. screen } } /// `/auth/2fa`. #[must_use] pub fn two_factor(error: Option<&str>) -> Described { let mut code = Field::new(layout::FieldKind::Text, "code", "Verification Code").required(); code.placeholder = Some("000000".to_owned()); // 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 below: // `Field` carries a maximum and no minimum. code.max_length = Some(8); page( "Two-Factor Authentication - Makenotwork", Slot::new(PAGE_REGION, RegionKind::Pane) .with(feedback(FEEDBACK, error)) .with(Node::section("Two-Factor Authentication")) .with(Node::text( "Enter the 6-digit code from your authenticator app, or use a backup code.", )) .with(Node::Form { action: Action::post("/auth/verify-2fa").replacing(FEEDBACK), submit: "Verify".to_owned(), fields: vec![code], }) .with(back_to_login()), ) .opening_at("code") } #[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 ); } } } }