forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
47.8 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 };
819 let state = AppState {
820 config: Arc::new(config),
821 store: store.clone(),
822 git: Arc::new(GitEnv {
823 git_bin: "git".into(),
824 data_dir: path.clone(),
825 }),
826 key: Arc::new(KEY),
827 internal_token: Arc::from("test-token"),
828 admin_token: Arc::from("test-admin-token"),
829 jobs: Arc::new(crate::backup::Jobs::default()),
830 internal_socket: path.join("internal.sock"),
831 throttle: Arc::new(Throttle::default()),
832 webhook_waker: Arc::new(tokio::sync::Notify::new()),
833 mirror_waker: Arc::new(tokio::sync::Notify::new()),
834 blobs: crate::blobs::BlobStore::open(&path.join("blobs")).expect("blob store"),
835 replication: Arc::new(crate::replication::ReplicationQueue::default()),
836 ci_waker: Arc::new(tokio::sync::Notify::new()),
837 mailer: Arc::new(crate::mail::FakeMailer::default()),
838 mail_waker: Arc::new(tokio::sync::Notify::new()),
839 };
840 let hash = hash_password_async(PASSWORD.into()).await.unwrap();
841 let user = store
842 .create_user("tahinli", None, &hash, false)
843 .await
844 .unwrap();
845 let expiry = stamp_after(std::time::Duration::from_secs(3600));
846 store
847 .create_session(&user.id, &hash_token(TOKEN), CSRF, &expiry, None)
848 .await
849 .unwrap();
850 let router = Router::builder()
851 .discover()
852 .cookies()
853 .app_context(state.clone())
854 .build();
855 Fixture {
856 router,
857 state,
858 user,
859 _dir: dir,
860 }
861 }
862
863 struct Reply {
864 status: u16,
865 location: Option<String>,
866 body: String,
867 }
868
869 async fn send(router: &Router, method: &str, uri: &str, signed_in: bool, form: &str) -> Reply {
870 let mut builder = Request::builder().method(method).uri(uri);
871 if signed_in {
872 builder = builder.header("cookie", format!("__Host-{SESSION_COOKIE}={TOKEN}"));
873 }
874 if method == "POST" {
875 builder = builder.header("content-type", "application/x-www-form-urlencoded");
876 }
877 let res = router
878 .handle(builder.body(Body::from(form.to_owned())).unwrap())
879 .await;
880 let status = res.status().as_u16();
881 let location = res
882 .headers()
883 .get("location")
884 .and_then(|v| v.to_str().ok())
885 .map(str::to_owned);
886 let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap();
887 Reply {
888 status,
889 location,
890 body: String::from_utf8_lossy(&bytes).into_owned(),
891 }
892 }
893
894 fn now_code(shared: &[u8]) -> String {
895 totp::code_at(shared, totp::step_for(crate::auth::unix_now()))
896 }
897
898 /// Turns two-factor on directly in the store. Returns the shared secret.
899 async fn enable_totp_directly(fx: &Fixture) -> Vec<u8> {
900 let shared = totp::new_secret();
901 let sealed = secret::seal(&KEY, &shared).unwrap();
902 fx.state
903 .store
904 .set_totp_secret(&fx.user.id, &sealed)
905 .await
906 .unwrap();
907 fx.state
908 .store
909 .enable_totp(&fx.user.id, 0, &[totp::recovery_code_hash("aaaa-bbbb")])
910 .await
911 .unwrap();
912 shared.to_vec()
913 }
914
915 #[tokio::test]
916 async fn security_page_requires_a_session() {
917 let fx = fixture().await;
918 let reply = send(&fx.router, "GET", "/settings/security", false, "").await;
919 assert_eq!(reply.status, 303);
920 assert!(reply.location.unwrap().starts_with("/login?next="));
921 }
922
923 #[tokio::test]
924 async fn password_change_revokes_other_sessions_and_keeps_this_one() {
925 let fx = fixture().await;
926 let expiry = stamp_after(std::time::Duration::from_secs(3600));
927 fx.state
928 .store
929 .create_session(
930 &fx.user.id,
931 &hash_token(OTHER_TOKEN),
932 "other",
933 &expiry,
934 None,
935 )
936 .await
937 .unwrap();
938 let form = format!(
939 "csrf={CSRF}&current_password={PASSWORD}&new_password=a+brand+new+password&confirm_password=a+brand+new+password"
940 );
941 let reply = send(
942 &fx.router,
943 "POST",
944 "/settings/security/password",
945 true,
946 &form,
947 )
948 .await;
949 assert_eq!(reply.status, 303);
950 assert_eq!(
951 reply.location.as_deref(),
952 Some("/settings/security?saved=password")
953 );
954
955 let store = &fx.state.store;
956 assert!(
957 store
958 .session_by_token_hash(&hash_token(TOKEN))
959 .await
960 .unwrap()
961 .is_some()
962 );
963 assert!(
964 store
965 .session_by_token_hash(&hash_token(OTHER_TOKEN))
966 .await
967 .unwrap()
968 .is_none()
969 );
970 let fresh = store.user_by_id(&fx.user.id).await.unwrap().unwrap();
971 assert!(
972 crate::auth::verify_password_async("a brand new password".into(), fresh.password_hash)
973 .await
974 .unwrap()
975 );
976 }
977
978 #[tokio::test]
979 async fn wrong_current_password_fails_and_changes_nothing() {
980 let fx = fixture().await;
981 let form = format!(
982 "csrf={CSRF}&current_password=wrong+one&new_password=a+brand+new+password&confirm_password=a+brand+new+password"
983 );
984 let reply = send(
985 &fx.router,
986 "POST",
987 "/settings/security/password",
988 true,
989 &form,
990 )
991 .await;
992 assert_eq!(reply.status, 200);
993 assert!(reply.body.contains("Current password is incorrect."));
994 let fresh = fx
995 .state
996 .store
997 .user_by_id(&fx.user.id)
998 .await
999 .unwrap()
1000 .unwrap();
1001 assert!(
1002 crate::auth::verify_password_async(PASSWORD.into(), fresh.password_hash)
1003 .await
1004 .unwrap()
1005 );
1006 }
1007
1008 #[tokio::test]
1009 async fn wrong_current_passwords_count_toward_the_login_throttle() {
1010 let fx = fixture().await;
1011 let bad = format!(
1012 "csrf={CSRF}&current_password=wrong&new_password=a+brand+new+password&confirm_password=a+brand+new+password"
1013 );
1014 for _ in 0..LOGIN_USER_LIMIT {
1015 let reply = send(
1016 &fx.router,
1017 "POST",
1018 "/settings/security/password",
1019 true,
1020 &bad,
1021 )
1022 .await;
1023 assert_eq!(reply.status, 200);
1024 }
1025 assert!(
1026 fx.state
1027 .throttle
1028 .check(
1029 &user_key("tahinli"),
1030 LOGIN_USER_LIMIT,
1031 std::time::Instant::now()
1032 )
1033 .is_some()
1034 );
1035 let blocked = send(
1036 &fx.router,
1037 "POST",
1038 "/settings/security/password",
1039 true,
1040 &bad,
1041 )
1042 .await;
1043 assert_eq!(blocked.status, 429);
1044 assert!(blocked.body.contains("Too many attempts"));
1045 }
1046
1047 #[tokio::test]
1048 async fn new_password_length_and_confirmation_are_checked() {
1049 let fx = fixture().await;
1050 let short = format!(
1051 "csrf={CSRF}&current_password={PASSWORD}&new_password=short&confirm_password=short"
1052 );
1053 let reply = send(
1054 &fx.router,
1055 "POST",
1056 "/settings/security/password",
1057 true,
1058 &short,
1059 )
1060 .await;
1061 assert!(reply.body.contains("at least 10 characters"));
1062 let mismatch = format!(
1063 "csrf={CSRF}&current_password={PASSWORD}&new_password=a+brand+new+one&confirm_password=a+brand+new+two"
1064 );
1065 let reply = send(
1066 &fx.router,
1067 "POST",
1068 "/settings/security/password",
1069 true,
1070 &mismatch,
1071 )
1072 .await;
1073 assert!(reply.body.contains("do not match"));
1074 }
1075
1076 #[tokio::test]
1077 async fn email_change_needs_the_password_and_a_valid_address() {
1078 let fx = fixture().await;
1079 // An SSH key puts the user's address into the allowed-signers file.
1080 fx.state
1081 .store
1082 .add_ssh_key(
1083 &fx.user.id,
1084 "laptop",
1085 "ssh-ed25519",
1086 &[1, 2, 3],
1087 "SHA256:test",
1088 )
1089 .await
1090 .unwrap();
1091 crate::signatures::rebuild_allowed_signers(&fx.state)
1092 .await
1093 .unwrap();
1094 let signers = fx.state.config.data_dir.join("run").join("allowed_signers");
1095 let before = std::fs::read_to_string(&signers).unwrap();
1096 // The fixture user has no email yet, so the username is the principal.
1097 assert!(before.starts_with("tahinli "), "{before}");
1098
1099 let wrong = format!("csrf={CSRF}&email=me%40example.com&current_password=nope");
1100 let reply = send(&fx.router, "POST", "/settings/security/email", true, &wrong).await;
1101 assert!(reply.body.contains("Current password is incorrect."));
1102 let invalid = format!("csrf={CSRF}&email=not+an+email&current_password={PASSWORD}");
1103 let reply = send(
1104 &fx.router,
1105 "POST",
1106 "/settings/security/email",
1107 true,
1108 &invalid,
1109 )
1110 .await;
1111 assert!(reply.body.contains("Enter an email address"));
1112 let ok = format!("csrf={CSRF}&email=me%40example.com&current_password={PASSWORD}");
1113 let reply = send(&fx.router, "POST", "/settings/security/email", true, &ok).await;
1114 assert_eq!(
1115 reply.location.as_deref(),
1116 Some("/settings/security?saved=email")
1117 );
1118 let fresh = fx
1119 .state
1120 .store
1121 .user_by_id(&fx.user.id)
1122 .await
1123 .unwrap()
1124 .unwrap();
1125 assert_eq!(fresh.email.as_deref(), Some("me@example.com"));
1126 let after = std::fs::read_to_string(&signers).unwrap();
1127 assert!(
1128 after.starts_with("me@example.com "),
1129 "allowed_signers still has the old address: {after}"
1130 );
1131 }
1132
1133 #[tokio::test]
1134 async fn enroll_confirm_then_disable_two_factor() {
1135 let fx = fixture().await;
1136 let enable = format!("csrf={CSRF}");
1137 let reply = send(
1138 &fx.router,
1139 "POST",
1140 "/settings/security/2fa/enable",
1141 true,
1142 &enable,
1143 )
1144 .await;
1145 assert_eq!(reply.status, 303);
1146 let pending = fx
1147 .state
1148 .store
1149 .totp_state(&fx.user.id)
1150 .await
1151 .unwrap()
1152 .unwrap();
1153 assert!(!pending.enabled);
1154 let sealed = pending.sealed_secret.expect("secret stored");
1155 let shared = secret::open(&KEY, &sealed).unwrap();
1156
1157 // The enrollment page shows the secret and the URI, not the codes.
1158 let page = send(&fx.router, "GET", "/settings/security", true, "").await;
1159 assert!(page.body.contains(&totp::base32_encode(&shared)));
1160 assert!(page.body.contains("otpauth://totp/forge:tahinli?secret="));
1161
1162 let confirm = format!("csrf={CSRF}&code={}", now_code(&shared));
1163 let reply = send(
1164 &fx.router,
1165 "POST",
1166 "/settings/security/2fa/confirm",
1167 true,
1168 &confirm,
1169 )
1170 .await;
1171 assert_eq!(reply.status, 200);
1172 assert!(reply.body.contains("Recovery codes"));
1173 let codes = reply
1174 .body
1175 .split("<code>")
1176 .skip(1)
1177 .filter_map(|chunk| chunk.split("</code>").next())
1178 .filter(|code| code.len() == 9 && code.as_bytes()[4] == b'-')
1179 .count();
1180 assert_eq!(codes, super::RECOVERY_CODE_COUNT);
1181 assert!(
1182 fx.state
1183 .store
1184 .totp_state(&fx.user.id)
1185 .await
1186 .unwrap()
1187 .unwrap()
1188 .enabled
1189 );
1190 assert_eq!(
1191 fx.state
1192 .store
1193 .unused_recovery_codes(&fx.user.id)
1194 .await
1195 .unwrap(),
1196 super::RECOVERY_CODE_COUNT as u64
1197 );
1198
1199 // Disabling needs the password and a code. A wrong password is refused.
1200 let no_password = format!(
1201 "csrf={CSRF}&current_password=nope&code={}",
1202 now_code(&shared)
1203 );
1204 let reply = send(
1205 &fx.router,
1206 "POST",
1207 "/settings/security/2fa/disable",
1208 true,
1209 &no_password,
1210 )
1211 .await;
1212 assert!(reply.body.contains("Current password is incorrect."));
1213 assert!(
1214 fx.state
1215 .store
1216 .totp_state(&fx.user.id)
1217 .await
1218 .unwrap()
1219 .unwrap()
1220 .enabled
1221 );
1222
1223 // The confirm step already used the current code, so disabling takes the next step's
1224 // code. Reusing the current one would be refused as a replay.
1225 let next = totp::code_at(&shared, totp::step_for(crate::auth::unix_now()) + 1);
1226 let disable = format!("csrf={CSRF}&current_password={PASSWORD}&code={next}");
1227 let reply = send(
1228 &fx.router,
1229 "POST",
1230 "/settings/security/2fa/disable",
1231 true,
1232 &disable,
1233 )
1234 .await;
1235 assert_eq!(
1236 reply.location.as_deref(),
1237 Some("/settings/security?saved=2fa-off")
1238 );
1239 let off = fx
1240 .state
1241 .store
1242 .totp_state(&fx.user.id)
1243 .await
1244 .unwrap()
1245 .unwrap();
1246 assert!(!off.enabled);
1247 assert_eq!(off.sealed_secret, None);
1248 }
1249
1250 #[tokio::test]
1251 async fn wrong_confirm_code_does_not_enable() {
1252 let fx = fixture().await;
1253 send(
1254 &fx.router,
1255 "POST",
1256 "/settings/security/2fa/enable",
1257 true,
1258 &format!("csrf={CSRF}"),
1259 )
1260 .await;
1261 let reply = send(
1262 &fx.router,
1263 "POST",
1264 "/settings/security/2fa/confirm",
1265 true,
1266 &format!("csrf={CSRF}&code=000000"),
1267 )
1268 .await;
1269 // The chance that 000000 is the live code is about 1 in a million per window.
1270 if reply.status == 200 && !reply.body.contains("Recovery codes") {
1271 assert!(reply.body.contains("That code is not right"));
1272 }
1273 let state = fx
1274 .state
1275 .store
1276 .totp_state(&fx.user.id)
1277 .await
1278 .unwrap()
1279 .unwrap();
1280 if !state.enabled {
1281 assert!(state.sealed_secret.is_some());
1282 }
1283 }
1284
1285 #[tokio::test]
1286 async fn regenerating_recovery_codes_requires_a_code_and_replaces_them() {
1287 let fx = fixture().await;
1288 let shared = enable_totp_directly(&fx).await;
1289 let bad = format!("csrf={CSRF}&code=123456x");
1290 let reply = send(
1291 &fx.router,
1292 "POST",
1293 "/settings/security/2fa/recovery",
1294 true,
1295 &bad,
1296 )
1297 .await;
1298 assert!(reply.body.contains("That code is not right"));
1299 assert_eq!(
1300 fx.state
1301 .store
1302 .unused_recovery_codes(&fx.user.id)
1303 .await
1304 .unwrap(),
1305 1
1306 );
1307
1308 let good = format!("csrf={CSRF}&code={}", now_code(&shared));
1309 let reply = send(
1310 &fx.router,
1311 "POST",
1312 "/settings/security/2fa/recovery",
1313 true,
1314 &good,
1315 )
1316 .await;
1317 assert!(reply.body.contains("Recovery codes"));
1318 assert_eq!(
1319 fx.state
1320 .store
1321 .unused_recovery_codes(&fx.user.id)
1322 .await
1323 .unwrap(),
1324 super::RECOVERY_CODE_COUNT as u64
1325 );
1326 assert!(
1327 !fx.state
1328 .store
1329 .consume_recovery_code(&fx.user.id, &totp::recovery_code_hash("aaaa-bbbb"))
1330 .await
1331 .unwrap()
1332 );
1333 }
1334
1335 #[tokio::test]
1336 async fn post_without_csrf_is_forbidden() {
1337 let fx = fixture().await;
1338 let form = format!(
1339 "csrf=wrong&current_password={PASSWORD}&new_password=a+brand+new+password&confirm_password=a+brand+new+password"
1340 );
1341 let reply = send(
1342 &fx.router,
1343 "POST",
1344 "/settings/security/password",
1345 true,
1346 &form,
1347 )
1348 .await;
1349 assert_eq!(reply.status, 403);
1350 }
1351}