auth.rs
⎇
Raw
1use argon2::Argon2;
2use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
3
4pub const COOKIE_NAME: &str = "fbng_session";
5/// 30 days.
6pub const SESSION_MAX_AGE: u64 = 60 * 60 * 24 * 30;
7
8pub fn hash_password(password: &str) -> anyhow::Result<String> {
9 let salt = SaltString::generate(&mut rand::thread_rng());
10 let hash = Argon2::default()
11 .hash_password(password.as_bytes(), &salt)
12 .map_err(|e| anyhow::anyhow!("password hashing failed: {e}"))?;
13 Ok(hash.to_string())
14}
15
16pub fn verify_password(password: &str, hash: &str) -> bool {
17 let Ok(parsed) = PasswordHash::new(hash) else {
18 return false;
19 };
20 Argon2::default()
21 .verify_password(password.as_bytes(), &parsed)
22 .is_ok()
23}
24
25/// 32 random bytes, hex-encoded (64 chars).
26pub fn random_token() -> String {
27 hex_token(32)
28}
29
30/// 16 random bytes, hex-encoded (32 chars). Used for public share links.
31pub fn share_token() -> String {
32 hex_token(16)
33}
34
35fn hex_token(bytes: usize) -> String {
36 use rand::RngCore;
37 use std::fmt::Write as _;
38 let mut b = vec![0u8; bytes];
39 rand::thread_rng().fill_bytes(&mut b);
40 b.iter().fold(String::with_capacity(bytes * 2), |mut s, x| {
41 let _ = write!(s, "{x:02x}");
42 s
43 })
44}
45
46pub fn session_cookie(token: &str, https: bool) -> String {
47 let mut c =
48 format!("{COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={SESSION_MAX_AGE}");
49 if https {
50 c.push_str("; Secure");
51 }
52 c
53}
54
55pub fn clear_session_cookie(https: bool) -> String {
56 let mut c = format!("{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0");
57 if https {
58 c.push_str("; Secure");
59 }
60 c
61}
62
63/// Extract the session token from the Cookie header, if present.
64pub fn parse_session_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
65 let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
66 for part in header.split(';') {
67 let part = part.trim();
68 if let Some((k, v)) = part.split_once('=')
69 && k == COOKIE_NAME
70 && !v.is_empty()
71 {
72 return Some(v.to_string());
73 }
74 }
75 None
76}
77
78#[cfg(test)]
79mod tests {
80 use super::*;
81 use axum::http::{HeaderMap, header};
82
83 #[test]
84 fn password_hash_round_trip() {
85 let h = hash_password("hunter22").unwrap();
86 assert!(verify_password("hunter22", &h));
87 assert!(!verify_password("wrong-password", &h));
88 assert!(!verify_password("hunter23", &h));
89 // Fresh salt on every hash.
90 assert_ne!(h, hash_password("hunter22").unwrap());
91 // Argon2id marker is present.
92 assert!(h.starts_with("$argon2id$"));
93 }
94
95 #[test]
96 fn verify_rejects_garbage_hashes() {
97 assert!(!verify_password("x", ""));
98 assert!(!verify_password("x", "not-a-hash"));
99 assert!(!verify_password("x", "$argon2id$"));
100 }
101
102 #[test]
103 fn token_shapes_and_uniqueness() {
104 let t = random_token();
105 assert_eq!(t.len(), 64);
106 assert!(t.chars().all(|c| c.is_ascii_hexdigit()));
107
108 let s = share_token();
109 assert_eq!(s.len(), 32);
110 assert!(s.chars().all(|c| c.is_ascii_hexdigit()));
111
112 let mut seen = std::collections::HashSet::new();
113 for _ in 0..100 {
114 assert!(seen.insert(random_token()), "session token collision");
115 assert!(seen.insert(share_token()), "share token collision");
116 }
117 }
118
119 #[test]
120 fn session_cookie_shape() {
121 let c = session_cookie("tok123", false);
122 assert!(c.starts_with("fbng_session=tok123;"));
123 assert!(c.contains("Path=/"));
124 assert!(c.contains("HttpOnly"));
125 assert!(c.contains("SameSite=Lax"));
126 assert!(c.contains(&format!("Max-Age={SESSION_MAX_AGE}")));
127 assert!(!c.contains("Secure"));
128
129 let c = session_cookie("tok123", true);
130 assert!(c.ends_with("; Secure"));
131
132 let c = clear_session_cookie(true);
133 assert!(c.starts_with("fbng_session=;"));
134 assert!(c.contains("Max-Age=0"));
135 assert!(c.contains("Secure"));
136 assert!(!clear_session_cookie(false).contains("Secure"));
137 }
138
139 #[test]
140 fn parse_session_cookie_variants() {
141 let mut h = HeaderMap::new();
142 h.insert(
143 header::COOKIE,
144 "other=1; fbng_session=abc123; x=y".parse().unwrap(),
145 );
146 assert_eq!(parse_session_cookie(&h).as_deref(), Some("abc123"));
147
148 let mut h = HeaderMap::new();
149 h.insert(header::COOKIE, "other=1".parse().unwrap());
150 assert_eq!(parse_session_cookie(&h), None);
151
152 // Empty value → treated as absent.
153 let mut h = HeaderMap::new();
154 h.insert(header::COOKIE, "fbng_session=".parse().unwrap());
155 assert_eq!(parse_session_cookie(&h), None);
156
157 assert_eq!(parse_session_cookie(&HeaderMap::new()), None);
158
159 // First occurrence wins.
160 let mut h = HeaderMap::new();
161 h.insert(
162 header::COOKIE,
163 "fbng_session=first; fbng_session=second".parse().unwrap(),
164 );
165 assert_eq!(parse_session_cookie(&h).as_deref(), Some("first"));
166
167 // Cookie name must match exactly.
168 let mut h = HeaderMap::new();
169 h.insert(
170 header::COOKIE,
171 "fbng_session2=x; Xfbng_session=y".parse().unwrap(),
172 );
173 assert_eq!(parse_session_cookie(&h), None);
174 }
175}
176