http: Remove session and all read-write endpoints

Updates API version to 2.0.0 and crate to 0.13.0
This commit is contained in:
Sebastian Martinez
2024-07-03 14:04:07 +02:00
parent 166934514e
commit ea6a7fb028
12 changed files with 20 additions and 2563 deletions
-13
View File
@@ -209,18 +209,6 @@ dependencies = [
"tower-service",
]
[[package]]
name = "axum-auth"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8169113a185f54f68614fcfc3581df585d30bf8542bcb99496990e1025e4120a"
dependencies = [
"async-trait",
"axum-core",
"base64 0.21.7",
"http",
]
[[package]]
name = "axum-core"
version = "0.4.3"
@@ -1724,7 +1712,6 @@ version = "0.12.1"
dependencies = [
"anyhow",
"axum",
"axum-auth",
"axum-server",
"base64 0.21.7",
"chrono",
-1
View File
@@ -23,7 +23,6 @@ path = "src/main.rs"
[dependencies]
anyhow = { version = "1" }
axum = { version = "0.7.2", default-features = false, features = ["json", "query", "tokio", "http1"] }
axum-auth = { version= "0.7.0", default-features = false, features = ["auth-bearer"] }
axum-server = { version = "0.6.0", default-features = false }
base64 = "0.21.3"
chrono = { version = "0.4.22", default-features = false, features = ["clock"] }
+2 -26
View File
@@ -1,6 +1,3 @@
pub mod auth;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
@@ -14,17 +11,15 @@ use radicle::patch::cache::Patches as _;
use radicle::storage::git::Repository;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tokio::sync::RwLock;
use tower_http::cors::{self, CorsLayer};
use radicle::cob::{issue, patch, Author};
use radicle::identity::{DocAt, RepoId};
use radicle::node::policy::Scope;
use radicle::node::routing::Store;
use radicle::node::AliasStore;
use radicle::node::{Handle, NodeId};
use radicle::node::{AliasStore, NodeId};
use radicle::storage::{ReadRepository, ReadStorage};
use radicle::{Node, Profile};
use radicle::Profile;
mod error;
mod json;
@@ -38,13 +33,9 @@ pub const RADICLE_VERSION: &str = env!("RADICLE_VERSION");
// This version has to be updated on every breaking change to the radicle-httpd API.
pub const API_VERSION: &str = "1.2.0";
/// Identifier for sessions
type SessionId = String;
#[derive(Clone)]
pub struct Context {
profile: Arc<Profile>,
sessions: Arc<RwLock<HashMap<SessionId, auth::Session>>>,
cache: Option<Cache>,
}
@@ -52,7 +43,6 @@ impl Context {
pub fn new(profile: Arc<Profile>, options: &Options) -> Self {
Self {
profile,
sessions: Default::default(),
cache: options.cache.map(Cache::new),
}
}
@@ -106,11 +96,6 @@ impl Context {
pub fn profile(&self) -> &Arc<Profile> {
&self.profile
}
#[cfg(test)]
pub fn sessions(&self) -> &Arc<RwLock<HashMap<SessionId, auth::Session>>> {
&self.sessions
}
}
pub fn router(ctx: Context) -> Router {
@@ -342,12 +327,3 @@ mod project {
pub seeding: usize,
}
}
/// Announce refs to the network for the given RID.
pub fn announce_refs(mut node: Node, rid: RepoId) -> Result<(), Error> {
match node.announce_refs(rid) {
Ok(_) => Ok(()),
Err(e) if e.is_connection_err() => Ok(()),
Err(e) => Err(e.into()),
}
}
-44
View File
@@ -1,44 +0,0 @@
use serde::{Deserialize, Serialize};
use time::serde::timestamp;
use time::{Duration, OffsetDateTime};
use radicle::crypto::PublicKey;
use radicle::node::Alias;
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);
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum AuthState {
Authorized,
Unauthorized,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Session {
pub status: AuthState,
pub public_key: PublicKey,
pub alias: Alias,
#[serde(with = "timestamp")]
pub issued_at: OffsetDateTime,
#[serde(with = "timestamp")]
pub expires_at: OffsetDateTime,
}
pub async fn validate(ctx: &Context, token: &str) -> Result<(), Error> {
let sessions_store = ctx.sessions.read().await;
let session = sessions_store
.get(token)
.ok_or(Error::Auth("Unauthorized"))?;
if session.status != AuthState::Authorized || session.expires_at <= OffsetDateTime::now_utc() {
return Err(Error::Auth("Unauthorized"));
}
Ok(())
}
-5
View File
@@ -97,10 +97,6 @@ pub enum Error {
/// Node error.
#[error(transparent)]
Node(#[from] radicle::node::Error),
/// Invalid update to issue or patch.
#[error("{0}")]
BadRequest(String),
}
impl IntoResponse for Error {
@@ -135,7 +131,6 @@ impl IntoResponse for Error {
Error::StorageRef(err) if err.is_not_found() => {
(StatusCode::NOT_FOUND, Some(err.to_string()))
}
Error::BadRequest(msg) => (StatusCode::BAD_REQUEST, Some(msg)),
other => {
tracing::error!("Error: {message}");
tracing::debug!("Error Debug: {:?}", other);
-14
View File
@@ -21,8 +21,6 @@ use radicle_surf::blob::Blob;
use radicle_surf::tree::{EntryKind, Tree};
use radicle_surf::{Commit, Oid};
use crate::api::auth::Session;
/// Returns JSON of a commit.
pub(crate) fn commit(commit: &Commit) -> Value {
json!({
@@ -42,18 +40,6 @@ pub(crate) fn commit(commit: &Commit) -> Value {
})
}
/// Returns JSON of a session.
pub(crate) fn session(session_id: String, session: &Session) -> Value {
json!({
"sessionId": session_id,
"status": session.status,
"publicKey": session.public_key,
"alias": session.alias,
"issuedAt": session.issued_at.unix_timestamp(),
"expiresAt": session.expires_at.unix_timestamp()
})
}
/// Returns JSON for a blob with a given `path`.
pub(crate) fn blob<T: AsRef<[u8]>>(blob: &Blob<T>, path: &str) -> Value {
json!({
-2
View File
@@ -2,7 +2,6 @@ mod delegates;
mod node;
mod profile;
mod projects;
mod sessions;
mod stats;
use axum::extract::State;
@@ -22,7 +21,6 @@ pub fn router(ctx: Context) -> Router {
.merge(root_router)
.merge(node::router(ctx.clone()))
.merge(profile::router(ctx.clone()))
.merge(sessions::router(ctx.clone()))
.merge(delegates::router(ctx.clone()))
.merge(projects::router(ctx.clone()))
.merge(stats::router(ctx));
+5 -48
View File
@@ -1,31 +1,24 @@
use axum::extract::State;
use axum::response::IntoResponse;
use axum::routing::{get, put};
use axum::routing::get;
use axum::{Json, Router};
use axum_auth::AuthBearer;
use hyper::StatusCode;
use serde_json::json;
use radicle::identity::RepoId;
use radicle::node::address::Store as AddressStore;
use radicle::node::routing::Store;
use radicle::node::{AliasStore, Handle, NodeId, DEFAULT_TIMEOUT};
use radicle::node::{AliasStore, Handle, NodeId};
use radicle::Node;
use crate::api::error::Error;
use crate::api::{self, Context, PoliciesQuery};
use crate::axum_extra::{Path, Query};
use crate::api::Context;
use crate::axum_extra::Path;
pub fn router(ctx: Context) -> Router {
Router::new()
.route("/node", get(node_handler))
.route("/node/policies/repos", get(node_policies_repos_handler))
.route(
"/node/policies/repos/:rid",
put(node_policies_seed_handler)
.delete(node_policies_unseed_handler)
.get(node_policies_repo_handler),
)
.route("/node/policies/repos/:rid", get(node_policies_repo_handler))
.route("/nodes/:nid", get(nodes_handler))
.route("/nodes/:nid/inventory", get(nodes_inventory_handler))
.with_state(ctx)
@@ -106,42 +99,6 @@ async fn node_policies_repo_handler(
Ok::<_, Error>(Json(*policy))
}
/// Seed a new repo.
/// `PUT /node/policies/repos/:rid`
async fn node_policies_seed_handler(
State(ctx): State<Context>,
AuthBearer(token): AuthBearer,
Path(project): Path<RepoId>,
Query(qs): Query<PoliciesQuery>,
) -> impl IntoResponse {
api::auth::validate(&ctx, &token).await?;
let mut node = Node::new(ctx.profile.socket());
node.seed(project, qs.scope.unwrap_or_default())?;
if let Some(from) = qs.from {
let results = node.fetch(project, from, DEFAULT_TIMEOUT)?;
return Ok::<_, Error>((
StatusCode::OK,
Json(json!({ "success": true, "results": results })),
));
}
Ok::<_, Error>((StatusCode::OK, Json(json!({ "success": true }))))
}
/// Unseed a repo.
/// `DELETE /node/policies/repos/:rid`
async fn node_policies_unseed_handler(
State(ctx): State<Context>,
AuthBearer(token): AuthBearer,
Path(project): Path<RepoId>,
) -> impl IntoResponse {
api::auth::validate(&ctx, &token).await?;
let mut node = Node::new(ctx.profile.socket());
node.unseed(project)?;
Ok::<_, Error>((StatusCode::OK, Json(json!({ "success": true }))))
}
#[cfg(test)]
mod routes {
use std::net::SocketAddr;
File diff suppressed because it is too large Load Diff
-201
View File
@@ -1,201 +0,0 @@
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 axum::body::Body;
use axum::http::StatusCode;
use serde::{Deserialize, Serialize};
use radicle::crypto::{PublicKey, Signature, Signer};
use crate::api::auth::{AuthState, Session};
use crate::test::{self, get, post, put};
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionInfo {
pub session_id: String,
pub public_key: PublicKey,
}
pub fn sign(
signer: Box<dyn Signer>,
session: &SessionInfo,
) -> Result<Signature, anyhow::Error> {
signer
.try_sign(format!("{}:{}", session.session_id, session.public_key).as_bytes())
.map_err(anyhow::Error::from)
}
#[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);
}
}
+1 -94
View File
@@ -8,11 +8,9 @@ use axum::body::{Body, Bytes};
use axum::http::{Method, Request};
use axum::Router;
use serde_json::Value;
use time::OffsetDateTime;
use tower::ServiceExt;
use radicle::cob::patch::MergeTarget;
use radicle::crypto::ssh::keystore::MemorySigner;
use radicle::crypto::ssh::Keystore;
use radicle::crypto::{KeyPair, Seed, Signer};
use radicle::git::{raw as git2, RefString};
@@ -23,7 +21,7 @@ use radicle::Storage;
use radicle::{node, profile};
use radicle_crypto::test::signer::MockSigner;
use crate::api::{auth, Context};
use crate::api::Context;
pub const RID: &str = "rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp";
pub const RID_PRIVATE: &str = "rad:zLuTzcmoWMcdK37xqArS8eckp9vK";
@@ -32,14 +30,8 @@ pub const PARENT: &str = "ee8d6a29304623a78ebfa5eeed5af674d0e58f83";
pub const INITIAL_COMMIT: &str = "f604ce9fd5b7cc77b7609beda45ea8760bee78f7";
pub const DID: &str = "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi";
pub const ISSUE_ID: &str = "ca67d195c0b308b51810dedd93157a20764d5db5";
pub const ISSUE_DISCUSSION_ID: &str = "41e2823caa54f1d53e375035ed4aabd0a89fa855";
pub const ISSUE_COMMENT_ID: &str = "e9f963fab82ad875e46b29a327c5d3d51f825cdc";
pub const SESSION_ID: &str = "u9MGAkkfkMOv0uDDB2WeUHBT7HbsO2Dy";
pub const TIMESTAMP: u64 = 1671125284;
pub const CONTRIBUTOR_RID: &str = "rad:z4XaCmN3jLSeiMvW15YTDpNbDHFhG";
pub const CONTRIBUTOR_DID: &str = "did:key:z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8";
pub const CONTRIBUTOR_ALIAS: &str = "seed";
pub const CONTRIBUTOR_PATCH_ID: &str = "3e3f0dc34b3eeb64cfbc7218fbd52b97246e0564";
/// Create a new profile.
pub fn profile(home: &Path, seed: [u8; 32]) -> radicle::Profile {
@@ -81,17 +73,6 @@ pub fn seed(dir: &Path) -> Context {
seed_with_signer(dir, profile, &signer)
}
pub fn contributor(dir: &Path) -> Context {
let mut seed = [0xff; 32];
*seed.last_mut().unwrap() = 0xee;
let home = dir.join("radicle");
let profile = profile(home.as_path(), seed);
let signer = MemorySigner::load(&profile.keystore, None).unwrap();
seed_with_signer(dir, profile, &signer)
}
fn seed_with_signer<G: Signer>(dir: &Path, profile: radicle::Profile, signer: &G) -> Context {
const DEFAULT_BRANCH: &str = "master";
@@ -319,24 +300,6 @@ fn seed_with_signer<G: Signer>(dir: &Path, profile: radicle::Profile, signer: &G
Context::new(Arc::new(profile), &options)
}
/// Adds an authorized session to the Context::sessions HashMap.
pub async fn create_session(ctx: Context) {
let issued_at = OffsetDateTime::now_utc();
let mut sessions = ctx.sessions().write().await;
sessions.insert(
String::from(SESSION_ID),
auth::Session {
status: auth::AuthState::Authorized,
public_key: ctx.profile().public_key,
alias: ctx.profile().config.node.alias.clone(),
issued_at,
expires_at: issued_at
.checked_add(auth::AUTHORIZED_SESSIONS_EXPIRATION)
.unwrap(),
},
);
}
pub async fn get(app: &Router, path: impl ToString) -> Response {
Response(
app.clone()
@@ -346,48 +309,6 @@ pub async fn get(app: &Router, path: impl ToString) -> Response {
)
}
pub async fn post(
app: &Router,
path: impl ToString,
body: Option<Body>,
auth: Option<String>,
) -> Response {
Response(
app.clone()
.oneshot(request(path, Method::POST, body, auth))
.await
.unwrap(),
)
}
pub async fn patch(
app: &Router,
path: impl ToString,
body: Option<Body>,
auth: Option<String>,
) -> Response {
Response(
app.clone()
.oneshot(request(path, Method::PATCH, body, auth))
.await
.unwrap(),
)
}
pub async fn put(
app: &Router,
path: impl ToString,
body: Option<Body>,
auth: Option<String>,
) -> Response {
Response(
app.clone()
.oneshot(request(path, Method::PUT, body, auth))
.await
.unwrap(),
)
}
fn request(
path: impl ToString,
method: Method,
@@ -414,20 +335,6 @@ impl Response {
serde_json::from_slice(&body).unwrap()
}
pub async fn id(self) -> radicle::git::Oid {
let json = self.json().await;
let string = json["id"].as_str().unwrap();
radicle::git::Oid::from_str(string).unwrap()
}
pub async fn success(self) -> bool {
let json = self.json().await;
let success = json["success"].as_bool();
success.unwrap_or(false)
}
pub fn status(&self) -> axum::http::StatusCode {
self.0.status()
}
-57
View File
@@ -1,57 +0,0 @@
<script lang="ts">
import type { SessionRoute } from "@app/lib/router/definitions";
import { onMount } from "svelte";
import * as httpd from "@app/lib/httpd";
import * as modal from "@app/lib/modal";
import * as router from "@app/lib/router";
import { experimental } from "@app/lib/appearance";
import AppLayout from "@app/App/AppLayout.svelte";
import Loading from "@app/components/Loading.svelte";
import AuthenticationErrorModal from "@app/modals/AuthenticationErrorModal.svelte";
import ErrorModal from "@app/modals/ErrorModal.svelte";
export let activeRoute: SessionRoute;
onMount(async () => {
const port = Number.parseInt(activeRoute.params.apiAddr.split(":")[1]);
if (port > 0 && port < 2 ** 16) {
httpd.changeHttpdPort(port);
}
const isAuthenticated = await httpd.authenticate(activeRoute.params);
if (!isAuthenticated && !$experimental) {
modal.show({
component: ErrorModal,
props: {
title: "Authentication failed",
subtitle: [
"Authentication only works in experimental mode.",
"Go to Settings, set experimental mode to On and try again.",
],
error: {
message: "Can't authenticate in read-only mode.",
stack: undefined,
},
},
});
} else if (!isAuthenticated) {
modal.show({
component: AuthenticationErrorModal,
props: {},
});
}
void router.navigateToUrl(
"push",
new URL(activeRoute.params.path || "", window.location.origin),
);
});
</script>
<AppLayout>
<Loading center />
</AppLayout>