forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
24.9 KiB
1//! Pages: self-service registration (`/register`), email verification (`/verify/{token}`) and
2//! the resend button on the security settings page. With `registration = "open"` anyone can
3//! register. With `"invite"` a valid invite link is needed. With `"closed"` the page is a 404.
4
5use std::time::Instant;
6
7use forge_core::names::validate_username;
8use forge_core::store::validate_email;
9use forge_core::{AuditAction, Registration, User};
10use serde::Deserialize;
11use topcoat::Result;
12use topcoat::context::{Cx, app_context};
13use topcoat::router::content::Form;
14use topcoat::router::error::{SeeOther, forbidden, not_found, see_other};
15use topcoat::router::{page, path_param_segment, query_params, route};
16use topcoat::view::{View, component, view};
17
18use crate::audit::{self, Actor, Target};
19use crate::auth::{
20 check_pre_csrf, clear_pre_csrf, current_user, hash_password_async, login, pre_csrf_token,
21 require_csrf,
22};
23use crate::client_ip::client_ip;
24use crate::i18n::{self, Key, Lang, password_too_long, password_too_short, t};
25use crate::mail::account;
26use crate::state::AppState;
27use crate::throttle;
28use crate::views::{alert, shell, user_facing_message};
29
30const MIN_PASSWORD_CHARS: usize = 12;
31const MAX_PASSWORD_BYTES: usize = 1024;
32/// Registration attempts allowed per client IP inside the throttle window.
33const REGISTER_IP_LIMIT: usize = 10;
34
35/// Checks a new password and its confirmation. Returns the message to show, or `None` when the
36/// password is acceptable. Shared with the password reset page.
37pub(crate) fn check_new_password(lang: Lang, password: &str, confirm: &str) -> Option<String> {
38 if password.chars().count() < MIN_PASSWORD_CHARS {
39 return Some(password_too_short(lang, MIN_PASSWORD_CHARS));
40 }
41 if password.len() > MAX_PASSWORD_BYTES {
42 return Some(password_too_long(lang, MAX_PASSWORD_BYTES));
43 }
44 if password != confirm {
45 return Some(t(lang, Key::ErrPasswordsDiffer).to_owned());
46 }
47 None
48}
49
50#[query_params(error = bad_request)]
51struct RegisterQuery {
52 invite: Option<String>,
53}
54
55#[derive(Deserialize)]
56struct RegisterForm {
57 #[serde(default)]
58 username: String,
59 #[serde(default)]
60 email: String,
61 #[serde(default)]
62 password: String,
63 #[serde(default)]
64 confirm: String,
65 #[serde(default)]
66 invite: String,
67 #[serde(default)]
68 precsrf: String,
69}
70
71#[derive(Deserialize)]
72struct VerifyForm {
73 #[serde(default)]
74 precsrf: String,
75}
76
77#[derive(Deserialize)]
78struct ResendForm {
79 #[serde(default)]
80 csrf: String,
81}
82
83/// Which state the register page is in.
84#[derive(Clone, Copy, PartialEq, Eq)]
85enum Gate {
86 /// The form is shown.
87 Form,
88 /// Invite-only registration, and no invite was given.
89 NeedsInvite,
90 /// An invite was given, but it is unknown, used or expired.
91 BadInvite,
92}
93
94/// Which state the verification page is in.
95#[derive(Clone, Copy, PartialEq, Eq)]
96enum VerifyMode {
97 /// The confirm button is shown. Nothing is consumed until it is pressed, so a mail scanner
98 /// that opens the link does not spend it.
99 Confirm,
100 Done,
101 Invalid,
102}
103
104fn gate_for(mode: &Registration, invite: &str, invite_valid: bool) -> Gate {
105 if invite.trim().is_empty() {
106 return if *mode == Registration::Invite {
107 Gate::NeedsInvite
108 } else {
109 Gate::Form
110 };
111 }
112 if invite_valid {
113 Gate::Form
114 } else {
115 Gate::BadInvite
116 }
117}
118
119#[component]
120async fn register_form(
121 lang: Lang,
122 gate: Gate,
123 invite: &str,
124 invited: bool,
125 username: &str,
126 email: &str,
127 precsrf: &str,
128 error: Option<String>,
129) -> Result<impl View> {
130 Ok(view! {
131 <section class="card narrow">
132 <h1>(t(lang, Key::RegisterTitle))</h1>
133 if gate == Gate::NeedsInvite {
134 <p class="muted">(t(lang, Key::RegisterNeedsInvite))</p>
135 }
136 if gate == Gate::BadInvite {
137 <p class="muted">(t(lang, Key::RegisterInvalidInvite))</p>
138 }
139 if gate == Gate::Form {
140 if invited {
141 <p class="muted">(t(lang, Key::RegisterInviteSub))</p>
142 }
143 if !invited {
144 <p class="muted">(t(lang, Key::RegisterSub))</p>
145 }
146 if let Some(message) = error {
147 alert(message: message.as_str())
148 }
149 <form data-hard="true" method="post" action="/register" class="stack">
150 <input type="hidden" name="precsrf" value=(precsrf)>
151 <input type="hidden" name="invite" value=(invite)>
152 <label>
153 <span>(t(lang, Key::Username))</span>
154 <input type="text" name="username" value=(username) autocomplete="username" required=(true)>
155 </label>
156 if invited {
157 <label>
158 <span>(t(lang, Key::Email))</span>
159 <input type="email" name="email" value=(email) readonly=(true)>
160 </label>
161 }
162 if !invited {
163 <label>
164 <span>(t(lang, Key::Email))</span>
165 <input type="email" name="email" value=(email) autocomplete="email" maxlength="254" required=(true)>
166 </label>
167 }
168 <label>
169 <span>(t(lang, Key::Password))</span>
170 <input type="password" name="password" autocomplete="new-password" required=(true)>
171 <small class="muted">(t(lang, Key::AtLeast12Chars))</small>
172 </label>
173 <label>
174 <span>(t(lang, Key::ConfirmPassword))</span>
175 <input type="password" name="confirm" autocomplete="new-password" required=(true)>
176 </label>
177 <button type="submit" class="primary">(t(lang, Key::RegisterSubmit))</button>
178 </form>
179 }
180 </section>
181 })
182}
183
184#[page("/register")]
185async fn register_page(cx: &Cx) -> Result<impl View> {
186 let state = app_context::<AppState>(cx);
187 if state.config.registration == Registration::Closed {
188 return Err(not_found().into());
189 }
190 if current_user(cx).await?.is_some() {
191 return Err(see_other("/").into());
192 }
193 let invite = query_params::<RegisterQuery>(cx)?
194 .invite
195 .clone()
196 .unwrap_or_default();
197 let invited_email = match invite.trim() {
198 "" => None,
199 token => state.store.invite_email(token).await?,
200 };
201 let gate = gate_for(&state.config.registration, &invite, invited_email.is_some());
202 let invited = invited_email.is_some();
203 let email = invited_email.unwrap_or_default();
204 let lang = i18n::lang(cx, None);
205 let precsrf = pre_csrf_token(cx);
206 Ok(view! {
207 shell(title: t(lang, Key::RegisterTitle), viewer: None, register_form(lang: lang, gate: gate, invite: invite.as_str(), invited: invited, username: "", email: email.as_str(), precsrf: precsrf.as_str(), error: None))
208 })
209}
210
211/// Validates the form and creates the account. The inner `Err` is a message for the user. With
212/// an invite, the account takes the invite's address and is verified from the start.
213async fn create_account(
214 state: &AppState,
215 lang: Lang,
216 form: &RegisterForm,
217 invite: Option<(&str, &str)>,
218) -> Result<std::result::Result<User, String>> {
219 let username = match validate_username(&form.username) {
220 Ok((name, _)) => name,
221 Err(e) => {
222 return Ok(Err(
223 user_facing_message(&e).unwrap_or_else(|| "invalid username".into())
224 ));
225 }
226 };
227 if let Some(message) = check_new_password(lang, &form.password, &form.confirm) {
228 return Ok(Err(message));
229 }
230 let address = match invite {
231 Some((_, address)) => address.to_owned(),
232 None => {
233 let address = form.email.trim().to_owned();
234 if let Err(e) = validate_email(&address) {
235 return Ok(Err(
236 user_facing_message(&e).unwrap_or_else(|| "invalid email address".into())
237 ));
238 }
239 address
240 }
241 };
242 let hash = hash_password_async(form.password.clone()).await?;
243 let created = match invite {
244 Some((token, _)) => match state.store.accept_invite(token, &username, &hash).await {
245 Ok(Some(user)) => Ok(user),
246 Ok(None) => return Ok(Err(t(lang, Key::RegisterInvalidInvite).to_owned())),
247 Err(e) => Err(e),
248 },
249 None => {
250 state
251 .store
252 .create_user(&username, Some(&address), &hash, false)
253 .await
254 }
255 };
256 match created {
257 Ok(user) => Ok(Ok(user)),
258 Err(e) => match user_facing_message(&e) {
259 Some(message) => Ok(Err(message)),
260 None => Err(e.into()),
261 },
262 }
263}
264
265#[page(POST "/register")]
266async fn register_submit(cx: &Cx, Form(form): Form<RegisterForm>) -> Result<impl View> {
267 let state = app_context::<AppState>(cx);
268 if state.config.registration == Registration::Closed {
269 return Err(not_found().into());
270 }
271 // Login CSRF: the pre-session token must match the cookie before anything else runs.
272 if !check_pre_csrf(cx, &form.precsrf) {
273 return Err(forbidden().into());
274 }
275 let lang = i18n::lang(cx, None);
276 let now = Instant::now();
277 let ip_key = throttle::ip_key("register-ip", client_ip(cx));
278 let limited = state
279 .throttle
280 .check(&ip_key, REGISTER_IP_LIMIT, now)
281 .is_some();
282 // Every attempt counts, so guessing invite tokens stays limited.
283 state.throttle.record_failure(&ip_key, now);
284
285 let invite = form.invite.trim().to_owned();
286 let invited_email = match invite.as_str() {
287 "" => None,
288 token => state.store.invite_email(token).await?,
289 };
290 let gate = gate_for(&state.config.registration, &invite, invited_email.is_some());
291 let mut error = None;
292 if limited && gate == Gate::Form {
293 error = Some(t(lang, Key::ErrThrottled).to_owned());
294 } else if gate == Gate::Form {
295 let invite_pair = invited_email
296 .as_deref()
297 .map(|address| (invite.as_str(), address));
298 match create_account(state, lang, &form, invite_pair).await? {
299 Ok(user) => {
300 let invited = invite_pair.is_some();
301 audit::record(
302 &state.store,
303 Some(cx),
304 Actor::User(&user),
305 AuditAction::UserCreated,
306 Target {
307 kind: "user",
308 id: &user.id,
309 name: &user.username,
310 },
311 serde_json::json!({ "register": true, "invite": invited }),
312 )
313 .await;
314 if invited {
315 audit::record(
316 &state.store,
317 Some(cx),
318 Actor::User(&user),
319 AuditAction::InviteAccepted,
320 Target {
321 kind: "user",
322 id: &user.id,
323 name: &user.username,
324 },
325 serde_json::Value::Null,
326 )
327 .await;
328 } else if let Some(address) = user.email.clone() {
329 account::send_verify(state, &user, &address).await?;
330 }
331 login(cx, &user).await?;
332 clear_pre_csrf(cx);
333 return Err(see_other("/").into());
334 }
335 Err(message) => error = Some(message),
336 }
337 }
338
339 let precsrf = form.precsrf.clone();
340 let invited = invited_email.is_some();
341 let email = invited_email.unwrap_or_else(|| form.email.clone());
342 Ok(view! {
343 shell(title: t(lang, Key::RegisterTitle), viewer: None, register_form(lang: lang, gate: gate, invite: invite.as_str(), invited: invited, username: form.username.as_str(), email: email.as_str(), precsrf: precsrf.as_str(), error: error))
344 })
345}
346
347#[component]
348async fn verify_body(
349 lang: Lang,
350 token: &str,
351 precsrf: &str,
352 mode: VerifyMode,
353) -> Result<impl View> {
354 Ok(view! {
355 <section class="card narrow">
356 <h1>(t(lang, Key::VerifyTitle))</h1>
357 if mode == VerifyMode::Confirm {
358 <p class="muted">(t(lang, Key::VerifyConfirmSub))</p>
359 <form data-hard="true" method="post" action=(format!("/verify/{token}")) class="stack">
360 <input type="hidden" name="precsrf" value=(precsrf)>
361 <button type="submit" class="primary">(t(lang, Key::VerifyConfirm))</button>
362 </form>
363 }
364 if mode == VerifyMode::Done {
365 <p>(t(lang, Key::VerifyDone))</p>
366 }
367 if mode == VerifyMode::Invalid {
368 <p class="muted">(t(lang, Key::VerifyInvalid))</p>
369 }
370 </section>
371 })
372}
373
374#[page("/verify/{token}")]
375async fn verify_page(cx: &Cx) -> Result<impl View> {
376 let lang = i18n::lang(cx, None);
377 let token = path_param_segment(cx, "token").to_owned();
378 let precsrf = pre_csrf_token(cx);
379 Ok(view! {
380 shell(title: t(lang, Key::VerifyTitle), viewer: None, verify_body(lang: lang, token: token.as_str(), precsrf: precsrf.as_str(), mode: VerifyMode::Confirm))
381 })
382}
383
384#[page(POST "/verify/{token}")]
385async fn verify_submit(cx: &Cx, Form(form): Form<VerifyForm>) -> Result<impl View> {
386 if !check_pre_csrf(cx, &form.precsrf) {
387 return Err(forbidden().into());
388 }
389 let state = app_context::<AppState>(cx);
390 let lang = i18n::lang(cx, None);
391 let token = path_param_segment(cx, "token").to_owned();
392 let mode = match state.store.redeem_verify_token(&token).await? {
393 Some(user_id) => {
394 if let Some(user) = state.store.user_by_id(&user_id).await? {
395 audit::record(
396 &state.store,
397 Some(cx),
398 Actor::User(&user),
399 AuditAction::EmailVerified,
400 Target {
401 kind: "user",
402 id: &user.id,
403 name: &user.username,
404 },
405 serde_json::Value::Null,
406 )
407 .await;
408 }
409 VerifyMode::Done
410 }
411 None => VerifyMode::Invalid,
412 };
413 Ok(view! {
414 shell(title: t(lang, Key::VerifyTitle), viewer: None, verify_body(lang: lang, token: token.as_str(), precsrf: form.precsrf.as_str(), mode: mode))
415 })
416}
417
418/// Sends a fresh verification link for the signed-in user's address, unless it is verified.
419#[route(POST "/settings/security/email/resend")]
420async fn verify_resend(cx: &Cx, Form(form): Form<ResendForm>) -> Result<SeeOther> {
421 let viewer = require_csrf(cx, &form.csrf).await?;
422 let state = app_context::<AppState>(cx);
423 if let Some(address) = viewer.0.email.clone()
424 && !state.store.email_is_verified(&viewer.0.id).await?
425 {
426 account::send_verify(state, &viewer.0, &address).await?;
427 }
428 Ok(see_other("/settings/security?saved=verify_sent"))
429}
430
431#[cfg(test)]
432pub(crate) mod test_helpers {
433 use http::HeaderMap;
434 use topcoat::router::request::Request;
435 use topcoat::router::{Body, Router, to_bytes};
436
437 /// The pre-session CSRF cookie name.
438 pub const PRECSRF_COOKIE: &str = "__Host-forge_precsrf";
439
440 /// Sends one request and returns the status, headers and body.
441 pub async fn send(
442 router: &Router,
443 method: &str,
444 uri: &str,
445 cookie: Option<&str>,
446 form: Option<&str>,
447 ) -> (u16, HeaderMap, String) {
448 let mut builder = Request::builder().method(method).uri(uri);
449 if let Some(c) = cookie {
450 builder = builder.header("cookie", c);
451 }
452 let body = match form {
453 Some(f) => {
454 builder = builder.header("content-type", "application/x-www-form-urlencoded");
455 Body::from(f.to_owned())
456 }
457 None => Body::empty(),
458 };
459 let res = router.handle(builder.body(body).unwrap()).await;
460 let status = res.status().as_u16();
461 let headers = res.headers().clone();
462 let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap();
463 (
464 status,
465 headers,
466 String::from_utf8_lossy(&bytes).into_owned(),
467 )
468 }
469
470 /// The `name=value` pair of a named Set-Cookie header, if present.
471 pub fn cookie_pair(headers: &HeaderMap, name: &str) -> Option<String> {
472 headers
473 .get_all("set-cookie")
474 .iter()
475 .filter_map(|v| v.to_str().ok())
476 .find(|raw| raw.starts_with(name))
477 .map(|raw| raw.split(';').next().unwrap().to_owned())
478 }
479
480 /// The value of a hidden `<input name=... value=...>` field.
481 pub fn field_from(html: &str, name: &str) -> String {
482 let marker = format!("name=\"{name}\" value=\"");
483 let start = html.find(&marker).unwrap_or_else(|| panic!("{name} field")) + marker.len();
484 let end = html[start..].find('"').unwrap() + start;
485 html[start..end].to_owned()
486 }
487
488 /// The first URL after `marker` in a mail body, up to whitespace.
489 pub fn link_in(body: &str, marker: &str) -> String {
490 let start = body
491 .find(marker)
492 .unwrap_or_else(|| panic!("{marker} in mail"));
493 body[start..].split_whitespace().next().unwrap().to_owned()
494 }
495
496 /// Fetches a page and returns its pre-session cookie pair and the CSRF value. The value is
497 /// the cookie's own value, so this works on pages whose form is not shown.
498 pub async fn precsrf_for(router: &Router, uri: &str) -> (String, String) {
499 let (status, headers, _) = send(router, "GET", uri, None, None).await;
500 assert_eq!(status, 200, "GET {uri}");
501 let cookie = cookie_pair(&headers, PRECSRF_COOKIE).expect("precsrf cookie");
502 let value = cookie.split_once('=').expect("cookie value").1.to_owned();
503 (cookie, value)
504 }
505}
506
507#[cfg(test)]
508mod tests {
509 use std::path::Path;
510
511 use forge_core::{Config, Registration};
512 use std::sync::Arc;
513
514 use super::test_helpers::{field_from, link_in, precsrf_for, send};
515 use crate::state::AppState;
516
517 async fn app(dir: &Path, mode: Registration) -> (topcoat::router::Router, AppState) {
518 let mut state = crate::test_support::test_state(dir).await;
519 state.config = Arc::new(Config {
520 registration: mode,
521 ..crate::test_support::test_config(dir)
522 });
523 (crate::test_support::app_router(state.clone()), state)
524 }
525
526 #[tokio::test]
527 async fn closed_registration_is_not_found() {
528 let tmp = tempfile::tempdir().expect("tempdir");
529 let (router, _state) = app(tmp.path(), Registration::Closed).await;
530 let (status, _, _) = send(&router, "GET", "/register", None, None).await;
531 assert_eq!(status, 404);
532 let (status, _, _) = send(
533 &router,
534 "POST",
535 "/register",
536 None,
537 Some("username=bob&password=x&confirm=x"),
538 )
539 .await;
540 assert_eq!(status, 404);
541 }
542
543 #[tokio::test]
544 async fn invite_mode_needs_an_invite() {
545 let tmp = tempfile::tempdir().expect("tempdir");
546 let (router, _state) = app(tmp.path(), Registration::Invite).await;
547 let (status, _, html) = send(&router, "GET", "/register", None, None).await;
548 assert_eq!(status, 200);
549 assert!(html.contains("Registration is by invitation only"));
550 assert!(
551 !html.contains("name=\"username\""),
552 "no form without an invite"
553 );
554 let (status, _, html) =
555 send(&router, "GET", "/register?invite=forge_nope", None, None).await;
556 assert_eq!(status, 200);
557 assert!(html.contains("This invite is invalid"));
558 }
559
560 #[tokio::test]
561 async fn invite_creates_one_verified_account_and_the_link_is_single_use() {
562 let tmp = tempfile::tempdir().expect("tempdir");
563 let (router, state) = app(tmp.path(), Registration::Invite).await;
564 let admin = state
565 .store
566 .create_user("admin", None, "hash", true)
567 .await
568 .unwrap();
569 let token = state
570 .store
571 .create_invite("bob@example.com", &admin.id)
572 .await
573 .unwrap();
574 let uri = format!("/register?invite={token}");
575 let (cookie, precsrf) = precsrf_for(&router, &uri).await;
576 let (status, _, html) = send(&router, "GET", &uri, Some(&cookie), None).await;
577 assert_eq!(status, 200);
578 assert!(html.contains("bob@example.com"), "invited address is shown");
579 assert_eq!(field_from(&html, "invite"), token);
580
581 let form = format!(
582 "precsrf={precsrf}&invite={token}&username=bob&email=mallory%40example.com\
583 &password=correct-horse-battery&confirm=correct-horse-battery"
584 );
585 let (status, headers, _) =
586 send(&router, "POST", "/register", Some(&cookie), Some(&form)).await;
587 assert_eq!(status, 303);
588 assert_eq!(headers["location"].to_str().unwrap(), "/");
589
590 let bob = state
591 .store
592 .user_by_username("bob")
593 .await
594 .unwrap()
595 .expect("account");
596 assert_eq!(
597 bob.email.as_deref(),
598 Some("bob@example.com"),
599 "the invite's address wins"
600 );
601 assert!(state.store.email_is_verified(&bob.id).await.unwrap());
602
603 // The same link now answers as spent, and creates nothing.
604 let (cookie, precsrf) = precsrf_for(&router, &uri).await;
605 let form = format!(
606 "precsrf={precsrf}&invite={token}&username=carol&email=\
607 &password=correct-horse-battery&confirm=correct-horse-battery"
608 );
609 let (status, _, html) =
610 send(&router, "POST", "/register", Some(&cookie), Some(&form)).await;
611 assert_eq!(status, 200);
612 assert!(html.contains("This invite is invalid"));
613 assert!(
614 state
615 .store
616 .user_by_username("carol")
617 .await
618 .unwrap()
619 .is_none()
620 );
621 }
622
623 #[tokio::test]
624 async fn open_registration_queues_a_verification_mail_and_no_verified_mark() {
625 let tmp = tempfile::tempdir().expect("tempdir");
626 let (router, state) = app(tmp.path(), Registration::Open).await;
627 let (cookie, precsrf) = precsrf_for(&router, "/register").await;
628 let form = format!(
629 "precsrf={precsrf}&invite=&username=dave&email=dave%40example.com\
630 &password=correct-horse-battery&confirm=correct-horse-battery"
631 );
632 let (status, _, _) = send(&router, "POST", "/register", Some(&cookie), Some(&form)).await;
633 assert_eq!(status, 303);
634 let dave = state
635 .store
636 .user_by_username("dave")
637 .await
638 .unwrap()
639 .expect("account");
640 assert!(!state.store.email_is_verified(&dave.id).await.unwrap());
641 let mail = state.store.recent_mail(10).await.unwrap();
642 assert!(
643 mail.iter()
644 .any(|m| m.to_addr == "dave@example.com" && m.body.contains("/verify/")),
645 "a verification link is queued"
646 );
647 }
648
649 #[tokio::test]
650 async fn verify_link_marks_the_address_verified_once() {
651 let tmp = tempfile::tempdir().expect("tempdir");
652 let (router, state) = app(tmp.path(), Registration::Open).await;
653 let user = state
654 .store
655 .create_user("erin", Some("erin@example.com"), "hash", false)
656 .await
657 .unwrap();
658 crate::mail::account::send_verify(&state, &user, "erin@example.com")
659 .await
660 .unwrap();
661 let body = state.store.recent_mail(10).await.unwrap()[0].body.clone();
662 let link = link_in(&body, "/verify/");
663 let path = link.trim_start_matches("https://forge.test");
664 assert!(!state.store.email_is_verified(&user.id).await.unwrap());
665
666 // A GET only shows the confirm button; it spends nothing.
667 let (status, _, html) = send(&router, "GET", path, None, None).await;
668 assert_eq!(status, 200);
669 assert!(html.contains("Confirm address"));
670 assert!(!state.store.email_is_verified(&user.id).await.unwrap());
671
672 let (cookie, precsrf) = precsrf_for(&router, path).await;
673 let form = format!("precsrf={precsrf}");
674 let (status, _, html) = send(&router, "POST", path, Some(&cookie), Some(&form)).await;
675 assert_eq!(status, 200);
676 assert!(html.contains("Your email address is verified"));
677 assert!(state.store.email_is_verified(&user.id).await.unwrap());
678
679 let (status, _, html) = send(&router, "POST", path, Some(&cookie), Some(&form)).await;
680 assert_eq!(status, 200);
681 assert!(html.contains("This verification link is invalid"));
682 }
683}