1use std::collections::HashMap;
31use std::net::IpAddr;
32use std::sync::Mutex;
33use std::time::Duration;
34
35use axum::extract::FromRequestParts;
36use axum::http::request::Parts;
37use axum::http::{HeaderMap, header};
38use base64::Engine as _;
39use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64_URL_SAFE_NO_PAD;
40use ring::rand::{SecureRandom, SystemRandom};
41use subtle::ConstantTimeEq;
42use tracing::{info, warn};
43
44use crate::sqlite::admin_session::AdminSession;
45use crate::sqlite::admin_user::AdminUser;
46use crate::sqlite::nonce::{fingerprint, now_secs};
47use crate::webadmin::AdminState;
48use crate::webadmin::error::AdminError;
49
50pub const COOKIE_NAME: &str = "__Host-acme_admin_session";
57
58pub const CSRF_HEADER: &str = "x-csrf-token";
60
61const TOKEN_LEN: usize = 32;
64
65const SESSION_TOUCH_INTERVAL: i64 = 60;
70
71pub const PENDING_MFA_TTL: Duration = Duration::from_secs(300);
84
85pub struct MintedToken {
87 pub token: String,
89 pub token_hash: String,
91}
92
93#[must_use]
96pub fn mint_token() -> MintedToken {
97 let mut bytes = [0u8; TOKEN_LEN];
98 SystemRandom::new()
99 .fill(&mut bytes)
100 .expect("system RNG unavailable");
101 let token = BASE64_URL_SAFE_NO_PAD.encode(bytes);
102 let token_hash = hash_token(&token);
103 MintedToken { token, token_hash }
104}
105
106#[must_use]
109pub fn mint_csrf_token() -> String {
110 let mut bytes = [0u8; TOKEN_LEN];
111 SystemRandom::new()
112 .fill(&mut bytes)
113 .expect("system RNG unavailable");
114 BASE64_URL_SAFE_NO_PAD.encode(bytes)
115}
116
117#[must_use]
119pub fn hash_token(token: &str) -> String {
120 let digest = ring::digest::digest(&ring::digest::SHA256, token.as_bytes());
121 hex::encode(digest.as_ref())
122}
123
124#[must_use]
126pub fn session_cookie(token: &str, ttl: Duration) -> String {
127 format!(
128 "{COOKIE_NAME}={token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age={}",
129 ttl.as_secs()
130 )
131}
132
133#[must_use]
135pub fn clearing_cookie() -> String {
136 format!("{COOKIE_NAME}=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0")
137}
138
139#[must_use]
146pub fn cookie_value(headers: &HeaderMap) -> Option<String> {
147 for header in headers.get_all(header::COOKIE) {
148 let Ok(raw) = header.to_str() else { continue };
149 for pair in raw.split(';') {
150 let Some((name, value)) = pair.split_once('=') else {
151 continue;
152 };
153 if name.trim() == COOKIE_NAME {
154 let value = value.trim();
156 let value = value
157 .strip_prefix('"')
158 .and_then(|v| v.strip_suffix('"'))
159 .unwrap_or(value);
160 return Some(value.to_string());
161 }
162 }
163 }
164 None
165}
166
167#[derive(Debug, Clone, Copy)]
180pub struct AdminClientIp(pub Option<IpAddr>);
181
182impl<S: Sync> FromRequestParts<S> for AdminClientIp {
183 type Rejection = std::convert::Infallible;
184
185 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
186 let address = parts
187 .extensions
188 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
189 .map(|info| info.0.ip().to_canonical());
193 Ok(AdminClientIp(address))
194 }
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub enum MfaStep {
205 Verify,
207 Enrol,
209}
210
211impl MfaStep {
212 #[must_use]
214 pub fn as_str(self) -> &'static str {
215 match self {
216 MfaStep::Verify => "verify",
217 MfaStep::Enrol => "enrol",
218 }
219 }
220}
221
222#[derive(Debug)]
224pub struct Authenticated {
225 pub session: AdminSession,
226 pub user: AdminUser,
227}
228
229#[derive(Debug)]
235pub struct AuthenticatedWrite(pub Authenticated);
236
237impl FromRequestParts<AdminState> for Authenticated {
238 type Rejection = AdminError;
239
240 async fn from_request_parts(
241 parts: &mut Parts,
242 state: &AdminState,
243 ) -> Result<Self, Self::Rejection> {
244 resolve_session(parts, state).await
245 }
246}
247
248impl FromRequestParts<AdminState> for AuthenticatedWrite {
249 type Rejection = AdminError;
250
251 async fn from_request_parts(
252 parts: &mut Parts,
253 state: &AdminState,
254 ) -> Result<Self, Self::Rejection> {
255 check_origin(&parts.headers, &state.config.admin.base_url)?;
258 let authenticated = resolve_session(parts, state).await?;
259 check_csrf(&parts.headers, &authenticated.session.csrf_token)?;
260 Ok(AuthenticatedWrite(authenticated))
261 }
262}
263
264#[derive(Debug)]
271pub struct PendingMfa {
272 pub session: AdminSession,
273 pub user: AdminUser,
274 pub step: MfaStep,
275}
276
277#[derive(Debug)]
290pub struct PendingMfaSubmit(pub PendingMfa);
291
292#[derive(Debug)]
307pub struct EnrolWrite {
308 pub session: AdminSession,
309 pub user: AdminUser,
310 pub pending: bool,
313}
314
315impl FromRequestParts<AdminState> for PendingMfa {
316 type Rejection = AdminError;
317
318 async fn from_request_parts(
319 parts: &mut Parts,
320 state: &AdminState,
321 ) -> Result<Self, Self::Rejection> {
322 resolve_pending(parts, state).await
323 }
324}
325
326impl FromRequestParts<AdminState> for PendingMfaSubmit {
327 type Rejection = AdminError;
328
329 async fn from_request_parts(
330 parts: &mut Parts,
331 state: &AdminState,
332 ) -> Result<Self, Self::Rejection> {
333 check_origin(&parts.headers, &state.config.admin.base_url)?;
334 Ok(PendingMfaSubmit(resolve_pending(parts, state).await?))
335 }
336}
337
338impl FromRequestParts<AdminState> for EnrolWrite {
339 type Rejection = AdminError;
340
341 async fn from_request_parts(
342 parts: &mut Parts,
343 state: &AdminState,
344 ) -> Result<Self, Self::Rejection> {
345 check_origin(&parts.headers, &state.config.admin.base_url)?;
346 let (_, session, user) = resolve_live(parts, state).await?;
347
348 if !session.is_active() && user.has_totp() {
352 return Err(AdminError::session_invalid());
353 }
354
355 check_csrf(&parts.headers, &session.csrf_token)?;
356 let pending = !session.is_active();
357 Ok(EnrolWrite {
358 session,
359 user,
360 pending,
361 })
362 }
363}
364
365async fn resolve_live(
372 parts: &Parts,
373 state: &AdminState,
374) -> Result<(String, AdminSession, AdminUser), AdminError> {
375 let token = cookie_value(&parts.headers).ok_or_else(AdminError::session_invalid)?;
376 let token_hash = hash_token(&token);
377
378 let Some(session) = AdminSession::find_by_token_hash(&token_hash, &state.database).await?
379 else {
380 return Err(AdminError::session_invalid());
381 };
382
383 let now = now_secs();
384 if session.is_expired(now) {
385 AdminSession::delete(&token_hash, &state.database).await?;
386 return Err(AdminError::session_expired());
387 }
388 let idle_timeout = Duration::from_secs(state.config.admin.session_idle_timeout_seconds);
389 if session.is_idle(now, idle_timeout) {
390 AdminSession::delete(&token_hash, &state.database).await?;
391 return Err(AdminError::session_idle());
392 }
393
394 let Some(user) = AdminUser::find_by_id(&session.user_id, &state.database).await? else {
395 warn!(event = "admin_session_orphaned", outcome = "failure", session_fp = %fingerprint(&token_hash));
398 AdminSession::delete(&token_hash, &state.database).await?;
399 return Err(AdminError::session_invalid());
400 };
401 if !user.is_active() {
402 return Err(AdminError::session_invalid());
403 }
404
405 Ok((token_hash, session, user))
406}
407
408async fn resolve_session(parts: &Parts, state: &AdminState) -> Result<Authenticated, AdminError> {
413 let (_, mut session, user) = resolve_live(parts, state).await?;
414
415 if !session.is_active() {
419 return Err(AdminError::session_invalid());
420 }
421
422 if now_secs() - session.last_seen_at >= SESSION_TOUCH_INTERVAL {
423 session.touch(&state.database).await?;
424 }
425
426 Ok(Authenticated { session, user })
427}
428
429async fn resolve_pending(parts: &Parts, state: &AdminState) -> Result<PendingMfa, AdminError> {
436 let (_, session, user) = resolve_live(parts, state).await?;
437
438 if session.is_active() {
439 return Err(AdminError::session_invalid());
440 }
441
442 let step = if user.has_totp() {
443 MfaStep::Verify
444 } else {
445 MfaStep::Enrol
446 };
447 Ok(PendingMfa {
448 session,
449 user,
450 step,
451 })
452}
453
454pub fn check_csrf(headers: &HeaderMap, expected: &str) -> Result<(), AdminError> {
457 let Some(supplied) = headers.get(CSRF_HEADER).and_then(|v| v.to_str().ok()) else {
458 return Err(AdminError::csrf_failed(format!(
459 "this request needs an {CSRF_HEADER} header carrying the session's csrfToken"
460 )));
461 };
462
463 let matches = supplied.len() == expected.len()
466 && bool::from(supplied.as_bytes().ct_eq(expected.as_bytes()));
467 if !matches {
468 return Err(AdminError::csrf_failed(
469 "the CSRF token does not match this session",
470 ));
471 }
472 Ok(())
473}
474
475pub fn check_origin(headers: &HeaderMap, base_url: &str) -> Result<(), AdminError> {
483 if let Some(site) = headers.get("sec-fetch-site").and_then(|v| v.to_str().ok())
484 && site != "same-origin"
485 && site != "none"
486 {
487 return Err(AdminError::csrf_failed(format!(
488 "cross-origin request refused (Sec-Fetch-Site: {site})"
489 )));
490 }
491
492 if let Some(origin) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) {
493 let expected = url::Url::parse(base_url)
494 .map(|u| u.origin().ascii_serialization())
495 .unwrap_or_default();
496 if origin != expected {
497 return Err(AdminError::csrf_failed(format!(
498 "cross-origin request refused (Origin: {origin}, expected {expected})"
499 )));
500 }
501 }
502 Ok(())
503}
504
505#[derive(Debug)]
511pub struct LoginLimiter {
512 max_attempts: u32,
513 window: Duration,
514 buckets: Mutex<HashMap<IpAddr, Bucket>>,
515}
516
517#[derive(Debug, Clone, Copy)]
518struct Bucket {
519 failures: u32,
520 window_started: i64,
521}
522
523impl LoginLimiter {
524 #[must_use]
525 pub fn new(max_attempts: u32, window_seconds: u64) -> Self {
526 Self {
527 max_attempts,
528 window: Duration::from_secs(window_seconds),
529 buckets: Mutex::new(HashMap::new()),
530 }
531 }
532
533 #[must_use]
546 pub fn rebuilt(&self, max_attempts: u32, window_seconds: u64) -> Self {
547 let buckets = std::mem::take(&mut *self.buckets.lock().unwrap_or_else(|e| e.into_inner()));
548 Self {
549 max_attempts,
550 window: Duration::from_secs(window_seconds),
551 buckets: Mutex::new(buckets),
552 }
553 }
554
555 pub fn check(&self, client: Option<IpAddr>) -> Result<(), u64> {
562 let Some(client) = client else { return Ok(()) };
568 let now = now_secs();
569 let window = self.window.as_secs() as i64;
570
571 let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
572 buckets.retain(|_, bucket| now - bucket.window_started < window);
574
575 match buckets.get(&client) {
576 Some(bucket) if bucket.failures >= self.max_attempts => {
577 Err((window - (now - bucket.window_started)).max(1) as u64)
578 }
579 _ => Ok(()),
580 }
581 }
582
583 pub fn record_failure(&self, client: Option<IpAddr>) {
585 let Some(client) = client else { return };
586 let now = now_secs();
587 let window = self.window.as_secs() as i64;
588
589 let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
590 let bucket = buckets.entry(client).or_insert(Bucket {
591 failures: 0,
592 window_started: now,
593 });
594 if now - bucket.window_started >= window {
595 *bucket = Bucket {
596 failures: 0,
597 window_started: now,
598 };
599 }
600 bucket.failures += 1;
601 }
602
603 pub fn record_success(&self, client: Option<IpAddr>) {
606 let Some(client) = client else { return };
607 self.buckets
608 .lock()
609 .unwrap_or_else(|e| e.into_inner())
610 .remove(&client);
611 }
612}
613
614pub fn log_login(succeeded: bool, username: &str, client: Option<IpAddr>, reason: &'static str) {
616 if succeeded {
617 info!(event = "admin_login_succeeded",
618 outcome = "success",
619 username = %username,
620 client_ip = ?client);
621 } else {
622 warn!(event = "admin_login_failed",
623 outcome = "failure",
624 username = %username,
625 client_ip = ?client,
626 reason = reason);
627 }
628}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633 use axum::http::HeaderValue;
634
635 fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
636 let mut map = HeaderMap::new();
637 for (name, value) in pairs {
638 map.append(
639 header::HeaderName::from_bytes(name.as_bytes()).unwrap(),
640 HeaderValue::from_str(value).unwrap(),
641 );
642 }
643 map
644 }
645
646 #[test]
647 fn a_minted_token_is_43_url_safe_characters_and_hashes_stably() {
648 let minted = mint_token();
649 assert_eq!(minted.token.len(), 43, "32 bytes, base64url unpadded");
650 assert!(!minted.token.contains('='));
651 assert!(!minted.token.contains('+'));
652 assert!(!minted.token.contains('/'));
653 assert_eq!(minted.token_hash, hash_token(&minted.token));
654 assert_eq!(minted.token_hash.len(), 64, "SHA-256 as hex");
655
656 assert_ne!(mint_token().token, minted.token);
658 assert!(!minted.token_hash.contains(&minted.token));
659 }
660
661 #[test]
662 fn hash_token_matches_a_known_vector() {
663 assert_eq!(
665 hash_token(""),
666 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
667 );
668 }
669
670 #[test]
671 fn csrf_tokens_are_unguessable_and_distinct() {
672 let first = mint_csrf_token();
673 assert_eq!(first.len(), 43);
674 assert_ne!(first, mint_csrf_token());
675 }
676
677 #[test]
678 fn the_session_cookie_carries_every_required_attribute() {
679 let cookie = session_cookie("the-token", Duration::from_secs(43_200));
680 assert!(cookie.starts_with("__Host-acme_admin_session=the-token;"));
681 assert!(cookie.contains("HttpOnly"));
682 assert!(cookie.contains("Secure"));
683 assert!(cookie.contains("SameSite=Strict"));
684 assert!(cookie.contains("Path=/"));
685 assert!(cookie.contains("Max-Age=43200"));
686 assert!(!cookie.contains("Domain"));
689 }
690
691 #[test]
692 fn the_clearing_cookie_expires_immediately_and_keeps_the_same_attributes() {
693 let cookie = clearing_cookie();
694 assert!(cookie.starts_with("__Host-acme_admin_session=;"));
695 assert!(cookie.contains("Max-Age=0"));
696 assert!(cookie.contains("Path=/"));
699 assert!(cookie.contains("Secure"));
700 assert!(cookie.contains("HttpOnly"));
701 }
702
703 type CookieCase = (
706 &'static str,
707 Vec<(&'static str, String)>,
708 Option<&'static str>,
709 );
710
711 #[test]
712 fn cookie_parsing_is_table_driven() {
713 let name = COOKIE_NAME;
714 let cases: Vec<CookieCase> = vec![
715 ("absent entirely", vec![], None),
716 (
717 "the only cookie",
718 vec![("cookie", format!("{name}=abc"))],
719 Some("abc"),
720 ),
721 (
722 "among others",
723 vec![("cookie", format!("theme=dark; {name}=abc; lang=en"))],
724 Some("abc"),
725 ),
726 (
727 "leading whitespace",
728 vec![("cookie", format!("theme=dark; {name}=abc"))],
729 Some("abc"),
730 ),
731 (
732 "a quoted value",
733 vec![("cookie", format!("{name}=\"abc\""))],
734 Some("abc"),
735 ),
736 (
737 "a segment with no equals sign",
738 vec![("cookie", format!("broken; {name}=abc"))],
739 Some("abc"),
740 ),
741 (
742 "present but empty",
743 vec![("cookie", format!("{name}="))],
744 Some(""),
745 ),
746 (
747 "a different cookie only",
748 vec![("cookie", "other=abc".to_string())],
749 None,
750 ),
751 (
752 "duplicated in one header",
755 vec![("cookie", format!("{name}=first; {name}=second"))],
756 Some("first"),
757 ),
758 (
759 "duplicated across two headers",
760 vec![
761 ("cookie", format!("{name}=first")),
762 ("cookie", format!("{name}=second")),
763 ],
764 Some("first"),
765 ),
766 (
767 "a name that merely contains ours",
768 vec![("cookie", format!("x{name}=nope"))],
769 None,
770 ),
771 ];
772
773 for (label, pairs, expected) in cases {
774 let owned: Vec<(&str, &str)> = pairs.iter().map(|(n, v)| (*n, v.as_str())).collect();
775 assert_eq!(
776 cookie_value(&headers(&owned)).as_deref(),
777 expected,
778 "case `{label}`"
779 );
780 }
781 }
782
783 #[test]
784 fn the_csrf_check_accepts_only_an_exact_match() {
785 let expected = "the-expected-token";
786 assert!(check_csrf(&headers(&[(CSRF_HEADER, expected)]), expected).is_ok());
787
788 let error = check_csrf(&HeaderMap::new(), expected).unwrap_err();
790 assert_eq!(error.code, "csrf_failed");
791 assert!(error.message.contains(CSRF_HEADER));
792
793 for supplied in [
795 "",
796 "wrong",
797 "the-expected-token-but-longer",
798 "the-expected-toke",
799 ] {
800 let error = check_csrf(&headers(&[(CSRF_HEADER, supplied)]), expected).unwrap_err();
801 assert_eq!(error.code, "csrf_failed", "for `{supplied}`");
802 }
803 }
804
805 #[test]
806 fn the_origin_gate_covers_the_cases_a_browser_produces() {
807 let base = "http://localhost:3001";
808
809 assert!(check_origin(&HeaderMap::new(), base).is_ok());
811
812 assert!(check_origin(&headers(&[("sec-fetch-site", "same-origin")]), base).is_ok());
814 assert!(check_origin(&headers(&[("sec-fetch-site", "none")]), base).is_ok());
815 assert!(check_origin(&headers(&[("origin", base)]), base).is_ok());
816
817 for site in ["cross-site", "same-site"] {
818 let error = check_origin(&headers(&[("sec-fetch-site", site)]), base).unwrap_err();
819 assert_eq!(error.code, "csrf_failed", "for {site}");
820 assert!(error.message.contains(site));
823 }
824
825 let error = check_origin(&headers(&[("origin", "http://evil.example")]), base).unwrap_err();
826 assert!(error.message.contains("evil.example"));
827
828 let error =
830 check_origin(&headers(&[("origin", "http://localhost:8080")]), base).unwrap_err();
831 assert_eq!(error.code, "csrf_failed");
832 }
833
834 fn ip(last: u8) -> Option<IpAddr> {
835 Some(IpAddr::from([192, 0, 2, last]))
836 }
837
838 #[test]
839 fn the_limiter_permits_up_to_the_limit_then_refuses() {
840 let limiter = LoginLimiter::new(3, 300);
841
842 for attempt in 0..3 {
843 assert!(limiter.check(ip(1)).is_ok(), "attempt {attempt} must pass");
844 limiter.record_failure(ip(1));
845 }
846
847 let retry_after = limiter.check(ip(1)).unwrap_err();
848 assert!(retry_after > 0 && retry_after <= 300, "got {retry_after}");
849
850 assert!(limiter.check(ip(2)).is_ok());
852 }
853
854 #[test]
855 fn a_success_clears_the_counter() {
856 let limiter = LoginLimiter::new(2, 300);
857 limiter.record_failure(ip(1));
858 limiter.record_success(ip(1));
859 limiter.record_failure(ip(1));
860 assert!(
861 limiter.check(ip(1)).is_ok(),
862 "the pre-success failure must not still count"
863 );
864 }
865
866 #[test]
867 fn the_window_rolls_over_and_prunes() {
868 let limiter = LoginLimiter::new(1, 1);
871 limiter.record_failure(ip(1));
872 assert!(limiter.check(ip(1)).is_err());
873
874 {
876 let mut buckets = limiter.buckets.lock().unwrap();
877 buckets.get_mut(&ip(1).unwrap()).unwrap().window_started -= 5;
878 }
879 assert!(limiter.check(ip(1)).is_ok(), "the window must roll over");
880 assert!(
881 limiter.buckets.lock().unwrap().is_empty(),
882 "a stale bucket must be pruned, or the map grows without bound"
883 );
884 }
885
886 #[test]
887 fn a_missing_client_address_is_not_limited() {
888 let limiter = LoginLimiter::new(1, 300);
889 limiter.record_failure(None);
890 limiter.record_success(None);
891 assert!(
892 limiter.check(None).is_ok(),
893 "failing closed here would lock out every request, not every attacker"
894 );
895 }
896
897 #[test]
898 fn log_login_renders_both_outcomes() {
899 log_login(true, "alice", ip(1), "");
903 log_login(false, "alice", ip(1), "wrong_password");
904 log_login(false, "alice", None, "unknown_user");
905 }
906}