auth_routes.rs (17124B)
1 use std::net::SocketAddr; 2 3 use axum::{ 4 Form, Json, 5 body::Body, 6 extract::{ConnectInfo, Extension, State}, 7 http::{HeaderMap, Request, StatusCode, header}, 8 middleware::Next, 9 response::{IntoResponse, Response}, 10 }; 11 12 use super::{ 13 AUTH_COOKIE_NAME, ActionResponse, AuthClientKey, AuthForm, AuthStatusResponse, 14 ChangePasswordForm, HttpState, SETUP_COOKIE_NAME, action_json, 15 auth::session_token_hash, 16 request_auth::{ 17 auth_client_key, auth_cookie, auth_exempt_path, change_auth_password, 18 consume_setup_capability, csrf_required, local_setup_page_request, local_setup_request, 19 login_auth_password, request_is_authenticated, same_origin_request, setup_auth_password, 20 setup_capability_cookie, validate_setup_capability, 21 }, 22 }; 23 24 pub(super) async fn require_auth_middleware( 25 State(state): State<HttpState>, 26 headers: HeaderMap, 27 mut request: Request<Body>, 28 next: Next, 29 ) -> Response { 30 let path = request.uri().path().to_string(); 31 let socket_addr = request 32 .extensions() 33 .get::<ConnectInfo<SocketAddr>>() 34 .map(|info| info.0); 35 if path == "/api/auth/setup" 36 && !local_setup_request(&headers, socket_addr, state.management_port) 37 { 38 return setup_origin_error().into_response(); 39 } 40 if csrf_required(request.method()) && !same_origin_request(&headers, socket_addr) { 41 return csrf_error().into_response(); 42 } 43 let issue_setup_cookie = path == "/" 44 && request.method() == axum::http::Method::GET 45 && local_setup_page_request(&headers, socket_addr, state.management_port) 46 && state.ui_config.lock().await.auth_password_hash.is_none(); 47 let client_key = auth_client_key(&headers, socket_addr); 48 request.extensions_mut().insert(AuthClientKey(client_key)); 49 if auth_exempt_path(&path) { 50 let mut response = next.run(request).await; 51 if issue_setup_cookie { 52 if let Some(cookie) = setup_capability_cookie(&state).await { 53 if let Ok(value) = cookie.parse() { 54 response.headers_mut().append(header::SET_COOKIE, value); 55 } 56 } 57 } 58 return response; 59 } 60 let configured = state.ui_config.lock().await.auth_password_hash.is_some(); 61 if !configured { 62 return auth_error("authentication setup is required").into_response(); 63 } 64 if request_is_authenticated(&state, &headers).await { 65 return next.run(request).await; 66 } 67 auth_error("authentication required").into_response() 68 } 69 70 pub(super) async fn api_auth_status( 71 State(state): State<HttpState>, 72 headers: HeaderMap, 73 ) -> Json<AuthStatusResponse> { 74 let configured = state.ui_config.lock().await.auth_password_hash.is_some(); 75 let authenticated = configured && request_is_authenticated(&state, &headers).await; 76 Json(AuthStatusResponse { 77 configured, 78 authenticated, 79 }) 80 } 81 82 pub(super) async fn api_auth_setup_form( 83 State(state): State<HttpState>, 84 Extension(client_key): Extension<AuthClientKey>, 85 headers: HeaderMap, 86 Form(form): Form<AuthForm>, 87 ) -> Response { 88 if let Err(error) = validate_setup_capability(&state, &headers).await { 89 return (StatusCode::FORBIDDEN, action_json(Err(error))).into_response(); 90 } 91 match setup_auth_password(&state, form.password, &client_key.0).await { 92 Ok(cookie) => { 93 consume_setup_capability(&state).await; 94 let mut response = action_json(Ok(())).into_response(); 95 if let Ok(value) = cookie.parse() { 96 response.headers_mut().append(header::SET_COOKIE, value); 97 } 98 if let Ok(value) = format!( 99 "{SETUP_COOKIE_NAME}=; Path=/api/auth/setup; HttpOnly; SameSite=Strict; Max-Age=0" 100 ) 101 .parse() 102 { 103 response.headers_mut().append(header::SET_COOKIE, value); 104 } 105 response 106 } 107 Err(error) => action_json(Err(error)).into_response(), 108 } 109 } 110 111 pub(super) async fn api_auth_login_form( 112 State(state): State<HttpState>, 113 Extension(client_key): Extension<AuthClientKey>, 114 Form(form): Form<AuthForm>, 115 ) -> Response { 116 match login_auth_password(&state, form.password, &client_key.0).await { 117 Ok(cookie) => ([(header::SET_COOKIE, cookie)], action_json(Ok(()))).into_response(), 118 Err(error) => action_json(Err(error)).into_response(), 119 } 120 } 121 122 pub(super) async fn api_auth_logout_form( 123 State(state): State<HttpState>, 124 headers: HeaderMap, 125 ) -> Response { 126 if let Some(token) = auth_cookie(&headers) { 127 state 128 .auth_sessions 129 .lock() 130 .await 131 .remove(&session_token_hash(token)); 132 } 133 ( 134 [( 135 header::SET_COOKIE, 136 format!("{AUTH_COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"), 137 )], 138 action_json(Ok(())), 139 ) 140 .into_response() 141 } 142 143 pub(super) async fn api_auth_change_password_form( 144 State(state): State<HttpState>, 145 Extension(client_key): Extension<AuthClientKey>, 146 Form(form): Form<ChangePasswordForm>, 147 ) -> Response { 148 match change_auth_password(&state, form.old_password, form.new_password, &client_key.0).await { 149 Ok(cookie) => ([(header::SET_COOKIE, cookie)], action_json(Ok(()))).into_response(), 150 Err(error) => action_json(Err(error)).into_response(), 151 } 152 } 153 154 fn auth_error(message: &str) -> (StatusCode, Json<ActionResponse>) { 155 ( 156 StatusCode::UNAUTHORIZED, 157 Json(ActionResponse { 158 ok: false, 159 error: Some(message.to_string()), 160 }), 161 ) 162 } 163 164 fn csrf_error() -> (StatusCode, Json<ActionResponse>) { 165 ( 166 StatusCode::FORBIDDEN, 167 Json(ActionResponse { 168 ok: false, 169 error: Some("same-origin request required".to_string()), 170 }), 171 ) 172 } 173 174 fn setup_origin_error() -> (StatusCode, Json<ActionResponse>) { 175 ( 176 StatusCode::FORBIDDEN, 177 Json(ActionResponse { 178 ok: false, 179 error: Some( 180 "password setup is only available from the local management origin".to_string(), 181 ), 182 }), 183 ) 184 } 185 186 #[cfg(test)] 187 mod tests { 188 use std::{ 189 collections::BTreeMap, 190 net::{IpAddr, Ipv4Addr, SocketAddr}, 191 sync::Arc, 192 }; 193 194 use axum::{ 195 Router, 196 body::{Body, to_bytes}, 197 extract::ConnectInfo, 198 http::{Method, Request, StatusCode, header}, 199 middleware, 200 routing::{get, post}, 201 }; 202 use tokio::sync::Mutex; 203 use tower::ServiceExt; 204 205 use crate::{ 206 adapters::{ 207 chain_store::SqliteChainStore, config_store::UiConfig, p2p::GossipNetwork, 208 ui_data_store::SqliteUiDataStore, wallet_store, 209 }, 210 app::{NodeCore, PeerBook, StratumStatus}, 211 domain::{GenesisBurn, Ledger, MICRO_IUNA}, 212 }; 213 214 use super::super::state::{AuthBackoff, AuthSession, HttpState}; 215 use super::{api_auth_setup_form, api_auth_status, require_auth_middleware}; 216 217 const MANAGEMENT_PORT: u16 = 18_661; 218 const SETUP_CAPABILITY: &str = "test-setup-capability"; 219 const TEST_SEED: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"; 220 221 fn loopback_peer() -> SocketAddr { 222 SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 51_234) 223 } 224 225 fn remote_peer() -> SocketAddr { 226 SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 10)), 51_234) 227 } 228 229 fn request( 230 method: Method, 231 uri: &str, 232 headers: &[(&str, &str)], 233 body: &str, 234 peer: SocketAddr, 235 ) -> Request<Body> { 236 let mut builder = Request::builder().method(method).uri(uri); 237 for (name, value) in headers { 238 builder = builder.header(*name, *value); 239 } 240 let mut request = builder.body(Body::from(body.to_string())).unwrap(); 241 request.extensions_mut().insert(ConnectInfo(peer)); 242 request 243 } 244 245 async fn test_state() -> HttpState { 246 let dir = tempfile::tempdir().unwrap().keep(); 247 let wallet_path = dir.join("wallet.json"); 248 let wallet = 249 wallet_store::replace_with_imported_seed_phrase(&wallet_path, TEST_SEED).unwrap(); 250 let mut allocations = BTreeMap::new(); 251 allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA); 252 let ledger = Ledger::new_with_genesis_burns( 253 allocations, 254 vec![GenesisBurn::new(wallet.address(), 1)], 255 1, 256 ) 257 .unwrap(); 258 let node = Arc::new(Mutex::new(NodeCore::from_ledger(wallet, ledger, 0))); 259 let peers = Arc::new(Mutex::new(PeerBook::default())); 260 let gossip = GossipNetwork::start( 261 node.clone(), 262 peers.clone(), 263 SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0), 264 None, 265 false, 266 ) 267 .await 268 .unwrap(); 269 270 HttpState { 271 node, 272 peers, 273 gossip, 274 ui_config: Arc::new(Mutex::new(UiConfig::default())), 275 config_path: dir.join("config.json"), 276 chain_store: SqliteChainStore::open(dir.join("chain.sqlite")).unwrap(), 277 ui_data_store: SqliteUiDataStore::open(dir.join("ui.sqlite")).unwrap(), 278 wallet_path, 279 stratum: StratumStatus { 280 enabled: false, 281 listen_addr: None, 282 }, 283 auth_sessions: Arc::new(Mutex::new(BTreeMap::<String, AuthSession>::new())), 284 auth_backoff: Arc::new(Mutex::new(BTreeMap::<String, AuthBackoff>::new())), 285 setup_capability: Arc::new(Mutex::new(Some(SETUP_CAPABILITY.into()))), 286 management_port: MANAGEMENT_PORT, 287 wallet_endpoint_addr: None, 288 } 289 } 290 291 fn test_app(state: HttpState) -> Router { 292 Router::new() 293 .route("/", get(|| async { "index" })) 294 .route("/api/auth/setup", post(api_auth_setup_form)) 295 .route("/api/auth/status", get(api_auth_status)) 296 .layer(middleware::from_fn_with_state( 297 state.clone(), 298 require_auth_middleware, 299 )) 300 .with_state(state) 301 } 302 303 #[tokio::test] 304 async fn setup_route_requires_local_origin_and_one_time_capability() { 305 let state = test_state().await; 306 let app = test_app(state.clone()); 307 let local_origin = format!("http://127.0.0.1:{MANAGEMENT_PORT}"); 308 let local_host = format!("127.0.0.1:{MANAGEMENT_PORT}"); 309 310 let page = app 311 .clone() 312 .oneshot(request( 313 Method::GET, 314 "/", 315 &[(header::HOST.as_str(), &local_host)], 316 "", 317 loopback_peer(), 318 )) 319 .await 320 .unwrap(); 321 assert_eq!(page.status(), StatusCode::OK); 322 let setup_cookie = page 323 .headers() 324 .get_all(header::SET_COOKIE) 325 .iter() 326 .filter_map(|value| value.to_str().ok()) 327 .find(|value| value.starts_with("iuna_setup=")) 328 .and_then(|value| value.split(';').next()) 329 .unwrap() 330 .to_string(); 331 332 let rebound = app 333 .clone() 334 .oneshot(request( 335 Method::POST, 336 "/api/auth/setup", 337 &[ 338 (header::HOST.as_str(), "rebound.evil:18661"), 339 (header::ORIGIN.as_str(), "http://rebound.evil:18661"), 340 ( 341 header::CONTENT_TYPE.as_str(), 342 "application/x-www-form-urlencoded", 343 ), 344 (header::COOKIE.as_str(), &setup_cookie), 345 ], 346 "password=correct-horse-battery-staple", 347 loopback_peer(), 348 )) 349 .await 350 .unwrap(); 351 assert_eq!(rebound.status(), StatusCode::FORBIDDEN); 352 assert!(state.ui_config.lock().await.auth_password_hash.is_none()); 353 354 let remote = app 355 .clone() 356 .oneshot(request( 357 Method::POST, 358 "/api/auth/setup", 359 &[ 360 (header::HOST.as_str(), &local_host), 361 (header::ORIGIN.as_str(), &local_origin), 362 ( 363 header::CONTENT_TYPE.as_str(), 364 "application/x-www-form-urlencoded", 365 ), 366 (header::COOKIE.as_str(), &setup_cookie), 367 ], 368 "password=correct-horse-battery-staple", 369 remote_peer(), 370 )) 371 .await 372 .unwrap(); 373 assert_eq!(remote.status(), StatusCode::FORBIDDEN); 374 375 let missing_capability = app 376 .clone() 377 .oneshot(request( 378 Method::POST, 379 "/api/auth/setup", 380 &[ 381 (header::HOST.as_str(), &local_host), 382 (header::ORIGIN.as_str(), &local_origin), 383 ( 384 header::CONTENT_TYPE.as_str(), 385 "application/x-www-form-urlencoded", 386 ), 387 ], 388 "password=correct-horse-battery-staple", 389 loopback_peer(), 390 )) 391 .await 392 .unwrap(); 393 assert_eq!(missing_capability.status(), StatusCode::FORBIDDEN); 394 395 let success = app 396 .clone() 397 .oneshot(request( 398 Method::POST, 399 "/api/auth/setup", 400 &[ 401 (header::HOST.as_str(), &local_host), 402 (header::ORIGIN.as_str(), &local_origin), 403 ( 404 header::CONTENT_TYPE.as_str(), 405 "application/x-www-form-urlencoded", 406 ), 407 (header::COOKIE.as_str(), &setup_cookie), 408 ], 409 "password=correct-horse-battery-staple", 410 loopback_peer(), 411 )) 412 .await 413 .unwrap(); 414 assert_eq!(success.status(), StatusCode::OK); 415 let response_cookies = success 416 .headers() 417 .get_all(header::SET_COOKIE) 418 .iter() 419 .map(|value| value.to_str().unwrap().to_string()) 420 .collect::<Vec<_>>(); 421 assert!( 422 response_cookies 423 .iter() 424 .any(|cookie| cookie.starts_with("iuna_session=")) 425 ); 426 assert!( 427 response_cookies.iter().any(|cookie| { 428 cookie.starts_with("iuna_setup=") && cookie.contains("Max-Age=0") 429 }) 430 ); 431 let success_body = to_bytes(success.into_body(), 16 * 1024).await.unwrap(); 432 let success_body = String::from_utf8(success_body.to_vec()).unwrap(); 433 assert!(success_body.contains("\"ok\":true")); 434 assert!(!success_body.contains("abandon")); 435 assert!(state.ui_config.lock().await.auth_password_hash.is_some()); 436 assert!( 437 wallet_store::metadata(&state.wallet_path) 438 .unwrap() 439 .unwrap() 440 .encrypted 441 ); 442 assert!(state.setup_capability.lock().await.is_none()); 443 444 let session_cookie = response_cookies 445 .iter() 446 .find(|cookie| cookie.starts_with("iuna_session=")) 447 .and_then(|cookie| cookie.split(';').next()) 448 .unwrap(); 449 let status = app 450 .clone() 451 .oneshot(request( 452 Method::GET, 453 "/api/auth/status", 454 &[ 455 (header::HOST.as_str(), &local_host), 456 (header::COOKIE.as_str(), session_cookie), 457 ], 458 "", 459 loopback_peer(), 460 )) 461 .await 462 .unwrap(); 463 assert_eq!(status.status(), StatusCode::OK); 464 let status_body = to_bytes(status.into_body(), 16 * 1024).await.unwrap(); 465 let status_body = String::from_utf8(status_body.to_vec()).unwrap(); 466 assert!(status_body.contains("\"configured\":true")); 467 assert!(status_body.contains("\"authenticated\":true")); 468 469 let replay = app 470 .oneshot(request( 471 Method::POST, 472 "/api/auth/setup", 473 &[ 474 (header::HOST.as_str(), &local_host), 475 (header::ORIGIN.as_str(), &local_origin), 476 ( 477 header::CONTENT_TYPE.as_str(), 478 "application/x-www-form-urlencoded", 479 ), 480 (header::COOKIE.as_str(), &setup_cookie), 481 ], 482 "password=correct-horse-battery-staple", 483 loopback_peer(), 484 )) 485 .await 486 .unwrap(); 487 assert_eq!(replay.status(), StatusCode::FORBIDDEN); 488 } 489 }