request_auth.rs (10620B)
1 use std::net::SocketAddr; 2 3 use anyhow::{Context, Result, bail}; 4 use axum::http::{HeaderMap, Method, header}; 5 6 use crate::{ 7 adapters::{config_store, wallet_store}, 8 domain::Wallet, 9 }; 10 11 use super::{ 12 AUTH_COOKIE_NAME, AUTH_LOCKOUT_MS, AUTH_MAX_FAILED_ATTEMPTS, AUTH_SESSION_TTL_MS, AuthSession, 13 HttpState, UNKNOWN_CLIENT_KEY, 14 auth::{hash_password, random_hex, session_token_hash, validate_password, verify_password}, 15 now_ms, 16 }; 17 18 pub(super) fn auth_exempt_path(path: &str) -> bool { 19 path == "/" 20 || path == "/favicon.ico" 21 || path == "/assets/alpine.min.js" 22 || path == "/assets/iuna-ui.js" 23 || path == "/api/auth/status" 24 || path == "/api/auth/setup" 25 || path == "/api/auth/login" 26 } 27 28 pub(super) fn csrf_required(method: &Method) -> bool { 29 !matches!(method, &Method::GET | &Method::HEAD | &Method::OPTIONS) 30 } 31 32 pub(super) fn same_origin_request(headers: &HeaderMap) -> bool { 33 let Some(request_host) = request_host(headers) else { 34 return false; 35 }; 36 let Some(origin_host) = origin_or_referer_host(headers) else { 37 return false; 38 }; 39 normalize_host(&origin_host) == normalize_host(&request_host) 40 } 41 42 fn request_host(headers: &HeaderMap) -> Option<String> { 43 header_string(headers, "x-forwarded-host").or_else(|| header_string(headers, "host")) 44 } 45 46 fn origin_or_referer_host(headers: &HeaderMap) -> Option<String> { 47 header_string(headers, "origin") 48 .and_then(|origin| url_host(&origin)) 49 .or_else(|| header_string(headers, "referer").and_then(|referer| url_host(&referer))) 50 } 51 52 fn header_string(headers: &HeaderMap, name: &'static str) -> Option<String> { 53 headers 54 .get(name) 55 .and_then(|value| value.to_str().ok()) 56 .map(str::trim) 57 .filter(|value| !value.is_empty()) 58 .map(ToOwned::to_owned) 59 } 60 61 fn url_host(value: &str) -> Option<String> { 62 let (_, rest) = value.split_once("://")?; 63 rest.split(['/', '?', '#']) 64 .next() 65 .map(str::trim) 66 .filter(|authority| !authority.is_empty() && *authority != "null") 67 .map(|authority| { 68 authority 69 .rsplit('@') 70 .next() 71 .unwrap_or(authority) 72 .to_string() 73 }) 74 } 75 76 fn normalize_host(host: &str) -> String { 77 host.trim().trim_end_matches('.').to_ascii_lowercase() 78 } 79 80 pub(super) async fn request_is_authenticated(state: &HttpState, headers: &HeaderMap) -> bool { 81 let Some(token) = auth_cookie(headers) else { 82 return false; 83 }; 84 let token_hash = session_token_hash(token); 85 let now = now_ms(); 86 let mut sessions = state.auth_sessions.lock().await; 87 sessions.retain(|_, session| session.expires_at > now); 88 sessions 89 .get(&token_hash) 90 .is_some_and(|session| session.expires_at > now) 91 } 92 93 pub(super) async fn wallet_password_for_request( 94 state: &HttpState, 95 headers: &HeaderMap, 96 ) -> Option<String> { 97 let token = auth_cookie(headers)?; 98 let token_hash = session_token_hash(token); 99 let now = now_ms(); 100 let mut sessions = state.auth_sessions.lock().await; 101 sessions.retain(|_, session| session.expires_at > now); 102 sessions 103 .get(&token_hash) 104 .filter(|session| session.expires_at > now) 105 .map(|session| session.wallet_password.clone()) 106 } 107 108 pub(super) fn auth_client_key(headers: &HeaderMap, socket_addr: Option<SocketAddr>) -> String { 109 if let Some(addr) = socket_addr { 110 if !trusted_forwarding_peer(addr.ip()) { 111 return addr.ip().to_string(); 112 } 113 } 114 forwarded_for_client(headers) 115 .or_else(|| header_string(headers, "x-real-ip")) 116 .or_else(|| forwarded_header_client(headers)) 117 .or_else(|| socket_addr.map(|addr| addr.ip().to_string())) 118 .unwrap_or_else(|| UNKNOWN_CLIENT_KEY.to_string()) 119 } 120 121 fn trusted_forwarding_peer(ip: std::net::IpAddr) -> bool { 122 match ip { 123 std::net::IpAddr::V4(ip) => ip.is_loopback() || ip.is_private() || ip.is_link_local(), 124 std::net::IpAddr::V6(ip) => { 125 ip.is_loopback() || ipv6_is_unique_local(ip) || ipv6_is_unicast_link_local(ip) 126 } 127 } 128 } 129 130 fn ipv6_is_unique_local(ip: std::net::Ipv6Addr) -> bool { 131 (ip.segments()[0] & 0xfe00) == 0xfc00 132 } 133 134 fn ipv6_is_unicast_link_local(ip: std::net::Ipv6Addr) -> bool { 135 (ip.segments()[0] & 0xffc0) == 0xfe80 136 } 137 138 fn forwarded_for_client(headers: &HeaderMap) -> Option<String> { 139 header_string(headers, "x-forwarded-for").and_then(|value| { 140 value 141 .split(',') 142 .next() 143 .map(str::trim) 144 .filter(|client| !client.is_empty()) 145 .map(ToOwned::to_owned) 146 }) 147 } 148 149 fn forwarded_header_client(headers: &HeaderMap) -> Option<String> { 150 let value = header_string(headers, "forwarded")?; 151 for item in value.split(';') { 152 let Some((name, value)) = item.split_once('=') else { 153 continue; 154 }; 155 if name.trim().eq_ignore_ascii_case("for") { 156 return Some( 157 value 158 .trim() 159 .trim_matches('"') 160 .trim_matches('[') 161 .trim_matches(']') 162 .to_string(), 163 ) 164 .filter(|client| !client.is_empty()); 165 } 166 } 167 None 168 } 169 170 pub(super) async fn setup_auth_password( 171 state: &HttpState, 172 password: &str, 173 client_key: &str, 174 ) -> Result<String> { 175 check_auth_backoff(state, client_key).await?; 176 if let Err(error) = validate_password(password) { 177 record_auth_failure(state, client_key).await; 178 return Err(error); 179 } 180 let mut config = state.ui_config.lock().await; 181 if config.auth_password_hash.is_some() { 182 record_auth_failure(state, client_key).await; 183 bail!("authentication is already configured"); 184 } 185 config.auth_password_hash = Some(hash_password(password)?); 186 config_store::save(&state.config_path, &config)?; 187 drop(config); 188 wallet_store::encrypt_existing_with_password(&state.wallet_path, password)?; 189 let wallet = wallet_store::load_with_password(&state.wallet_path, password)?; 190 restore_node_wallet_from_store(state, wallet, Some(password)).await?; 191 clear_auth_backoff(state, client_key).await; 192 create_session_cookie(state, password).await 193 } 194 195 pub(super) async fn login_auth_password( 196 state: &HttpState, 197 password: &str, 198 client_key: &str, 199 ) -> Result<String> { 200 check_auth_backoff(state, client_key).await?; 201 let hash = state 202 .ui_config 203 .lock() 204 .await 205 .auth_password_hash 206 .clone() 207 .context("authentication setup is required")?; 208 if !verify_password(password, &hash)? { 209 record_auth_failure(state, client_key).await; 210 bail!("invalid password"); 211 } 212 wallet_store::encrypt_existing_with_password(&state.wallet_path, password)?; 213 let wallet = wallet_store::load_with_password(&state.wallet_path, password)?; 214 restore_node_wallet_from_store(state, wallet, Some(password)).await?; 215 clear_auth_backoff(state, client_key).await; 216 create_session_cookie(state, password).await 217 } 218 219 pub(super) async fn change_auth_password( 220 state: &HttpState, 221 old_password: &str, 222 new_password: &str, 223 client_key: &str, 224 ) -> Result<String> { 225 check_auth_backoff(state, client_key).await?; 226 validate_password(new_password)?; 227 let current_hash = state 228 .ui_config 229 .lock() 230 .await 231 .auth_password_hash 232 .clone() 233 .context("authentication setup is required")?; 234 if !verify_password(old_password, ¤t_hash)? { 235 record_auth_failure(state, client_key).await; 236 bail!("invalid current password"); 237 } 238 let wallet = 239 wallet_store::reencrypt_with_password(&state.wallet_path, old_password, new_password)?; 240 { 241 let mut config = state.ui_config.lock().await; 242 config.auth_password_hash = Some(hash_password(new_password)?); 243 config_store::save(&state.config_path, &config)?; 244 } 245 restore_node_wallet_from_store(state, wallet, Some(new_password)).await?; 246 state.auth_sessions.lock().await.clear(); 247 clear_auth_backoff(state, client_key).await; 248 create_session_cookie(state, new_password).await 249 } 250 251 pub(super) async fn restore_node_wallet_from_store( 252 state: &HttpState, 253 wallet: Wallet, 254 password: Option<&str>, 255 ) -> Result<()> { 256 let owned_blinded_transactions = 257 wallet_store::load_owned_blinded_transactions(&state.wallet_path, password)?; 258 let mut node = state.node.lock().await; 259 node.replace_wallet(wallet); 260 node.restore_owned_blinded_transactions(owned_blinded_transactions) 261 } 262 263 async fn check_auth_backoff(state: &HttpState, client_key: &str) -> Result<()> { 264 let now = now_ms(); 265 let mut backoffs = state.auth_backoff.lock().await; 266 let backoff = backoffs.entry(client_key.to_string()).or_default(); 267 if backoff 268 .locked_until_ms 269 .is_some_and(|locked_until| locked_until > now) 270 { 271 bail!("too many failed login attempts; try again later"); 272 } 273 if backoff.locked_until_ms.is_some() { 274 backoff.locked_until_ms = None; 275 backoff.failed_attempts = 0; 276 } 277 Ok(()) 278 } 279 280 async fn record_auth_failure(state: &HttpState, client_key: &str) { 281 let mut backoffs = state.auth_backoff.lock().await; 282 let backoff = backoffs.entry(client_key.to_string()).or_default(); 283 backoff.failed_attempts = backoff.failed_attempts.saturating_add(1); 284 if backoff.failed_attempts >= AUTH_MAX_FAILED_ATTEMPTS { 285 backoff.locked_until_ms = Some(now_ms().saturating_add(AUTH_LOCKOUT_MS)); 286 } 287 } 288 289 async fn clear_auth_backoff(state: &HttpState, client_key: &str) { 290 state.auth_backoff.lock().await.remove(client_key); 291 } 292 293 async fn create_session_cookie(state: &HttpState, password: &str) -> Result<String> { 294 let token = random_hex(32)?; 295 let token_hash = session_token_hash(&token); 296 let expires_at = now_ms().saturating_add(AUTH_SESSION_TTL_MS); 297 state.auth_sessions.lock().await.insert( 298 token_hash, 299 AuthSession { 300 expires_at, 301 wallet_password: password.to_string(), 302 }, 303 ); 304 Ok(format!( 305 "{AUTH_COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age={}", 306 AUTH_SESSION_TTL_MS / 1000 307 )) 308 } 309 310 pub(super) fn auth_cookie(headers: &HeaderMap) -> Option<&str> { 311 let cookie = headers.get(header::COOKIE)?.to_str().ok()?; 312 cookie.split(';').find_map(|part| { 313 let (name, value) = part.trim().split_once('=')?; 314 (name == AUTH_COOKIE_NAME).then_some(value) 315 }) 316 }