Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Migrate `/v1/sessions` route
xphoniex committed 3 years ago
commit b26562f2c5763737a712c3a600b4174dccf52d40
parent ae2a35abb91585e1e989ea5e53298f4e00db8137
7 files changed +284 -8
modified radicle-httpd/Cargo.toml
@@ -19,6 +19,7 @@ anyhow = { version = "1" }
axum = { version = "0.5.16", default-features = false, features = ["json", "headers", "query"] }
axum-server = { version = "0.4.2", default-features = false }
ethers-core = { version = "1.0" }
+
fastrand = { version = "1.7.0" }
flate2 = { version = "1" }
hyper = { version = "0.14.17", default-features = false }
lexopt = { version = "0.2.1" }
modified radicle-httpd/src/api.rs
@@ -16,9 +16,11 @@ use tower_http::cors::{self, CorsLayer};
use tower_http::trace::TraceLayer;
use tracing::Span;

-
use radicle::{Profile, Storage};
+
use radicle::Profile;

mod auth;
+
mod axum_extra;
+
mod error;
mod v1;

pub const VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -39,10 +41,6 @@ impl Context {
            sessions: Default::default(),
        }
    }
-

-
    fn storage(&self) -> &Storage {
-
        &self.profile.storage
-
    }
}

pub fn router(ctx: Context) -> Router {
modified radicle-httpd/src/api/auth.rs
@@ -5,10 +5,10 @@ use ethers_core::types::{Signature, H160};
use serde::{Deserialize, Serialize, Serializer};
use time::OffsetDateTime;

-
use crate::error::Error;
+
use crate::api::error::Error;

#[derive(Clone)]
-
pub struct DateTime(OffsetDateTime);
+
pub struct DateTime(pub OffsetDateTime);

impl Serialize for DateTime {
    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
added radicle-httpd/src/api/axum_extra.rs
@@ -0,0 +1,88 @@
+
use axum::extract::path::ErrorKind;
+
use axum::extract::rejection::{PathRejection, QueryRejection};
+
use axum::extract::{FromRequest, RequestParts};
+
use axum::http::StatusCode;
+
use axum::{async_trait, Json};
+

+
use serde::de::DeserializeOwned;
+
use serde::Serialize;
+

+
pub struct Path<T>(pub T);
+

+
#[async_trait]
+
impl<B, T> FromRequest<B> for Path<T>
+
where
+
    T: DeserializeOwned + Send,
+
    B: Send,
+
{
+
    type Rejection = (StatusCode, axum::Json<Error>);
+

+
    async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
+
        match axum::extract::Path::<T>::from_request(req).await {
+
            Ok(value) => Ok(Self(value.0)),
+
            Err(rejection) => {
+
                let status = StatusCode::BAD_REQUEST;
+
                let body = match rejection {
+
                    PathRejection::FailedToDeserializePathParams(inner) => {
+
                        let kind = inner.into_kind();
+
                        match &kind {
+
                            ErrorKind::Message(msg) => Json(Error {
+
                                success: false,
+
                                error: msg.to_string(),
+
                            }),
+
                            _ => Json(Error {
+
                                success: false,
+
                                error: kind.to_string(),
+
                            }),
+
                        }
+
                    }
+
                    _ => Json(Error {
+
                        success: false,
+
                        error: format!("{}", rejection),
+
                    }),
+
                };
+

+
                Err((status, body))
+
            }
+
        }
+
    }
+
}
+

+
#[derive(Default)]
+
pub struct Query<T>(pub T);
+

+
#[async_trait]
+
impl<B, T> FromRequest<B> for Query<T>
+
where
+
    T: DeserializeOwned + Send,
+
    B: Send,
+
{
+
    type Rejection = (StatusCode, axum::Json<Error>);
+

+
    async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
+
        match axum::extract::Query::<T>::from_request(req).await {
+
            Ok(value) => Ok(Self(value.0)),
+
            Err(rejection) => {
+
                let status = StatusCode::BAD_REQUEST;
+
                let body = match rejection {
+
                    QueryRejection::FailedToDeserializeQueryString(inner) => Json(Error {
+
                        success: false,
+
                        error: inner.to_string(),
+
                    }),
+
                    _ => Json(Error {
+
                        success: false,
+
                        error: format!("{}", rejection),
+
                    }),
+
                };
+

+
                Err((status, body))
+
            }
+
        }
+
    }
+
}
+

