| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | use std::time::Duration; |
| 7 | |
| 8 | use forge_core::password::{hash_password, verify_password}; |
| 9 | use forge_core::session::{new_csrf_token, new_session_token}; |
| 10 | use forge_core::time::stamp_after; |
| 11 | use forge_core::token::{constant_time_eq, hash_token}; |
| 12 | use forge_core::{Session, Store, User, secret, totp}; |
| 13 | use topcoat::Result; |
| 14 | use topcoat::context::{Cx, app_context}; |
| 15 | use topcoat::cookie::{Cookie, Cookies, SameSite, cookies, time}; |
| 16 | use topcoat::router::error::{forbidden, see_other}; |
| 17 | use topcoat::router::request::{headers, uri}; |
| 18 | |
| 19 | use crate::state::AppState; |
| 20 | |
| 21 | |
| 22 | pub const SESSION_COOKIE: &str = "forge_session"; |
| 23 | |
| 24 | pub const SESSION_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60); |
| 25 | |
| 26 | |
| 27 | |
| 28 | pub const PRE_CSRF_COOKIE: &str = "forge_precsrf"; |
| 29 | |
| 30 | const PRE_CSRF_TTL: Duration = Duration::from_secs(60 * 60); |
| 31 | |
| 32 | |
| 33 | |
| 34 | pub const PENDING_2FA_COOKIE: &str = "forge_2fa"; |
| 35 | |
| 36 | pub const PENDING_2FA_TTL: Duration = Duration::from_secs(5 * 60); |
| 37 | |
| 38 | |
| 39 | const MAX_NEXT_LEN: usize = 2048; |
| 40 | |
| 41 | |
| 42 | fn session_jar(cx: &Cx) -> impl Cookies + '_ { |
| 43 | cookies(cx).override_prefix_host() |
| 44 | } |
| 45 | |
| 46 | |
| 47 | |
| 48 | |
| 49 | |
| 50 | pub 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 | |
| 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 | |
| 74 | pub 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 | |
| 100 | pub 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 | |
| 110 | pub fn check_csrf(session: &Session, submitted: &str) -> bool { |
| 111 | |
| 112 | !session.csrf_token.is_empty() |
| 113 | && constant_time_eq(session.csrf_token.as_bytes(), submitted.as_bytes()) |
| 114 | } |
| 115 | |
| 116 | |
| 117 | |
| 118 | |
| 119 | |
| 120 | pub 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 | |
| 133 | pub 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 | |
| 141 | pub fn clear_pre_csrf(cx: &Cx) { |
| 142 | session_jar(cx).remove(Cookie::build((PRE_CSRF_COOKIE, "")).path("/")); |
| 143 | } |
| 144 | |
| 145 | fn 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 | |
| 158 | pub 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 | |
| 165 | pub 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 | |
| 172 | pub 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 | |
| 184 | pub 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 | |
| 194 | pub struct PendingLogin { |
| 195 | pub user_id: String, |
| 196 | pub next: String, |
| 197 | } |
| 198 | |
| 199 | |
| 200 | pub 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 | |
| 207 | |
| 208 | pub 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 | |
| 230 | |
| 231 | pub 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 | |
| 249 | pub fn clear_pending_login(cx: &Cx) { |
| 250 | session_jar(cx).remove(Cookie::build((PENDING_2FA_COOKIE, "")).path("/")); |
| 251 | } |
| 252 | |
| 253 | |
| 254 | pub 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 | |
| 262 | |
| 263 | |
| 264 | pub 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 | |
| 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 | |
| 302 | |
| 303 | |
| 304 | |
| 305 | pub fn safe_next(next: Option<&str>) -> &str { |
| 306 | match next { |
| 307 | Some(n) if is_local_path(n) => n, |
| 308 | _ => "/", |
| 309 | } |
| 310 | } |
| 311 | |
| 312 | fn 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 | |
| 321 | pub 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 | |
| 335 | pub 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)] |
| 343 | mod 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 | |
| 412 | |
| 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 | |
| 443 | assert!( |
| 444 | verify_second_factor(&store, &key, &alice.id, "aaaa-bbbb") |
| 445 | .await |
| 446 | .unwrap() |
| 447 | ); |
| 448 | } |
| 449 | } |