request_auth.rs (25520B)
1 use std::{ 2 net::{IpAddr, SocketAddr}, 3 str::FromStr, 4 sync::Arc, 5 }; 6 7 use anyhow::{Context, Result, bail}; 8 use axum::http::{HeaderMap, Method, Uri, header, uri::Authority}; 9 use secrecy::{ExposeSecret, SecretString}; 10 11 use crate::{ 12 adapters::{config_store, wallet_store}, 13 domain::Wallet, 14 }; 15 16 use super::{ 17 AUTH_COOKIE_NAME, AUTH_LOCKOUT_MS, AUTH_MAX_FAILED_ATTEMPTS, AUTH_SESSION_TTL_MS, AuthSession, 18 HttpState, SETUP_COOKIE_NAME, SETUP_COOKIE_TTL_SECS, UNKNOWN_CLIENT_KEY, 19 auth::{hash_password, random_hex, session_token_hash, validate_password, verify_password}, 20 now_ms, 21 }; 22 23 pub(super) fn auth_exempt_path(path: &str) -> bool { 24 path == "/" 25 || path == "/favicon.ico" 26 || path == "/assets/alpine.min.js" 27 || path == "/assets/iuna-ui.js" 28 || path == "/api/auth/status" 29 || path == "/api/auth/setup" 30 || path == "/api/auth/login" 31 } 32 33 pub(super) fn csrf_required(method: &Method) -> bool { 34 !matches!(method, &Method::GET | &Method::HEAD | &Method::OPTIONS) 35 } 36 37 pub(super) fn same_origin_request(headers: &HeaderMap, socket_addr: Option<SocketAddr>) -> bool { 38 let Some(request_host) = request_host(headers, socket_addr) else { 39 return false; 40 }; 41 let Some(origin_host) = origin_or_referer_host(headers) else { 42 return false; 43 }; 44 normalize_host(&origin_host) == normalize_host(&request_host) 45 } 46 47 pub(super) fn local_setup_page_request( 48 headers: &HeaderMap, 49 socket_addr: Option<SocketAddr>, 50 management_port: u16, 51 ) -> bool { 52 socket_addr.is_some_and(|addr| addr.ip().is_loopback()) 53 && raw_request_local_authority(headers, management_port).is_some() 54 } 55 56 pub(super) fn local_setup_request( 57 headers: &HeaderMap, 58 socket_addr: Option<SocketAddr>, 59 management_port: u16, 60 ) -> bool { 61 if !local_setup_page_request(headers, socket_addr, management_port) { 62 return false; 63 } 64 let Some(request_authority) = raw_request_local_authority(headers, management_port) else { 65 return false; 66 }; 67 let Some(origin_authority) = local_origin_authority(headers, management_port) else { 68 return false; 69 }; 70 request_authority == origin_authority 71 } 72 73 fn raw_request_local_authority(headers: &HeaderMap, management_port: u16) -> Option<(String, u16)> { 74 let host = header_string(headers, "host")?; 75 parse_local_authority(&host, management_port) 76 } 77 78 fn local_origin_authority(headers: &HeaderMap, management_port: u16) -> Option<(String, u16)> { 79 let value = header_string(headers, "origin").or_else(|| header_string(headers, "referer"))?; 80 let uri = Uri::from_str(&value).ok()?; 81 if uri.scheme_str() != Some("http") { 82 return None; 83 } 84 parse_local_authority(uri.authority()?.as_str(), management_port) 85 } 86 87 fn parse_local_authority(value: &str, management_port: u16) -> Option<(String, u16)> { 88 let authority = Authority::from_str(value.trim()).ok()?; 89 let port = authority.port_u16()?; 90 if port != management_port { 91 return None; 92 } 93 let host = authority 94 .host() 95 .trim_matches(['[', ']']) 96 .trim_end_matches('.') 97 .to_ascii_lowercase(); 98 if host == "localhost" { 99 return Some((host, port)); 100 } 101 let ip = IpAddr::from_str(&host).ok()?; 102 ip.is_loopback().then(|| (ip.to_string(), port)) 103 } 104 105 fn request_host(headers: &HeaderMap, socket_addr: Option<SocketAddr>) -> Option<String> { 106 let forwarded_host = socket_addr 107 .filter(|addr| trusted_forwarding_peer(addr.ip())) 108 .and_then(|_| header_string(headers, "x-forwarded-host")); 109 forwarded_host.or_else(|| header_string(headers, "host")) 110 } 111 112 fn origin_or_referer_host(headers: &HeaderMap) -> Option<String> { 113 header_string(headers, "origin") 114 .and_then(|origin| url_host(&origin)) 115 .or_else(|| header_string(headers, "referer").and_then(|referer| url_host(&referer))) 116 } 117 118 fn header_string(headers: &HeaderMap, name: &'static str) -> Option<String> { 119 headers 120 .get(name) 121 .and_then(|value| value.to_str().ok()) 122 .map(str::trim) 123 .filter(|value| !value.is_empty()) 124 .map(ToOwned::to_owned) 125 } 126 127 fn url_host(value: &str) -> Option<String> { 128 let (_, rest) = value.split_once("://")?; 129 rest.split(['/', '?', '#']) 130 .next() 131 .map(str::trim) 132 .filter(|authority| !authority.is_empty() && *authority != "null") 133 .map(|authority| { 134 authority 135 .rsplit('@') 136 .next() 137 .unwrap_or(authority) 138 .to_string() 139 }) 140 } 141 142 fn normalize_host(host: &str) -> String { 143 host.trim().trim_end_matches('.').to_ascii_lowercase() 144 } 145 146 pub(super) async fn request_is_authenticated(state: &HttpState, headers: &HeaderMap) -> bool { 147 let Some(token) = auth_cookie(headers) else { 148 return false; 149 }; 150 let token_hash = session_token_hash(token); 151 let now = now_ms(); 152 let mut sessions = state.auth_sessions.lock().await; 153 sessions.retain(|_, session| session.expires_at > now); 154 sessions 155 .get(&token_hash) 156 .is_some_and(|session| session.expires_at > now) 157 } 158 159 pub(super) async fn setup_capability_cookie(state: &HttpState) -> Option<String> { 160 state.setup_capability.lock().await.as_ref().map(|token| { 161 format!( 162 "{SETUP_COOKIE_NAME}={}; Path=/api/auth/setup; HttpOnly; SameSite=Strict; Max-Age={SETUP_COOKIE_TTL_SECS}", 163 token.expose_secret() 164 ) 165 }) 166 } 167 168 pub(super) async fn validate_setup_capability( 169 state: &HttpState, 170 headers: &HeaderMap, 171 ) -> Result<()> { 172 let supplied = named_cookie(headers, SETUP_COOKIE_NAME) 173 .context("local password setup capability is required")?; 174 let capability = state.setup_capability.lock().await; 175 let expected = capability 176 .as_ref() 177 .context("local password setup capability is no longer available")?; 178 if session_token_hash(supplied) != session_token_hash(expected.expose_secret()) { 179 bail!("local password setup capability is invalid"); 180 } 181 Ok(()) 182 } 183 184 pub(super) async fn consume_setup_capability(state: &HttpState) { 185 state.setup_capability.lock().await.take(); 186 } 187 188 pub(super) async fn wallet_password_for_request( 189 state: &HttpState, 190 headers: &HeaderMap, 191 ) -> Option<Arc<SecretString>> { 192 let token = auth_cookie(headers)?; 193 let token_hash = session_token_hash(token); 194 let now = now_ms(); 195 let mut sessions = state.auth_sessions.lock().await; 196 sessions.retain(|_, session| session.expires_at > now); 197 sessions 198 .get(&token_hash) 199 .filter(|session| session.expires_at > now) 200 .map(|session| session.wallet_password.clone()) 201 } 202 203 pub(super) fn auth_client_key(headers: &HeaderMap, socket_addr: Option<SocketAddr>) -> String { 204 if let Some(addr) = socket_addr { 205 if !trusted_forwarding_peer(addr.ip()) { 206 return addr.ip().to_string(); 207 } 208 } 209 forwarded_for_client(headers) 210 .or_else(|| header_string(headers, "x-real-ip")) 211 .or_else(|| forwarded_header_client(headers)) 212 .or_else(|| socket_addr.map(|addr| addr.ip().to_string())) 213 .unwrap_or_else(|| UNKNOWN_CLIENT_KEY.to_string()) 214 } 215 216 fn trusted_forwarding_peer(ip: std::net::IpAddr) -> bool { 217 ip.is_loopback() 218 } 219 220 fn forwarded_for_client(headers: &HeaderMap) -> Option<String> { 221 header_string(headers, "x-forwarded-for").and_then(|value| { 222 value 223 .split(',') 224 .next() 225 .map(str::trim) 226 .filter(|client| !client.is_empty()) 227 .map(ToOwned::to_owned) 228 }) 229 } 230 231 fn forwarded_header_client(headers: &HeaderMap) -> Option<String> { 232 let value = header_string(headers, "forwarded")?; 233 for item in value.split(';') { 234 let Some((name, value)) = item.split_once('=') else { 235 continue; 236 }; 237 if name.trim().eq_ignore_ascii_case("for") { 238 return Some( 239 value 240 .trim() 241 .trim_matches('"') 242 .trim_matches('[') 243 .trim_matches(']') 244 .to_string(), 245 ) 246 .filter(|client| !client.is_empty()); 247 } 248 } 249 None 250 } 251 252 pub(super) async fn setup_auth_password( 253 state: &HttpState, 254 password: SecretString, 255 client_key: &str, 256 ) -> Result<String> { 257 let exposed_password = password.expose_secret(); 258 check_auth_backoff(state, client_key).await?; 259 if let Err(error) = validate_password(exposed_password) { 260 record_auth_failure(state, client_key).await; 261 return Err(error); 262 } 263 let mut config = state.ui_config.lock().await; 264 if config.auth_password_hash.is_some() { 265 record_auth_failure(state, client_key).await; 266 bail!("authentication is already configured"); 267 } 268 config.auth_password_hash = Some(hash_password(exposed_password)?); 269 config_store::save(&state.config_path, &config)?; 270 drop(config); 271 wallet_store::encrypt_existing_with_password(&state.wallet_path, exposed_password)?; 272 let wallet = wallet_store::load_with_password(&state.wallet_path, exposed_password)?; 273 restore_node_wallet_from_store(state, wallet, Some(exposed_password)).await?; 274 clear_auth_backoff(state, client_key).await; 275 create_session_cookie(state, password).await 276 } 277 278 pub(super) async fn login_auth_password( 279 state: &HttpState, 280 password: SecretString, 281 client_key: &str, 282 ) -> Result<String> { 283 let exposed_password = password.expose_secret(); 284 check_auth_backoff(state, client_key).await?; 285 let hash = state 286 .ui_config 287 .lock() 288 .await 289 .auth_password_hash 290 .clone() 291 .context("authentication setup is required")?; 292 if !verify_password(exposed_password, &hash)? { 293 record_auth_failure(state, client_key).await; 294 bail!("invalid password"); 295 } 296 wallet_store::encrypt_existing_with_password(&state.wallet_path, exposed_password)?; 297 let wallet = wallet_store::load_with_password(&state.wallet_path, exposed_password)?; 298 restore_node_wallet_from_store(state, wallet, Some(exposed_password)).await?; 299 clear_auth_backoff(state, client_key).await; 300 create_session_cookie(state, password).await 301 } 302 303 pub(super) async fn change_auth_password( 304 state: &HttpState, 305 old_password: SecretString, 306 new_password: SecretString, 307 client_key: &str, 308 ) -> Result<String> { 309 let exposed_old_password = old_password.expose_secret(); 310 let exposed_new_password = new_password.expose_secret(); 311 check_auth_backoff(state, client_key).await?; 312 validate_password(exposed_new_password)?; 313 let current_hash = state 314 .ui_config 315 .lock() 316 .await 317 .auth_password_hash 318 .clone() 319 .context("authentication setup is required")?; 320 if !verify_password(exposed_old_password, ¤t_hash)? { 321 record_auth_failure(state, client_key).await; 322 bail!("invalid current password"); 323 } 324 let wallet = wallet_store::reencrypt_with_password( 325 &state.wallet_path, 326 exposed_old_password, 327 exposed_new_password, 328 )?; 329 { 330 let mut config = state.ui_config.lock().await; 331 config.auth_password_hash = Some(hash_password(exposed_new_password)?); 332 config_store::save(&state.config_path, &config)?; 333 } 334 restore_node_wallet_from_store(state, wallet, Some(exposed_new_password)).await?; 335 state.auth_sessions.lock().await.clear(); 336 clear_auth_backoff(state, client_key).await; 337 create_session_cookie(state, new_password).await 338 } 339 340 pub(super) async fn restore_node_wallet_from_store( 341 state: &HttpState, 342 wallet: Wallet, 343 _password: Option<&str>, 344 ) -> Result<()> { 345 let mut node = state.node.lock().await; 346 node.replace_wallet(wallet); 347 Ok(()) 348 } 349 350 async fn check_auth_backoff(state: &HttpState, client_key: &str) -> Result<()> { 351 let now = now_ms(); 352 let mut backoffs = state.auth_backoff.lock().await; 353 let backoff = backoffs.entry(client_key.to_string()).or_default(); 354 if backoff 355 .locked_until_ms 356 .is_some_and(|locked_until| locked_until > now) 357 { 358 bail!("too many failed login attempts; try again later"); 359 } 360 if backoff.locked_until_ms.is_some() { 361 backoff.locked_until_ms = None; 362 backoff.failed_attempts = 0; 363 } 364 Ok(()) 365 } 366 367 async fn record_auth_failure(state: &HttpState, client_key: &str) { 368 let mut backoffs = state.auth_backoff.lock().await; 369 let backoff = backoffs.entry(client_key.to_string()).or_default(); 370 backoff.failed_attempts = backoff.failed_attempts.saturating_add(1); 371 if backoff.failed_attempts >= AUTH_MAX_FAILED_ATTEMPTS { 372 backoff.locked_until_ms = Some(now_ms().saturating_add(AUTH_LOCKOUT_MS)); 373 } 374 } 375 376 async fn clear_auth_backoff(state: &HttpState, client_key: &str) { 377 state.auth_backoff.lock().await.remove(client_key); 378 } 379 380 async fn create_session_cookie(state: &HttpState, password: SecretString) -> Result<String> { 381 let token = random_hex(32)?; 382 let token_hash = session_token_hash(token.expose_secret()); 383 let expires_at = now_ms().saturating_add(AUTH_SESSION_TTL_MS); 384 state.auth_sessions.lock().await.insert( 385 token_hash, 386 AuthSession { 387 expires_at, 388 wallet_password: Arc::new(password), 389 }, 390 ); 391 Ok(format!( 392 "{AUTH_COOKIE_NAME}={}; Path=/; HttpOnly; SameSite=Strict; Max-Age={}", 393 token.expose_secret(), 394 AUTH_SESSION_TTL_MS / 1000 395 )) 396 } 397 398 pub(super) fn auth_cookie(headers: &HeaderMap) -> Option<&str> { 399 named_cookie(headers, AUTH_COOKIE_NAME) 400 } 401 402 fn named_cookie<'a>(headers: &'a HeaderMap, expected_name: &str) -> Option<&'a str> { 403 let cookie = headers.get(header::COOKIE)?.to_str().ok()?; 404 cookie.split(';').find_map(|part| { 405 let (name, value) = part.trim().split_once('=')?; 406 (name == expected_name).then_some(value) 407 }) 408 } 409 410 #[cfg(test)] 411 mod tests { 412 use std::{ 413 collections::BTreeMap, 414 net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, 415 sync::Arc, 416 }; 417 418 use axum::http::{HeaderMap, HeaderValue, header}; 419 use tokio::sync::Mutex; 420 421 use crate::{ 422 adapters::{ 423 chain_store::SqliteChainStore, config_store::UiConfig, p2p::GossipNetwork, 424 ui_data_store::SqliteUiDataStore, 425 }, 426 app::{NodeCore, PeerBook, StratumStatus}, 427 domain::{GenesisBurn, Ledger, MICRO_IUNA, Wallet}, 428 }; 429 430 use super::super::state::{AuthSession, HttpState}; 431 use super::{AUTH_COOKIE_NAME, now_ms}; 432 use super::{ 433 auth_client_key, check_auth_backoff, consume_setup_capability, local_setup_page_request, 434 local_setup_request, record_auth_failure, request_is_authenticated, same_origin_request, 435 session_token_hash, setup_capability_cookie, validate_setup_capability, 436 }; 437 438 fn headers(values: &[(&'static str, &'static str)]) -> HeaderMap { 439 let mut headers = HeaderMap::new(); 440 for (name, value) in values { 441 headers.insert(*name, HeaderValue::from_static(value)); 442 } 443 headers 444 } 445 446 fn socket(ip: [u8; 4]) -> SocketAddr { 447 SocketAddr::new(IpAddr::V4(Ipv4Addr::from(ip)), 9444) 448 } 449 450 fn ipv6_loopback_socket() -> SocketAddr { 451 SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 9444) 452 } 453 454 async fn test_state() -> HttpState { 455 let dir = tempfile::tempdir().unwrap().keep(); 456 let wallet = Wallet::from_seed("http-auth-abuse-tests"); 457 let mut allocations = BTreeMap::new(); 458 allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA); 459 let ledger = Ledger::new_with_genesis_burns( 460 allocations, 461 vec![GenesisBurn::new(wallet.address(), 1)], 462 1, 463 ) 464 .unwrap(); 465 let node = Arc::new(Mutex::new(NodeCore::from_ledger(wallet, ledger, 0))); 466 let peers = Arc::new(Mutex::new(PeerBook::default())); 467 let gossip = GossipNetwork::start( 468 node.clone(), 469 peers.clone(), 470 socket([127, 0, 0, 1]), 471 None, 472 false, 473 ) 474 .await 475 .unwrap(); 476 477 HttpState { 478 node, 479 peers, 480 gossip, 481 ui_config: Arc::new(Mutex::new(UiConfig::default())), 482 config_path: dir.join("config.json"), 483 chain_store: SqliteChainStore::open(dir.join("chain.sqlite")).unwrap(), 484 ui_data_store: SqliteUiDataStore::open(dir.join("ui.sqlite")).unwrap(), 485 wallet_path: dir.join("wallet.json"), 486 stratum: StratumStatus { 487 enabled: false, 488 listen_addr: None, 489 }, 490 auth_sessions: Arc::new(Mutex::new(BTreeMap::new())), 491 auth_backoff: Arc::new(Mutex::new(BTreeMap::new())), 492 setup_capability: Arc::new(Mutex::new(Some("test-setup-capability".into()))), 493 management_port: 9444, 494 wallet_endpoint_addr: None, 495 } 496 } 497 498 #[test] 499 fn csrf_same_origin_requires_matching_origin_or_referer_host() { 500 assert!(same_origin_request( 501 &headers(&[ 502 ("host", "127.0.0.1:9444"), 503 ("origin", "http://127.0.0.1:9444") 504 ]), 505 None 506 )); 507 assert!(same_origin_request( 508 &headers(&[ 509 ("host", "iuna.local:9444"), 510 ("referer", "http://iuna.local:9444/settings") 511 ]), 512 None 513 )); 514 assert!(!same_origin_request( 515 &headers(&[ 516 ("host", "127.0.0.1:9444"), 517 ("origin", "https://evil.example") 518 ]), 519 None 520 )); 521 assert!(!same_origin_request( 522 &headers(&[("host", "127.0.0.1:9444")]), 523 None 524 )); 525 } 526 527 #[test] 528 fn local_setup_accepts_only_exact_loopback_management_origins() { 529 assert!(local_setup_page_request( 530 &headers(&[("host", "127.0.0.1:9444")]), 531 Some(socket([127, 0, 0, 1])), 532 9444 533 )); 534 assert!(local_setup_request( 535 &headers(&[ 536 ("host", "127.0.0.1:9444"), 537 ("origin", "http://127.0.0.1:9444") 538 ]), 539 Some(socket([127, 0, 0, 1])), 540 9444 541 )); 542 assert!(local_setup_request( 543 &headers(&[ 544 ("host", "localhost:9444"), 545 ("referer", "http://localhost:9444/setup") 546 ]), 547 Some(socket([127, 0, 0, 1])), 548 9444 549 )); 550 assert!(local_setup_request( 551 &headers(&[("host", "[::1]:9444"), ("origin", "http://[::1]:9444")]), 552 Some(ipv6_loopback_socket()), 553 9444 554 )); 555 } 556 557 #[test] 558 fn local_setup_rejects_dns_rebinding_and_remote_requests() { 559 assert!(!local_setup_request( 560 &headers(&[ 561 ("host", "rebound.evil:9444"), 562 ("origin", "http://rebound.evil:9444") 563 ]), 564 Some(socket([127, 0, 0, 1])), 565 9444 566 )); 567 assert!(!local_setup_request( 568 &headers(&[ 569 ("host", "127.0.0.1:9444"), 570 ("origin", "http://localhost:9444") 571 ]), 572 Some(socket([127, 0, 0, 1])), 573 9444 574 )); 575 assert!(!local_setup_request( 576 &headers(&[ 577 ("host", "127.0.0.1:9444"), 578 ("origin", "https://127.0.0.1:9444") 579 ]), 580 Some(socket([127, 0, 0, 1])), 581 9444 582 )); 583 assert!(!local_setup_request( 584 &headers(&[ 585 ("host", "127.0.0.1:9444"), 586 ("origin", "http://127.0.0.1:9444") 587 ]), 588 Some(socket([203, 0, 113, 10])), 589 9444 590 )); 591 assert!(!local_setup_request( 592 &headers(&[ 593 ("host", "127.0.0.1:9555"), 594 ("origin", "http://127.0.0.1:9555") 595 ]), 596 Some(socket([127, 0, 0, 1])), 597 9444 598 )); 599 assert!(!local_setup_request( 600 &headers(&[ 601 ("host", "rebound.evil:9444"), 602 ("x-forwarded-host", "127.0.0.1:9444"), 603 ("origin", "http://127.0.0.1:9444") 604 ]), 605 Some(socket([127, 0, 0, 1])), 606 9444 607 )); 608 } 609 610 #[tokio::test] 611 async fn setup_capability_cookie_is_required_and_one_time() { 612 let state = test_state().await; 613 let cookie = setup_capability_cookie(&state).await.unwrap(); 614 assert!(cookie.starts_with("iuna_setup=test-setup-capability;")); 615 assert!(cookie.contains("Path=/api/auth/setup")); 616 assert!(cookie.contains("HttpOnly")); 617 assert!(cookie.contains("SameSite=Strict")); 618 619 assert!( 620 validate_setup_capability(&state, &HeaderMap::new()) 621 .await 622 .is_err() 623 ); 624 assert!( 625 validate_setup_capability( 626 &state, 627 &headers(&[("cookie", "iuna_setup=wrong-capability")]) 628 ) 629 .await 630 .is_err() 631 ); 632 assert!( 633 validate_setup_capability( 634 &state, 635 &headers(&[("cookie", "other=value; iuna_setup=test-setup-capability")]) 636 ) 637 .await 638 .is_ok() 639 ); 640 641 consume_setup_capability(&state).await; 642 assert!( 643 validate_setup_capability( 644 &state, 645 &headers(&[("cookie", "iuna_setup=test-setup-capability")]) 646 ) 647 .await 648 .is_err() 649 ); 650 assert!(setup_capability_cookie(&state).await.is_none()); 651 } 652 653 #[test] 654 fn csrf_ignores_forwarded_host_from_untrusted_peer() { 655 let headers = headers(&[ 656 ("host", "127.0.0.1:9444"), 657 ("x-forwarded-host", "evil.example"), 658 ("origin", "https://evil.example"), 659 ]); 660 661 assert!(!same_origin_request( 662 &headers, 663 Some(socket([203, 0, 113, 10])) 664 )); 665 assert!(!same_origin_request( 666 &headers, 667 Some(socket([192, 168, 1, 10])) 668 )); 669 } 670 671 #[test] 672 fn csrf_accepts_forwarded_host_from_trusted_proxy() { 673 let headers = headers(&[ 674 ("host", "127.0.0.1:9444"), 675 ("x-forwarded-host", "iuna.example"), 676 ("origin", "https://iuna.example"), 677 ]); 678 679 assert!(same_origin_request(&headers, Some(socket([127, 0, 0, 1])))); 680 } 681 682 #[test] 683 fn auth_client_key_ignores_forwarded_client_from_untrusted_peer() { 684 let headers = headers(&[ 685 ("x-forwarded-for", "198.51.100.50"), 686 ("x-real-ip", "198.51.100.51"), 687 ("forwarded", "for=198.51.100.52"), 688 ]); 689 690 assert_eq!( 691 auth_client_key(&headers, Some(socket([203, 0, 113, 10]))), 692 "203.0.113.10" 693 ); 694 assert_eq!( 695 auth_client_key(&headers, Some(socket([192, 168, 1, 10]))), 696 "192.168.1.10" 697 ); 698 assert_eq!( 699 auth_client_key(&headers, Some(socket([127, 0, 0, 1]))), 700 "198.51.100.50" 701 ); 702 } 703 704 #[tokio::test] 705 async fn auth_backoff_locks_out_and_resets_after_expiry() { 706 let state = test_state().await; 707 let client_key = "client-a"; 708 for _ in 0..super::AUTH_MAX_FAILED_ATTEMPTS { 709 record_auth_failure(&state, client_key).await; 710 } 711 712 assert!(check_auth_backoff(&state, client_key).await.is_err()); 713 714 { 715 let mut backoffs = state.auth_backoff.lock().await; 716 let backoff = backoffs.get_mut(client_key).unwrap(); 717 backoff.locked_until_ms = Some(now_ms().saturating_sub(1)); 718 } 719 720 assert!(check_auth_backoff(&state, client_key).await.is_ok()); 721 let backoffs = state.auth_backoff.lock().await; 722 let backoff = backoffs.get(client_key).unwrap(); 723 assert_eq!(backoff.failed_attempts, 0); 724 assert_eq!(backoff.locked_until_ms, None); 725 } 726 727 #[tokio::test] 728 async fn expired_sessions_are_rejected_and_pruned() { 729 let state = test_state().await; 730 let expired_token = "expired"; 731 let live_token = "live"; 732 state.auth_sessions.lock().await.insert( 733 session_token_hash(expired_token), 734 AuthSession { 735 expires_at: now_ms().saturating_sub(1), 736 wallet_password: Arc::new("expired-password".into()), 737 }, 738 ); 739 state.auth_sessions.lock().await.insert( 740 session_token_hash(live_token), 741 AuthSession { 742 expires_at: now_ms().saturating_add(60_000), 743 wallet_password: Arc::new("live-password".into()), 744 }, 745 ); 746 747 let expired_headers = headers(&[(header::COOKIE.as_str(), "iuna_session=expired")]); 748 assert!(!request_is_authenticated(&state, &expired_headers).await); 749 assert!( 750 !state 751 .auth_sessions 752 .lock() 753 .await 754 .contains_key(&session_token_hash(expired_token)) 755 ); 756 757 let live_cookie = format!("{AUTH_COOKIE_NAME}={live_token}"); 758 let mut live_headers = HeaderMap::new(); 759 live_headers.insert(header::COOKIE, HeaderValue::from_str(&live_cookie).unwrap()); 760 assert!(request_is_authenticated(&state, &live_headers).await); 761 } 762 }