Skip to main content

teamcraft/
auth.rs

1//! Authentication primitives: password hashing, random tokens, cookies and
2//! the request extractors that resolve the current user.
3//!
4//! * Browser users authenticate with a session cookie (`tc_session`).
5//! * API clients authenticate with a personal access token sent as
6//!   `Authorization: Bearer tc_pat_…`. The API never accepts cookies, which
7//!   keeps it immune to CSRF.
8
9use 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/// The authenticated user on whose behalf a request is executed.
42#[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    /// UI skin (see `domain::SKINS`).
49    pub skin: String,
50    /// Preferred CAD program (a format family, see `cadformat::families`);
51    /// `""` = show all files alike.
52    pub preferred_cad: String,
53    /// Project groups the user is a member of.
54    pub projects: Vec<Uuid>,
55    /// Restricted project groups the user is NOT a member of; always empty
56    /// for administrators. Never serialized: it names groups the user may
57    /// not know about. The SQL guards in `services::access` bind it.
58    #[serde(skip)]
59    pub hidden_projects: Vec<Uuid>,
60}
61
62impl Actor {
63    /// Whether the actor may read data of a project group (`None` = items
64    /// without a group, readable by everyone).
65    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    /// The same actor without the read rights to `groups` (an interface that
70    /// is switched off for them, see `services::project_policy`): they are
71    /// hidden like groups the user is no member of.
72    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    /// A webhook evaluated as a virtual reader: it sees its own group and
83    /// every internal group; a global webhook (`project_id = None`) sees
84    /// internal groups only. `restricted` = all restricted groups.
85    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    /// An actor for unit tests.
103    #[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    /// The system itself (background workers): sees everything.
118    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    /// Fails with `403 Forbidden` unless the actor has at least role `min`.
132    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    /// CAD editor or admin (may edit at least somewhere).
148    pub fn can_edit(&self) -> bool {
149        self.role >= Role::Editor
150    }
151
152    /// Whether the actor may modify data of a project group. Admins may edit
153    /// everything, CAD editors only the projects they are members of; items
154    /// without a project (legacy data) are editable by every CAD editor.
155    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
164// ---------------------------------------------------------------------------
165// Passwords
166// ---------------------------------------------------------------------------
167
168/// Hashes a password with Argon2id (OWASP recommended default parameters).
169pub 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
177/// Verifies a password against a stored PHC hash string.
178pub 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
187/// A valid hash used to spend the same time on unknown usernames as on
188/// wrong passwords, so login timing does not reveal which users exist.
189/// It hashes a random value made at start-up: the hash never verifies
190/// anything (the unknown-user branch always answers `false`), so the code
191/// holds no fixed "dummy password" either.
192static DUMMY_HASH: LazyLock<String> =
193    LazyLock::new(|| hash_password(&random_hex()).expect("hashing works"));
194
195/// Runs Argon2 on the blocking thread pool (it is deliberately CPU heavy).
196pub 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
202/// Verifies on the blocking pool. `hash = None` performs a dummy verification.
203pub 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
215// ---------------------------------------------------------------------------
216// Tokens
217// ---------------------------------------------------------------------------
218
219/// Generates a random token with 256 bits of entropy, e.g. `tc_pat_3f9a…`.
220pub fn generate_token(prefix: &str) -> String {
221    format!("{prefix}{}", random_hex())
222}
223
224/// 256 random bits from the OS, hex-encoded (64 characters).
225fn random_hex() -> String {
226    let mut bytes = [0u8; 32];
227    OsRng.fill_bytes(&mut bytes);
228    hex::encode(bytes)
229}
230
231/// Tokens are high-entropy, so a fast hash is sufficient for storage.
232pub fn hash_token(token: &str) -> String {
233    hex::encode(Sha256::digest(token.as_bytes()))
234}
235
236// ---------------------------------------------------------------------------
237// Cookies
238// ---------------------------------------------------------------------------
239
240/// Returns the value of the cookie `name` from the request headers.
241pub 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
279// ---------------------------------------------------------------------------
280// Extractors
281// ---------------------------------------------------------------------------
282
283/// Extracts the user of a browser session. Rejects with `401`, which the web
284/// UI turns into a redirect to the login page.
285pub 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        // conditions on membership that depend on the request (#151)
304        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
317/// The client address of a request (see [`client_address`]).
318fn 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
326/// Where the signed-in request comes from (client address, session start, a
327/// key of the session or token), set by [`WebUser`] and [`ApiCaller`]; long
328/// work on behalf of the request (the assistant) applies the conditions on
329/// membership again with it. Empty when the request has no user.
330pub 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
347/// Extracts the user of an API request from a bearer token.
348///
349/// Built on [`ApiCaller`], so the token's scope is enforced on every API
350/// route that takes an `ApiUser`.
351pub 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/// The user of an API request and the scope of its token. Refuses (403) a
366/// request outside the scope: a `read_only` token may only send `GET`/`HEAD`
367/// requests and post to the MCP endpoint (whose dispatcher allows it the
368/// read-only tools only), an `mcp_only` token may only post to the MCP
369/// endpoint (`domain::TokenScope::allows_request`).
370#[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            // resolved from a browser session by `WebUser`: no token, no scope
387            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            // conditions on membership that depend on the request (#151);
407            // a token's age is the user's last sign-in (checked in SQL)
408            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        // the full path: routes nested under /api/v1 see only their own part
422        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
433/// Channel and client address of a request, for the read log of restricted
434/// groups (`services::access_log`). Never rejects: a bearer token means the
435/// API channel, otherwise the browser session.
436impl 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
459// ---------------------------------------------------------------------------
460// Sign-in throttle
461// ---------------------------------------------------------------------------
462
463/// How long failed sign-ins are counted (from the first failure).
464pub const SIGN_IN_WINDOW: Duration = Duration::from_secs(15 * 60);
465/// A client address may fail this many times the per-username limit: an
466/// office behind one NAT address shares it.
467pub const SIGN_IN_ADDRESS_FACTOR: u32 = 5;
468/// Counters kept at most; beyond, new usernames are not counted (client
469/// addresses still are), so the map stays bounded under a spray.
470const SIGN_IN_MAX_ENTRIES: usize = 100_000;
471
472/// Failed password sign-ins per username and per client address
473/// (`TEAMCRAFT_SIGNIN_MAX_FAILURES` within [`SIGN_IN_WINDOW`]); once a
474/// counter is full, sign-in for that name or address is refused with 429
475/// until the window has passed – without checking the password. Counted in
476/// memory per server process; a successful sign-in clears the username's
477/// counter, not the address's (else an attacker with an account of their
478/// own could reset it between guesses).
479#[derive(Debug)]
480pub struct SignInThrottle {
481    max_failures: u32,
482    window: Duration,
483    counters: Mutex<HashMap<String, (Instant, u32)>>,
484}
485
486impl SignInThrottle {
487    /// `max_failures` 0 turns the throttle off.
488    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    /// The counters a sign-in attempt touches.
497    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    /// `Some(wait)` when one of the counters is full.
518    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    /// Counts a failed attempt.
534    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    /// A successful sign-in clears the username's counter.
558    pub fn succeeded(&self, username: &str) {
559        self.counters()
560            .remove(&format!("user:{}", username.trim().to_ascii_lowercase()));
561    }
562}
563
564/// The client address of a request: the peer, or – when the peer is a
565/// reverse proxy on an internal address – the last `X-Forwarded-For` entry,
566/// the one that proxy added (a client can only prepend to the header). In
567/// tests without a peer address, the header alone.
568pub 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        // another name from the same address: the address has room (3 × 5)
605        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        // success clears the name, not the address
621        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        // the window passes
626        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        // 0 = off
636        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        // the proxy's own entry, not what the client prepended
653        assert_eq!(client_address(&h, Some(proxy)), "203.0.113.9".parse().ok());
654        // a client talking to the server directly cannot choose its address
655        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        // random passwords: no hard-coded credential, even in a test
666        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        // project groups
722        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        // webhooks: own group plus internal groups
748        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}