forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
48.0 KiB
1//! Page: account security (`/settings/security`). Password change, email change and
2//! two-factor authentication (enrollment, disabling, recovery codes). Every POST checks the
3//! CSRF token first. Changing the password signs out the other sessions.
4
5use std::time::{Duration, Instant};
6
7use forge_core::names::normalize_name;
8use forge_core::totp;
9use forge_core::{AuditAction, Session, User, secret};
10use http::header::{HeaderValue, RETRY_AFTER};
11use serde::Deserialize;
12use topcoat::Result;
13use topcoat::context::{Cx, app_context};
14use topcoat::router::content::Form;
15use topcoat::router::error::{SeeOther, see_other};
16use topcoat::router::{HeaderMap, StatusCode, page, query_params, route};
17use topcoat::view::{View, component, view};
18
19use crate::audit::{self, Actor, Target};
20use crate::auth::{
21 hash_password_async, logout, require_csrf, require_user, two_factor_enabled, unix_now,
22 verify_password_async, verify_second_factor,
23};
24use crate::i18n::{self, Key, Lang, password_too_long, password_too_short, t, two_factor_on_line};
25use crate::state::AppState;
26use crate::throttle::{self, LOGIN_USER_LIMIT, TWO_FACTOR_USER_LIMIT};
27use crate::views::{alert, shell, user_facing_message};
28
29/// Shortest and longest new passwords, in characters.
30const MIN_PASSWORD_CHARS: usize = 10;
31const MAX_PASSWORD_CHARS: usize = 1024;
32/// Recovery codes issued by each enrollment or regeneration.
33const RECOVERY_CODE_COUNT: usize = 10;
34
35#[query_params(error = bad_request)]
36struct SecurityQuery {
37 saved: Option<String>,
38}
39
40#[derive(Deserialize)]
41struct CsrfForm {
42 #[serde(default)]
43 csrf: String,
44}
45
46#[derive(Deserialize)]
47struct PasswordForm {
48 #[serde(default)]
49 csrf: String,
50 #[serde(default)]
51 current_password: String,
52 #[serde(default)]
53 new_password: String,
54 #[serde(default)]
55 confirm_password: String,
56}
57
58#[derive(Deserialize)]
59struct EmailForm {
60 #[serde(default)]
61 csrf: String,
62 #[serde(default)]
63 email: String,
64 #[serde(default)]
65 current_password: String,
66}
67
68#[derive(Deserialize)]
69struct CodeForm {
70 #[serde(default)]
71 csrf: String,
72 #[serde(default)]
73 code: String,
74}
75
76#[derive(Deserialize)]
77struct DisableForm {
78 #[serde(default)]
79 csrf: String,
80 #[serde(default)]
81 current_password: String,
82 #[serde(default)]
83 code: String,
84}
85
86#[derive(Deserialize)]
87struct DeleteAccountForm {
88 #[serde(default)]
89 csrf: String,
90 #[serde(default)]
91 current_password: String,
92 #[serde(default)]
93 code: String,
94 #[serde(default)]
95 confirm_username: String,
96}
97
98/// Messages and one-time output for one render of the page.
99#[derive(Default)]
100struct Shown {
101 notice: Option<&'static str>,
102 delete_error: Option<String>,
103 password_error: Option<String>,
104 email_error: Option<String>,
105 email_draft: Option<String>,
106 two_factor_error: Option<String>,
107 /// Recovery codes, shown once in the response that created them.
108 codes: Option<Vec<String>>,
109 /// Set when a check was refused by the throttle. The response then answers 429.
110 throttled: Option<Duration>,
111}
112
113/// What the page needs to draw itself.
114struct SecurityPage {
115 lang: Lang,
116 csrf: String,
117 email: String,
118 /// Whether the stored address has been verified.
119 email_verified: bool,
120 notice: Option<&'static str>,
121 password_error: Option<String>,
122 email_error: Option<String>,
123 two_factor_error: Option<String>,
124 codes: Option<Vec<String>>,
125 /// Secret (base32) and `otpauth://` URI while enrollment is waiting for a code.
126 pending: Option<(String, String)>,
127 /// Unused recovery codes, when two-factor is on.
128 enabled_unused_codes: Option<u64>,
129 delete_error: Option<String>,
130 /// Repositories still owned by the user. Deletion is refused while there are any.
131 owned_repos: Vec<String>,
132 two_factor_on: bool,
133}
134
135#[component]
136async fn security_body(page: SecurityPage) -> Result<impl View> {
137 let SecurityPage {
138 lang,
139 csrf,
140 email,
141 email_verified,
142 notice,
143 password_error,
144 email_error,
145 two_factor_error,
146 codes,
147 pending,
148 enabled_unused_codes,
149 delete_error,
150 owned_repos,
151 two_factor_on,
152 } = page;
153 Ok(view! {
154 <div class="settings-layout">
155 <section class="page-head">
156 <h1>(t(lang, Key::Security))</h1>
157 <p class="muted"><a href="/settings/security/log">(t(lang, Key::SecurityLog))</a>(t(lang, Key::SecurityLogRest))</p>
158 </section>
159 if let Some(message) = notice {
160 <section class="card notice" role="status">
161 <p>(message)</p>
162 </section>
163 }
164 if let Some(list) = codes {
165 <section class="card">
166 <h2>(t(lang, Key::RecoveryCodes))</h2>
167 <p>(t(lang, Key::RecoveryCodesSave))</p>
168 <ul class="item-list">
169 for code in list.into_iter() {
170 <li><code>(code)</code></li>
171 }
172 </ul>
173 </section>
174 }
175 <section class="card">
176 <h2>(t(lang, Key::Password))</h2>
177 if let Some(message) = password_error {
178 alert(message: message.as_str())
179 }
180 <form data-hard="true" method="post" action="/settings/security/password" class="stack">
181 <input type="hidden" name="csrf" value=(csrf.as_str())>
182 <label>
183 <span>(t(lang, Key::CurrentPassword))</span>
184 <input type="password" name="current_password" autocomplete="current-password" required=(true)>
185 </label>
186 <label>
187 <span>(t(lang, Key::NewPassword))</span>
188 <input type="password" name="new_password" autocomplete="new-password" minlength="10" maxlength="1024" required=(true)>
189 <small class="muted">(t(lang, Key::NewPasswordHint))</small>
190 </label>
191 <label>
192 <span>(t(lang, Key::ConfirmNewPassword))</span>
193 <input type="password" name="confirm_password" autocomplete="new-password" required=(true)>
194 </label>
195 <button type="submit" class="primary">(t(lang, Key::ChangePassword))</button>
196 </form>
197 </section>
198 <section class="card">
199 <h2>(t(lang, Key::Email))</h2>
200 if let Some(message) = email_error {
201 alert(message: message.as_str())
202 }
203 <form method="post" action="/settings/security/email" class="stack">
204 <input type="hidden" name="csrf" value=(csrf.as_str())>
205 <label>
206 <span>(t(lang, Key::Email))</span>
207 <input type="email" name="email" autocomplete="email" maxlength="254" required=(true) value=(email.as_str())>
208 </label>
209 <label>
210 <span>(t(lang, Key::CurrentPassword))</span>
211 <input type="password" name="current_password" autocomplete="current-password" required=(true)>
212 </label>
213 <button type="submit" class="primary">(t(lang, Key::SaveEmail))</button>
214 </form>
215 if !email.is_empty() {
216 if email_verified {
217 <p class="muted">(t(lang, Key::VerifyVerified))</p>
218 }
219 if !email_verified {
220 <p class="muted">(t(lang, Key::VerifyUnverified))</p>
221 <form method="post" action="/settings/security/email/resend" class="stack">
222 <input type="hidden" name="csrf" value=(csrf.as_str())>
223 <button type="submit">(t(lang, Key::VerifyResend))</button>
224 </form>
225 }
226 }
227 </section>
228 <section class="card">
229 <h2>(t(lang, Key::TwoFactorAuth))</h2>
230 if let Some(message) = two_factor_error {
231 alert(message: message.as_str())
232 }
233 if let Some(unused) = enabled_unused_codes {
234 <p>(two_factor_on_line(lang, unused))</p>
235 <h3>(t(lang, Key::RegenerateCodes))</h3>
236 <p class="muted">(t(lang, Key::RegenerateCodesHint))</p>
237 <form data-hard="true" method="post" action="/settings/security/2fa/recovery" class="stack">
238 <input type="hidden" name="csrf" value=(csrf.as_str())>
239 <label>
240 <span>(t(lang, Key::Code))</span>
241 <input type="text" name="code" autocomplete="one-time-code" required=(true)>
242 </label>
243 <button type="submit" class="primary">(t(lang, Key::RegenerateCodes))</button>
244 </form>
245 <h3>(t(lang, Key::TurnOffTwoFactor))</h3>
246 <form data-hard="true" method="post" action="/settings/security/2fa/disable" class="stack">
247 <input type="hidden" name="csrf" value=(csrf.as_str())>
248 <label>
249 <span>(t(lang, Key::CurrentPassword))</span>
250 <input type="password" name="current_password" autocomplete="current-password" required=(true)>
251 </label>
252 <label>
253 <span>(t(lang, Key::Code))</span>
254 <input type="text" name="code" autocomplete="one-time-code" required=(true)>
255 </label>
256 <button type="submit" class="danger">(t(lang, Key::TurnOffTwoFactorButton))</button>
257 </form>
258 } else if let Some((secret, uri)) = pending {
259 <p>(t(lang, Key::TwoFactorPendingIntro))</p>
260 <p>(t(lang, Key::SecretPrefix)) <code>(secret)</code></p>
261 <p>(t(lang, Key::LinkPrefix)) <code>(uri)</code></p>
262 <form data-hard="true" method="post" action="/settings/security/2fa/confirm" class="stack">
263 <input type="hidden" name="csrf" value=(csrf.as_str())>
264 <label>
265 <span>(t(lang, Key::CodeFromApp))</span>
266 <input type="text" name="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" required=(true)>
267 </label>
268 <button type="submit" class="primary">(t(lang, Key::ConfirmAndTurnOn))</button>
269 </form>
270 } else {
271 <p class="muted">(t(lang, Key::TwoFactorOffIntro))</p>
272 <form data-hard="true" method="post" action="/settings/security/2fa/enable">
273 <input type="hidden" name="csrf" value=(csrf.as_str())>
274 <button type="submit" class="primary">(t(lang, Key::Enable2fa))</button>
275 </form>
276 }
277 </section>
278 <section class="card">
279 <h2>(t(lang, Key::DeleteAccount))</h2>
280 if let Some(message) = delete_error {
281 alert(message: message.as_str())
282 }
283 if owned_repos.is_empty() {
284 <p class="muted">(t(lang, Key::DeleteAccountIntro))</p>
285 <form data-hard="true" method="post" action="/settings/security/delete" class="stack">
286 <input type="hidden" name="csrf" value=(csrf.as_str())>
287 <label>
288 <span>(t(lang, Key::CurrentPassword))</span>
289 <input type="password" name="current_password" autocomplete="current-password" required=(true)>
290 </label>
291 if two_factor_on {
292 <label>
293 <span>(t(lang, Key::Code))</span>
294 <input type="text" name="code" autocomplete="one-time-code" required=(true)>
295 </label>
296 }
297 <label>
298 <span>(t(lang, Key::TypeUsernameToConfirm))</span>
299 <input type="text" name="confirm_username" autocomplete="off" required=(true)>
300 </label>
301 <button type="submit" class="danger">(t(lang, Key::DeleteAccount))</button>
302 </form>
303 } else {
304 <p>(t(lang, Key::TransferOrDeleteRepos))</p>
305 <ul class="item-list">
306 for name in owned_repos.into_iter() {
307 <li>(name)</li>
308 }
309 </ul>
310 }
311 </section>
312 </div>
313 })
314}
315
316fn notice_for(lang: Lang, saved: Option<&str>) -> Option<&'static str> {
317 match saved {
318 Some("password") => Some(t(lang, Key::OkPasswordChanged)),
319 Some("email") => Some(t(lang, Key::OkEmailSaved)),
320 Some("verify_sent") => Some(t(lang, Key::OkVerifySent)),
321 Some("2fa-off") => Some(t(lang, Key::OkTwoFactorOff)),
322 _ => None,
323 }
324}
325
326/// Builds the page data from the stored state and the message set for this render.
327async fn build_page(
328 state: &AppState,
329 lang: Lang,
330 viewer: &(User, Session),
331 shown: Shown,
332) -> forge_core::Result<SecurityPage> {
333 let totp_state = state.store.totp_state(&viewer.0.id).await?;
334 let (pending, enabled_unused_codes) = match totp_state {
335 Some(t) if t.enabled => (
336 None,
337 Some(state.store.unused_recovery_codes(&viewer.0.id).await?),
338 ),
339 Some(t) => match t.sealed_secret {
340 Some(sealed) => {
341 let shared = secret::open(&state.key, &sealed)?;
342 let pending = (
343 totp::base32_encode(&shared),
344 totp::otpauth_uri(&viewer.0.username, &shared),
345 );
346 (Some(pending), None)
347 }
348 None => (None, None),
349 },
350 None => (None, None),
351 };
352 let owned_repos = state
353 .store
354 .list_repos_by_owner(&viewer.0.id)
355 .await?
356 .into_iter()
357 .map(|repo| format!("{}/{}", repo.owner_name, repo.name))
358 .collect();
359 let two_factor_on = enabled_unused_codes.is_some();
360 Ok(SecurityPage {
361 lang,
362 csrf: viewer.1.csrf_token.clone(),
363 delete_error: shown.delete_error,
364 owned_repos,
365 two_factor_on,
366 email_verified: state.store.email_is_verified(&viewer.0.id).await?,
367 email: shown
368 .email_draft
369 .unwrap_or_else(|| viewer.0.email.clone().unwrap_or_default()),
370 notice: shown.notice,
371 password_error: shown.password_error,
372 email_error: shown.email_error,
373 two_factor_error: shown.two_factor_error,
374 codes: shown.codes,
375 pending,
376 enabled_unused_codes,
377 })
378}
379
380/// Renders the page. A throttled check answers 429 with `Retry-After`.
381async fn respond(cx: &Cx, viewer: (User, Session), shown: Shown) -> Result<impl View> {
382 let state = app_context::<AppState>(cx);
383 // `view!` expects the `__cx` binding that `#[page]` and `#[component]` provide. This plain
384 // helper has to bind it itself.
385 let __cx = cx;
386 let mut headers = HeaderMap::new();
387 let status = match shown.throttled {
388 Some(retry) => {
389 headers.insert(RETRY_AFTER, HeaderValue::from(retry.as_secs().max(1)));
390 StatusCode::TOO_MANY_REQUESTS
391 }
392 None => StatusCode::OK,
393 };
394 let lang = i18n::lang(cx, Some(&viewer.0));
395 let page = build_page(state, lang, &viewer, shown).await?;
396 Ok(view! {
397 (status)
398 (headers)
399 shell(title: t(lang, Key::Security), viewer: Some(&viewer), security_body(page: page))
400 })
401}
402
403/// Why a sensitive action was refused at the password step.
404struct Refused {
405 message: &'static str,
406 retry: Option<Duration>,
407}
408
409/// Checks the current password for a sensitive change. A wrong answer counts against the
410/// user's login throttle, so guessing here also locks logins. A right answer clears the count.
411async fn require_password(
412 lang: Lang,
413 state: &AppState,
414 user: &User,
415 password: &str,
416) -> forge_core::Result<std::result::Result<(), Refused>> {
417 let key = throttle::user_key(&normalize_name(&user.username));
418 let now = Instant::now();
419 if let Some(retry) = state.throttle.check(&key, LOGIN_USER_LIMIT, now) {
420 return Ok(Err(Refused {
421 message: t(lang, Key::ErrThrottled),
422 retry: Some(retry),
423 }));
424 }
425 if verify_password_async(password.to_owned(), user.password_hash.clone()).await? {
426 state.throttle.clear(&key);
427 Ok(Ok(()))
428 } else {
429 state.throttle.record_failure(&key, now);
430 Ok(Err(Refused {
431 message: t(lang, Key::ErrWrongPassword),
432 retry: None,
433 }))
434 }
435}
436
437/// The retry delay when second-factor checks for this user are throttled.
438fn second_factor_blocked(state: &AppState, user_id: &str) -> Option<Duration> {
439 state.throttle.check(
440 &throttle::two_factor_key(user_id),
441 TWO_FACTOR_USER_LIMIT,
442 Instant::now(),
443 )
444}
445
446/// Fresh recovery codes and their hashes. The plain codes are for display only.
447fn new_recovery_codes() -> (Vec<String>, Vec<String>) {
448 let codes: Vec<String> = (0..RECOVERY_CODE_COUNT)
449 .map(|_| totp::new_recovery_code())
450 .collect();
451 let hashes = codes.iter().map(|c| totp::recovery_code_hash(c)).collect();
452 (codes, hashes)
453}
454
455/// Why a new password is refused, or `None` when it is acceptable.
456fn check_new_password(lang: Lang, new: &str, confirm: &str) -> Option<String> {
457 let len = new.chars().count();
458 if len < MIN_PASSWORD_CHARS {
459 return Some(password_too_short(lang, MIN_PASSWORD_CHARS));
460 }
461 if len > MAX_PASSWORD_CHARS {
462 return Some(password_too_long(lang, MAX_PASSWORD_CHARS));
463 }
464 if new != confirm {
465 return Some(t(lang, Key::ErrPasswordsDiffer).into());
466 }
467 None
468}
469
470#[page("/settings/security")]
471async fn security_page(cx: &Cx) -> Result<impl View> {
472 let viewer = require_user(cx).await?;
473 let lang = i18n::lang(cx, Some(&viewer.0));
474 let saved = query_params::<SecurityQuery>(cx)?.saved.clone();
475 let shown = Shown {
476 notice: notice_for(lang, saved.as_deref()),
477 ..Shown::default()
478 };
479 respond(cx, viewer, shown).await
480}
481
482#[page(POST "/settings/security/password")]
483async fn password_change(cx: &Cx, Form(form): Form<PasswordForm>) -> Result<impl View> {
484 let viewer = require_csrf(cx, &form.csrf).await?;
485 let lang = i18n::lang(cx, Some(&viewer.0));
486 let state = app_context::<AppState>(cx);
487 let mut shown = Shown::default();
488 if let Err(refused) = require_password(lang, state, &viewer.0, &form.current_password).await? {
489 shown.password_error = Some(refused.message.into());
490 shown.throttled = refused.retry;
491 return respond(cx, viewer, shown).await;
492 }
493 if let Some(message) = check_new_password(lang, &form.new_password, &form.confirm_password) {
494 shown.password_error = Some(message);
495 return respond(cx, viewer, shown).await;
496 }
497 let hash = hash_password_async(form.new_password).await?;
498 state.store.set_password_hash(&viewer.0.id, &hash).await?;
499 // Every other browser loses its session. The current one stays signed in.
500 state
501 .store
502 .delete_sessions_except(&viewer.0.id, &viewer.1.id)
503 .await?;
504 audit::record(
505 &state.store,
506 Some(cx),
507 Actor::User(&viewer.0),
508 AuditAction::PasswordChanged,
509 Target::default(),
510 serde_json::Value::Null,
511 )
512 .await;
513 Err(see_other("/settings/security?saved=password").into())
514}
515
516#[page(POST "/settings/security/email")]
517async fn email_change(cx: &Cx, Form(form): Form<EmailForm>) -> Result<impl View> {
518 let viewer = require_csrf(cx, &form.csrf).await?;
519 let lang = i18n::lang(cx, Some(&viewer.0));
520 let state = app_context::<AppState>(cx);
521 let email = form.email.trim().to_owned();
522 let mut shown = Shown {
523 email_draft: Some(email.clone()),
524 ..Shown::default()
525 };
526 if let Err(refused) = require_password(lang, state, &viewer.0, &form.current_password).await? {
527 shown.email_error = Some(refused.message.into());
528 shown.throttled = refused.retry;
529 return respond(cx, viewer, shown).await;
530 }
531 match state.store.set_email(&viewer.0.id, &email).await {
532 Ok(()) => {
533 // A new address is unverified until its link is opened.
534 state.store.clear_email_verified(&viewer.0.id).await?;
535 crate::mail::account::send_verify(state, &viewer.0, &email).await?;
536 // The allowed-signers lines and cached verdicts carry the old address.
537 crate::signatures::on_keys_changed(state, &viewer.0.id).await;
538 audit::record(
539 &state.store,
540 Some(cx),
541 Actor::User(&viewer.0),
542 AuditAction::EmailChanged,
543 Target::default(),
544 serde_json::Value::Null,
545 )
546 .await;
547 Err(see_other("/settings/security?saved=email").into())
548 }
549 Err(e) => match user_facing_message(&e) {
550 Some(message) => {
551 shown.email_error = Some(message);
552 respond(cx, viewer, shown).await
553 }
554 None => Err(e.into()),
555 },
556 }
557}
558
559#[route(POST "/settings/security/2fa/enable")]
560async fn two_factor_enable(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<SeeOther> {
561 let (user, _session) = require_csrf(cx, &form.csrf).await?;
562 let state = app_context::<AppState>(cx);
563 if !two_factor_enabled(&state.store, &user.id).await? {
564 // A new enrollment replaces any unconfirmed secret.
565 let sealed = secret::seal(&state.key, &totp::new_secret())?;
566 state.store.set_totp_secret(&user.id, &sealed).await?;
567 }
568 Ok(see_other("/settings/security"))
569}
570
571#[page(POST "/settings/security/2fa/confirm")]
572async fn two_factor_confirm(cx: &Cx, Form(form): Form<CodeForm>) -> Result<impl View> {
573 let viewer = require_csrf(cx, &form.csrf).await?;
574 let lang = i18n::lang(cx, Some(&viewer.0));
575 let state = app_context::<AppState>(cx);
576 let user_id = viewer.0.id.clone();
577 let Some(totp_state) = state.store.totp_state(&user_id).await? else {
578 return Err(see_other("/settings/security").into());
579 };
580 let sealed = match (&totp_state.sealed_secret, totp_state.enabled) {
581 (Some(sealed), false) => sealed.clone(),
582 _ => return Err(see_other("/settings/security").into()),
583 };
584 let mut shown = Shown::default();
585 if let Some(retry) = second_factor_blocked(state, &user_id) {
586 shown.two_factor_error = Some(t(lang, Key::ErrThrottled).into());
587 shown.throttled = Some(retry);
588 return respond(cx, viewer, shown).await;
589 }
590 let shared = secret::open(&state.key, &sealed)?;
591 let key = throttle::two_factor_key(&user_id);
592 match totp::verify(
593 &shared,
594 form.code.trim(),
595 totp::step_for(unix_now()),
596 totp_state.last_step,
597 ) {
598 Some(step) => {
599 state.throttle.clear(&key);
600 let (codes, hashes) = new_recovery_codes();
601 state.store.enable_totp(&user_id, step, &hashes).await?;
602 audit::record(
603 &state.store,
604 Some(cx),
605 Actor::User(&viewer.0),
606 AuditAction::TotpEnabled,
607 Target::default(),
608 serde_json::Value::Null,
609 )
610 .await;
611 shown.notice = Some(t(lang, Key::OkTwoFactorOn));
612 shown.codes = Some(codes);
613 respond(cx, viewer, shown).await
614 }
615 None => {
616 state.throttle.record_failure(&key, Instant::now());
617 shown.two_factor_error = Some(t(lang, Key::ErrWrongConfirmCode).into());
618 respond(cx, viewer, shown).await
619 }
620 }
621}
622
623#[page(POST "/settings/security/2fa/disable")]
624async fn two_factor_disable(cx: &Cx, Form(form): Form<DisableForm>) -> Result<impl View> {
625 let viewer = require_csrf(cx, &form.csrf).await?;
626 let lang = i18n::lang(cx, Some(&viewer.0));
627 let state = app_context::<AppState>(cx);
628 let user_id = viewer.0.id.clone();
629 if !two_factor_enabled(&state.store, &user_id).await? {
630 return Err(see_other("/settings/security").into());
631 }
632 let mut shown = Shown::default();
633 if let Err(refused) = require_password(lang, state, &viewer.0, &form.current_password).await? {
634 shown.two_factor_error = Some(refused.message.into());
635 shown.throttled = refused.retry;
636 return respond(cx, viewer, shown).await;
637 }
638 if let Some(retry) = second_factor_blocked(state, &user_id) {
639 shown.two_factor_error = Some(t(lang, Key::ErrThrottled).into());
640 shown.throttled = Some(retry);
641 return respond(cx, viewer, shown).await;
642 }
643 let key = throttle::two_factor_key(&user_id);
644 if !verify_second_factor(&state.store, &state.key, &user_id, &form.code).await? {
645 state.throttle.record_failure(&key, Instant::now());
646 shown.two_factor_error = Some(t(lang, Key::ErrWrongCode).into());
647 return respond(cx, viewer, shown).await;
648 }
649 state.throttle.clear(&key);
650 state.store.disable_totp(&user_id).await?;
651 audit::record(
652 &state.store,
653 Some(cx),
654 Actor::User(&viewer.0),
655 AuditAction::TotpDisabled,
656 Target::default(),
657 serde_json::Value::Null,
658 )
659 .await;
660 Err(see_other("/settings/security?saved=2fa-off").into())
661}
662
663#[page(POST "/settings/security/delete")]
664async fn account_delete(cx: &Cx, Form(form): Form<DeleteAccountForm>) -> Result<impl View> {
665 let viewer = require_csrf(cx, &form.csrf).await?;
666 let lang = i18n::lang(cx, Some(&viewer.0));
667 let state = app_context::<AppState>(cx);
668 let user_id = viewer.0.id.clone();
669 let mut shown = Shown::default();
670 if form.confirm_username != viewer.0.username {
671 shown.delete_error = Some(t(lang, Key::ErrConfirmUsername).into());
672 return respond(cx, viewer, shown).await;
673 }
674 if let Err(refused) = require_password(lang, state, &viewer.0, &form.current_password).await? {
675 shown.delete_error = Some(refused.message.into());
676 shown.throttled = refused.retry;
677 return respond(cx, viewer, shown).await;
678 }
679 if two_factor_enabled(&state.store, &user_id).await? {
680 if let Some(retry) = second_factor_blocked(state, &user_id) {
681 shown.delete_error = Some(t(lang, Key::ErrThrottled).into());
682 shown.throttled = Some(retry);
683 return respond(cx, viewer, shown).await;
684 }
685 let key = throttle::two_factor_key(&user_id);
686 if !verify_second_factor(&state.store, &state.key, &user_id, &form.code).await? {
687 state.throttle.record_failure(&key, Instant::now());
688 shown.delete_error = Some(t(lang, Key::ErrWrongCode).into());
689 return respond(cx, viewer, shown).await;
690 }
691 state.throttle.clear(&key);
692 }
693 let ghost = state.store.ensure_ghost_user().await?;
694 match state.store.delete_user(&user_id, &ghost.id).await {
695 Ok(_) => {
696 // The account row is gone, so the entry names no actor; the username is kept.
697 audit::record(
698 &state.store,
699 Some(cx),
700 Actor::Anonymous,
701 AuditAction::UserDeleted,
702 Target {
703 kind: "user",
704 id: &user_id,
705 name: &viewer.0.username,
706 },
707 serde_json::json!({ "self_service": true }),
708 )
709 .await;
710 logout(cx, &viewer.1).await?;
711 Err(see_other("/").into())
712 }
713 Err(e) => match user_facing_message(&e) {
714 Some(message) => {
715 shown.delete_error = Some(message);
716 respond(cx, viewer, shown).await
717 }
718 None => Err(e.into()),
719 },
720 }
721}
722
723#[page(POST "/settings/security/2fa/recovery")]
724async fn two_factor_recovery(cx: &Cx, Form(form): Form<CodeForm>) -> Result<impl View> {
725 let viewer = require_csrf(cx, &form.csrf).await?;
726 let lang = i18n::lang(cx, Some(&viewer.0));
727 let state = app_context::<AppState>(cx);
728 let user_id = viewer.0.id.clone();
729 if !two_factor_enabled(&state.store, &user_id).await? {
730 return Err(see_other("/settings/security").into());
731 }
732 let mut shown = Shown::default();
733 if let Some(retry) = second_factor_blocked(state, &user_id) {
734 shown.two_factor_error = Some(t(lang, Key::ErrThrottled).into());
735 shown.throttled = Some(retry);
736 return respond(cx, viewer, shown).await;
737 }
738 let key = throttle::two_factor_key(&user_id);
739 if !verify_second_factor(&state.store, &state.key, &user_id, &form.code).await? {
740 state.throttle.record_failure(&key, Instant::now());
741 shown.two_factor_error = Some(t(lang, Key::ErrWrongCode).into());
742 return respond(cx, viewer, shown).await;
743 }
744 state.throttle.clear(&key);
745 let (codes, hashes) = new_recovery_codes();
746 state
747 .store
748 .replace_recovery_codes(&user_id, &hashes)
749 .await?;
750 audit::record(
751 &state.store,
752 Some(cx),
753 Actor::User(&viewer.0),
754 AuditAction::RecoveryCodesRegenerated,
755 Target::default(),
756 serde_json::Value::Null,
757 )
758 .await;
759 shown.notice = Some(t(lang, Key::OkRecoveryCodesNew));
760 shown.codes = Some(codes);
761 respond(cx, viewer, shown).await
762}
763
764#[cfg(test)]
765mod tests {
766 use std::sync::Arc;
767
768 use forge_core::time::stamp_after;
769 use forge_core::token::hash_token;
770 use forge_core::{Config, Registration, Store, User, secret, totp};
771 use forge_git::GitEnv;
772 use topcoat::cookie::RouterBuilderCookieExt;
773 use topcoat::router::request::Request;
774 use topcoat::router::{Body, Router, RouterBuilderDiscoverExt, to_bytes};
775
776 use crate::auth::{SESSION_COOKIE, hash_password_async};
777 use crate::state::AppState;
778 use crate::throttle::{LOGIN_USER_LIMIT, Throttle, user_key};
779
780 const TOKEN: &str = "security-test-session";
781 const OTHER_TOKEN: &str = "security-test-other-session";
782 const CSRF: &str = "security-test-csrf";
783 const PASSWORD: &str = "current password 1";
784 const KEY: [u8; 32] = [7u8; 32];
785
786 struct Fixture {
787 router: Router,
788 state: AppState,
789 user: User,
790 _dir: tempfile::TempDir,
791 }
792
793 async fn fixture() -> Fixture {
794 let dir = tempfile::tempdir().unwrap();
795 let path = dir.path().to_path_buf();
796 let store = Store::open(&path.join("forge.db")).await.unwrap();
797 let config = Config {
798 database: path.join("forge.db"),
799 data_dir: path.clone(),
800 listen: "127.0.0.1:7660".parse().unwrap(),
801 base_url: "https://forge.test/".into(),
802 user_content_base_url: "https://forge.test".into(),
803 ssh_listen: "127.0.0.1:2222".parse().unwrap(),
804 user_content_listen: "127.0.0.1:7661".parse().unwrap(),
805 ssh_public_host: "forge.test".into(),
806 ssh_public_port: 22,
807 registration: Registration::Closed,
808 trusted_proxies: Vec::new(),
809 webhooks: Default::default(),
810 backup: Default::default(),
811 releases: Default::default(),
812 replication: Default::default(),
813 lfs: Default::default(),
814 ci: Default::default(),
815 advisories: Default::default(),
816 mail: Default::default(),
817 orgs: Default::default(),
818 self_update: Default::default(),
819 };
820 let state = AppState {
821 config: Arc::new(config),
822 store: store.clone(),
823 git: Arc::new(GitEnv {
824 git_bin: "git".into(),
825 data_dir: path.clone(),
826 }),
827 key: Arc::new(KEY),
828 internal_token: Arc::from("test-token"),
829 admin_token: Arc::from("test-admin-token"),
830 jobs: Arc::new(crate::backup::Jobs::default()),
831 internal_socket: path.join("internal.sock"),
832 throttle: Arc::new(Throttle::default()),
833 webhook_waker: Arc::new(tokio::sync::Notify::new()),
834 mirror_waker: Arc::new(tokio::sync::Notify::new()),
835 blobs: crate::blobs::BlobStore::open(&path.join("blobs")).expect("blob store"),
836 replication: Arc::new(crate::replication::ReplicationQueue::default()),
837 ci_waker: Arc::new(tokio::sync::Notify::new()),
838 mailer: Arc::new(crate::mail::FakeMailer::default()),
839 mail_waker: Arc::new(tokio::sync::Notify::new()),
840 notify_hub: Arc::new(crate::notify_hub::NotifyHub::new()),
841 ci_live: Arc::new(crate::ci::CiLive::default()),
842 };
843 let hash = hash_password_async(PASSWORD.into()).await.unwrap();
844 let user = store
845 .create_user("tahinli", None, &hash, false)
846 .await
847 .unwrap();
848 let expiry = stamp_after(std::time::Duration::from_secs(3600));
849 store
850 .create_session(&user.id, &hash_token(TOKEN), CSRF, &expiry, None)
851 .await
852 .unwrap();
853 let router = Router::builder()
854 .discover()
855 .cookies()
856 .app_context(state.clone())
857 .build();
858 Fixture {
859 router,
860 state,
861 user,
862 _dir: dir,
863 }
864 }
865
866 struct Reply {
867 status: u16,
868 location: Option<String>,
869 body: String,
870 }
871
872 async fn send(router: &Router, method: &str, uri: &str, signed_in: bool, form: &str) -> Reply {
873 let mut builder = Request::builder().method(method).uri(uri);
874 if signed_in {
875 builder = builder.header("cookie", format!("__Host-{SESSION_COOKIE}={TOKEN}"));
876 }
877 if method == "POST" {
878 builder = builder.header("content-type", "application/x-www-form-urlencoded");
879 }
880 let res = router
881 .handle(builder.body(Body::from(form.to_owned())).unwrap())
882 .await;
883 let status = res.status().as_u16();
884 let location = res
885 .headers()
886 .get("location")
887 .and_then(|v| v.to_str().ok())
888 .map(str::to_owned);
889 let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap();
890 Reply {
891 status,
892 location,
893 body: String::from_utf8_lossy(&bytes).into_owned(),
894 }
895 }
896
897 fn now_code(shared: &[u8]) -> String {
898 totp::code_at(shared, totp::step_for(crate::auth::unix_now()))
899 }
900
901 /// Turns two-factor on directly in the store. Returns the shared secret.
902 async fn enable_totp_directly(fx: &Fixture) -> Vec<u8> {
903 let shared = totp::new_secret();
904 let sealed = secret::seal(&KEY, &shared).unwrap();
905 fx.state
906 .store
907 .set_totp_secret(&fx.user.id, &sealed)
908 .await
909 .unwrap();
910 fx.state
911 .store
912 .enable_totp(&fx.user.id, 0, &[totp::recovery_code_hash("aaaa-bbbb")])
913 .await
914 .unwrap();
915 shared.to_vec()
916 }
917
918 #[tokio::test]
919 async fn security_page_requires_a_session() {
920 let fx = fixture().await;
921 let reply = send(&fx.router, "GET", "/settings/security", false, "").await;
922 assert_eq!(reply.status, 303);
923 assert!(reply.location.unwrap().starts_with("/login?next="));
924 }
925
926 #[tokio::test]
927 async fn password_change_revokes_other_sessions_and_keeps_this_one() {
928 let fx = fixture().await;
929 let expiry = stamp_after(std::time::Duration::from_secs(3600));
930 fx.state
931 .store
932 .create_session(
933 &fx.user.id,
934 &hash_token(OTHER_TOKEN),
935 "other",
936 &expiry,
937 None,
938 )
939 .await
940 .unwrap();
941 let form = format!(
942 "csrf={CSRF}&current_password={PASSWORD}&new_password=a+brand+new+password&confirm_password=a+brand+new+password"
943 );
944 let reply = send(
945 &fx.router,
946 "POST",
947 "/settings/security/password",
948 true,
949 &form,
950 )
951 .await;
952 assert_eq!(reply.status, 303);
953 assert_eq!(
954 reply.location.as_deref(),
955 Some("/settings/security?saved=password")
956 );
957
958 let store = &fx.state.store;
959 assert!(
960 store
961 .session_by_token_hash(&hash_token(TOKEN))
962 .await
963 .unwrap()
964 .is_some()
965 );
966 assert!(
967 store
968 .session_by_token_hash(&hash_token(OTHER_TOKEN))
969 .await
970 .unwrap()
971 .is_none()
972 );
973 let fresh = store.user_by_id(&fx.user.id).await.unwrap().unwrap();
974 assert!(
975 crate::auth::verify_password_async("a brand new password".into(), fresh.password_hash)
976 .await
977 .unwrap()
978 );
979 }
980
981 #[tokio::test]
982 async fn wrong_current_password_fails_and_changes_nothing() {
983 let fx = fixture().await;
984 let form = format!(
985 "csrf={CSRF}&current_password=wrong+one&new_password=a+brand+new+password&confirm_password=a+brand+new+password"
986 );
987 let reply = send(
988 &fx.router,
989 "POST",
990 "/settings/security/password",
991 true,
992 &form,
993 )
994 .await;
995 assert_eq!(reply.status, 200);
996 assert!(reply.body.contains("Current password is incorrect."));
997 let fresh = fx
998 .state
999 .store
1000 .user_by_id(&fx.user.id)
1001 .await
1002 .unwrap()
1003 .unwrap();
1004 assert!(
1005 crate::auth::verify_password_async(PASSWORD.into(), fresh.password_hash)
1006 .await
1007 .unwrap()
1008 );
1009 }
1010
1011 #[tokio::test]
1012 async fn wrong_current_passwords_count_toward_the_login_throttle() {
1013 let fx = fixture().await;
1014 let bad = format!(
1015 "csrf={CSRF}&current_password=wrong&new_password=a+brand+new+password&confirm_password=a+brand+new+password"
1016 );
1017 for _ in 0..LOGIN_USER_LIMIT {
1018 let reply = send(
1019 &fx.router,
1020 "POST",
1021 "/settings/security/password",
1022 true,
1023 &bad,
1024 )
1025 .await;
1026 assert_eq!(reply.status, 200);
1027 }
1028 assert!(
1029 fx.state
1030 .throttle
1031 .check(
1032 &user_key("tahinli"),
1033 LOGIN_USER_LIMIT,
1034 std::time::Instant::now()
1035 )
1036 .is_some()
1037 );
1038 let blocked = send(
1039 &fx.router,
1040 "POST",
1041 "/settings/security/password",
1042 true,
1043 &bad,
1044 )
1045 .await;
1046 assert_eq!(blocked.status, 429);
1047 assert!(blocked.body.contains("Too many attempts"));
1048 }
1049
1050 #[tokio::test]
1051 async fn new_password_length_and_confirmation_are_checked() {
1052 let fx = fixture().await;
1053 let short = format!(
1054 "csrf={CSRF}&current_password={PASSWORD}&new_password=short&confirm_password=short"
1055 );
1056 let reply = send(
1057 &fx.router,
1058 "POST",
1059 "/settings/security/password",
1060 true,
1061 &short,
1062 )
1063 .await;
1064 assert!(reply.body.contains("at least 10 characters"));
1065 let mismatch = format!(
1066 "csrf={CSRF}&current_password={PASSWORD}&new_password=a+brand+new+one&confirm_password=a+brand+new+two"
1067 );
1068 let reply = send(
1069 &fx.router,
1070 "POST",
1071 "/settings/security/password",
1072 true,
1073 &mismatch,
1074 )
1075 .await;
1076 assert!(reply.body.contains("do not match"));
1077 }
1078
1079 #[tokio::test]
1080 async fn email_change_needs_the_password_and_a_valid_address() {
1081 let fx = fixture().await;
1082 // An SSH key puts the user's address into the allowed-signers file.
1083 fx.state
1084 .store
1085 .add_ssh_key(
1086 &fx.user.id,
1087 "laptop",
1088 "ssh-ed25519",
1089 &[1, 2, 3],
1090 "SHA256:test",
1091 )
1092 .await
1093 .unwrap();
1094 crate::signatures::rebuild_allowed_signers(&fx.state)
1095 .await
1096 .unwrap();
1097 let signers = fx.state.config.data_dir.join("run").join("allowed_signers");
1098 let before = std::fs::read_to_string(&signers).unwrap();
1099 // The fixture user has no email yet, so the username is the principal.
1100 assert!(before.starts_with("tahinli "), "{before}");
1101
1102 let wrong = format!("csrf={CSRF}&email=me%40example.com&current_password=nope");
1103 let reply = send(&fx.router, "POST", "/settings/security/email", true, &wrong).await;
1104 assert!(reply.body.contains("Current password is incorrect."));
1105 let invalid = format!("csrf={CSRF}&email=not+an+email&current_password={PASSWORD}");
1106 let reply = send(
1107 &fx.router,
1108 "POST",
1109 "/settings/security/email",
1110 true,
1111 &invalid,
1112 )
1113 .await;
1114 assert!(reply.body.contains("Enter an email address"));
1115 let ok = format!("csrf={CSRF}&email=me%40example.com&current_password={PASSWORD}");
1116 let reply = send(&fx.router, "POST", "/settings/security/email", true, &ok).await;
1117 assert_eq!(
1118 reply.location.as_deref(),
1119 Some("/settings/security?saved=email")
1120 );
1121 let fresh = fx
1122 .state
1123 .store
1124 .user_by_id(&fx.user.id)
1125 .await
1126 .unwrap()
1127 .unwrap();
1128 assert_eq!(fresh.email.as_deref(), Some("me@example.com"));
1129 let after = std::fs::read_to_string(&signers).unwrap();
1130 assert!(
1131 after.starts_with("me@example.com "),
1132 "allowed_signers still has the old address: {after}"
1133 );
1134 }
1135
1136 #[tokio::test]
1137 async fn enroll_confirm_then_disable_two_factor() {
1138 let fx = fixture().await;
1139 let enable = format!("csrf={CSRF}");
1140 let reply = send(
1141 &fx.router,
1142 "POST",
1143 "/settings/security/2fa/enable",
1144 true,
1145 &enable,
1146 )
1147 .await;
1148 assert_eq!(reply.status, 303);
1149 let pending = fx
1150 .state
1151 .store
1152 .totp_state(&fx.user.id)
1153 .await
1154 .unwrap()
1155 .unwrap();
1156 assert!(!pending.enabled);
1157 let sealed = pending.sealed_secret.expect("secret stored");
1158 let shared = secret::open(&KEY, &sealed).unwrap();
1159
1160 // The enrollment page shows the secret and the URI, not the codes.
1161 let page = send(&fx.router, "GET", "/settings/security", true, "").await;
1162 assert!(page.body.contains(&totp::base32_encode(&shared)));
1163 assert!(page.body.contains("otpauth://totp/forge:tahinli?secret="));
1164
1165 let confirm = format!("csrf={CSRF}&code={}", now_code(&shared));
1166 let reply = send(
1167 &fx.router,
1168 "POST",
1169 "/settings/security/2fa/confirm",
1170 true,
1171 &confirm,
1172 )
1173 .await;
1174 assert_eq!(reply.status, 200);
1175 assert!(reply.body.contains("Recovery codes"));
1176 let codes = reply
1177 .body
1178 .split("<code>")
1179 .skip(1)
1180 .filter_map(|chunk| chunk.split("</code>").next())
1181 .filter(|code| code.len() == 9 && code.as_bytes()[4] == b'-')
1182 .count();
1183 assert_eq!(codes, super::RECOVERY_CODE_COUNT);
1184 assert!(
1185 fx.state
1186 .store
1187 .totp_state(&fx.user.id)
1188 .await
1189 .unwrap()
1190 .unwrap()
1191 .enabled
1192 );
1193 assert_eq!(
1194 fx.state
1195 .store
1196 .unused_recovery_codes(&fx.user.id)
1197 .await
1198 .unwrap(),
1199 super::RECOVERY_CODE_COUNT as u64
1200 );
1201
1202 // Disabling needs the password and a code. A wrong password is refused.
1203 let no_password = format!(
1204 "csrf={CSRF}&current_password=nope&code={}",
1205 now_code(&shared)
1206 );
1207 let reply = send(
1208 &fx.router,
1209 "POST",
1210 "/settings/security/2fa/disable",
1211 true,
1212 &no_password,
1213 )
1214 .await;
1215 assert!(reply.body.contains("Current password is incorrect."));
1216 assert!(
1217 fx.state
1218 .store
1219 .totp_state(&fx.user.id)
1220 .await
1221 .unwrap()
1222 .unwrap()
1223 .enabled
1224 );
1225
1226 // The confirm step already used the current code, so disabling takes the next step's
1227 // code. Reusing the current one would be refused as a replay.
1228 let next = totp::code_at(&shared, totp::step_for(crate::auth::unix_now()) + 1);
1229 let disable = format!("csrf={CSRF}&current_password={PASSWORD}&code={next}");
1230 let reply = send(
1231 &fx.router,
1232 "POST",
1233 "/settings/security/2fa/disable",
1234 true,
1235 &disable,
1236 )
1237 .await;
1238 assert_eq!(
1239 reply.location.as_deref(),
1240 Some("/settings/security?saved=2fa-off")
1241 );
1242 let off = fx
1243 .state
1244 .store
1245 .totp_state(&fx.user.id)
1246 .await
1247 .unwrap()
1248 .unwrap();
1249 assert!(!off.enabled);
1250 assert_eq!(off.sealed_secret, None);
1251 }
1252
1253 #[tokio::test]
1254 async fn wrong_confirm_code_does_not_enable() {
1255 let fx = fixture().await;
1256 send(
1257 &fx.router,
1258 "POST",
1259 "/settings/security/2fa/enable",
1260 true,
1261 &format!("csrf={CSRF}"),
1262 )
1263 .await;
1264 let reply = send(
1265 &fx.router,
1266 "POST",
1267 "/settings/security/2fa/confirm",
1268 true,
1269 &format!("csrf={CSRF}&code=000000"),
1270 )
1271 .await;
1272 // The chance that 000000 is the live code is about 1 in a million per window.
1273 if reply.status == 200 && !reply.body.contains("Recovery codes") {
1274 assert!(reply.body.contains("That code is not right"));
1275 }
1276 let state = fx
1277 .state
1278 .store
1279 .totp_state(&fx.user.id)
1280 .await
1281 .unwrap()
1282 .unwrap();
1283 if !state.enabled {
1284 assert!(state.sealed_secret.is_some());
1285 }
1286 }
1287
1288 #[tokio::test]
1289 async fn regenerating_recovery_codes_requires_a_code_and_replaces_them() {
1290 let fx = fixture().await;
1291 let shared = enable_totp_directly(&fx).await;
1292 let bad = format!("csrf={CSRF}&code=123456x");
1293 let reply = send(
1294 &fx.router,
1295 "POST",
1296 "/settings/security/2fa/recovery",
1297 true,
1298 &bad,
1299 )
1300 .await;
1301 assert!(reply.body.contains("That code is not right"));
1302 assert_eq!(
1303 fx.state
1304 .store
1305 .unused_recovery_codes(&fx.user.id)
1306 .await
1307 .unwrap(),
1308 1
1309 );
1310
1311 let good = format!("csrf={CSRF}&code={}", now_code(&shared));
1312 let reply = send(
1313 &fx.router,
1314 "POST",
1315 "/settings/security/2fa/recovery",
1316 true,
1317 &good,
1318 )
1319 .await;
1320 assert!(reply.body.contains("Recovery codes"));
1321 assert_eq!(
1322 fx.state
1323 .store
1324 .unused_recovery_codes(&fx.user.id)
1325 .await
1326 .unwrap(),
1327 super::RECOVERY_CODE_COUNT as u64
1328 );
1329 assert!(
1330 !fx.state
1331 .store
1332 .consume_recovery_code(&fx.user.id, &totp::recovery_code_hash("aaaa-bbbb"))
1333 .await
1334 .unwrap()
1335 );
1336 }
1337
1338 #[tokio::test]
1339 async fn post_without_csrf_is_forbidden() {
1340 let fx = fixture().await;
1341 let form = format!(
1342 "csrf=wrong&current_password={PASSWORD}&new_password=a+brand+new+password&confirm_password=a+brand+new+password"
1343 );
1344 let reply = send(
1345 &fx.router,
1346 "POST",
1347 "/settings/security/password",
1348 true,
1349 &form,
1350 )
1351 .await;
1352 assert_eq!(reply.status, 403);
1353 }
1354}