diff --git a/Cargo.lock b/Cargo.lock index 7954ecc4..6e09c229 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2242,6 +2242,7 @@ dependencies = [ "axum", "axum-server", "ethers-core", + "fastrand", "flate2", "hyper", "lexopt", diff --git a/radicle-httpd/Cargo.toml b/radicle-httpd/Cargo.toml index b0f93c73..69b6b7f6 100644 --- a/radicle-httpd/Cargo.toml +++ b/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" } diff --git a/radicle-httpd/src/api.rs b/radicle-httpd/src/api.rs index feda09c9..264491e1 100644 --- a/radicle-httpd/src/api.rs +++ b/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 { diff --git a/radicle-httpd/src/api/auth.rs b/radicle-httpd/src/api/auth.rs index bdb9b95d..3c29f8b1 100644 --- a/radicle-httpd/src/api/auth.rs +++ b/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(&self, serializer: S) -> Result { diff --git a/radicle-httpd/src/api/axum_extra.rs b/radicle-httpd/src/api/axum_extra.rs new file mode 100644 index 00000000..8c23f965 --- /dev/null +++ b/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(pub T); + +#[async_trait] +impl FromRequest for Path +where + T: DeserializeOwned + Send, + B: Send, +{ + type Rejection = (StatusCode, axum::Json); + + async fn from_request(req: &mut RequestParts) -> Result { + match axum::extract::Path::::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(pub T); + +#[async_trait] +impl FromRequest for Query +where + T: DeserializeOwned + Send, + B: Send, +{ + type Rejection = (StatusCode, axum::Json); + + async fn from_request(req: &mut RequestParts) -> Result { + match axum::extract::Query::::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, +} diff --git a/radicle-httpd/src/api/error.rs b/radicle-httpd/src/api/error.rs new file mode 100644 index 00000000..78ff62df --- /dev/null +++ b/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() + } +} diff --git a/radicle-httpd/src/api/v1.rs b/radicle-httpd/src/api/v1.rs index 66e2c6c0..f09751f1 100644 --- a/radicle-httpd/src/api/v1.rs +++ b/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) } diff --git a/radicle-httpd/src/api/v1/sessions.rs b/radicle-httpd/src/api/v1/sessions.rs new file mode 100644 index 00000000..1b03d737 --- /dev/null +++ b/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) -> 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, + Path(id): Path, +) -> 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, + Path(id): Path, + Json(request): Json, +) -> 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, + 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::>()); + + let auth_state = AuthState::Unauthorized { + nonce: nonce.clone(), + expiration_time, + }; + + map.insert(id.clone(), auth_state); + + (id, nonce) +}