diff --git a/Cargo.lock b/Cargo.lock index 7854549..7a915a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -25,14 +25,15 @@ checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" [[package]] name = "ahash" -version = "0.8.3" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f" +checksum = "91429305e9f0a25f6205c5b8e0d2db09e0708a7a6df0f42212bb56c32c8ac97a" dependencies = [ "cfg-if", "getrandom", "once_cell", "version_check 0.9.4", + "zerocopy", ] [[package]] @@ -134,6 +135,20 @@ dependencies = [ "num-traits", ] +[[package]] +name = "authdb" +version = "0.1.0" +dependencies = [ + "base64ct", + "cool_asserts", + "serde 1.0.188", + "sha2", + "sqlx", + "thiserror", + "tokio", + "uuid 0.4.0", +] + [[package]] name = "autocfg" version = "0.1.8" @@ -562,9 +577,9 @@ dependencies = [ [[package]] name = "crc-catalog" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cace84e55f07e7301bae1c519df89cdad8cc3cd868413d3fdbdeca9ff3db484" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" [[package]] name = "crc32fast" @@ -929,8 +944,6 @@ dependencies = [ "pretty_env_logger", "serde 1.0.188", "serde_json", - "sha2", - "sqlx", "tempdir", "thiserror", "tokio", @@ -3293,16 +3306,14 @@ dependencies = [ [[package]] name = "rsa" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ab43bb47d23c1a631b4b680199a45255dce26fa9ab2fa902581f624ff13e6a8" +checksum = "86ef35bf3e7fe15a53c4ab08a998e42271eab13eb0db224126bc7bc4c4bad96d" dependencies = [ - "byteorder", "const-oid", "digest", "num-bigint-dig", "num-integer", - "num-iter", "num-traits", "pkcs1", "pkcs8", @@ -3577,9 +3588,9 @@ dependencies = [ [[package]] name = "signature" -version = "2.1.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e1788eed21689f9cf370582dfc467ef36ed9c707f073528ddafa8d83e3b8500" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", "rand_core 0.6.4", @@ -3993,18 +4004,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.49" +version = "1.0.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1177e8c6d7ede7afde3585fd2513e611227efd6481bd78d2e82ba1ce16557ed4" +checksum = "f9a7210f5c9a7156bb50aa36aed4c95afb51df0df00713949448cf9e97d382d2" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.49" +version = "1.0.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10712f02019e9288794769fba95cd6847df9874d49d871d062172f9dd41bc4cc" +checksum = "266b2e40bc00e5a6c09c3584011e08b06f123c00362c92b975ba9843aaaa14b8" dependencies = [ "proc-macro2", "quote", @@ -4239,9 +4250,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.26" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", @@ -4817,10 +4828,30 @@ dependencies = [ ] [[package]] -name = "zeroize" -version = "1.6.0" +name = "zerocopy" +version = "0.7.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9" +checksum = "e97e415490559a91254a2979b4829267a57d2fcd741a98eee8b722fb57289aa0" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd7e48ccf166952882ca8bd778a43502c64f33bf94c12ebe2a7f08e5a0f6689f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.37", +] + +[[package]] +name = "zeroize" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" [[package]] name = "zune-inflate" diff --git a/Cargo.toml b/Cargo.toml index 6c07497..31874fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] resolver = "2" members = [ + "authdb", "changeset", "config", "config-derive", diff --git a/authdb/Cargo.toml b/authdb/Cargo.toml new file mode 100644 index 0000000..2ba9123 --- /dev/null +++ b/authdb/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "authdb" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +base64ct = { version = "1", features = [ "alloc" ] } +serde = { version = "1.0", features = ["derive"] } +sha2 = { version = "0.10" } +sqlx = { version = "0.7", features = [ "runtime-tokio", "sqlite" ] } +thiserror = { version = "1" } +tokio = { version = "1", features = [ "full" ] } +uuid = { version = "0.4", features = [ "serde", "v4" ] } + +[dev-dependencies] +cool_asserts = "*" diff --git a/file-service/migrations/20231003154201_initial_auth_db.sql b/authdb/migrations/20231003154201_initial_auth_db.sql similarity index 100% rename from file-service/migrations/20231003154201_initial_auth_db.sql rename to authdb/migrations/20231003154201_initial_auth_db.sql diff --git a/authdb/src/lib.rs b/authdb/src/lib.rs new file mode 100644 index 0000000..221d8e3 --- /dev/null +++ b/authdb/src/lib.rs @@ -0,0 +1,302 @@ +use base64ct::{Base64, Encoding}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use sqlx::{ + sqlite::{SqlitePool, SqliteRow}, + Row, +}; +use std::ops::Deref; +use std::path::PathBuf; +use thiserror::Error; +use uuid::Uuid; + +#[derive(Debug, Error)] +pub enum AuthError { + #[error("authentication token is duplicated")] + DuplicateAuthToken, + + #[error("session token is duplicated")] + DuplicateSessionToken, + + #[error("database failed")] + SqlError(sqlx::Error), +} + +impl From for AuthError { + fn from(err: sqlx::Error) -> AuthError { + AuthError::SqlError(err) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)] +pub struct Username(String); + +impl From for Username { + fn from(s: String) -> Self { + Self(s) + } +} + +impl From<&str> for Username { + fn from(s: &str) -> Self { + Self(s.to_owned()) + } +} + +impl From for String { + fn from(s: Username) -> Self { + Self::from(&s) + } +} + +impl From<&Username> for String { + fn from(s: &Username) -> Self { + let Username(s) = s; + Self::from(s) + } +} + +impl Deref for Username { + type Target = String; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl sqlx::FromRow<'_, SqliteRow> for Username { + fn from_row(row: &SqliteRow) -> sqlx::Result { + let name: String = row.try_get("username")?; + Ok(Username::from(name)) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)] +pub struct AuthToken(String); + +impl From for AuthToken { + fn from(s: String) -> Self { + Self(s) + } +} + +impl From<&str> for AuthToken { + fn from(s: &str) -> Self { + Self(s.to_owned()) + } +} + +impl From for PathBuf { + fn from(s: AuthToken) -> Self { + Self::from(&s) + } +} + +impl From<&AuthToken> for PathBuf { + fn from(s: &AuthToken) -> Self { + let AuthToken(s) = s; + Self::from(s) + } +} + +impl Deref for AuthToken { + type Target = String; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)] +pub struct SessionToken(String); + +impl From for SessionToken { + fn from(s: String) -> Self { + Self(s) + } +} + +impl From<&str> for SessionToken { + fn from(s: &str) -> Self { + Self(s.to_owned()) + } +} + +impl From for PathBuf { + fn from(s: SessionToken) -> Self { + Self::from(&s) + } +} + +impl From<&SessionToken> for PathBuf { + fn from(s: &SessionToken) -> Self { + let SessionToken(s) = s; + Self::from(s) + } +} + +impl Deref for SessionToken { + type Target = String; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Clone)] +pub struct AuthDB { + pool: SqlitePool, +} + +impl AuthDB { + pub async fn new(path: PathBuf) -> Result { + let migrator = sqlx::migrate!("./migrations"); + let pool = SqlitePool::connect(&format!("sqlite://{}", path.to_str().unwrap())).await?; + migrator.run(&pool).await?; + Ok(Self { pool }) + } + + pub async fn add_user(&self, username: Username) -> Result { + let mut hasher = Sha256::new(); + hasher.update(Uuid::new_v4().hyphenated().to_string()); + hasher.update(username.to_string()); + let auth_token = Base64::encode_string(&hasher.finalize()); + + let _ = sqlx::query("INSERT INTO users (username, token) VALUES ($1, $2)") + .bind(username.to_string()) + .bind(auth_token.clone()) + .execute(&self.pool) + .await?; + + Ok(AuthToken::from(auth_token)) + } + + pub async fn list_users(&self) -> Result, AuthError> { + let usernames = sqlx::query_as::<_, Username>("SELECT (username) FROM users") + .fetch_all(&self.pool) + .await?; + + Ok(usernames) + } + + pub async fn authenticate(&self, token: AuthToken) -> Result, AuthError> { + let results = sqlx::query("SELECT * FROM users WHERE token = $1") + .bind(token.to_string()) + .fetch_all(&self.pool) + .await?; + + if results.len() > 1 { + return Err(AuthError::DuplicateAuthToken); + } + + if results.is_empty() { + return Ok(None); + } + + let user_id: i64 = results[0].try_get("id")?; + + let mut hasher = Sha256::new(); + hasher.update(Uuid::new_v4().hyphenated().to_string()); + hasher.update(token.to_string()); + let session_token = Base64::encode_string(&hasher.finalize()); + + let _ = sqlx::query("INSERT INTO sessions (token, user_id) VALUES ($1, $2)") + .bind(session_token.clone()) + .bind(user_id) + .execute(&self.pool) + .await?; + + Ok(Some(SessionToken::from(session_token))) + } + + pub async fn validate_session( + &self, + token: SessionToken, + ) -> Result, AuthError> { + let rows = sqlx::query( + "SELECT users.username FROM sessions INNER JOIN users ON sessions.user_id = users.id WHERE sessions.token = $1", + ) + .bind(token.to_string()) + .fetch_all(&self.pool) + .await?; + if rows.len() > 1 { + return Err(AuthError::DuplicateSessionToken); + } + + if rows.is_empty() { + return Ok(None); + } + + let username: String = rows[0].try_get("username")?; + Ok(Some(Username::from(username))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cool_asserts::assert_matches; + use std::collections::HashSet; + + #[tokio::test] + async fn can_create_and_list_users() { + let db = AuthDB::new(PathBuf::from(":memory:")) + .await + .expect("a memory-only database will be created"); + let _ = db + .add_user(Username::from("savanni")) + .await + .expect("user to be created"); + assert_matches!(db.list_users().await, Ok(names) => { + let names = names.into_iter().collect::>(); + assert!(names.contains(&Username::from("savanni"))); + }) + } + + #[tokio::test] + async fn unknown_auth_token_returns_nothing() { + let db = AuthDB::new(PathBuf::from(":memory:")) + .await + .expect("a memory-only database will be created"); + let _ = db + .add_user(Username::from("savanni")) + .await + .expect("user to be created"); + + let token = AuthToken::from("0000000000"); + + assert_matches!(db.authenticate(token).await, Ok(None)); + } + + #[tokio::test] + async fn auth_token_becomes_session_token() { + let db = AuthDB::new(PathBuf::from(":memory:")) + .await + .expect("a memory-only database will be created"); + let token = db + .add_user(Username::from("savanni")) + .await + .expect("user to be created"); + + assert_matches!(db.authenticate(token).await, Ok(_)); + } + + #[tokio::test] + async fn can_validate_session_token() { + let db = AuthDB::new(PathBuf::from(":memory:")) + .await + .expect("a memory-only database will be created"); + let token = db + .add_user(Username::from("savanni")) + .await + .expect("user to be created"); + let session = db + .authenticate(token) + .await + .expect("token authentication should succeed") + .expect("session token should be found"); + + assert_matches!( + db.validate_session(session).await, + Ok(Some(username)) => { + assert_eq!(username, Username::from("savanni")); + }); + } +} diff --git a/file-service/Cargo.toml b/file-service/Cargo.toml index a2efaf6..051898d 100644 --- a/file-service/Cargo.toml +++ b/file-service/Cargo.toml @@ -38,9 +38,7 @@ mime_guess = "2.0.3" pretty_env_logger = { version = "0.5" } serde_json = "*" serde = { version = "1.0", features = ["derive"] } -sha2 = "0.10" -sqlx = { version = "0.7", features = [ "runtime-tokio", "sqlite" ] } -thiserror = "1.0.20" +thiserror = { version = "1" } tokio = { version = "1", features = [ "full" ] } uuid = { version = "0.4", features = [ "serde", "v4" ] } warp = { version = "0.3" } diff --git a/file-service/src/store/mod.rs b/file-service/src/store/mod.rs index 1f7ad05..c4ddd2d 100644 --- a/file-service/src/store/mod.rs +++ b/file-service/src/store/mod.rs @@ -1,10 +1,5 @@ use base64ct::{Base64, Encoding}; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use sqlx::{ - sqlite::{SqlitePool, SqliteRow}, - Row, -}; use std::{collections::HashSet, ops::Deref, path::PathBuf}; use thiserror::Error; use uuid::Uuid; @@ -90,136 +85,6 @@ impl From for DeleteFileError { } } -#[derive(Debug, Error)] -pub enum AuthError { - #[error("authentication token is duplicated")] - DuplicateAuthToken, - - #[error("session token is duplicated")] - DuplicateSessionToken, - - #[error("database failed")] - SqlError(sqlx::Error), -} - -impl From for AuthError { - fn from(err: sqlx::Error) -> AuthError { - AuthError::SqlError(err) - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)] -pub struct Username(String); - -impl From for Username { - fn from(s: String) -> Self { - Self(s) - } -} - -impl From<&str> for Username { - fn from(s: &str) -> Self { - Self(s.to_owned()) - } -} - -impl From for String { - fn from(s: Username) -> Self { - Self::from(&s) - } -} - -impl From<&Username> for String { - fn from(s: &Username) -> Self { - let Username(s) = s; - Self::from(s) - } -} - -impl Deref for Username { - type Target = String; - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl sqlx::FromRow<'_, SqliteRow> for Username { - fn from_row(row: &SqliteRow) -> sqlx::Result { - let name: String = row.try_get("username")?; - Ok(Username::from(name)) - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)] -pub struct AuthToken(String); - -impl From for AuthToken { - fn from(s: String) -> Self { - Self(s) - } -} - -impl From<&str> for AuthToken { - fn from(s: &str) -> Self { - Self(s.to_owned()) - } -} - -impl From for PathBuf { - fn from(s: AuthToken) -> Self { - Self::from(&s) - } -} - -impl From<&AuthToken> for PathBuf { - fn from(s: &AuthToken) -> Self { - let AuthToken(s) = s; - Self::from(s) - } -} - -impl Deref for AuthToken { - type Target = String; - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)] -pub struct SessionToken(String); - -impl From for SessionToken { - fn from(s: String) -> Self { - Self(s) - } -} - -impl From<&str> for SessionToken { - fn from(s: &str) -> Self { - Self(s.to_owned()) - } -} - -impl From for PathBuf { - fn from(s: SessionToken) -> Self { - Self::from(&s) - } -} - -impl From<&SessionToken> for PathBuf { - fn from(s: &SessionToken) -> Self { - let SessionToken(s) = s; - Self::from(s) - } -} - -impl Deref for SessionToken { - type Target = String; - fn deref(&self) -> &Self::Target { - &self.0 - } -} - #[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)] pub struct FileId(String); @@ -267,95 +132,6 @@ impl FileRoot for Context { } } -#[derive(Clone)] -pub struct AuthDB { - pool: SqlitePool, -} - -impl AuthDB { - pub async fn new(path: PathBuf) -> Result { - let migrator = sqlx::migrate!("./migrations"); - let pool = SqlitePool::connect(&format!("sqlite://{}", path.to_str().unwrap())).await?; - migrator.run(&pool).await?; - Ok(Self { pool }) - } - - pub async fn add_user(&self, username: Username) -> Result { - let mut hasher = Sha256::new(); - hasher.update(Uuid::new_v4().hyphenated().to_string()); - hasher.update(username.to_string()); - let auth_token = Base64::encode_string(&hasher.finalize()); - - let _ = sqlx::query("INSERT INTO users (username, token) VALUES ($1, $2)") - .bind(username.to_string()) - .bind(auth_token.clone()) - .execute(&self.pool) - .await?; - - Ok(AuthToken::from(auth_token)) - } - - pub async fn list_users(&self) -> Result, AuthError> { - let usernames = sqlx::query_as::<_, Username>("SELECT (username) FROM users") - .fetch_all(&self.pool) - .await?; - - Ok(usernames) - } - - pub async fn authenticate(&self, token: AuthToken) -> Result, AuthError> { - let results = sqlx::query("SELECT * FROM users WHERE token = $1") - .bind(token.to_string()) - .fetch_all(&self.pool) - .await?; - - if results.len() > 1 { - return Err(AuthError::DuplicateAuthToken); - } - - if results.is_empty() { - return Ok(None); - } - - let user_id: i64 = results[0].try_get("id")?; - - let mut hasher = Sha256::new(); - hasher.update(Uuid::new_v4().hyphenated().to_string()); - hasher.update(token.to_string()); - let session_token = Base64::encode_string(&hasher.finalize()); - - let _ = sqlx::query("INSERT INTO sessions (token, user_id) VALUES ($1, $2)") - .bind(session_token.clone()) - .bind(user_id) - .execute(&self.pool) - .await?; - - Ok(Some(SessionToken::from(session_token))) - } - - pub async fn validate_session( - &self, - token: SessionToken, - ) -> Result, AuthError> { - let rows = sqlx::query( - "SELECT users.username FROM sessions INNER JOIN users ON sessions.user_id = users.id WHERE sessions.token = $1", - ) - .bind(token.to_string()) - .fetch_all(&self.pool) - .await?; - if rows.len() > 1 { - return Err(AuthError::DuplicateSessionToken); - } - - if rows.is_empty() { - return Ok(None); - } - - let username: String = rows[0].try_get("username")?; - Ok(Some(Username::from(username))) - } -} - pub struct Store { files_root: PathBuf, } @@ -493,74 +269,3 @@ mod test { }); } } - -#[cfg(test)] -mod authdb_test { - use super::*; - use cool_asserts::assert_matches; - - #[tokio::test] - async fn can_create_and_list_users() { - let db = AuthDB::new(PathBuf::from(":memory:")) - .await - .expect("a memory-only database will be created"); - let _ = db - .add_user(Username::from("savanni")) - .await - .expect("user to be created"); - assert_matches!(db.list_users().await, Ok(names) => { - let names = names.into_iter().collect::>(); - assert!(names.contains(&Username::from("savanni"))); - }) - } - - #[tokio::test] - async fn unknown_auth_token_returns_nothing() { - let db = AuthDB::new(PathBuf::from(":memory:")) - .await - .expect("a memory-only database will be created"); - let _ = db - .add_user(Username::from("savanni")) - .await - .expect("user to be created"); - - let token = AuthToken::from("0000000000"); - - assert_matches!(db.authenticate(token).await, Ok(None)); - } - - #[tokio::test] - async fn auth_token_becomes_session_token() { - let db = AuthDB::new(PathBuf::from(":memory:")) - .await - .expect("a memory-only database will be created"); - let token = db - .add_user(Username::from("savanni")) - .await - .expect("user to be created"); - - assert_matches!(db.authenticate(token).await, Ok(_)); - } - - #[tokio::test] - async fn can_validate_session_token() { - let db = AuthDB::new(PathBuf::from(":memory:")) - .await - .expect("a memory-only database will be created"); - let token = db - .add_user(Username::from("savanni")) - .await - .expect("user to be created"); - let session = db - .authenticate(token) - .await - .expect("token authentication should succeed") - .expect("session token should be found"); - - assert_matches!( - db.validate_session(session).await, - Ok(Some(username)) => { - assert_eq!(username, Username::from("savanni")); - }); - } -}