Skip to main content

max / makenotwork

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