iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

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 }