Migrate `/v1/sessions` route
Signed-off-by: xphoniex <dj.2dixx@gmail.com>
This commit is contained in:
parent
acd7aeb9c9
commit
6f8511c360
|
|
@ -2242,6 +2242,7 @@ dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"axum-server",
|
"axum-server",
|
||||||
"ethers-core",
|
"ethers-core",
|
||||||
|
"fastrand",
|
||||||
"flate2",
|
"flate2",
|
||||||
"hyper",
|
"hyper",
|
||||||
"lexopt",
|
"lexopt",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ anyhow = { version = "1" }
|
||||||
axum = { version = "0.5.16", default-features = false, features = ["json", "headers", "query"] }
|
axum = { version = "0.5.16", default-features = false, features = ["json", "headers", "query"] }
|
||||||
axum-server = { version = "0.4.2", default-features = false }
|
axum-server = { version = "0.4.2", default-features = false }
|
||||||
ethers-core = { version = "1.0" }
|
ethers-core = { version = "1.0" }
|
||||||
|
fastrand = { version = "1.7.0" }
|
||||||
flate2 = { version = "1" }
|
flate2 = { version = "1" }
|
||||||
hyper = { version = "0.14.17", default-features = false }
|
hyper = { version = "0.14.17", default-features = false }
|
||||||
lexopt = { version = "0.2.1" }
|
lexopt = { version = "0.2.1" }
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,11 @@ use tower_http::cors::{self, CorsLayer};
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
use tracing::Span;
|
use tracing::Span;
|
||||||
|
|
||||||
use radicle::{Profile, Storage};
|
use radicle::Profile;
|
||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
|
mod axum_extra;
|
||||||
|
mod error;
|
||||||
mod v1;
|
mod v1;
|
||||||
|
|
||||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
@ -39,10 +41,6 @@ impl Context {
|
||||||
sessions: Default::default(),
|
sessions: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn storage(&self) -> &Storage {
|
|
||||||
&self.profile.storage
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router(ctx: Context) -> Router {
|
pub fn router(ctx: Context) -> Router {
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,10 @@ use ethers_core::types::{Signature, H160};
|
||||||
use serde::{Deserialize, Serialize, Serializer};
|
use serde::{Deserialize, Serialize, Serializer};
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
use crate::error::Error;
|
use crate::api::error::Error;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct DateTime(OffsetDateTime);
|
pub struct DateTime(pub OffsetDateTime);
|
||||||
|
|
||||||
impl Serialize for DateTime {
|
impl Serialize for DateTime {
|
||||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
mod node;
|
mod node;
|
||||||
|
mod sessions;
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
|
||||||
use crate::api::Context;
|
use crate::api::Context;
|
||||||
|
|
||||||
pub fn router(ctx: Context) -> Router {
|
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)
|
Router::new().nest("/v1", routes)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue