Add web-based authentication

Signed-off-by: Sebastian Martinez <me@sebastinez.dev>
This commit is contained in:
Sebastian Martinez
2023-01-27 17:23:05 +01:00
committed by Alexis Sellier
parent 71edcfe7f2
commit 932d8d2ed8
9 changed files with 144 additions and 182 deletions
-2
View File
@@ -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"] }
+1 -1
View File
@@ -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]),
)
}
+7 -68
View File
@@ -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,
}
+5 -6
View File
@@ -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()),
+30 -8
View File
@@ -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 {
+2 -2
View File
@@ -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",
)
+12 -12
View File
@@ -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!(
+85 -81
View File
@@ -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));
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<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 }),
)),
}
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 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<String, AuthState>,
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::<Vec<u8>>());
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);
}
}
+2 -2
View File
@@ -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!(