1use std::{
10 collections::HashMap,
11 net::IpAddr,
12 sync::{LazyLock, Mutex},
13 time::{Duration, Instant},
14};
15
16use argon2::{
17 password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
18 Argon2,
19};
20use axum::{
21 extract::{FromRequestParts, OriginalUri},
22 http::{header, request::Parts, HeaderMap},
23};
24use rand::{rngs::OsRng, RngCore};
25use serde::Serialize;
26use sha2::{Digest, Sha256};
27use uuid::Uuid;
28
29use crate::{
30 config::Config,
31 domain::{Role, TokenScope},
32 error::{AppError, AppResult},
33 services, AppState,
34};
35
36pub const SESSION_COOKIE: &str = "tc_session";
37pub const FLASH_COOKIE: &str = "tc_flash";
38pub const SESSION_TOKEN_PREFIX: &str = "tc_sess_";
39pub const API_TOKEN_PREFIX: &str = "tc_pat_";
40
41#[derive(Clone, Debug, Serialize)]
43pub struct Actor {
44 pub id: Uuid,
45 pub username: String,
46 pub display_name: String,
47 pub role: Role,
48 pub skin: String,
50 pub preferred_cad: String,
53 pub projects: Vec<Uuid>,
55 #[serde(skip)]
59 pub hidden_projects: Vec<Uuid>,
60}
61
62impl Actor {
63 pub fn can_see_project(&self, project_id: Option<Uuid>) -> bool {
66 project_id.is_none_or(|p| !self.hidden_projects.contains(&p))
67 }
68
69 pub fn without_groups(mut self, groups: &[Uuid]) -> Actor {
73 for g in groups {
74 if !self.hidden_projects.contains(g) {
75 self.hidden_projects.push(*g);
76 }
77 }
78 self.projects.retain(|p| !groups.contains(p));
79 self
80 }
81
82 pub fn for_webhook(project_id: Option<Uuid>, restricted: &[Uuid]) -> Actor {
86 Actor {
87 id: Uuid::nil(),
88 username: "webhook".into(),
89 display_name: "Webhook".into(),
90 role: Role::Viewer,
91 skin: String::new(),
92 preferred_cad: String::new(),
93 projects: project_id.into_iter().collect(),
94 hidden_projects: restricted
95 .iter()
96 .copied()
97 .filter(|p| Some(*p) != project_id)
98 .collect(),
99 }
100 }
101
102 #[doc(hidden)]
104 pub fn test(role: Role, projects: Vec<Uuid>, hidden_projects: Vec<Uuid>) -> Actor {
105 Actor {
106 id: Uuid::new_v4(),
107 username: "test".into(),
108 display_name: "Test".into(),
109 role,
110 skin: "teamcraft".into(),
111 preferred_cad: String::new(),
112 projects,
113 hidden_projects,
114 }
115 }
116
117 pub fn system() -> Actor {
119 Actor {
120 id: Uuid::nil(),
121 username: "system".into(),
122 display_name: "TeamCraft".into(),
123 role: Role::Admin,
124 skin: String::new(),
125 preferred_cad: String::new(),
126 projects: Vec::new(),
127 hidden_projects: Vec::new(),
128 }
129 }
130
131 pub fn require(&self, min: Role) -> AppResult<()> {
133 if self.role >= min {
134 Ok(())
135 } else {
136 Err(AppError::Forbidden(format!(
137 "This action requires the '{}' role.",
138 min.label()
139 )))
140 }
141 }
142
143 pub fn is_admin(&self) -> bool {
144 self.role >= Role::Admin
145 }
146
147 pub fn can_edit(&self) -> bool {
149 self.role >= Role::Editor
150 }
151
152 pub fn can_edit_project(&self, project_id: Option<Uuid>) -> bool {
156 match self.role {
157 Role::Admin => true,
158 Role::Editor => project_id.is_none_or(|p| self.projects.contains(&p)),
159 Role::Viewer => false,
160 }
161 }
162}
163
164pub fn hash_password(password: &str) -> AppResult<String> {
170 let salt = SaltString::generate(&mut OsRng);
171 Argon2::default()
172 .hash_password(password.as_bytes(), &salt)
173 .map(|h| h.to_string())
174 .map_err(|e| AppError::Other(anyhow::anyhow!("password hashing failed: {e}")))
175}
176
177pub fn verify_password(password: &str, hash: &str) -> bool {
179 match PasswordHash::new(hash) {
180 Ok(parsed) => Argon2::default()
181 .verify_password(password.as_bytes(), &parsed)
182 .is_ok(),
183 Err(_) => false,
184 }
185}
186
187static DUMMY_HASH: LazyLock<String> =
193 LazyLock::new(|| hash_password(&random_hex()).expect("hashing works"));
194
195pub async fn hash_password_async(password: String) -> AppResult<String> {
197 tokio::task::spawn_blocking(move || hash_password(&password))
198 .await
199 .map_err(|e| AppError::Other(anyhow::anyhow!("hashing task failed: {e}")))?
200}
201
202pub async fn verify_password_async(password: String, hash: Option<String>) -> bool {
204 tokio::task::spawn_blocking(move || match hash {
205 Some(hash) => verify_password(&password, &hash),
206 None => {
207 let _ = verify_password(&password, &DUMMY_HASH);
208 false
209 }
210 })
211 .await
212 .unwrap_or(false)
213}
214
215pub fn generate_token(prefix: &str) -> String {
221 format!("{prefix}{}", random_hex())
222}
223
224fn random_hex() -> String {
226 let mut bytes = [0u8; 32];
227 OsRng.fill_bytes(&mut bytes);
228 hex::encode(bytes)
229}
230
231pub fn hash_token(token: &str) -> String {
233 hex::encode(Sha256::digest(token.as_bytes()))
234}
235
236pub fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
242 headers
243 .get_all(header::COOKIE)
244 .iter()
245 .filter_map(|v| v.to_str().ok())
246 .flat_map(|v| v.split(';'))
247 .filter_map(|pair| pair.trim().split_once('='))
248 .find(|(k, _)| *k == name)
249 .map(|(_, v)| v.trim().to_string())
250}
251
252fn cookie_attributes(config: &Config, max_age_secs: u64) -> String {
253 let secure = if config.cookie_secure { "; Secure" } else { "" };
254 format!("Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age_secs}{secure}")
255}
256
257pub fn session_cookie(config: &Config, token: &str) -> String {
258 format!(
259 "{SESSION_COOKIE}={token}; {}",
260 cookie_attributes(config, config.session_ttl.as_secs())
261 )
262}
263
264pub fn clear_session_cookie(config: &Config) -> String {
265 format!("{SESSION_COOKIE}=; {}", cookie_attributes(config, 0))
266}
267
268pub fn flash_cookie(config: &Config, encoded: &str) -> String {
269 format!(
270 "{FLASH_COOKIE}={encoded}; {}",
271 cookie_attributes(config, 60)
272 )
273}
274
275pub fn clear_flash_cookie(config: &Config) -> String {
276 format!("{FLASH_COOKIE}=; {}", cookie_attributes(config, 0))
277}
278
279pub struct WebUser(pub Actor);
286
287impl FromRequestParts<AppState> for WebUser {
288 type Rejection = AppError;
289
290 async fn from_request_parts(
291 parts: &mut Parts,
292 state: &AppState,
293 ) -> Result<Self, Self::Rejection> {
294 if let Some(actor) = parts.extensions.get::<Actor>() {
295 return Ok(WebUser(actor.clone()));
296 }
297 let token = cookie_value(&parts.headers, SESSION_COOKIE)
298 .filter(|t| !t.is_empty())
299 .ok_or(AppError::Unauthorized)?;
300 let (actor, started) = services::users::session_actor(&state.db, &token)
301 .await?
302 .ok_or(AppError::Unauthorized)?;
303 let origin = Origin {
305 client: request_client(parts),
306 session_started: Some(started),
307 key: Some(hash_token(&token)[..16].to_string()),
308 token: false,
309 };
310 let actor = services::membership_conditions::apply(&state.db, actor, &origin).await?;
311 parts.extensions.insert(actor.clone());
312 parts.extensions.insert(origin);
313 Ok(WebUser(actor))
314 }
315}
316
317fn request_client(parts: &Parts) -> Option<IpAddr> {
319 let peer = parts
320 .extensions
321 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
322 .map(|c| c.0.ip());
323 client_address(&parts.headers, peer)
324}
325
326pub use services::membership_conditions::Origin;
331
332impl FromRequestParts<AppState> for Origin {
333 type Rejection = std::convert::Infallible;
334
335 async fn from_request_parts(
336 parts: &mut Parts,
337 _state: &AppState,
338 ) -> Result<Self, Self::Rejection> {
339 Ok(parts
340 .extensions
341 .get::<Origin>()
342 .cloned()
343 .unwrap_or_default())
344 }
345}
346
347pub struct ApiUser(pub Actor);
352
353impl FromRequestParts<AppState> for ApiUser {
354 type Rejection = AppError;
355
356 async fn from_request_parts(
357 parts: &mut Parts,
358 state: &AppState,
359 ) -> Result<Self, Self::Rejection> {
360 let caller = ApiCaller::from_request_parts(parts, state).await?;
361 Ok(ApiUser(caller.actor))
362 }
363}
364
365#[derive(Clone, Debug)]
371pub struct ApiCaller {
372 pub actor: Actor,
373 pub scope: TokenScope,
374}
375
376impl FromRequestParts<AppState> for ApiCaller {
377 type Rejection = AppError;
378
379 async fn from_request_parts(
380 parts: &mut Parts,
381 state: &AppState,
382 ) -> Result<Self, Self::Rejection> {
383 let caller = if let Some(caller) = parts.extensions.get::<ApiCaller>() {
384 caller.clone()
385 } else if let Some(actor) = parts.extensions.get::<Actor>() {
386 ApiCaller {
388 actor: actor.clone(),
389 scope: TokenScope::Full,
390 }
391 } else {
392 let token = parts
393 .headers
394 .get(header::AUTHORIZATION)
395 .and_then(|v| v.to_str().ok())
396 .and_then(|v| {
397 v.strip_prefix("Bearer ")
398 .or_else(|| v.strip_prefix("bearer "))
399 })
400 .map(str::trim)
401 .filter(|t| !t.is_empty())
402 .ok_or(AppError::Unauthorized)?;
403 let (actor, scope) = services::users::actor_for_api_token(&state.db, token)
404 .await?
405 .ok_or(AppError::Unauthorized)?;
406 let origin = Origin {
409 client: request_client(parts),
410 session_started: None,
411 key: Some(hash_token(token)[..16].to_string()),
412 token: true,
413 };
414 let actor = services::membership_conditions::apply(&state.db, actor, &origin).await?;
415 let caller = ApiCaller { actor, scope };
416 parts.extensions.insert(caller.actor.clone());
417 parts.extensions.insert(caller.clone());
418 parts.extensions.insert(origin);
419 caller
420 };
421 let path = parts
423 .extensions
424 .get::<OriginalUri>()
425 .map_or_else(|| parts.uri.path(), |uri| uri.0.path());
426 if !caller.scope.allows_request(parts.method.as_str(), path) {
427 return Err(AppError::Forbidden(caller.scope.refusal().into()));
428 }
429 Ok(caller)
430 }
431}
432
433impl FromRequestParts<AppState> for services::access_log::AccessCtx {
437 type Rejection = std::convert::Infallible;
438
439 async fn from_request_parts(
440 parts: &mut Parts,
441 _state: &AppState,
442 ) -> Result<Self, Self::Rejection> {
443 let bearer = parts
444 .headers
445 .get(header::AUTHORIZATION)
446 .and_then(|v| v.to_str().ok())
447 .is_some_and(|v| v.len() > 7 && v[..7].eq_ignore_ascii_case("bearer "));
448 Ok(services::access_log::AccessCtx {
449 channel: if bearer {
450 services::access_log::Channel::Api
451 } else {
452 services::access_log::Channel::Web
453 },
454 client: request_client(parts),
455 })
456 }
457}
458
459pub const SIGN_IN_WINDOW: Duration = Duration::from_secs(15 * 60);
465pub const SIGN_IN_ADDRESS_FACTOR: u32 = 5;
468const SIGN_IN_MAX_ENTRIES: usize = 100_000;
471
472#[derive(Debug)]
480pub struct SignInThrottle {
481 max_failures: u32,
482 window: Duration,
483 counters: Mutex<HashMap<String, (Instant, u32)>>,
484}
485
486impl SignInThrottle {
487 pub fn new(max_failures: u32, window: Duration) -> SignInThrottle {
489 SignInThrottle {
490 max_failures,
491 window,
492 counters: Mutex::new(HashMap::new()),
493 }
494 }
495
496 pub fn keys(username: &str, client: Option<IpAddr>) -> Vec<String> {
498 let mut keys = vec![format!("user:{}", username.trim().to_ascii_lowercase())];
499 if let Some(ip) = client {
500 keys.push(format!("ip:{ip}"));
501 }
502 keys
503 }
504
505 fn limit(&self, key: &str) -> u32 {
506 if key.starts_with("ip:") {
507 self.max_failures.saturating_mul(SIGN_IN_ADDRESS_FACTOR)
508 } else {
509 self.max_failures
510 }
511 }
512
513 fn counters(&self) -> std::sync::MutexGuard<'_, HashMap<String, (Instant, u32)>> {
514 self.counters.lock().unwrap_or_else(|e| e.into_inner())
515 }
516
517 pub fn blocked(&self, keys: &[String]) -> Option<Duration> {
519 if self.max_failures == 0 {
520 return None;
521 }
522 let now = Instant::now();
523 let counters = self.counters();
524 keys.iter()
525 .filter_map(|k| {
526 let (start, n) = counters.get(k)?;
527 let elapsed = now.saturating_duration_since(*start);
528 (elapsed < self.window && *n >= self.limit(k)).then(|| self.window - elapsed)
529 })
530 .max()
531 }
532
533 pub fn failed(&self, keys: &[String]) {
535 if self.max_failures == 0 {
536 return;
537 }
538 let now = Instant::now();
539 let window = self.window;
540 let mut counters = self.counters();
541 if counters.len() >= SIGN_IN_MAX_ENTRIES {
542 counters.retain(|_, (start, _)| now.saturating_duration_since(*start) < window);
543 }
544 for key in keys {
545 let full = counters.len() >= SIGN_IN_MAX_ENTRIES;
546 if full && !key.starts_with("ip:") && !counters.contains_key(key) {
547 continue;
548 }
549 let entry = counters.entry(key.clone()).or_insert((now, 0));
550 if now.saturating_duration_since(entry.0) >= window {
551 *entry = (now, 0);
552 }
553 entry.1 = entry.1.saturating_add(1);
554 }
555 }
556
557 pub fn succeeded(&self, username: &str) {
559 self.counters()
560 .remove(&format!("user:{}", username.trim().to_ascii_lowercase()));
561 }
562}
563
564pub fn client_address(headers: &HeaderMap, peer: Option<IpAddr>) -> Option<IpAddr> {
569 let forwarded = || {
570 headers
571 .get("x-forwarded-for")?
572 .to_str()
573 .ok()?
574 .rsplit(',')
575 .next()?
576 .trim()
577 .parse::<IpAddr>()
578 .ok()
579 };
580 match peer {
581 Some(ip) if !crate::webhooks::is_internal(ip) => Some(ip),
582 Some(ip) => forwarded().or(Some(ip)),
583 None => forwarded(),
584 }
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590 use axum::http::HeaderValue;
591
592 #[test]
593 fn sign_in_throttle_counts_per_name_and_address() {
594 let t = SignInThrottle::new(3, Duration::from_secs(60));
595 let ip: IpAddr = "203.0.113.7".parse().unwrap();
596 let erika = SignInThrottle::keys(" Erika ", Some(ip));
597 assert_eq!(erika, ["user:erika", "ip:203.0.113.7"]);
598 for _ in 0..3 {
599 assert_eq!(t.blocked(&erika), None);
600 t.failed(&erika);
601 }
602 let wait = t.blocked(&erika).expect("the name is blocked");
603 assert!(wait <= Duration::from_secs(60) && wait > Duration::from_secs(50));
604 let max = SignInThrottle::keys("max", Some(ip));
606 assert_eq!(t.blocked(&max), None);
607 for _ in 0..11 {
608 t.failed(&max);
609 }
610 assert!(
611 t.blocked(&SignInThrottle::keys("anna", Some(ip))).is_none(),
612 "14 of 15"
613 );
614 t.failed(&SignInThrottle::keys("anna", Some(ip)));
615 assert!(
616 t.blocked(&SignInThrottle::keys("anna", Some(ip))).is_some(),
617 "the address is full"
618 );
619 assert!(t.blocked(&SignInThrottle::keys("anna", None)).is_none());
620 t.succeeded("ERIKA");
622 assert!(t.blocked(&["user:erika".to_string()]).is_none());
623 assert!(t.blocked(&erika).is_some(), "the address stays counted");
624
625 let short = SignInThrottle::new(1, Duration::from_millis(30));
627 let k = SignInThrottle::keys("x", None);
628 short.failed(&k);
629 assert!(short.blocked(&k).is_some());
630 std::thread::sleep(Duration::from_millis(40));
631 assert!(short.blocked(&k).is_none());
632 short.failed(&k);
633 assert!(short.blocked(&k).is_some(), "a new window starts");
634
635 let off = SignInThrottle::new(0, Duration::from_secs(60));
637 for _ in 0..100 {
638 off.failed(&k);
639 }
640 assert!(off.blocked(&k).is_none());
641 }
642
643 #[test]
644 fn client_addresses_behind_a_proxy() {
645 let mut h = HeaderMap::new();
646 h.insert(
647 "x-forwarded-for",
648 HeaderValue::from_static("198.51.100.1, 203.0.113.9"),
649 );
650 let proxy: IpAddr = "127.0.0.1".parse().unwrap();
651 let public: IpAddr = "192.0.2.44".parse().unwrap();
652 assert_eq!(client_address(&h, Some(proxy)), "203.0.113.9".parse().ok());
654 assert_eq!(client_address(&h, Some(public)), Some(public));
656 assert_eq!(client_address(&HeaderMap::new(), Some(proxy)), Some(proxy));
657 assert_eq!(client_address(&h, None), "203.0.113.9".parse().ok());
658 let mut bad = HeaderMap::new();
659 bad.insert("x-forwarded-for", HeaderValue::from_static("not-an-ip"));
660 assert_eq!(client_address(&bad, Some(proxy)), Some(proxy));
661 }
662
663 #[test]
664 fn password_hash_round_trip() {
665 let (password, other) = (random_hex(), random_hex());
667 let hash = hash_password(&password).unwrap();
668 assert!(hash.starts_with("$argon2id$"));
669 assert!(verify_password(&password, &hash));
670 assert!(!verify_password(&other, &hash));
671 assert!(!verify_password(&password, "not-a-hash"));
672 }
673
674 #[test]
675 fn tokens_are_random_and_hashed() {
676 let a = generate_token(API_TOKEN_PREFIX);
677 let b = generate_token(API_TOKEN_PREFIX);
678 assert_ne!(a, b);
679 assert!(a.starts_with("tc_pat_"));
680 assert_eq!(a.len(), "tc_pat_".len() + 64);
681 assert_eq!(hash_token(&a), hash_token(&a));
682 assert_ne!(hash_token(&a), hash_token(&b));
683 assert_eq!(hash_token(&a).len(), 64);
684 }
685
686 #[test]
687 fn cookie_parsing() {
688 let mut headers = HeaderMap::new();
689 headers.append(
690 header::COOKIE,
691 HeaderValue::from_static("a=1; tc_session=abc ; b=2"),
692 );
693 headers.append(header::COOKIE, HeaderValue::from_static("tc_flash=ff"));
694 assert_eq!(
695 cookie_value(&headers, SESSION_COOKIE).as_deref(),
696 Some("abc")
697 );
698 assert_eq!(cookie_value(&headers, FLASH_COOKIE).as_deref(), Some("ff"));
699 assert_eq!(cookie_value(&headers, "missing"), None);
700 }
701
702 #[test]
703 fn actor_role_checks() {
704 let rocket = Uuid::new_v4();
705 let rover = Uuid::new_v4();
706 let mut actor = Actor {
707 id: Uuid::new_v4(),
708 username: "eng".into(),
709 display_name: "Eng".into(),
710 role: Role::Editor,
711 skin: "teamcraft".into(),
712 preferred_cad: String::new(),
713 projects: vec![rocket],
714 hidden_projects: vec![],
715 };
716 assert!(actor.require(Role::Viewer).is_ok());
717 assert!(actor.require(Role::Editor).is_ok());
718 assert!(actor.require(Role::Admin).is_err());
719 assert!(actor.can_edit());
720 assert!(!actor.is_admin());
721 assert!(actor.can_edit_project(Some(rocket)));
723 assert!(!actor.can_edit_project(Some(rover)));
724 assert!(
725 actor.can_edit_project(None),
726 "legacy items without a project"
727 );
728 actor.role = Role::Admin;
729 assert!(
730 actor.can_edit_project(Some(rover)),
731 "admins edit everything"
732 );
733 actor.role = Role::Viewer;
734 assert!(!actor.can_edit_project(Some(rocket)), "viewers never edit");
735 }
736
737 #[test]
738 fn actor_read_visibility() {
739 let open = Uuid::new_v4();
740 let secret = Uuid::new_v4();
741 let actor = Actor::test(Role::Editor, vec![open], vec![secret]);
742 assert!(actor.can_see_project(None), "items without a group");
743 assert!(actor.can_see_project(Some(open)));
744 assert!(!actor.can_see_project(Some(secret)));
745 let admin = Actor::test(Role::Admin, vec![], vec![]);
746 assert!(admin.can_see_project(Some(secret)));
747 let hook = Actor::for_webhook(Some(secret), &[secret, open]);
749 assert!(hook.can_see_project(Some(secret)));
750 assert!(!hook.can_see_project(Some(open)));
751 let global = Actor::for_webhook(None, &[secret]);
752 assert!(!global.can_see_project(Some(secret)));
753 }
754
755 #[test]
756 fn actor_serialization_skips_hidden_projects() {
757 let secret = Uuid::new_v4();
758 let actor = Actor::test(Role::Viewer, vec![], vec![secret]);
759 let json = serde_json::to_string(&actor).unwrap();
760 assert!(!json.contains("hidden_projects"));
761 assert!(!json.contains(&secret.to_string()));
762 }
763}