From 932d8d2ed8dea6a9431b3f269e001db2185784ba Mon Sep 17 00:00:00 2001 From: Sebastian Martinez Date: Fri, 27 Jan 2023 09:46:03 +0100 Subject: [PATCH] Add web-based authentication Signed-off-by: Sebastian Martinez --- radicle-httpd/Cargo.toml | 2 - radicle-httpd/src/api.rs | 2 +- radicle-httpd/src/api/auth.rs | 75 ++---------- radicle-httpd/src/api/error.rs | 11 +- radicle-httpd/src/api/test.rs | 38 ++++-- radicle-httpd/src/api/v1/delegates.rs | 4 +- radicle-httpd/src/api/v1/projects.rs | 24 ++-- radicle-httpd/src/api/v1/sessions.rs | 166 +++++++++++++------------- radicle-httpd/src/api/v1/stats.rs | 4 +- 9 files changed, 144 insertions(+), 182 deletions(-) diff --git a/radicle-httpd/Cargo.toml b/radicle-httpd/Cargo.toml index 821416dc4..1ecedff9e 100644 --- a/radicle-httpd/Cargo.toml +++ b/radicle-httpd/Cargo.toml @@ -19,14 +19,12 @@ anyhow = { version = "1" } axum = { version = "0.6.2", default-features = false, features = ["headers", "json", "query", "tokio"] } axum-server = { version = "0.4.4", default-features = false } chrono = { version = "0.4.22" } -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" } serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } -siwe = { version = "0.5" } thiserror = { version = "1" } time = { version = "0.3.17" } tokio = { version = "1.21", default-features = false, features = ["macros", "rt-multi-thread"] } diff --git a/radicle-httpd/src/api.rs b/radicle-httpd/src/api.rs index 3bb9a6681..a6cf2af52 100644 --- a/radicle-httpd/src/api.rs +++ b/radicle-httpd/src/api.rs @@ -73,7 +73,7 @@ pub fn router(ctx: Context) -> Router { CorsLayer::new() .max_age(Duration::from_secs(86400)) .allow_origin(cors::Any) - .allow_methods([Method::GET, Method::POST, Method::PUT]) + .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::PUT]) .allow_headers([CONTENT_TYPE, AUTHORIZATION]), ) } diff --git a/radicle-httpd/src/api/auth.rs b/radicle-httpd/src/api/auth.rs index 3c29f8b1c..74c2666ef 100644 --- a/radicle-httpd/src/api/auth.rs +++ b/radicle-httpd/src/api/auth.rs @@ -1,13 +1,8 @@ -use std::convert::TryFrom; -use std::str::FromStr; - -use ethers_core::types::{Signature, H160}; -use serde::{Deserialize, Serialize, Serializer}; +use radicle::crypto::PublicKey; +use serde::{Serialize, Serializer}; use time::OffsetDateTime; -use crate::api::error::Error; - -#[derive(Clone)] +#[derive(Clone, PartialEq, PartialOrd)] pub struct DateTime(pub OffsetDateTime); impl Serialize for DateTime { @@ -16,26 +11,11 @@ impl Serialize for DateTime { } } -#[derive(Deserialize, Serialize)] -pub struct AuthRequest { - pub message: String, - #[serde(deserialize_with = "deserialize_signature")] - pub signature: Signature, -} - -fn deserialize_signature<'de, D>(deserializer: D) -> Result -where - D: serde::de::Deserializer<'de>, -{ - let buf = String::deserialize(deserializer)?; - Signature::from_str(&buf).map_err(serde::de::Error::custom) -} - pub enum AuthState { Authorized(Session), Unauthorized { - nonce: String, - expiration_time: DateTime, + public_key: PublicKey, + expires_at: DateTime, }, } @@ -43,48 +23,7 @@ pub enum AuthState { #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct Session { - pub domain: String, - pub address: H160, - pub statement: Option, - pub uri: String, - pub version: u64, - pub chain_id: u64, - pub nonce: String, + pub public_key: PublicKey, pub issued_at: DateTime, - pub expiration_time: Option, - pub resources: Vec, -} - -impl TryFrom for Session { - type Error = Error; - - fn try_from(message: siwe::Message) -> Result { - Ok(Session { - domain: message.domain.host().to_string(), - address: H160(message.address), - statement: None, - uri: message.uri.to_string(), - version: message.version as u64, - chain_id: message.chain_id, - nonce: message.nonce, - issued_at: DateTime(message.issued_at.as_ref().to_owned()), - expiration_time: message - .expiration_time - .map(|x| DateTime(x.as_ref().to_owned())), - resources: message.resources.iter().map(|r| r.to_string()).collect(), - }) - } -} - -#[cfg(test)] -mod test { - #[test] - fn test_auth_request_de() { - let json = serde_json::json!({ - "message": "Hello World!", - "signature": "20096c6ed2bcccb88c9cafbbbbda7a5a3cff6d0ca318c07faa58464083ca40a92f899fbeb26a4c763a7004b13fd0f1ba6c321d4e3a023e30f63c40d4154b99a41c" - }); - - let _req: super::AuthRequest = serde_json::from_value(json).unwrap(); - } + pub expires_at: DateTime, } diff --git a/radicle-httpd/src/api/error.rs b/radicle-httpd/src/api/error.rs index d2b1dd089..7995f5a6f 100644 --- a/radicle-httpd/src/api/error.rs +++ b/radicle-httpd/src/api/error.rs @@ -18,13 +18,13 @@ pub enum Error { #[error(transparent)] Env(#[from] std::env::VarError), - /// An error occurred while verifying the siwe message. + /// Profile error. #[error(transparent)] - SiweVerification(#[from] siwe::VerificationError), + Profile(#[from] radicle::profile::Error), - /// An error occurred while parsing the siwe message. + /// Crypto error. #[error(transparent)] - SiweParse(#[from] siwe::ParseError), + Crypto(#[from] radicle::crypto::Error), /// Storage error. #[error(transparent)] @@ -60,8 +60,7 @@ impl IntoResponse for Error { let (status, msg) = match &self { Error::NotFound => (StatusCode::NOT_FOUND, None), Error::Auth(msg) => (StatusCode::BAD_REQUEST, Some(msg.to_string())), - Error::SiweParse(msg) => (StatusCode::BAD_REQUEST, Some(msg.to_string())), - Error::SiweVerification(msg) => (StatusCode::BAD_REQUEST, Some(msg.to_string())), + Error::Crypto(msg) => (StatusCode::BAD_REQUEST, Some(msg.to_string())), Error::Git2(e) => ( StatusCode::INTERNAL_SERVER_ERROR, Some(e.message().to_owned()), diff --git a/radicle-httpd/src/api/test.rs b/radicle-httpd/src/api/test.rs index dd43df58d..5331c5b72 100644 --- a/radicle-httpd/src/api/test.rs +++ b/radicle-httpd/src/api/test.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use std::{env, fs}; use axum::body::Body; -use axum::http::Request; +use axum::http::{Method, Request}; use axum::Router; use serde_json::Value; use tower::ServiceExt; @@ -116,20 +116,42 @@ pub fn seed(dir: &Path) -> Context { } } -pub async fn request(app: &Router, path: impl ToString) -> Response { +pub async fn get(app: &Router, path: impl ToString) -> Response { Response( app.clone() - .oneshot( - Request::builder() - .uri(path.to_string()) - .body(Body::empty()) - .unwrap(), - ) + .oneshot(request(path, Method::GET, None)) .await .unwrap(), ) } +pub async fn post(app: &Router, path: impl ToString, body: Option) -> Response { + Response( + app.clone() + .oneshot(request(path, Method::POST, body)) + .await + .unwrap(), + ) +} + +pub async fn put(app: &Router, path: impl ToString, body: Option) -> Response { + Response( + app.clone() + .oneshot(request(path, Method::PUT, body)) + .await + .unwrap(), + ) +} + +fn request(path: impl ToString, method: Method, body: Option) -> Request { + Request::builder() + .method(method) + .uri(path.to_string()) + .header("Content-Type", "application/json") + .body(body.unwrap_or_else(Body::empty)) + .unwrap() +} + pub struct Response(axum::response::Response); impl Response { diff --git a/radicle-httpd/src/api/v1/delegates.rs b/radicle-httpd/src/api/v1/delegates.rs index 499ca9092..ce25f58b3 100644 --- a/radicle-httpd/src/api/v1/delegates.rs +++ b/radicle-httpd/src/api/v1/delegates.rs @@ -69,13 +69,13 @@ mod routes { use axum::http::StatusCode; use serde_json::json; - use crate::api::test::{self, request, HEAD}; + use crate::api::test::{self, get, HEAD}; #[tokio::test] async fn test_delegates_projects() { let tmp = tempfile::tempdir().unwrap(); let app = super::router(test::seed(tmp.path())); - let response = request( + let response = get( &app, "/delegates/did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/projects", ) diff --git a/radicle-httpd/src/api/v1/projects.rs b/radicle-httpd/src/api/v1/projects.rs index 5322fadc9..a785ca8d5 100644 --- a/radicle-httpd/src/api/v1/projects.rs +++ b/radicle-httpd/src/api/v1/projects.rs @@ -465,13 +465,13 @@ mod routes { use axum::http::StatusCode; use serde_json::json; - use crate::api::test::{self, request, HEAD, HEAD_1}; + use crate::api::test::{self, get, HEAD, HEAD_1}; #[tokio::test] async fn test_projects_root() { let tmp = tempfile::tempdir().unwrap(); let app = super::router(test::seed(tmp.path())); - let response = request(&app, "/projects").await; + let response = get(&app, "/projects").await; assert_eq!(response.status(), StatusCode::OK); assert_eq!( @@ -494,7 +494,7 @@ mod routes { async fn test_projects() { let tmp = tempfile::tempdir().unwrap(); let app = super::router(test::seed(tmp.path())); - let response = request(&app, "/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp").await; + let response = get(&app, "/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp").await; assert_eq!(response.status(), StatusCode::OK); assert_eq!( @@ -515,7 +515,7 @@ mod routes { async fn test_projects_commits_root() { let tmp = tempfile::tempdir().unwrap(); let app = super::router(test::seed(tmp.path())); - let response = request(&app, "/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/commits").await; + let response = get(&app, "/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/commits").await; assert_eq!(response.status(), StatusCode::FOUND); assert_eq!( @@ -637,7 +637,7 @@ mod routes { async fn test_projects_commits() { let tmp = tempfile::tempdir().unwrap(); let app = super::router(test::seed(tmp.path())); - let response = request( + let response = get( &app, format!("/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/commits/{HEAD}"), ) @@ -703,7 +703,7 @@ mod routes { async fn test_projects_tree() { let tmp = tempfile::tempdir().unwrap(); let app = super::router(test::seed(tmp.path())); - let response = request( + let response = get( &app, format!("/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/tree/{HEAD}/"), ) @@ -750,7 +750,7 @@ mod routes { ) ); - let response = request( + let response = get( &app, format!("/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/tree/{HEAD}/dir1"), ) @@ -796,7 +796,7 @@ mod routes { async fn test_projects_remotes_root() { let tmp = tempfile::tempdir().unwrap(); let app = super::router(test::seed(tmp.path())); - let response = request(&app, "/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/remotes").await; + let response = get(&app, "/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/remotes").await; assert_eq!(response.status(), StatusCode::OK); assert_eq!( @@ -817,7 +817,7 @@ mod routes { async fn test_projects_remotes() { let tmp = tempfile::tempdir().unwrap(); let app = super::router(test::seed(tmp.path())); - let response = request(&app, "/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/remotes/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi").await; + let response = get(&app, "/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/remotes/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi").await; assert_eq!(response.status(), StatusCode::OK); assert_eq!( @@ -836,7 +836,7 @@ mod routes { async fn test_projects_blob() { let tmp = tempfile::tempdir().unwrap(); let app = super::router(test::seed(tmp.path())); - let response = request( + let response = get( &app, format!("/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/blob/{HEAD}/README"), ) @@ -872,7 +872,7 @@ mod routes { async fn test_projects_readme() { let tmp = tempfile::tempdir().unwrap(); let app = super::router(test::seed(tmp.path())); - let response = request( + let response = get( &app, format!("/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/readme/{HEAD}"), ) @@ -908,7 +908,7 @@ mod routes { async fn test_projects_issues_root() { let tmp = tempfile::tempdir().unwrap(); let app = super::router(test::seed(tmp.path())); - let response = request(&app, "/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/issues").await; + let response = get(&app, "/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/issues").await; assert_eq!(response.status(), StatusCode::OK); assert_eq!( diff --git a/radicle-httpd/src/api/v1/sessions.rs b/radicle-httpd/src/api/v1/sessions.rs index 6ce7ad311..0f2798455 100644 --- a/radicle-httpd/src/api/v1/sessions.rs +++ b/radicle-httpd/src/api/v1/sessions.rs @@ -1,131 +1,135 @@ -use std::collections::HashMap; -use std::convert::TryInto; -use std::env; use std::iter::repeat_with; -use std::str::FromStr; use axum::extract::State; use axum::response::IntoResponse; -use axum::routing::{get, post}; +use axum::routing::{post, put}; use axum::{Json, Router}; -use ethers_core::utils::hex; -use hyper::http::uri::Authority; +use radicle::crypto::{PublicKey, Signature}; +use serde::{Deserialize, Serialize}; use serde_json::json; -use siwe::Message; use time::{Duration, OffsetDateTime}; -use crate::api::auth::{AuthRequest, AuthState, DateTime, Session}; +use crate::api::auth::{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 const AUTHORIZED_SESSIONS_EXPIRATION: Duration = Duration::weeks(1); 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), - ) + .route("/sessions/:id", put(session_signin_handler)) .with_state(ctx) } +#[derive(Debug, Deserialize, Serialize)] +struct AuthChallenge { + sig: Signature, + pk: PublicKey, +} + /// Create session. /// `POST /sessions` async fn session_create_handler(State(ctx): State) -> impl IntoResponse { + let rng = fastrand::Rng::new(); + let session_id = repeat_with(|| rng.alphanumeric()) + .take(32) + .collect::(); + let signer = ctx.profile.signer().map_err(Error::from)?; let expiration_time = OffsetDateTime::now_utc() .checked_add(UNAUTHORIZED_SESSIONS_EXPIRATION) .unwrap(); + let auth_state = AuthState::Unauthorized { + public_key: *signer.public_key(), + expires_at: DateTime(expiration_time), + }; let mut sessions = ctx.sessions.write().await; - let (session_id, nonce) = create_session(&mut sessions, DateTime(expiration_time)); + sessions.insert(session_id.clone(), auth_state); - Json(json!({ "id": session_id, "nonce": nonce })) -} - -/// Get session. -/// `GET /sessions/:id` -async fn session_get_handler( - State(ctx): State, - 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 }), - )), - } + Ok::<_, Error>(Json( + json!({"sessionId": session_id, "publicKey": signer.public_key()}), + )) } /// Update session. /// `PUT /sessions/:id` async fn session_signin_handler( State(ctx): State, - Path(id): Path, - Json(request): Json, + Path(session_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")); + let session = sessions.get(&session_id).ok_or(Error::NotFound)?; + if let AuthState::Unauthorized { + public_key, + expires_at, + } = session + { + if public_key != &request.pk { + return Err(Error::Auth("Invalid public key")); } - - // 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")); + if expires_at <= &DateTime(OffsetDateTime::now_utc()) { + return Err(Error::Auth("Session expired")); } - - // 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 + let payload = format!("{}:{}", session_id, request.pk); + request + .pk + .verify(payload.as_bytes(), &request.sig) .map_err(Error::from)?; + let expiration_time = OffsetDateTime::now_utc() + .checked_add(AUTHORIZED_SESSIONS_EXPIRATION) + .unwrap(); + let session = Session { + public_key: request.pk, + issued_at: DateTime(OffsetDateTime::now_utc()), + expires_at: DateTime(expiration_time), + }; + sessions.insert(session_id.clone(), AuthState::Authorized(session)); - let session: Session = message.try_into()?; - sessions.insert(id.clone(), AuthState::Authorized(session.clone())); - - return Ok::<_, Error>(Json(json!({ "id": id, "session": session }))); + return Ok::<_, Error>(()); } Err(Error::Auth("Session already authorized")) } -fn create_session( - map: &mut HashMap, - expiration_time: DateTime, -) -> (String, String) { - let nonce = siwe::generate_nonce(); +#[cfg(test)] +mod routes { + use axum::body::Body; + use axum::http::StatusCode; + use radicle_cli::commands::rad_web::{self, SessionInfo}; - // 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::>()); + use crate::api::test::{self, post, put}; - let auth_state = AuthState::Unauthorized { - nonce: nonce.clone(), - expiration_time, - }; + #[tokio::test] + async fn test_session() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test::seed(tmp.path()); + let app = super::router(ctx.to_owned()); - map.insert(id.clone(), auth_state); + // Create session + let response = post(&app, "/sessions", None).await; + assert_eq!(response.status(), StatusCode::OK); + let json = response.json().await; + let session_info: SessionInfo = serde_json::from_value(json).unwrap(); - (id, nonce) + // Create request body + let signer = ctx.profile.signer().unwrap(); + let signature = rad_web::sign(signer, &session_info).unwrap(); + let body = serde_json::to_vec(&super::AuthChallenge { + sig: signature, + pk: session_info.public_key, + }) + .unwrap(); + + let response = put( + &app, + format!("/sessions/{}", session_info.session_id), + Some(Body::from(body)), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + } } diff --git a/radicle-httpd/src/api/v1/stats.rs b/radicle-httpd/src/api/v1/stats.rs index aadbf8d29..79d10cec5 100644 --- a/radicle-httpd/src/api/v1/stats.rs +++ b/radicle-httpd/src/api/v1/stats.rs @@ -29,13 +29,13 @@ mod routes { use axum::http::StatusCode; use serde_json::json; - use crate::api::test::{self, request}; + use crate::api::test::{self, get}; #[tokio::test] async fn test_stats() { let tmp = tempfile::tempdir().unwrap(); let app = super::router(test::seed(tmp.path())); - let response = request(&app, "/stats").await; + let response = get(&app, "/stats").await; assert_eq!(response.status(), StatusCode::OK); assert_eq!(