+
#[derive(Serialize)]
+
pub struct Error {
+
    success: bool,
+
    error: String,
+
}
added radicle-httpd/src/api/error.rs
@@ -0,0 +1,56 @@
+
use axum::http;
+
use axum::response::{IntoResponse, Response};
+

+
/// Errors relating to the HTTP backend.
+
#[derive(Debug, thiserror::Error)]
+
pub enum Error {
+
    /// The entity was not found.
+
    #[error("entity not found")]
+
    NotFound,
+

+
    /// An error occurred during an authentication process.
+
    #[error("could not authenticate: {0}")]
+
    Auth(&'static str),
+

+
    /// An error occurred with env variables.
+
    #[error(transparent)]
+
    Env(#[from] std::env::VarError),
+

+
    /// I/O error.
+
    #[error("i/o error: {0}")]
+
    Io(#[from] std::io::Error),
+

+
    /// Invalid identifier.
+
    #[error("invalid radicle identifier: {0}")]
+
    Id(#[from] radicle::identity::IdError),
+

+
    /// HeaderName error.
+
    #[error(transparent)]
+
    InvalidHeaderName(#[from] axum::http::header::InvalidHeaderName),
+

+
    /// HeaderValue error.
+
    #[error(transparent)]
+
    InvalidHeaderValue(#[from] axum::http::header::InvalidHeaderValue),
+

+
    /// An error occurred while verifying the siwe message.
+
    #[error(transparent)]
+
    SiweVerification(#[from] siwe::VerificationError),
+

+
    /// An error occurred while parsing the siwe message.
+
    #[error(transparent)]
+
    SiweParse(#[from] siwe::ParseError),
+
}
+

+
impl Error {
+
    pub fn status(&self) -> http::StatusCode {
+
        http::StatusCode::INTERNAL_SERVER_ERROR
+
    }
+
}
+

+
impl IntoResponse for Error {
+
    fn into_response(self) -> Response {
+
        tracing::error!("{}", self);
+

+
        self.status().into_response()
+
    }
+
}
modified radicle-httpd/src/api/v1.rs
@@ -1,11 +1,14 @@
mod node;
+
mod sessions;

use axum::Router;

use crate::api::Context;

pub fn router(ctx: Context) -> Router {
-
    let routes = Router::new().merge(node::router(ctx));
+
    let routes = Router::new()
+
        .merge(node::router(ctx.clone()))
+
        .merge(sessions::router(ctx));

    Router::new().nest("/v1", routes)
}
added radicle-httpd/src/api/v1/sessions.rs
@@ -0,0 +1,130 @@
+
use std::collections::HashMap;
+
use std::convert::TryInto;
+
use std::env;
+
use std::iter::repeat_with;
+
use std::str::FromStr;
+

+
use axum::response::IntoResponse;
+
use axum::routing::{get, post};
+
use axum::{Extension, Json, Router};
+
use ethers_core::utils::hex;
+
use hyper::http::uri::Authority;
+
use serde_json::json;
+
use siwe::Message;
+
use time::{Duration, OffsetDateTime};
+

+
use crate::api::auth::{AuthRequest, AuthState, DateTime, Session};
+
use crate::api::axum_extra::Path;
+
use crate::api::error::Error;
+
use crate::api::Context;
+

+
pub const UNAUTHORIZED_SESSIONS_EXPIRATION: Duration = Duration::seconds(60);
+

+
pub fn router(ctx: Context) -> Router {
+
    Router::new()
+
        .route("/sessions", post(session_create_handler))
+
        .route(
+
            "/sessions/:id",
+
            get(session_get_handler).put(session_signin_handler),
+
        )
+
        .layer(Extension(ctx))
+
}
+

+
/// Create session.
+
/// `POST /sessions`
+
async fn session_create_handler(Extension(ctx): Extension<Context>) -> impl IntoResponse {
+
    let expiration_time = OffsetDateTime::now_utc()
+
        .checked_add(UNAUTHORIZED_SESSIONS_EXPIRATION)
+
        .unwrap();
+
    let mut sessions = ctx.sessions.write().await;
+
    let (session_id, nonce) = create_session(&mut sessions, DateTime(expiration_time));
+

+
    Json(json!({ "id": session_id, "nonce": nonce }))
+
}
+

+
/// Get session.
+
/// `GET /sessions/:id`
+
async fn session_get_handler(
+
    Extension(ctx): Extension<Context>,
+
    Path(id): Path<String>,
+
) -> impl IntoResponse {
+
    let sessions = ctx.sessions.read().await;
+
    let session = sessions.get(&id).ok_or(Error::NotFound)?;
+

+
    match session {
+
        AuthState::Authorized(session) => {
+
            Ok::<_, Error>(Json(json!({ "id": id, "session": session })))
+
        }
+
        AuthState::Unauthorized {
+
            nonce,
+
            expiration_time,
+
        } => Ok::<_, Error>(Json(
+
            json!({ "id": id, "nonce": nonce, "expirationTime": expiration_time }),
+
        )),
+
    }
+
}
+

+
/// Update session.
+
/// `PUT /sessions/:id`
+
async fn session_signin_handler(
+
    Extension(ctx): Extension<Context>,
+
    Path(id): Path<String>,
+
    Json(request): Json<AuthRequest>,
+
) -> impl IntoResponse {
+
    // Get unauthenticated session data, return early if not found
+
    let mut sessions = ctx.sessions.write().await;
+
    let session = sessions.get(&id).ok_or(Error::NotFound)?;
+

+
    if let AuthState::Unauthorized { nonce, .. } = session {
+
        let message = Message::from_str(request.message.as_str()).map_err(Error::from)?;
+

+
        let host = env::var("RADICLE_DOMAIN").map_err(Error::from)?;
+

+
        // Validate nonce
+
        if *nonce != message.nonce {
+
            return Err(Error::Auth("Invalid nonce"));
+
        }
+

+
        // Verify that domain is the correct one
+
        let authority = Authority::from_str(&host).map_err(|_| Error::Auth("Invalid host"))?;
+
        if authority != message.domain {
+
            return Err(Error::Auth("Invalid domain"));
+
        }
+

+
        // Verifies the following:
+
        // - AuthRequest sig matches the address passed in the AuthRequest message.
+
        // - expirationTime is not in the past.
+
        // - notBefore time is in the future.
+
        message
+
            .verify(&request.signature.to_vec(), &Default::default())
+
            .await
+
            .map_err(Error::from)?;
+

+
        let session: Session = message.try_into()?;
+
        sessions.insert(id.clone(), AuthState::Authorized(session.clone()));
+

+
        return Ok::<_, Error>(Json(json!({ "id": id, "session": session })));
+
    }
+

+
    Err(Error::Auth("Session already authorized"))
+
}
+

+
fn create_session(
+
    map: &mut HashMap<String, AuthState>,
+
    expiration_time: DateTime,
+
) -> (String, String) {
+
    let nonce = siwe::generate_nonce();
+

+
    // We generate a value from the RNG for the session id
+
    let rng = fastrand::Rng::new();
+
    let id = hex::encode(repeat_with(|| rng.u8(..)).take(32).collect::<Vec<u8>>());
+

+
    let auth_state = AuthState::Unauthorized {
+
        nonce: nonce.clone(),
+
        expiration_time,
+
    };
+

+
    map.insert(id.clone(), auth_state);
+

+
    (id, nonce)
+
}