Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add web-based authentication
Sebastian Martinez committed 3 years ago
commit 932d8d2ed8dea6a9431b3f269e001db2185784ba
parent 71edcfe7f23fce9928368c13f698643158d7757d
9 files changed +147 -185
modified 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"] }
modified 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]),
        )
}
modified 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<Signature, D::Error>
-
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<String>,
-
    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<DateTime>,
-
    pub resources: Vec<String>,
-
}
-

-
impl TryFrom<siwe::Message> for Session {
-
    type Error = Error;
-

-
    fn try_from(message: siwe::Message) -> Result<Session, Error> {
-
        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,
}
modified 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()),
modified 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<Body>) -> Response {
+
    Response(
+
        app.clone()
+
            .oneshot(request(path, Method::POST, body))
+
            .await
+
            .unwrap(),
+
    )
+
}
+

+
pub async fn put(app: &Router, path: impl ToString, body: Option<Body>) -> Response {
+
    Response(
+
        app.clone()
+
            .oneshot(request(path, Method::PUT, body))
+
            .await
+
            .unwrap(),
+
    )
+
}
+

+
fn request(path: impl ToString, method: Method, body: Option<Body>) -> Request<Body> {
+
    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 {
modified 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",
        )
modified 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!(
modified 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<Context>) -> impl IntoResponse {
+
    let rng = fastrand::Rng::new();
+
    let session_id = repeat_with(|| rng.alphanumeric())
+
        .take(32)
+
        .collect::<String>();
+
    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));
-

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

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

-
    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<Context>,
-
    Path(id): Path<String>,
-
    Json(request): Json<AuthRequest>,
+
    Path(session_id): Path<String>,
+
    Json(request): Json<AuthChallenge>,
) -> 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 session: Session = message.try_into()?;
-
        sessions.insert(id.clone(), AuthState::Authorized(session.clone()));
-

-
        return Ok::<_, Error>(Json(json!({ "id": id, "session": session })));
+
        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));
+

+
        return Ok::<_, Error>(());
    }

    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,
-
    };
+
#[cfg(test)]
+
mod routes {
+
    use axum::body::Body;
+
    use axum::http::StatusCode;
+
    use radicle_cli::commands::rad_web::{self, SessionInfo};
+

+
    use crate::api::test::{self, post, put};
+

+
    #[tokio::test]
+
    async fn test_session() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let ctx = test::seed(tmp.path());
+
        let app = super::router(ctx.to_owned());
+

+
        // 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();
+

+
        // 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();

-
    map.insert(id.clone(), auth_state);
+
        let response = put(
+
            &app,
+
            format!("/sessions/{}", session_info.session_id),
+
            Some(Body::from(body)),
+
        )
+
        .await;

-
    (id, nonce)
+
        assert_eq!(response.status(), StatusCode::OK);
+
    }
}
modified 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!(