From f54d8a11a16d7fd2f321ac129df0236c1fb831b1 Mon Sep 17 00:00:00 2001 From: core Date: Mon, 3 Apr 2023 18:39:49 -0400 Subject: [PATCH] networks --- Cargo.lock | 3 + trifid-api/Cargo.toml | 5 +- trifid-api/src/auth_tokens.rs | 3 +- trifid-api/src/cursor.rs | 40 +++++ trifid-api/src/main.rs | 2 + trifid-api/src/routes/v1/networks.rs | 219 ++++++++++++++++++++++++++- 6 files changed, 262 insertions(+), 10 deletions(-) create mode 100644 trifid-api/src/cursor.rs diff --git a/Cargo.lock b/Cargo.lock index b6904c7..79b5ef3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3473,13 +3473,16 @@ version = "0.1.0" dependencies = [ "actix-request-identifier", "actix-web", + "base64 0.21.0", "chacha20poly1305", + "chrono", "hex", "log", "once_cell", "rand", "sea-orm", "serde", + "serde_json", "simple_logger", "toml 0.7.3", "totp-rs", diff --git a/trifid-api/Cargo.toml b/trifid-api/Cargo.toml index 25034a5..e80dbaa 100644 --- a/trifid-api/Cargo.toml +++ b/trifid-api/Cargo.toml @@ -10,9 +10,10 @@ actix-web = "4" # Web framework actix-request-identifier = "4" # Web framework serde = { version = "1", features = ["derive"] } # Serialization and deserialization +serde_json = "1.0.95" # Serialization and deserialization (cursors) once_cell = "1" # Config -toml = "0.7" # Config +toml = "0.7" # Config / Serialization and deserialization log = "0.4" # Logging simple_logger = "4" # Logging @@ -24,6 +25,8 @@ trifid_api_entities = { version = "0.1.0", path = "trifid_api_entities" } rand = "0.8" # Misc. hex = "0.4" # Misc. totp-rs = { version = "5.0.1", features = ["gen_secret", "otpauth"] } # Misc. +base64 = "0.21.0" # Misc. +chrono = "0.4.24" # Misc. trifid-pki = { version = "0.1.9" } # Cryptography chacha20poly1305 = "0.10.1" # Cryptography \ No newline at end of file diff --git a/trifid-api/src/auth_tokens.rs b/trifid-api/src/auth_tokens.rs index efb48c4..ae89c91 100644 --- a/trifid-api/src/auth_tokens.rs +++ b/trifid-api/src/auth_tokens.rs @@ -12,7 +12,8 @@ use crate::timers::expired; pub enum TokenInfo { SessionToken(SessionTokenInfo), AuthToken(AuthTokenInfo), - ApiToken(ApiTokenInfo) + ApiToken(ApiTokenInfo), + NotPresent } pub struct SessionTokenInfo { diff --git a/trifid-api/src/cursor.rs b/trifid-api/src/cursor.rs new file mode 100644 index 0000000..8fb7335 --- /dev/null +++ b/trifid-api/src/cursor.rs @@ -0,0 +1,40 @@ +use std::error::Error; +use base64::Engine; +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Cursor { + pub page: u64 +} + +impl TryFrom for String { + type Error = Box; + + fn try_from(value: Cursor) -> Result { + // Serialize it to json + let json_str = serde_json::to_string(&value)?; + // Then base64-encode the json + let base64_str = base64::engine::general_purpose::STANDARD.encode(json_str); + Ok(base64_str) + } +} + +impl TryFrom for Cursor { + type Error = Box; + + fn try_from(value: String) -> Result { + if value.is_empty() { + // If empty, it's page 0 + return Ok(Cursor { + page: 0 + }) + } + // Base64-decode the value + let json_bytes = base64::engine::general_purpose::STANDARD.decode(value)?; + // Convert it into a string + let json_str = String::from_utf8(json_bytes)?; + // Deserialize it from json + let cursor = serde_json::from_str(&json_str)?; + Ok(cursor) + } +} \ No newline at end of file diff --git a/trifid-api/src/main.rs b/trifid-api/src/main.rs index 2c65ca2..2815883 100644 --- a/trifid-api/src/main.rs +++ b/trifid-api/src/main.rs @@ -17,6 +17,7 @@ pub mod tokens; pub mod timers; pub mod magic_link; pub mod auth_tokens; +pub mod cursor; pub struct AppState { pub conn: DatabaseConnection @@ -68,6 +69,7 @@ async fn main() -> Result<(), Box> { .service(routes::v1::totp_authenticators::totp_authenticators_request) .service(routes::v1::verify_totp_authenticators::verify_totp_authenticators_request) .service(routes::v1::auth::totp::totp_request) + .service(routes::v1::networks::get_networks) }).bind(CONFIG.server.bind)?.run().await?; Ok(()) diff --git a/trifid-api/src/routes/v1/networks.rs b/trifid-api/src/routes/v1/networks.rs index 834dd0a..8af7f9d 100644 --- a/trifid-api/src/routes/v1/networks.rs +++ b/trifid-api/src/routes/v1/networks.rs @@ -1,4 +1,16 @@ use serde::{Serialize, Deserialize}; +use actix_web::{get, HttpRequest, HttpResponse}; +use actix_web::web::{Data, Query}; +use chacha20poly1305::consts::P1; +use chrono::{TimeZone, Utc}; +use log::error; +use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder}; +use crate::AppState; +use crate::auth_tokens::{enforce_2fa, enforce_api_token, enforce_session, TokenInfo}; +use crate::error::{APIError, APIErrorsResponse}; +use trifid_api_entities::entity::organization; +use trifid_api_entities::entity::network; +use crate::cursor::Cursor; #[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetNetworksResponse { @@ -15,7 +27,7 @@ pub struct GetNetworksResponseData { #[serde(rename = "signingCAID")] pub signing_ca_id: String, #[serde(rename = "createdAt")] - pub created_at: String, // 2023-03-22T18:55:47.009Z + pub created_at: String, // 2023-03-22T18:55:47.009Z, %Y-%m-%dT%H-%M-%S.%.3fZ pub name: String, #[serde(rename = "lighthousesAsRelays")] pub lighthouses_as_relays: bool @@ -24,23 +36,214 @@ pub struct GetNetworksResponseData { #[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetNetworksResponseMetadata { #[serde(rename = "totalCount")] - pub total_count: i64, + pub total_count: u64, #[serde(rename = "hasNextPage")] pub has_next_page: bool, #[serde(rename = "hasPrevPage")] pub has_prev_page: bool, - #[serde(default, skip_serializing_if = "is_none", rename = "prevCursor")] + #[serde(default, rename = "prevCursor")] pub prev_cursor: Option, - #[serde(default, skip_serializing_if = "is_none", rename = "nextCursor")] + #[serde(default, rename = "nextCursor")] pub next_cursor: Option, - #[serde(default, skip_serializing_if = "is_none")] + #[serde(default)] pub page: Option } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetNetworksResponseMetadataPage { - pub count: i64, - pub start: i64 + pub count: u64, + pub start: u64 } -fn is_none(o: &Option) -> bool { o.is_none() } \ No newline at end of file +fn u64_25() -> u64 { 25 } + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GetNetworksQueryParams { + #[serde(default, rename = "includeCounts")] + pub include_counts: bool, + #[serde(default)] + pub cursor: String, + #[serde(default = "u64_25", rename = "pageSize")] + pub page_size: u64 +} + +#[get("/v1/networks")] +pub async fn get_networks(opts: Query, req_info: HttpRequest, db: Data) -> HttpResponse { + // For this endpoint, you either need to be a fully authenticated user OR a token with networks:list + let session_info = enforce_2fa(&req_info, &db.conn).await.unwrap_or(TokenInfo::NotPresent); + let api_token_info = enforce_api_token(&req_info, &["networks:list"], &db.conn).await.unwrap_or(TokenInfo::NotPresent); + + // If neither are present, throw an error + if matches!(session_info, TokenInfo::NotPresent) && matches!(api_token_info, TokenInfo::NotPresent) { + return HttpResponse::Unauthorized().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_UNAUTHORIZED".to_string(), + message: "This endpoint requires either a fully authenticated user or a token with the networks:list scope".to_string(), + path: None, + } + ], + }) + } + + // If both are present, throw an error + if matches!(session_info, TokenInfo::AuthToken(_)) && matches!(api_token_info, TokenInfo::ApiToken(_)) { + return HttpResponse::BadRequest().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_AMBIGUOUS_AUTHENTICATION".to_string(), + message: "Both a user token and an API token with the proper scope was provided. Please only provide one.".to_string(), + path: None + } + ], + }) + } + + let org = match api_token_info { + TokenInfo::ApiToken(tkn) => tkn.organization, + _ => { + // we have a session token, which means we have to do a db request to get the organization that this user owns + let user = match session_info { + TokenInfo::AuthToken(tkn) => tkn.session_info.user, + _ => unreachable!() + }; + + let org = match organization::Entity::find().filter(organization::Column::Owner.eq(user.id)).one(&db.conn).await { + Ok(r) => r, + Err(e) => { + error!("database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error performing the database request, please try again later.".to_string(), + path: None, + } + ], + }); + } + }; + + if let Some(org) = org { + org.id + } else { + return HttpResponse::Unauthorized().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_NO_ORG".to_string(), + message: "This user does not own any organizations. Try using an API token instead.".to_string(), + path: None + } + ], + }) + } + } + }; + + let cursor: Cursor = match opts.cursor.clone().try_into() { + Ok(r) => r, + Err(e) => { + error!("invalid cursor: {}", e); + return HttpResponse::BadRequest().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_INVALID_CURSOR".to_string(), + message: "The provided cursor was invalid, please try again later.".to_string(), + path: None + } + ], + }) + } + }; + + let network_pages = network::Entity::find().filter(network::Column::Organization.eq(org)).order_by_asc(network::Column::CreatedAt).paginate(&db.conn, opts.page_size); + + let total = match network_pages.num_items().await { + Ok(r) => r, + Err(e) => { + error!("database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error performing the database request, please try again later.".to_string(), + path: None, + } + ], + }); + } + }; + let pages = match network_pages.num_pages().await { + Ok(r) => r, + Err(e) => { + error!("database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error performing the database request, please try again later.".to_string(), + path: None, + } + ], + }); + } + }; + + let models = match network_pages.fetch_page(cursor.page).await { + Ok(r) => r, + Err(e) => { + error!("database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error performing the database request, please try again later.".to_string(), + path: None, + } + ], + }); + } + }; + let models_mapped = models.iter().map(|u| { + GetNetworksResponseData { + id: u.id.clone(), + cidr: u.cidr.clone(), + organization_id: u.organization.clone(), + signing_ca_id: u.signing_ca.clone(), + created_at: Utc.timestamp_opt(u.created_at, 0).unwrap().format("%Y-%m-%dT%H-%M-%S.%.3fZ").to_string(), + name: u.name.clone(), + lighthouses_as_relays: u.lighthouses_as_relays, + } + }).collect(); + + HttpResponse::Ok().json(GetNetworksResponse { + data: models_mapped, + metadata: GetNetworksResponseMetadata { + total_count: total, + has_next_page: cursor.page != pages, + has_prev_page: cursor.page != 0, + prev_cursor: if cursor.page != 0 { + match (Cursor { page: cursor.page - 1 }).try_into() { + Ok(r) => Some(r), + Err(_) => None + } + } else { + None + }, + next_cursor: if cursor.page != pages { + match (Cursor { page: cursor.page + 1 }).try_into() { + Ok(r) => Some(r), + Err(_) => None + } + } else { + None + }, + page: if opts.include_counts { + Some(GetNetworksResponseMetadataPage { + count: opts.page_size, + start: opts.page_size * cursor.page, + }) + } else { None }, + }, + }) +} \ No newline at end of file