iuna

iuna - experimental devnet protocol
git clone https://iuna.jhx.app/git/iuna.git
Log | Files | Refs | README | LICENSE

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, &current_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 }