From 9f5396133b6264c39ee29b4d35271acc3ca7b15f Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Fri, 12 Apr 2024 11:31:38 +0800 Subject: [PATCH] Basic HTTP auth for sd-server (#2314) * basic http auth * fix types * Fix * auth docs --- .github/workflows/ci.yml | 19 +++-- Cargo.lock | 11 +++ apps/server/Cargo.toml | 9 +- apps/server/src/main.rs | 106 +++++++++++++++++++++++- docs/product/getting-started/setup.mdx | 15 ++-- interface/app/$libraryId/debug/sync.tsx | 37 +++++---- 6 files changed, 156 insertions(+), 41 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cbe79397..0f6e408c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,7 @@ env: CARGO_NET_RETRY: 10 RUST_BACKTRACE: short RUSTUP_MAX_RETRIES: 10 + SD_AUTH: disabled # Cancel previous runs of the same workflow on the same branch. concurrency: @@ -107,10 +108,10 @@ jobs: with: swap-size-mb: 3072 root-reserve-mb: 6144 - remove-dotnet: "true" - remove-codeql: "true" - remove-haskell: "true" - remove-docker-images: "true" + remove-dotnet: 'true' + remove-codeql: 'true' + remove-haskell: 'true' + remove-docker-images: 'true' - name: Symlink target to C:\ if: ${{ runner.os == 'Windows' }} @@ -143,7 +144,7 @@ jobs: if: steps.filter.outcome != 'success' || steps.filter.outputs.changes == 'true' uses: ./.github/actions/setup-rust with: - restore-cache: "false" + restore-cache: 'false' - name: Run rustfmt if: steps.filter.outcome != 'success' || steps.filter.outputs.changes == 'true' @@ -162,10 +163,10 @@ jobs: with: swap-size-mb: 3072 root-reserve-mb: 6144 - remove-dotnet: "true" - remove-codeql: "true" - remove-haskell: "true" - remove-docker-images: "true" + remove-dotnet: 'true' + remove-codeql: 'true' + remove-haskell: 'true' + remove-docker-images: 'true' - name: Symlink target to C:\ if: ${{ runner.os == 'Windows' }} diff --git a/Cargo.lock b/Cargo.lock index 8c376e824..37a572a33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8679,11 +8679,13 @@ name = "sd-server" version = "0.1.0" dependencies = [ "axum", + "base64 0.21.7", "http", "include_dir", "mime_guess", "rspc", "sd-core", + "secstr", "tempfile", "tokio", "tracing", @@ -8781,6 +8783,15 @@ dependencies = [ "zbus", ] +[[package]] +name = "secstr" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04f657244f605c4cf38f6de5993e8bd050c8a303f86aeabff142d5c7c113e12" +dependencies = [ + "libc", +] + [[package]] name = "security-framework" version = "2.9.2" diff --git a/apps/server/Cargo.toml b/apps/server/Cargo.toml index 1ccf74706..654fcc0fc 100644 --- a/apps/server/Cargo.toml +++ b/apps/server/Cargo.toml @@ -12,18 +12,17 @@ ai-models = ["sd-core/ai"] [dependencies] # Spacedrive Sub-crates -sd-core = { path = "../../core", features = [ - "ffmpeg", - "heif", -] } +sd-core = { path = "../../core", features = ["ffmpeg", "heif"] } -axum = { workspace = true } +axum = { workspace = true, features = ["headers"] } http = { workspace = true } rspc = { workspace = true, features = ["axum"] } tokio = { workspace = true, features = ["sync", "rt-multi-thread", "signal"] } tracing = { workspace = true } +base64 = { workspace = true } tempfile = "3.10.1" include_dir = "0.7.3" mime_guess = "2.0.4" +secstr = "0.5.1" diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs index 5aa3939bf..a325715fd 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -1,8 +1,17 @@ -use std::{env, net::SocketAddr, path::Path}; +use std::{collections::HashMap, env, net::SocketAddr, path::Path}; -use axum::routing::get; +use axum::{ + extract::{FromRequestParts, State}, + headers::{authorization::Basic, Authorization}, + http::Request, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::get, + TypedHeader, +}; use sd_core::{custom_uri, Node}; -use tracing::info; +use secstr::SecStr; +use tracing::{info, warn}; mod utils; @@ -10,6 +19,46 @@ mod utils; static ASSETS_DIR: include_dir::Dir<'static> = include_dir::include_dir!("$CARGO_MANIFEST_DIR/../web/dist"); +#[derive(Clone)] +pub struct AppState { + auth: HashMap, +} + +async fn basic_auth( + State(state): State, + request: Request, + next: Next, +) -> Response { + let (mut parts, body) = request.into_parts(); + let Ok(TypedHeader(Authorization(hdr))) = + TypedHeader::>::from_request_parts(&mut parts, &()).await + else { + return Response::builder() + .status(401) + .header("WWW-Authenticate", "Basic realm=\"Spacedrive\"") + .body("Unauthorized".into_response().into_body()) + .expect("hardcoded response will be valid"); + }; + let request = Request::from_parts(parts, body); + + if state.auth.len() != 0 { + if state + .auth + .get(hdr.username()) + .and_then(|pass| Some(*pass == SecStr::from(hdr.password()))) + != Some(true) + { + return Response::builder() + .status(401) + .header("WWW-Authenticate", "Basic realm=\"Spacedrive\"") + .body("Unauthorized".into_response().into_body()) + .expect("hardcoded response will be valid"); + } + } + + next.run(request).await +} + #[tokio::main] async fn main() { let data_dir = match env::var("DATA_DIR") { @@ -43,6 +92,54 @@ async fn main() { } }; + let (auth, disabled) = { + let input = env::var("SD_AUTH").unwrap_or_default(); + + if input == "disabled" { + (Default::default(), true) + } else { + ( + input + .split(',') + .collect::>() + .into_iter() + .enumerate() + .filter_map(|(i, s)| { + if s.len() == 0 { + return None; + } + + let mut parts = s.split(':'); + + let result = parts.next().and_then(|user| { + parts + .next() + .map(|pass| (user.to_string(), SecStr::from(pass))) + }); + if result.is_none() { + warn!("Found invalid credential {i}. Skipping..."); + } + result + }) + .collect::>(), + false, + ) + } + }; + + // We require credentials in production builds (unless explicitly disabled) + if auth.len() == 0 && !disabled { + #[cfg(not(debug_assertions))] + { + warn!("The 'SD_AUTH' environment variable is not set!"); + warn!("If you want to disable auth set 'SD_AUTH=disabled', or"); + warn!("Provide your credentials in the following format 'SD_AUTH=username:password,username2:password2'"); + std::process::exit(1); + } + } + + let state = AppState { auth }; + let (node, router) = match Node::new( data_dir, sd_core::Env { @@ -140,7 +237,8 @@ async fn main() { #[cfg(not(feature = "assets"))] let app = app .route("/", get(|| async { "Spacedrive Server!" })) - .fallback(|| async { "404 Not Found: We're past the event horizon..." }); + .fallback(|| async { "404 Not Found: We're past the event horizon..." }) + .layer(middleware::from_fn_with_state(state, basic_auth)); let mut addr = "[::]:8080".parse::().unwrap(); // This listens on IPv6 and IPv4 addr.set_port(port); diff --git a/docs/product/getting-started/setup.mdx b/docs/product/getting-started/setup.mdx index b96e117d3..0aba9699d 100644 --- a/docs/product/getting-started/setup.mdx +++ b/docs/product/getting-started/setup.mdx @@ -34,15 +34,20 @@ You can run Spacedrive in a Docker container using the following command. type="note" text="For the best performance of the docker container, we recommend to run on Linux (linux/amd64). The container is not yet optimized for other platforms." /> - ```bash -docker run -d --name spacedrive -p 8080:8080 -v /var/spacedrive:/var/spacedrive ghcr.io/spacedriveapp/spacedrive/server +docker run -d --name spacedrive -p 8080:8080 -e SD_AUTH=admin,spacedrive -v /var/spacedrive:/var/spacedrive ghcr.io/spacedriveapp/spacedrive/server ``` +#### Authentication + +When using the Spacedrive server you can use the `SD_AUTH` environment variable to configure authentication. + +Valid values: + - `SD_AUTH=disabled` - Disables authentication. + - `SD_AUTH=username:password` - Enables authentication for a single user. + - `SD_AUTH=username:password,username1:password1` - Enables authentication with multiple users (you can add as many users as you want). + ### Mobile (Preview) Take your Spacedrive library on the go with our mobile apps. You can join the betas by following the links below. diff --git a/interface/app/$libraryId/debug/sync.tsx b/interface/app/$libraryId/debug/sync.tsx index 24d4f9956..3ad070162 100644 --- a/interface/app/$libraryId/debug/sync.tsx +++ b/interface/app/$libraryId/debug/sync.tsx @@ -88,30 +88,31 @@ const OperationGroup = ({ group }: { group: MessageGroup }) => { function calculateGroups(messages: CRDTOperation[]) { return messages.reduce((acc, op) => { - const { data } = op; + // TODO: fix Typescript + // const { data } = op; - const id = JSON.stringify(op.record_id); + // const id = JSON.stringify(op.record_id); - const latest = (() => { - const latest = acc[acc.length - 1]; + // const latest = (() => { + // const latest = acc[acc.length - 1]; - if (!latest || latest.model !== op.model || latest.id !== id) { - const group: MessageGroup = { - model: op.model, - id, - messages: [] - }; + // if (!latest || latest.model !== op.model || latest.id !== id) { + // const group: MessageGroup = { + // model: op.model, + // id, + // messages: [] + // }; - acc.push(group); + // acc.push(group); - return group; - } else return latest; - })(); + // return group; + // } else return latest; + // })(); - latest.messages.push({ - data, - timestamp: op.timestamp - }); + // latest.messages.push({ + // data, + // timestamp: op.timestamp + // }); return acc; }, []);