Skip to main content

max / makenotwork

22.3 KB · 567 lines History Blame Raw
1 //! The four sessionless auth pages, described.
2 //!
3 //! `/login`, `/forgot-password`, `/reset-password` and `/auth/2fa`: the screens
4 //! a reader reaches without a session, where the site header would offer a
5 //! Library and a Dashboard they cannot open. They replace
6 //! `templates/pages/{login,forgot_password,reset_password,two_factor}.html`
7 //! and the four Askama structs behind them.
8 //!
9 //! They are the first consumer of [`quasi_router::Screen::opens_at`], which is
10 //! why they were converted together: each carried exactly one `autofocus`, and
11 //! the caret landing in the one box a reader came here to type into is the
12 //! whole of what these pages do.
13 //!
14 //! # Why these are not on a quasi mount
15 //!
16 //! Every other described document in this server is registered through
17 //! [`super::public_document_mount`], and these are not. The mount hands a
18 //! handler a [`Viewer`](super::Viewer), which carries the app state, the
19 //! runtime and the CSRF token and does **not** carry the session. All four of
20 //! these GETs need it: `/auth/2fa` reads a pending-2FA key and re-checks the
21 //! tracking row against the database, `/reset-password` re-validates a signed
22 //! HMAC link, and `/login` reads two query parameters and `Config::sso`.
23 //!
24 //! So the route stays an ordinary axum handler with the extractors it already
25 //! had, and what is described is the *document*: this module owns the screens
26 //! and the shell, and the handler renders one instead of a template. Moving the
27 //! addresses onto the mount would mean moving four auth guards with them, which
28 //! is a rewrite of four security-sensitive flows to change no pixel.
29 //!
30 //! The line that separates them: the mount owns *routing* -- the address, the
31 //! viewer factory, the fragment protocol -- and these pages register no
32 //! described route. Their POSTs are answered by the handlers that always
33 //! answered them, into the region the description names.
34 //!
35 //! # What each form's answer lands in
36 //!
37 //! A named region, through [`Action::replacing`], which is documented as the
38 //! call for "a route the description layer does not serve". `/login` fills
39 //! `login-errors` and the other three fill `form-feedback`, which is what the
40 //! templates already targeted.
41 //!
42 //! `/auth/verify-2fa` is the one that changed. It targeted
43 //! `closest .login-container` with `outerHTML` and answered with a whole
44 //! rendered page, so a failed code swapped an entire document into a `div`.
45 //! That is not sayable -- [`quasi_router::Replaces`] names a region, not a
46 //! selector -- and it should not be: it now answers an alert into
47 //! `form-feedback` like its two siblings.
48 //!
49 //! # The one thing that did not survive
50 //!
51 //! `reset_password.html` wrote `minlength="8"` on both password boxes.
52 //! [`quasi_router::Field`] carries `required` and `max_length` and has no
53 //! minimum, and adding one means a member on `makeover_layout::Field`, which is
54 //! published and would pull the whole suite through a cascade for a browser
55 //! hint. The rule is the server's and always was --
56 //! `crate::validation::limits::PASSWORD_MIN`, checked in
57 //! `reset_password_handler` before the token is consumed -- so what is lost is
58 //! a tooltip. A hint on the field says the same thing in words, before the
59 //! reader submits rather than after.
60
61 use makeover_layout as layout;
62 use quasi_router::screen::Field;
63 use quasi_router::{Action, Document, Node, RegionKind, Screen as Described, Slot};
64 use quasi_webview::Webview;
65
66 /// The region every one of these pages is, and what the skip link points at.
67 ///
68 /// `login-container` because that is what the templates called the `div` and
69 /// what `style.css` still styles.
70 pub const PAGE_REGION: &str = "login-container";
71
72 /// Where `/login`'s answer lands.
73 pub const LOGIN_FEEDBACK: &str = "login-errors";
74
75 /// Where the other three pages' answers land.
76 pub const FEEDBACK: &str = "form-feedback";
77
78 /// The width these pages run at. Every one of them wrote it on the body.
79 const MEASURE: layout::Measure = layout::Measure::Contained;
80
81 /// The document any of these screens is drawn in.
82 ///
83 /// The wordmark rather than the site header, which is the whole reason these
84 /// pages are their own family: [`crate::shell::wordmark`] says why.
85 ///
86 /// No `Chrome`, so no shortcuts binding and no overlay container. A reader who
87 /// cannot sign in has nothing to reach with a key.
88 #[must_use]
89 pub fn renderer(csrf: &str, tail: &str) -> Webview {
90 Webview::new().with_shell(
91 crate::shell::described()
92 .sending("X-CSRF-Token", csrf)
93 .with_body_last(format!("{}{tail}", crate::shell::body_last()))
94 .with_body_first(format!(
95 "{}{}",
96 crate::shell::skip_link(PAGE_REGION),
97 crate::shell::wordmark()
98 ))
99 .with_head(format!(
100 "<meta name=\"csrf-token\" content=\"{}\">",
101 crate::helpers::escape_html(csrf)
102 )),
103 )
104 }
105
106 /// The passkey offer, as markup, because none of it is describable.
107 ///
108 /// A `data-action` the classic dispatcher resolves, a container a script
109 /// unhides once it has asked the browser whether it can do WebAuthn at all, and
110 /// an element that script writes a failure into. A description can say a
111 /// control calls a route; it cannot say a control calls a function in this
112 /// page's own JavaScript, and it should not learn to.
113 ///
114 /// So the region is a [`RegionKind::Handover`]: the description says there is a
115 /// place here and who owes the markup, and this is the host paying it. The id
116 /// is the one `static/page-login-2.js` looks for.
117 const PASSKEY: &str = concat!(
118 "<div class=\"login-divider\">or</div>",
119 "<button class=\"btn-secondary login-passkey-btn\" data-action=\"loginWithPasskey\">",
120 "Sign in with Passkey (fingerprint, face, or security key)</button>",
121 "<div id=\"passkey-login-error\" class=\"login-passkey-error\"></div>",
122 );
123
124 /// The region the passkey offer is handed over in.
125 pub const PASSKEY_REGION: &str = "passkey-login";
126
127 /// The two scripts the login page carried in `{% block scripts %}`.
128 const LOGIN_SCRIPTS: &str = concat!(
129 "<script src=\"/static/passkey.js\"></script>",
130 "<script src=\"/static/page-login-2.js?v=0623\" defer></script>",
131 );
132
133 /// Render one of these screens as a whole document.
134 #[must_use]
135 pub fn document(csrf: Option<&str>, screen: &Described) -> String {
136 use quasi_axum::Serves as _;
137
138 // The login screen is the only one of the four that hands a region over,
139 // and it is the only one that needs the two scripts that fill it. Asked of
140 // the screen rather than passed in, so a caller cannot render the login
141 // page without the thing that makes its passkey button work.
142 let offers_passkey = screen.slot(PASSKEY_REGION).is_some();
143 let mut webview = renderer(
144 csrf.unwrap_or_default(),
145 if offers_passkey { LOGIN_SCRIPTS } else { "" },
146 );
147 if offers_passkey {
148 webview = webview.with_fill(PASSKEY_REGION, PASSKEY);
149 }
150 webview.screen(screen)
151 }
152
153 /// The feedback region, filled, for a POST to answer an htmx submit with.
154 ///
155 /// The described forms use [`Action::replacing`], which is `hx-target="#<id>"`
156 /// plus `hx-swap="outerMorph"`: what the answer replaces is **the region
157 /// itself**. So an answer that were a bare alert would replace the element the
158 /// next attempt has to aim at, and a second wrong password would land nowhere.
159 /// Answering with the region keeps its id, which is what makes a refused form
160 /// retryable.
161 ///
162 /// That is also why these no longer answer `AlertTemplate`. The banner is the
163 /// description's now, drawn by the same renderer that drew the page, so the two
164 /// halves of one screen cannot come out of two hands.
165 #[must_use]
166 pub fn answered(
167 id: &str,
168 tone: layout::Tone,
169 message: &str,
170 onward: Option<(&str, &str)>,
171 ) -> String {
172 use quasi_axum::Serves as _;
173
174 let mut slot = Slot::new(id, RegionKind::Pane).with(Node::banner(tone, message));
175 if let Some((route, label)) = onward {
176 slot = slot.with(Node::act(label, Action::get(route).navigating()));
177 }
178 Webview::new().fragment(&Node::Region(slot))
179 }
180
181 /// The page body every one of these four shares: one region, at the measure
182 /// they all run at, carrying whatever the page put in it.
183 fn page(title: &str, body: Slot) -> Described {
184 Described::single(title)
185 .measured(MEASURE)
186 .documented(Document::default().classed(crate::shell::body_class(MEASURE, &[])))
187 // None of the four wants to be indexed: three of them answer only to a
188 // reader holding a link or a pending session, and a login form in a
189 // search result is a phishing lure with our name on it.
190 .indexed(false)
191 .with(body)
192 }
193
194 /// An empty region for a route's answer to land in.
195 ///
196 /// Empty, and that is the point: it is an address rather than content. The
197 /// error a full-page POST re-renders goes in through `error` instead, because
198 /// on that path there is no swap to land anything.
199 fn feedback(id: &str, error: Option<&str>) -> Node {
200 let mut slot = Slot::new(id, RegionKind::Pane);
201 if let Some(message) = error {
202 slot = slot.with(Node::banner(layout::Tone::Danger, message));
203 }
204 Node::Region(slot)
205 }
206
207 /// The link back to the login form, which three of these four carry.
208 fn back_to_login() -> Node {
209 Node::Link {
210 text: "Back to login".to_owned(),
211 action: Action::get("/login").navigating(),
212 }
213 }
214
215 /// `/login`.
216 ///
217 /// `sso_enabled` is the testnot.work preview, where there is no local password
218 /// at all and the whole form is replaced by one link. Two shapes of the same
219 /// screen rather than two screens, because everything around them -- the
220 /// wordmark, the measure, the notice -- is the same.
221 #[must_use]
222 pub fn login(
223 prefill: &str,
224 error: Option<&str>,
225 notice: Option<&str>,
226 sso_enabled: bool,
227 ) -> Described {
228 let mut body = Slot::new(PAGE_REGION, RegionKind::Pane);
229
230 if let Some(note) = notice {
231 body = body.with(Node::banner(layout::Tone::Info, note));
232 }
233
234 if sso_enabled {
235 return page(
236 "Log In - Makenotwork",
237 body.with(feedback(LOGIN_FEEDBACK, error))
238 .with(Node::section("Log in"))
239 .with(Node::text(
240 "testnot.work is a preview of makenot.work. Sign in with your \
241 makenot.work account to continue. Your password is only ever \
242 entered on makenot.work.",
243 ))
244 .with(Node::act(
245 "Sign in with Makenot.work",
246 Action::get("/sso/login").navigating(),
247 )),
248 );
249 }
250
251 let mut login_field = Field::new(layout::FieldKind::Text, "login", "Username or Email")
252 .required()
253 // What was typed, so a wrong password does not cost the address as
254 // well. `login_handler`'s full-page error path has always done this.
255 .value(prefill);
256 login_field.placeholder = Some("username or you@example.com".to_owned());
257
258 let mut password = Field::new(layout::FieldKind::Secret, "password", "Password").required();
259 password.placeholder = Some("--------".to_owned());
260
261 page(
262 "Log In - Makenotwork",
263 body.with(feedback(LOGIN_FEEDBACK, error))
264 .with(Node::section("Log in"))
265 .with(Node::Form {
266 action: Action::post("/login").replacing(LOGIN_FEEDBACK),
267 submit: "Log In".to_owned(),
268 fields: vec![
269 login_field,
270 password,
271 Field::new(layout::FieldKind::Checkbox, "remember_me", "Remember me"),
272 ],
273 })
274 .with(Node::Link {
275 text: "Reset Password".to_owned(),
276 action: Action::get("/forgot-password").navigating(),
277 })
278 .with(Node::Link {
279 text: "Join now".to_owned(),
280 action: Action::get("/join").navigating(),
281 })
282 // Hidden until `page-login-2.js` has asked the browser whether it
283 // can do WebAuthn. See `PASSKEY`.
284 .with(Node::Region(Slot::handover(
285 PASSKEY_REGION,
286 "the passkey offer",
287 ))),
288 )
289 // The caret in the box the reader came here to fill. The password box is
290 // the wrong answer even for somebody whose browser fills the first one:
291 // a filled box is still where a correction is made.
292 .opening_at("login")
293 }
294
295 /// `/forgot-password`.
296 #[must_use]
297 pub fn forgot_password() -> Described {
298 let mut email = Field::new(layout::FieldKind::Email, "email", "Email").required();
299 email.placeholder = Some("you@example.com".to_owned());
300
301 page(
302 "Reset Password - Makenotwork",
303 Slot::new(PAGE_REGION, RegionKind::Pane)
304 .with(feedback(FEEDBACK, None))
305 .with(Node::section("Reset Password"))
306 .with(Node::text(
307 "Enter your email address and we'll send you a link to reset your password.",
308 ))
309 .with(Node::Form {
310 action: Action::post("/forgot-password").replacing(FEEDBACK),
311 submit: "Send Reset Link".to_owned(),
312 fields: vec![email],
313 })
314 .with(back_to_login()),
315 )
316 .opening_at("email")
317 }
318
319 /// `/reset-password`.
320 ///
321 /// `valid` is whether the signed link still resolves. An expired one is a
322 /// different screen rather than a disabled form: there is nothing to type, and
323 /// the only useful control is the one that asks for a fresh link.
324 ///
325 /// The token rides on the action as a parameter rather than as a hidden field.
326 /// A hidden input is markup standing in for a value the call already carries,
327 /// and [`Action::with`] is what the vocabulary has for "send this along".
328 #[must_use]
329 pub fn reset_password(valid: bool, token: &str, error: Option<&str>) -> Described {
330 let mut body = Slot::new(PAGE_REGION, RegionKind::Pane).with(feedback(FEEDBACK, error));
331
332 if valid {
333 let mut password = Field::new(layout::FieldKind::Secret, "password", "New Password")
334 .required()
335 // The rule the server enforces, said before the reader submits
336 // rather than after. See the module header for why it is not
337 // `minlength`.
338 .hint("At least 8 characters");
339 password.placeholder = Some("--------".to_owned());
340
341 let mut confirm = Field::new(
342 layout::FieldKind::Secret,
343 "password_confirm",
344 "Confirm Password",
345 )
346 .required();
347 confirm.placeholder = Some("--------".to_owned());
348
349 body = body
350 .with(Node::section("Set New Password"))
351 .with(Node::text("Enter your new password below."))
352 .with(Node::Form {
353 action: Action::post("/reset-password")
354 .with("token", token)
355 .replacing(FEEDBACK),
356 submit: "Set Password".to_owned(),
357 fields: vec![password, confirm],
358 });
359 } else {
360 body = body
361 .with(Node::section("Link Expired"))
362 .with(Node::text(
363 "This password reset link has expired or is invalid. Please request a new one.",
364 ))
365 .with(Node::act(
366 "Request New Link",
367 Action::get("/forgot-password").navigating(),
368 ));
369 }
370
371 let screen = page("Set New Password - Makenotwork", body.with(back_to_login()));
372 if valid {
373 screen.opening_at("password")
374 } else {
375 // Nothing to type into, so nothing to open at. A name no field carries
376 // would be honoured as nothing anyway; saying nothing is the honest
377 // spelling of it.
378 screen
379 }
380 }
381
382 /// `/auth/2fa`.
383 #[must_use]
384 pub fn two_factor(error: Option<&str>) -> Described {
385 let mut code = Field::new(layout::FieldKind::Text, "code", "Verification Code").required();
386 code.placeholder = Some("000000".to_owned());
387 // Six digits or an eight-character backup code, which is what the template
388 // capped it at. A cap the box enforces, unlike the password minimum below:
389 // `Field` carries a maximum and no minimum.
390 code.max_length = Some(8);
391
392 page(
393 "Two-Factor Authentication - Makenotwork",
394 Slot::new(PAGE_REGION, RegionKind::Pane)
395 .with(feedback(FEEDBACK, error))
396 .with(Node::section("Two-Factor Authentication"))
397 .with(Node::text(
398 "Enter the 6-digit code from your authenticator app, or use a backup code.",
399 ))
400 .with(Node::Form {
401 action: Action::post("/auth/verify-2fa").replacing(FEEDBACK),
402 submit: "Verify".to_owned(),
403 fields: vec![code],
404 })
405 .with(back_to_login()),
406 )
407 .opening_at("code")
408 }
409
410 #[cfg(test)]
411 mod tests {
412 use super::*;
413
414 fn html(screen: &Described) -> String {
415 document(Some("tok&en"), screen)
416 }
417
418 /// The whole point of the pass: each of these pages carried exactly one
419 /// `autofocus`, and the caret lands in the box the reader came to type in.
420 #[test]
421 fn every_page_opens_where_its_template_put_the_caret() {
422 for (screen, name) in [
423 (login("", None, None, false), "login"),
424 (forgot_password(), "email"),
425 (reset_password(true, "t", None), "password"),
426 (two_factor(None), "code"),
427 ] {
428 assert_eq!(
429 screen.opens_at.as_deref(),
430 Some(name),
431 "{} opened somewhere else",
432 screen.title
433 );
434 let rendered = html(&screen);
435 assert_eq!(
436 rendered.matches("autofocus").count(),
437 1,
438 "{} emitted more or less than one caret: {rendered}",
439 screen.title
440 );
441 }
442 }
443
444 /// A page with nothing to type into opens nowhere. Saying a name no field
445 /// carries would be honoured as nothing anyway; saying nothing is the
446 /// honest spelling.
447 #[test]
448 fn an_expired_link_has_no_caret_to_place() {
449 let screen = reset_password(false, "", None);
450 assert!(screen.opens_at.is_none());
451 assert!(!html(&screen).contains("autofocus"));
452 }
453
454 /// The token rides on the call rather than in a hidden input, and it is
455 /// what the POST reads back as `token`.
456 #[test]
457 fn the_reset_token_travels_with_the_call() {
458 let rendered = html(&reset_password(true, "a-real-token", None));
459 assert!(rendered.contains("a-real-token"), "{rendered}");
460 assert!(
461 !rendered.contains("type=\"hidden\""),
462 "the token is markup again: {rendered}"
463 );
464 }
465
466 /// Each form's answer aims at the region the template's `hx-target` named,
467 /// which is what keeps the POST handlers untouched.
468 #[test]
469 fn every_form_aims_at_the_region_its_handler_answers_into() {
470 assert!(html(&login("", None, None, false)).contains(LOGIN_FEEDBACK));
471 for screen in [
472 forgot_password(),
473 reset_password(true, "t", None),
474 two_factor(None),
475 ] {
476 assert!(html(&screen).contains(FEEDBACK), "{}", screen.title);
477 }
478 }
479
480 /// A failed POST re-renders the page with the address intact, which the
481 /// template did and a conversion that dropped it would cost a retype.
482 #[test]
483 fn a_refused_login_keeps_what_was_typed_and_says_why() {
484 let rendered = html(&login(
485 "areader",
486 Some("Invalid username or password"),
487 None,
488 false,
489 ));
490 assert!(rendered.contains("areader"), "{rendered}");
491 assert!(
492 rendered.contains("Invalid username or password"),
493 "{rendered}"
494 );
495 }
496
497 /// The preview mirror has no local password at all, so the form is one
498 /// link. The caret has nothing to go in either.
499 #[test]
500 fn the_sso_shape_offers_one_way_in_and_no_form() {
501 let screen = login("", None, None, true);
502 assert!(screen.opens_at.is_none());
503 let rendered = html(&screen);
504 assert!(rendered.contains("/sso/login"), "{rendered}");
505 assert!(!rendered.contains("name=\"password\""), "{rendered}");
506 }
507
508 /// The passkey offer is markup because none of it is describable, and the
509 /// scripts that drive it ride with the page that has it.
510 #[test]
511 fn the_login_page_hands_over_the_passkey_offer_and_carries_its_scripts() {
512 let rendered = html(&login("", None, None, false));
513 assert!(rendered.contains("loginWithPasskey"), "{rendered}");
514 assert!(rendered.contains("page-login-2.js"), "{rendered}");
515 assert!(rendered.contains("passkey.js"), "{rendered}");
516
517 // And no other page pays for them.
518 let other = html(&forgot_password());
519 assert!(!other.contains("passkey"), "{other}");
520 }
521
522 /// None of the four is a page a search result should offer. A login form
523 /// in one is a phishing lure with our name on it.
524 #[test]
525 fn none_of_these_pages_is_indexable() {
526 for screen in [
527 login("", None, None, false),
528 forgot_password(),
529 reset_password(true, "t", None),
530 two_factor(None),
531 ] {
532 assert!(!screen.discovery.indexable, "{}", screen.title);
533 assert!(html(&screen).contains("noindex"), "{}", screen.title);
534 }
535 }
536
537 /// These pages carry the wordmark and no header: the nav would offer a
538 /// Library and a Dashboard a reader who cannot sign in cannot open.
539 #[test]
540 fn these_pages_show_the_wordmark_and_no_site_header() {
541 let rendered = html(&login("", None, None, false));
542 assert!(rendered.contains("brand-h1"), "{rendered}");
543 assert!(!rendered.contains("chrome-band"), "{rendered}");
544 }
545
546 /// `736f45a5`: a described screen's markup carries none of the four
547 /// spellings. All four templates wrote an `htmx-indicator` span.
548 #[test]
549 fn these_pages_spell_no_spinner() {
550 for screen in [
551 login("", None, None, false),
552 forgot_password(),
553 reset_password(true, "t", None),
554 two_factor(None),
555 ] {
556 let rendered = html(&screen);
557 for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
558 assert!(
559 !rendered.contains(spelling),
560 "{spelling} in {}",
561 screen.title
562 );
563 }
564 }
565 }
566 }
567