forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
15.4 KiB
1//! Session cookie, current user, CSRF checks and login redirect helpers.
2//!
3//! The browser holds the raw session token in `__Host-forge_session`. The database
4//! stores only `hash_token(token)`, so a leaked DB row cannot be replayed as a cookie.
5
6use std::time::Duration;
7
8use forge_core::password::{hash_password, verify_password};
9use forge_core::session::{new_csrf_token, new_session_token};
10use forge_core::time::stamp_after;
11use forge_core::token::{constant_time_eq, hash_token};
12use forge_core::{Session, Store, User, secret, totp};
13use topcoat::Result;
14use topcoat::context::{Cx, app_context};
15use topcoat::cookie::{Cookie, Cookies, SameSite, cookies, time};
16use topcoat::router::error::{forbidden, see_other};
17use topcoat::router::request::{headers, uri};
18
19use crate::state::AppState;
20
21/// Logical cookie name. `override_prefix_host()` stores it as `__Host-forge_session`.
22pub const SESSION_COOKIE: &str = "forge_session";
23/// Lifetime of a web session, both in the database and on the cookie.
24pub const SESSION_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60);
25
26/// Logical name of the pre-session CSRF cookie, used by `/login` and `/setup` before any
27/// session exists. Stored as `__Host-forge_precsrf`.
28pub const PRE_CSRF_COOKIE: &str = "forge_precsrf";
29/// Lifetime of the pre-session CSRF cookie.
30const PRE_CSRF_TTL: Duration = Duration::from_secs(60 * 60);
31
32/// Logical name of the pending-login cookie set between the password and the second factor.
33/// Stored as `__Host-forge_2fa`. Its value is sealed with the instance key.
34pub const PENDING_2FA_COOKIE: &str = "forge_2fa";
35/// How long a password-verified login may wait for its second factor.
36pub const PENDING_2FA_TTL: Duration = Duration::from_secs(5 * 60);
37
38/// Maximum accepted length of a `next` redirect target.
39const MAX_NEXT_LEN: usize = 2048;
40
41/// Cookie jar that forces the `__Host-` prefix (Secure, Path=/, no Domain).
42fn session_jar(cx: &Cx) -> impl Cookies + '_ {
43 cookies(cx).override_prefix_host()
44}
45
46/// Resolves the signed-in user from the session cookie.
47///
48/// Returns `None` when there is no cookie, the session is unknown or expired, or its
49/// user no longer exists. Every successful lookup refreshes `last_seen`.
50pub async fn current_user(cx: &Cx) -> forge_core::Result<Option<(User, Session)>> {
51 let Some(cookie) = session_jar(cx).get(SESSION_COOKIE) else {
52 return Ok(None);
53 };
54 let store = &app_context::<AppState>(cx).store;
55 let Some(session) = store
56 .session_by_token_hash(&hash_token(cookie.value()))
57 .await?
58 else {
59 return Ok(None);
60 };
61 // An organization never signs in, even if a session row names one.
62 let Some(user) = store
63 .user_by_id(&session.user_id)
64 .await?
65 .filter(|user| !user.is_org())
66 else {
67 return Ok(None);
68 };
69 store.touch_session(&session.id).await?;
70 Ok(Some((user, session)))
71}
72
73/// Creates a session for `user` and sets the session cookie on the response.
74pub async fn login(cx: &Cx, user: &User) -> forge_core::Result<Session> {
75 let store = &app_context::<AppState>(cx).store;
76 let token = new_session_token();
77 let session = store
78 .create_session(
79 &user.id,
80 &hash_token(&token),
81 &new_csrf_token(),
82 &stamp_after(SESSION_TTL),
83 user_agent(cx).as_deref(),
84 )
85 .await?;
86 let max_age = time::Duration::seconds(i64::try_from(SESSION_TTL.as_secs()).unwrap_or(i64::MAX));
87 session_jar(cx).add(
88 Cookie::build((SESSION_COOKIE, token))
89 .path("/")
90 .secure(true)
91 .http_only(true)
92 .same_site(SameSite::Lax)
93 .max_age(max_age)
94 .build(),
95 );
96 Ok(session)
97}
98
99/// Deletes the session row and clears the cookie.
100pub async fn logout(cx: &Cx, session: &Session) -> forge_core::Result<()> {
101 app_context::<AppState>(cx)
102 .store
103 .delete_session(&session.id)
104 .await?;
105 session_jar(cx).remove(Cookie::build((SESSION_COOKIE, "")).path("/"));
106 Ok(())
107}
108
109/// Constant-time comparison of the submitted CSRF token with the session's token.
110pub fn check_csrf(session: &Session, submitted: &str) -> bool {
111 // An empty stored token must never match, even an empty submission.
112 !session.csrf_token.is_empty()
113 && constant_time_eq(session.csrf_token.as_bytes(), submitted.as_bytes())
114}
115
116/// Returns the pre-session CSRF token for a form, issuing one when the browser has none.
117///
118/// The existing cookie is reused so that two open login tabs keep working. The same value
119/// goes into a hidden `precsrf` field; the POST must echo it back.
120pub fn pre_csrf_token(cx: &Cx) -> String {
121 if let Some(cookie) = session_jar(cx).get(PRE_CSRF_COOKIE) {
122 let value = cookie.value();
123 if !value.is_empty() {
124 return value.to_owned();
125 }
126 }
127 let token = new_csrf_token();
128 session_jar(cx).add(pre_csrf_cookie(token.clone()));
129 token
130}
131
132/// True only when the submitted `precsrf` equals the cookie, compared in constant time.
133pub fn check_pre_csrf(cx: &Cx, submitted: &str) -> bool {
134 let Some(cookie) = session_jar(cx).get(PRE_CSRF_COOKIE) else {
135 return false;
136 };
137 !cookie.value().is_empty() && constant_time_eq(cookie.value().as_bytes(), submitted.as_bytes())
138}
139
140/// Removes the pre-session CSRF cookie. Called after a successful login or setup.
141pub fn clear_pre_csrf(cx: &Cx) {
142 session_jar(cx).remove(Cookie::build((PRE_CSRF_COOKIE, "")).path("/"));
143}
144
145fn pre_csrf_cookie(token: String) -> Cookie<'static> {
146 Cookie::build((PRE_CSRF_COOKIE, token))
147 .path("/")
148 .secure(true)
149 .http_only(true)
150 .same_site(SameSite::Strict)
151 .max_age(time::Duration::seconds(
152 i64::try_from(PRE_CSRF_TTL.as_secs()).unwrap_or(i64::MAX),
153 ))
154 .build()
155}
156
157/// Hashes a password with argon2 on the blocking pool, keeping the async runtime free.
158pub async fn hash_password_async(password: String) -> forge_core::Result<String> {
159 tokio::task::spawn_blocking(move || hash_password(&password))
160 .await
161 .map_err(|_| forge_core::Error::Crypto("password hashing task failed".into()))?
162}
163
164/// Verifies a password with argon2 on the blocking pool.
165pub async fn verify_password_async(password: String, hash: String) -> forge_core::Result<bool> {
166 tokio::task::spawn_blocking(move || verify_password(&password, &hash))
167 .await
168 .map_err(|_| forge_core::Error::Crypto("password verification task failed".into()))
169}
170
171/// Returns the signed-in user, or a 303 redirect to `/login?next=<current path>`.
172pub async fn require_user(cx: &Cx) -> Result<(User, Session)> {
173 match current_user(cx).await? {
174 Some(viewer) => Ok(viewer),
175 None => {
176 let path = uri(cx).path_and_query().map_or("/", |p| p.as_str());
177 let target = format!("/login?next={}", percent_encode(path));
178 Err(see_other(target).into())
179 }
180 }
181}
182
183/// Requires a signed-in user and a valid `csrf` form field. Returns 403 on a bad token.
184pub async fn require_csrf(cx: &Cx, submitted: &str) -> Result<(User, Session)> {
185 let (user, session) = require_user(cx).await?;
186 if check_csrf(&session, submitted) {
187 Ok((user, session))
188 } else {
189 Err(forbidden().into())
190 }
191}
192
193/// A login that has passed its password and waits for the second factor.
194pub struct PendingLogin {
195 pub user_id: String,
196 pub next: String,
197}
198
199/// Seconds since the Unix epoch, or 0 if the clock is before it.
200pub fn unix_now() -> u64 {
201 std::time::SystemTime::now()
202 .duration_since(std::time::UNIX_EPOCH)
203 .map_or(0, |d| d.as_secs())
204}
205
206/// Sets the pending-login cookie. Its payload is `expires|user_id|next`, sealed so the browser
207/// cannot change the user or the expiry.
208pub fn set_pending_login(
209 cx: &Cx,
210 key: &[u8; 32],
211 user_id: &str,
212 next: &str,
213) -> forge_core::Result<()> {
214 let expires = unix_now() + PENDING_2FA_TTL.as_secs();
215 let sealed = secret::seal(key, format!("{expires}|{user_id}|{next}").as_bytes())?;
216 let max_age = time::Duration::seconds(i64::try_from(PENDING_2FA_TTL.as_secs()).unwrap_or(0));
217 session_jar(cx).add(
218 Cookie::build((PENDING_2FA_COOKIE, sealed))
219 .path("/")
220 .secure(true)
221 .http_only(true)
222 .same_site(SameSite::Strict)
223 .max_age(max_age)
224 .build(),
225 );
226 Ok(())
227}
228
229/// The pending login from the cookie, or `None` when the cookie is missing, tampered with,
230/// malformed or past its expiry.
231pub fn pending_login(cx: &Cx, key: &[u8; 32]) -> Option<PendingLogin> {
232 let cookie = session_jar(cx).get(PENDING_2FA_COOKIE)?;
233 let plain = secret::open(key, cookie.value()).ok()?;
234 let text = String::from_utf8(plain).ok()?;
235 let mut parts = text.splitn(3, '|');
236 let expires: u64 = parts.next()?.parse().ok()?;
237 let user_id = parts.next()?;
238 let next = parts.next()?;
239 if unix_now() >= expires {
240 return None;
241 }
242 Some(PendingLogin {
243 user_id: user_id.to_owned(),
244 next: next.to_owned(),
245 })
246}
247
248/// Removes the pending-login cookie.
249pub fn clear_pending_login(cx: &Cx) {
250 session_jar(cx).remove(Cookie::build((PENDING_2FA_COOKIE, "")).path("/"));
251}
252
253/// True when the user has confirmed two-factor authentication.
254pub async fn two_factor_enabled(store: &Store, user_id: &str) -> forge_core::Result<bool> {
255 Ok(store
256 .totp_state(user_id)
257 .await?
258 .is_some_and(|state| state.enabled))
259}
260
261/// Checks a second-factor input for a user with two-factor on. A six-digit input is a TOTP
262/// code, checked with replay protection. Anything else is a recovery code, which is used up
263/// on success. Returns false for any wrong or spent input.
264pub async fn verify_second_factor(
265 store: &Store,
266 key: &[u8; 32],
267 user_id: &str,
268 input: &str,
269) -> forge_core::Result<bool> {
270 let input = input.trim();
271 // An organization has no second factor and never passes one.
272 if store
273 .user_by_id(user_id)
274 .await?
275 .is_none_or(|user| user.is_org())
276 {
277 return Ok(false);
278 }
279 let Some(state) = store.totp_state(user_id).await? else {
280 return Ok(false);
281 };
282 let Some(sealed) = state.sealed_secret.as_deref() else {
283 return Ok(false);
284 };
285 if !state.enabled {
286 return Ok(false);
287 }
288 let shared = secret::open(key, sealed)?;
289 let is_totp = input.len() == totp::DIGITS && input.bytes().all(|b| b.is_ascii_digit());
290 if is_totp {
291 return match totp::verify(&shared, input, totp::step_for(unix_now()), state.last_step) {
292 Some(step) => store.accept_totp_step(user_id, step).await,
293 None => Ok(false),
294 };
295 }
296 store
297 .consume_recovery_code(user_id, &totp::recovery_code_hash(input))
298 .await
299}
300
301/// Returns `next` only when it is a same-site relative path; otherwise `/`.
302///
303/// Accepts a single leading `/`, rejects `//` and `/\` (protocol-relative or browser
304/// backslash tricks), rejects control characters and caps the length.
305pub fn safe_next(next: Option<&str>) -> &str {
306 match next {
307 Some(n) if is_local_path(n) => n,
308 _ => "/",
309 }
310}
311
312fn is_local_path(path: &str) -> bool {
313 path.len() <= MAX_NEXT_LEN
314 && path.starts_with('/')
315 && !path.starts_with("//")
316 && !path.starts_with("/\\")
317 && path.chars().all(|c| !c.is_control())
318}
319
320/// Percent-encodes everything except unreserved characters and `/`.
321pub fn percent_encode(input: &str) -> String {
322 let mut out = String::with_capacity(input.len());
323 for b in input.bytes() {
324 match b {
325 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
326 out.push(char::from(b));
327 }
328 _ => out.push_str(&format!("%{b:02X}")),
329 }
330 }
331 out
332}
333
334/// The request's `User-Agent`, truncated to 512 characters.
335pub fn user_agent(cx: &Cx) -> Option<String> {
336 headers(cx)
337 .get(http::header::USER_AGENT)
338 .and_then(|v| v.to_str().ok())
339 .map(|s| s.chars().take(512).collect())
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 fn session_with_csrf(csrf: &str) -> Session {
347 Session {
348 id: "s1".into(),
349 user_id: "u1".into(),
350 csrf_token: csrf.into(),
351 created_at: String::new(),
352 expires_at: String::new(),
353 last_seen: String::new(),
354 user_agent: None,
355 }
356 }
357
358 #[test]
359 fn csrf_accepts_only_the_exact_token() {
360 let s = session_with_csrf("abc123");
361 assert!(check_csrf(&s, "abc123"));
362 assert!(!check_csrf(&s, "abc124"));
363 assert!(!check_csrf(&s, "abc12"));
364 assert!(!check_csrf(&s, ""));
365 }
366
367 #[test]
368 fn csrf_rejects_empty_session_token() {
369 let s = session_with_csrf("");
370 assert!(!check_csrf(&s, ""));
371 assert!(!check_csrf(&s, "x"));
372 }
373
374 #[test]
375 fn next_allows_local_paths() {
376 assert_eq!(safe_next(Some("/")), "/");
377 assert_eq!(safe_next(Some("/alice/repo")), "/alice/repo");
378 assert_eq!(safe_next(Some("/settings/keys?x=1")), "/settings/keys?x=1");
379 }
380
381 #[test]
382 fn next_rejects_open_redirects() {
383 for bad in [
384 "",
385 "https://evil.example/",
386 "//evil.example/",
387 "/\\evil.example/",
388 "evil.example",
389 "\\\\evil",
390 "/\n/evil",
391 "/\t",
392 ] {
393 assert_eq!(safe_next(Some(bad)), "/", "input {bad:?}");
394 }
395 assert_eq!(safe_next(None), "/");
396 }
397
398 #[test]
399 fn next_rejects_overlong_paths() {
400 let long = format!("/{}", "a".repeat(MAX_NEXT_LEN));
401 assert_eq!(safe_next(Some(&long)), "/");
402 }
403
404 #[test]
405 fn percent_encode_keeps_slashes_and_encodes_the_rest() {
406 assert_eq!(percent_encode("/a/b-c_d.e~f"), "/a/b-c_d.e~f");
407 assert_eq!(percent_encode("/s?x=1&y=2"), "/s%3Fx%3D1%26y%3D2");
408 assert_eq!(percent_encode("/a b"), "/a%20b");
409 }
410
411 /// An organization has no second factor and never passes one, even with a valid TOTP code or
412 /// a recovery code on its row. A person with the same setup does pass.
413 #[tokio::test]
414 async fn an_org_never_passes_a_second_factor() {
415 let dir = tempfile::tempdir().unwrap();
416 let store = Store::open(&dir.path().join("forge.db")).await.unwrap();
417 let key = [7u8; 32];
418 let alice = store.create_user("alice", None, "h", false).await.unwrap();
419 let org = store.create_org("acme", &alice.id).await.unwrap();
420 let shared = forge_core::totp::new_secret();
421 let sealed = forge_core::secret::seal(&key, &shared).unwrap();
422 let code = forge_core::totp::code_at(&shared, forge_core::totp::step_for(unix_now()));
423 let recovery = forge_core::totp::recovery_code_hash("aaaa-bbbb");
424 for id in [&org.id, &alice.id] {
425 store.set_totp_secret(id, &sealed).await.unwrap();
426 store
427 .enable_totp(id, 0, std::slice::from_ref(&recovery))
428 .await
429 .unwrap();
430 }
431
432 assert!(
433 !verify_second_factor(&store, &key, &org.id, &code)
434 .await
435 .unwrap()
436 );
437 assert!(
438 !verify_second_factor(&store, &key, &org.id, "aaaa-bbbb")
439 .await
440 .unwrap()
441 );
442 // The same setup on a person works, so the refusal is the org rule.
443 assert!(
444 verify_second_factor(&store, &key, &alice.id, "aaaa-bbbb")
445 .await
446 .unwrap()
447 );
448 }
449}