radicle-heartwood-lfs/radicle-httpd/src/api/v1/sessions.rs

184 lines
5.7 KiB
Rust

use std::iter::repeat_with;
use axum::extract::State;
use axum::response::IntoResponse;
use axum::routing::{post, put};
use axum::{Json, Router};
use axum_auth::AuthBearer;
use hyper::StatusCode;
use radicle::crypto::{PublicKey, Signature};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use crate::api::auth::{self, AuthState, Session};
use crate::api::error::Error;
use crate::api::json;
use crate::api::Context;
use crate::axum_extra::Path;
pub fn router(ctx: Context) -> Router {
Router::new()
.route("/sessions", post(session_create_handler))
.route(
"/sessions/:id",
put(session_signin_handler)
.get(session_handler)
.delete(session_delete_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 mut 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 session = Session {
status: AuthState::Unauthorized,
public_key: *signer.public_key(),
alias: ctx.profile.config.node.alias.clone(),
issued_at: OffsetDateTime::now_utc(),
expires_at: OffsetDateTime::now_utc()
.checked_add(auth::UNAUTHORIZED_SESSIONS_EXPIRATION)
.unwrap(),
};
let mut sessions = ctx.sessions.write().await;
sessions.insert(session_id.clone(), session.clone());
Ok::<_, Error>((
StatusCode::CREATED,
Json(json::session(session_id, &session)),
))
}
/// Get a session.
/// `GET /sessions/:id`
async fn session_handler(
State(ctx): State<Context>,
Path(session_id): Path<String>,
) -> impl IntoResponse {
let sessions = ctx.sessions.read().await;
let session = sessions.get(&session_id).ok_or(Error::NotFound)?;
Ok::<_, Error>(Json(json::session(session_id, session)))
}
/// Update session.
/// `PUT /sessions/:id`
async fn session_signin_handler(
State(ctx): State<Context>,
Path(session_id): Path<String>,
Json(request): Json<AuthChallenge>,
) -> impl IntoResponse {
let mut sessions = ctx.sessions.write().await;
let session = sessions.get_mut(&session_id).ok_or(Error::NotFound)?;
if session.status == AuthState::Unauthorized {
if session.public_key != request.pk {
return Err(Error::Auth("Invalid public key"));
}
if session.expires_at <= OffsetDateTime::now_utc() {
return Err(Error::Auth("Session expired"));
}
let payload = format!("{}:{}", session_id, request.pk);
request
.pk
.verify(payload.as_bytes(), &request.sig)
.map_err(Error::from)?;
session.status = AuthState::Authorized;
session.expires_at = OffsetDateTime::now_utc()
.checked_add(auth::AUTHORIZED_SESSIONS_EXPIRATION)
.unwrap();
return Ok::<_, Error>(Json(json!({ "success": true })));
}
Err(Error::Auth("Session already authorized"))
}
/// Delete session.
/// `DELETE /sessions/:id`
async fn session_delete_handler(
State(ctx): State<Context>,
AuthBearer(token): AuthBearer,
Path(session_id): Path<String>,
) -> impl IntoResponse {
if token != session_id {
return Err(Error::Auth("Not authorized to delete this session"));
}
let mut sessions = ctx.sessions.write().await;
sessions.remove_entry(&token).ok_or(Error::NotFound)?;
Ok::<_, Error>(Json(json!({ "success": true })))
}
#[cfg(test)]
mod routes {
use crate::commands::web::{sign, SessionInfo};
use axum::body::Body;
use axum::http::StatusCode;
use crate::api::auth::{AuthState, Session};
use crate::test::{self, get, 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, None).await;
let status = response.status();
let json = response.json().await;
let session_info: SessionInfo = serde_json::from_value(json).unwrap();
assert_eq!(status, StatusCode::CREATED);
// Check that an unauthorized session has been created.
let response = get(&app, format!("/sessions/{}", session_info.session_id)).await;
let status = response.status();
let json = response.json().await;
let body: Session = serde_json::from_value(json).unwrap();
assert_eq!(status, StatusCode::OK);
assert_eq!(body.status, AuthState::Unauthorized);
// Create request body
let signer = ctx.profile.signer().unwrap();
let signature = 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)),
None,
)
.await;
assert_eq!(response.status(), StatusCode::OK);
// Check that session has been authorized.
let response = get(&app, format!("/sessions/{}", session_info.session_id)).await;
let status = response.status();
let json = response.json().await;
let body: Session = serde_json::from_value(json).unwrap();
assert_eq!(status, StatusCode::OK);
assert_eq!(body.status, AuthState::Authorized);
}
}