From 3825263fa3eaef4819fa0aa5c63a36b4e5d9761d Mon Sep 17 00:00:00 2001 From: "Philip (a-0)" <@ph:a-0.me> Date: Fri, 5 Jan 2024 20:43:47 +0100 Subject: [PATCH] Add basic JWT authentication for app API --- Cargo.lock | 89 ++++++++++++++++++++++++++++---- Cargo.toml | 2 + src/api/mod.rs | 18 +++++++ src/api/v0/app.rs | 67 ++++++++++++++++++++++++ src/api/{v0.rs => v0/element.rs} | 19 ++----- src/api/v0/mod.rs | 20 +++++++ src/config.rs | 3 +- src/lib.rs | 4 +- src/state/api_state.rs | 47 +++++++++++++++-- src/state/queries/apps.rs | 36 +++++++++++++ src/state/queries/mod.rs | 5 +- src/state/queries/peers.rs | 2 - src/state/schema.rs | 7 +++ tests/api.rs | 10 ++-- 14 files changed, 289 insertions(+), 40 deletions(-) create mode 100644 src/api/v0/app.rs rename src/api/{v0.rs => v0/element.rs} (57%) create mode 100644 src/api/v0/mod.rs create mode 100644 src/state/queries/apps.rs diff --git a/Cargo.lock b/Cargo.lock index ed1a070..69f3e0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -311,9 +311,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.0.82" +version = "1.0.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "305fe645edc1442a0fa8b6726ba61d422798d37a52e12eaecf4b022ebbb88f01" +checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" dependencies = [ "jobserver", "libc", @@ -986,8 +986,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1414,6 +1416,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7ea04a7c5c055c175f189b6dc6ba036fd62306b58c66c9f6389036c503a3f4" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring 0.17.7", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -1422,9 +1439,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.147" +version = "0.2.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" +checksum = "302d7ab3130588088d277783b1e2d2e10c9e9e4a16dd9050e6ec93fb3e7048f4" [[package]] name = "linereader" @@ -1890,6 +1907,16 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" +[[package]] +name = "pem" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8fcc794035347fb64beda2d3b462595dd2753e3f268d89c5aae77e8cf2c310" +dependencies = [ + "base64", + "serde", +] + [[package]] name = "percent-encoding" version = "2.3.0" @@ -2225,12 +2252,26 @@ dependencies = [ "cc", "libc", "once_cell", - "spin", - "untrusted", + "spin 0.5.2", + "untrusted 0.7.1", "web-sys", "winapi", ] +[[package]] +name = "ring" +version = "0.17.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688c63d65483050968b2a8937f7995f443e27041a0f7700aa59b0822aedebb74" +dependencies = [ + "cc", + "getrandom", + "libc", + "spin 0.9.8", + "untrusted 0.9.0", + "windows-sys", +] + [[package]] name = "rmp" version = "0.8.12" @@ -2305,7 +2346,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b80e3dec595989ea8510028f30c408a4630db12c9cbb8de34203b89d6577e99" dependencies = [ "log", - "ring", + "ring 0.16.20", "sct", "webpki", ] @@ -2349,8 +2390,8 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4" dependencies = [ - "ring", - "untrusted", + "ring 0.16.20", + "untrusted 0.7.1", ] [[package]] @@ -2533,6 +2574,18 @@ dependencies = [ "libc", ] +[[package]] +name = "simple_asn1" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "siphasher" version = "0.3.11" @@ -2601,6 +2654,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "sqlite" version = "0.31.1" @@ -3011,9 +3070,11 @@ dependencies = [ "anyhow", "async-trait", "axum", + "chrono", "cozo", "i2p", "itertools 0.12.0", + "jsonwebtoken", "reqwest", "serde", "serde_json", @@ -3076,6 +3137,12 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.4.0" @@ -3219,8 +3286,8 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0e74f82d49d545ad128049b7e88f6576df2da6b02e9ce565c6f533be576957e" dependencies = [ - "ring", - "untrusted", + "ring 0.16.20", + "untrusted 0.7.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a7560d1..97d9cbb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,9 @@ edition = "2021" anyhow = "1.0.71" async-trait = "0.1.73" axum = { version = "0.7.2", features = [ "macros" ] } +chrono = "0.4.31" itertools = "0.12.0" +jsonwebtoken = "9.2.0" serde = { version = "1.0.166", features = [ "derive" ] } serde_json = "1.0.99" serde_with = "3.3.0" diff --git a/src/api/mod.rs b/src/api/mod.rs index 2f3ae3a..901b7ca 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,11 +1,29 @@ use axum::Router; use tokio::{net::TcpListener, task::JoinHandle}; +use serde::{Serialize, Deserialize}; +use uuid::Uuid; use crate::{state::ApiState, config::ApiConfig}; mod v0; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct AppId(Uuid); + +impl AppId { + pub fn new() -> Self { + AppId { 0: Uuid::new_v4() } + } +} + +#[derive(Serialize, Deserialize)] +pub struct AppDescription { + pub name: String, + pub desc_text: String, +} + pub struct Api { server_thread: JoinHandle<()>, } diff --git a/src/api/v0/app.rs b/src/api/v0/app.rs new file mode 100644 index 0000000..ad94b04 --- /dev/null +++ b/src/api/v0/app.rs @@ -0,0 +1,67 @@ +use std::sync::Arc; + +use serde::{Serialize, Deserialize}; +use axum::{Extension, extract::Request, body::Body, middleware::Next, response::{IntoResponse, Response}, http::{header::AUTHORIZATION, StatusCode}, Json}; +use jsonwebtoken::{decode, Header}; +use tracing::{debug, warn, error}; + +use crate::{state::ApiState, api::{AppId, AppDescription}}; + +#[derive(Serialize, Deserialize, Debug)] +struct JWTClaims { + sub: AppId, +} + + +pub(super) async fn auth(s: Extension>, mut request: Request, next: Next) -> Response { + if let Some(auth_header) = request.headers().get(AUTHORIZATION) { + if let Ok(header_string) = auth_header.to_str() { + if header_string.starts_with("Bearer") { + if let Ok(token) = decode::( + &header_string[7..], + s.jwt_decoding_key(), + s.jwt_validation(), + ) { + if let Ok(true) = s.app_exists(&token.claims.sub) { + debug!("Authentication for {:?} succeeded.", &token.claims.sub); + request.extensions_mut().insert(token.claims.sub); + return next.run(request).await; + } + } + } + } + } + debug!("Authentication failed for request '{:?}'.", &request); + StatusCode::UNAUTHORIZED.into_response() +} + + + +pub(super) async fn register(s: Extension>, Json(data): Json) -> Response { + // Maybe ask for consent by user + + // If user wants registration, proceed + let result = s.add_app(&data); + + match result { + Ok(id) => { + // Build JWT, respond + let jwt = jsonwebtoken::encode( + &Header::default(), + &JWTClaims {sub: id}, + &s.jwt_encoding_key(), + ); + match jwt { + Ok(token) => (StatusCode::OK, token).into_response(), + Err(e) => { + warn!("Failed to encode token: {:?}", e); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } + }, + Err(e) => { + error!("Failed to register new application! {:?}", e); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + }, + } +} \ No newline at end of file diff --git a/src/api/v0.rs b/src/api/v0/element.rs similarity index 57% rename from src/api/v0.rs rename to src/api/v0/element.rs index 3741745..b2b89c3 100644 --- a/src/api/v0.rs +++ b/src/api/v0/element.rs @@ -1,21 +1,12 @@ use std::sync::Arc; -use axum::{Router, routing::{put, get}, extract::{Path, Json}, Extension, response::{IntoResponse, Response}, http::StatusCode}; +use axum::{extract::{Path, Json}, Extension, response::{IntoResponse, Response}, http::StatusCode}; use tracing::{warn, debug}; use crate::state::{types::{ElementId, ElementContent}, ApiState}; - - -pub fn get_router(state: ApiState) -> Router { - Router::new() - .route("/element", put(create_element)) - .route("/element/:id", get(get_element).post(set_element).delete(remove_element)) - .layer(Extension(Arc::new(state))) -} - -async fn get_element(Path(id): Path, s: Extension>) -> Response { +pub(super) async fn get(Path(id): Path, s: Extension>) -> Response { let element = s.get_element(&id); match element { Ok(el) => (StatusCode::OK, Json{0: el}).into_response(), @@ -26,7 +17,7 @@ async fn get_element(Path(id): Path, s: Extension>) -> } } -async fn create_element(s: Extension>, Json(content): Json) -> Response { +pub(super) async fn create(s: Extension>, Json(content): Json) -> Response { let element_id = s.create_element(&content); debug!("{:?}", element_id); match element_id { @@ -35,7 +26,7 @@ async fn create_element(s: Extension>, Json(content): Json, s: Extension>, Json(content): Json) -> Response { +pub(super) async fn set(Path(id): Path, s: Extension>, Json(content): Json) -> Response { let res = s.write_element_content(&id, &content); match res { Ok(_) => StatusCode::OK.into_response(), @@ -43,7 +34,7 @@ async fn set_element(Path(id): Path, s: Extension>, Jso } } -async fn remove_element(Path(id): Path, s: Extension>) -> Response { +pub(super) async fn remove(Path(id): Path, s: Extension>) -> Response { let res = s.remove_element(&id); match res { Ok(_) => StatusCode::OK.into_response(), diff --git a/src/api/v0/mod.rs b/src/api/v0/mod.rs new file mode 100644 index 0000000..5845b62 --- /dev/null +++ b/src/api/v0/mod.rs @@ -0,0 +1,20 @@ +use std::sync::Arc; +use axum::{Router, routing::{put, get}, Extension, middleware::from_fn}; + +use crate::state::ApiState; + +mod app; +mod element; + +pub fn get_router(state: ApiState) -> Router { + Router::new() + // authenticated routes + .route("/element", put(element::create)) + .route("/element/:id", get(element::get).post(element::set).delete(element::remove)) + .layer(from_fn(app::auth)) + // public / unauthenticated routes + .route("/app/register", put(app::register)) + .layer(Extension(Arc::new(state))) +} + + diff --git a/src/config.rs b/src/config.rs index a8ff0a1..d8a52e9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,11 +4,12 @@ use serde::{Serialize, Deserialize}; pub struct Config { pub i2p_private_key: Option, pub api_config: ApiConfig, + pub jwt_secret: String, } impl Default for Config { fn default() -> Self { - Config { i2p_private_key: None, api_config: Default::default() } + Config { i2p_private_key: None, api_config: Default::default(), jwt_secret: "insecuresecret".to_string() } } } diff --git a/src/lib.rs b/src/lib.rs index fa3e8be..c5e3df2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,7 +8,7 @@ use i2p::net::I2pSocketAddr; use serde::{Serialize, Deserialize}; use state::{State, types::{MessageId, PeerId}, CommState, ApiState}; -mod api; +pub mod api; pub mod comm; pub mod config; pub mod state; @@ -31,7 +31,7 @@ impl Ubisync { let comm_handle = Arc::new(CommHandle::new(CommState::new(state.clone()), config)?); state.set_comm_handle(comm_handle.clone()); - let api = Arc::new(ApiBuilder::from(config.api_config.clone()).build(ApiState::new(state.clone())).await); + let api = Arc::new(ApiBuilder::from(config.api_config.clone()).build(ApiState::new(state.clone(), &config.jwt_secret)).await); comm_handle.run().await; Ok(Ubisync { diff --git a/src/state/api_state.rs b/src/state/api_state.rs index d80447f..d41e6c2 100644 --- a/src/state/api_state.rs +++ b/src/state/api_state.rs @@ -1,9 +1,11 @@ use std::sync::Arc; +use chrono::Utc; use cozo::DbInstance; +use jsonwebtoken::{EncodingKey, DecodingKey, Validation}; use tracing::debug; -use crate::{state::{types::ElementId, queries}, comm::messages::MessageContent}; +use crate::{state::{types::ElementId, queries}, comm::messages::MessageContent, api::{AppDescription, AppId}}; use super::{State, types::{ElementContent, Element}}; @@ -11,11 +13,34 @@ use super::{State, types::{ElementContent, Element}}; pub struct ApiState { state: Arc, + jwt_encoding_key: EncodingKey, + jwt_decoding_key: DecodingKey, + jwt_validation: Validation, } impl ApiState { - pub fn new(state: Arc) -> Self { - ApiState { state: state } + pub fn new(state: Arc, jwt_secret: &str) -> Self { + let mut validation = Validation::default(); + validation.set_required_spec_claims(&vec!["sub"]); + ApiState { + state: state, + jwt_encoding_key: EncodingKey::from_secret(jwt_secret.as_bytes()), + jwt_decoding_key: DecodingKey::from_secret(jwt_secret.as_bytes()), + jwt_validation: validation, + } + } + + pub fn add_app(&self, description: &AppDescription) -> anyhow::Result { + let id = AppId::new(); + let last_access = Utc::now(); + queries::apps::add(self.db(), &id, &last_access, &description.name, &description.desc_text)?; + debug!("Successfully added app"); + + Ok(id) + } + + pub fn app_exists(&self, id: &AppId) -> anyhow::Result { + queries::apps::exists(self.db(), id) } pub fn create_element(&self, content: &ElementContent) -> anyhow::Result { @@ -50,6 +75,18 @@ impl ApiState { fn db(&self) -> &DbInstance { &self.state.db } + + pub fn jwt_encoding_key(&self) -> &EncodingKey { + &self.jwt_encoding_key + } + + pub fn jwt_decoding_key(&self) -> &DecodingKey { + &self.jwt_decoding_key + } + + pub fn jwt_validation(&self) -> &Validation { + &self.jwt_validation + } } #[cfg(test)] @@ -61,7 +98,7 @@ mod tests { #[serial_test::serial] async fn test_element_create() { tracing_subscriber::fmt().pretty().init(); - let state = ApiState::new(State::new().await.unwrap()); + let state = ApiState::new(State::new().await.unwrap(), "abcdabcdabcdabcdabcdabcdabcdabcd"); let id = state.create_element(&ElementContent::Text("Test-text".to_string())).unwrap(); let el = state.get_element(&id).unwrap(); assert_eq!( @@ -74,7 +111,7 @@ mod tests { #[serial_test::serial] async fn test_element_write() { tracing_subscriber::fmt().pretty().init(); - let state = ApiState::new(State::new().await.unwrap()); + let state = ApiState::new(State::new().await.unwrap(), "abcdabcdabcdabcdabcdabcdabcdabcd"); let id = state.create_element(&ElementContent::Text("Test-text".to_string())).unwrap(); state.write_element_content(&id,&ElementContent::Text("Test-text 2".to_string())).unwrap(); let el = state.get_element(&id).unwrap(); diff --git a/src/state/queries/apps.rs b/src/state/queries/apps.rs new file mode 100644 index 0000000..1c6b1b7 --- /dev/null +++ b/src/state/queries/apps.rs @@ -0,0 +1,36 @@ +use std::collections::BTreeMap; + +use anyhow::{bail, Error}; +use chrono::{DateTime, Utc}; +use cozo::{DbInstance, DataValue, Num}; + +use crate::{run_query, api::AppId}; + + + +pub fn add(db: &DbInstance, id: &AppId, last_access: &DateTime, name: &str, description: &str) -> anyhow::Result<()> { + let params = vec![ + ("id", DataValue::Str(serde_json::to_string(&id).unwrap().into())), + ("last_access", DataValue::Num(Num::Int(last_access.timestamp()))), + ("name", DataValue::Str(name.into())), + ("description", DataValue::Str(description.into())), + ]; + + match run_query!(&db, ":insert apps {id => last_access, name, description}", params, cozo::ScriptMutability::Mutable) { + Ok(_) => Ok(()), + Err(report) => bail!(report) + } +} + +pub fn exists(db: &DbInstance, id: &AppId) -> anyhow::Result { + let mut params = BTreeMap::new(); + params.insert("id".to_string(), DataValue::Str(serde_json::to_string(&id)?.into())); + + let result = db.run_script("?[name] := *apps[$id, last_access, name, description]", params, cozo::ScriptMutability::Immutable); + + if let Ok(rows) = result { + return Ok(rows.rows.len() == 1); + } + + Err(Error::msg("Could not check whether app is registered")) +} \ No newline at end of file diff --git a/src/state/queries/mod.rs b/src/state/queries/mod.rs index fe8c0a7..9799dcb 100644 --- a/src/state/queries/mod.rs +++ b/src/state/queries/mod.rs @@ -1,12 +1,13 @@ +pub mod apps; pub mod elements; - pub mod peers; - #[macro_export] macro_rules! build_query { ($payload:expr, $params:expr) => { { + use cozo::DataValue; + use std::collections::BTreeMap; // Build parameters map let mut params_map: BTreeMap = Default::default(); let mut parameters_init = String::new(); diff --git a/src/state/queries/peers.rs b/src/state/queries/peers.rs index 3eae8be..eebfe89 100644 --- a/src/state/queries/peers.rs +++ b/src/state/queries/peers.rs @@ -1,5 +1,3 @@ -use std::collections::BTreeMap; - use anyhow::Error; use cozo::{DbInstance, DataValue, ScriptMutability}; diff --git a/src/state/schema.rs b/src/state/schema.rs index e4bd905..72a9572 100644 --- a/src/state/schema.rs +++ b/src/state/schema.rs @@ -8,6 +8,13 @@ use cozo::DbInstance; pub fn add_schema(db: &DbInstance) -> anyhow::Result<()> { let params = BTreeMap::new(); match db.run_script(" + {:create apps { + id: String, + => + last_access: Int, + name: String, + description: String, + }} {:create peers { id: String, => diff --git a/tests/api.rs b/tests/api.rs index 71af727..74c970b 100644 --- a/tests/api.rs +++ b/tests/api.rs @@ -1,7 +1,7 @@ use std::time::Duration; use tracing::{Level, debug}; -use ubisync::{Ubisync, config::Config, state::types::{ElementContent, Element, ElementId}}; +use ubisync::{Ubisync, config::Config, state::types::{ElementContent, Element, ElementId}, api::AppDescription}; #[tokio::test(flavor = "multi_thread")] @@ -15,9 +15,13 @@ async fn two_nodes_element_creation() { ubi1.add_peer_from_id(ubi2.get_destination().unwrap().into()).unwrap(); let http_client = reqwest::Client::new(); + let register_response = http_client.put("http://localhost:9981/v0/app/register").json(&AppDescription{name: "Test".to_string(), desc_text: "desc".to_string()}).send().await.unwrap(); + let jwt1 = register_response.text().await.expect("Couldn't fetch token from response"); + let register_response = http_client.put("http://localhost:9982/v0/app/register").json(&AppDescription{name: "Test".to_string(), desc_text: "desc".to_string()}).send().await.unwrap(); + let jwt2 = register_response.text().await.expect("Couldn't fetch token from response"); let test_element_content = ElementContent::Text("Text".to_string()); - let put_resp = http_client.put(&format!("http://localhost:9981/v0/element")).json(&test_element_content).send().await.unwrap(); + let put_resp = http_client.put(&format!("http://localhost:9981/v0/element")).json(&test_element_content).header("Authorization", &format!("Bearer {}", &jwt1)).send().await.unwrap(); debug!("{:?}", &put_resp); let put_resp_text = put_resp.text().await.expect("No put response body"); debug!("{}", put_resp_text); @@ -25,7 +29,7 @@ async fn two_nodes_element_creation() { tokio::time::sleep(Duration::from_millis(3000)).await; - let get_resp = http_client.get(&format!("http://localhost:9982/v0/element/{}", Into::::into(&id))).send().await.expect("Get request failed"); + let get_resp = http_client.get(&format!("http://localhost:9982/v0/element/{}", Into::::into(&id))).header("Authorization", &format!("Bearer {}", &jwt2)).send().await.expect("Get request failed"); let get_resp_text = get_resp.text().await.expect("No get request body"); debug!("{}", get_resp_text); let received_element = serde_json::from_str::(&get_resp_text).expect("Could not deserialize Element");