diff --git a/Cargo.lock b/Cargo.lock index 3898ecb..e3751d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -78,6 +78,18 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "224afbd727c3d6e4b90103ece64b8d1b67fbb1973b1046c2281eed3f3803f800" +[[package]] +name = "apps" +version = "0.0.0" +dependencies = [ + "chrono", + "database", + "hash", + "id", + "thiserror", + "url", +] + [[package]] name = "argon2" version = "0.4.1" @@ -769,6 +781,7 @@ dependencies = [ name = "ezidam" version = "0.1.0" dependencies = [ + "apps", "database_pool", "erased-serde", "futures", @@ -1140,6 +1153,8 @@ name = "hash" version = "0.0.0" dependencies = [ "argon2", + "nanoid", + "nanoid-dictionary", "rand_core", "thiserror", ] diff --git a/crates/apps/Cargo.toml b/crates/apps/Cargo.toml new file mode 100644 index 0000000..22c7578 --- /dev/null +++ b/crates/apps/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "apps" +version = "0.0.0" +edition = "2021" + +[dependencies] +thiserror = { workspace = true } +chrono = { workspace = true } +url = { workspace = true } + +# local crates +id = { path = "../id" } +database = { path = "../database" } +hash = { path = "../hash" } diff --git a/crates/apps/src/database.rs b/crates/apps/src/database.rs new file mode 100644 index 0000000..4f46f03 --- /dev/null +++ b/crates/apps/src/database.rs @@ -0,0 +1,61 @@ +use crate::error::Error; +use crate::App; +use database::sqlx::SqliteExecutor; +use database::Apps as DatabaseApps; +use hash::Secret; +use id::AppID; +use url::Url; + +impl From for App { + fn from(db: DatabaseApps) -> Self { + Self { + id: AppID(db.id), + created_at: db.created_at, + updated_at: db.updated_at, + is_archived: db.is_archived, + label: db.label, + redirect_uri: db.redirect_uri, + secret: db.secret, + is_confidential: db.is_confidential, + } + } +} + +impl App { + pub async fn insert( + conn: impl SqliteExecutor<'_>, + id: &AppID, + label: &str, + redirect_uri: &Url, + secret: &Secret, + is_confidential: bool, + ) -> Result, Error> { + Ok(DatabaseApps::insert( + conn, + id.as_ref(), + label, + redirect_uri.as_str(), + secret.hash(), + is_confidential, + ) + .await?) + } + + /// App needs to be not archived + pub async fn get_one( + conn: impl SqliteExecutor<'_>, + id: &str, + redirect: &str, + ) -> Result, Error> { + Ok(DatabaseApps::get_one(conn, id, redirect) + .await? + .map(Self::from)) + } + + pub async fn get_one_by_id( + conn: impl SqliteExecutor<'_>, + id: &str, + ) -> Result, Error> { + Ok(DatabaseApps::get_one_by_id(conn, id).await?.map(Self::from)) + } +} diff --git a/crates/apps/src/error.rs b/crates/apps/src/error.rs new file mode 100644 index 0000000..47def78 --- /dev/null +++ b/crates/apps/src/error.rs @@ -0,0 +1,8 @@ +// error +#[derive(thiserror::Error)] +// the rest +#[derive(Debug)] +pub enum Error { + #[error("Database: {0}")] + Database(#[from] database::Error), +} diff --git a/crates/apps/src/lib.rs b/crates/apps/src/lib.rs new file mode 100644 index 0000000..d952366 --- /dev/null +++ b/crates/apps/src/lib.rs @@ -0,0 +1,31 @@ +mod database; +mod error; + +use chrono::{DateTime, Utc}; +use id::AppID; + +pub use crate::error::Error; + +#[derive(Debug)] +pub struct App { + id: AppID, + created_at: DateTime, + updated_at: DateTime, + label: String, + redirect_uri: String, + secret: String, + is_confidential: bool, + is_archived: bool, +} + +impl App { + pub fn id(&self) -> &AppID { + &self.id + } + pub fn label(&self) -> &str { + &self.label + } + pub fn redirect_uri(&self) -> &str { + &self.redirect_uri + } +} diff --git a/crates/database/migrations/20230314174914_apps.down.sql b/crates/database/migrations/20230314174914_apps.down.sql new file mode 100644 index 0000000..879d917 --- /dev/null +++ b/crates/database/migrations/20230314174914_apps.down.sql @@ -0,0 +1 @@ +drop table if exists apps; diff --git a/crates/database/migrations/20230314174914_apps.up.sql b/crates/database/migrations/20230314174914_apps.up.sql new file mode 100644 index 0000000..98b3a78 --- /dev/null +++ b/crates/database/migrations/20230314174914_apps.up.sql @@ -0,0 +1,22 @@ +create table if not exists apps +( + id TEXT not null primary key, + created_at TEXT not null default CURRENT_TIMESTAMP, + updated_at TEXT not null default CURRENT_TIMESTAMP, + label TEXT not null, + redirect_uri TEXT not null, + secret TEXT not null, + is_confidential INTEGER not null, + is_archived INTEGER not null default 0 +); + +-- update "updated_at" +create trigger if not exists apps_updated_at + after update + on apps + for each row +begin + update apps + set updated_at = CURRENT_TIMESTAMP + where id is NEW.id; +end; diff --git a/crates/database/queries/apps/get_one.sql b/crates/database/queries/apps/get_one.sql new file mode 100644 index 0000000..c4410ee --- /dev/null +++ b/crates/database/queries/apps/get_one.sql @@ -0,0 +1,13 @@ +select id, + created_at as "created_at: DateTime", + updated_at as "updated_at: DateTime", + label, + redirect_uri, + secret, + is_confidential as "is_confidential: bool", + is_archived as "is_archived: bool" +from apps + +where id is (?) + and redirect_uri is (?) + and is_archived is 0 diff --git a/crates/database/queries/apps/get_one_by_id.sql b/crates/database/queries/apps/get_one_by_id.sql new file mode 100644 index 0000000..053284b --- /dev/null +++ b/crates/database/queries/apps/get_one_by_id.sql @@ -0,0 +1,11 @@ +select id, + created_at as "created_at: DateTime", + updated_at as "updated_at: DateTime", + label, + redirect_uri, + secret, + is_confidential as "is_confidential: bool", + is_archived as "is_archived: bool" +from apps + +where id is (?) diff --git a/crates/database/queries/apps/insert.sql b/crates/database/queries/apps/insert.sql new file mode 100644 index 0000000..475831f --- /dev/null +++ b/crates/database/queries/apps/insert.sql @@ -0,0 +1,2 @@ +insert into apps (id, label, redirect_uri, secret, is_confidential) +values (?, ?, ?, ?, ?) diff --git a/crates/database/sqlx-data.json b/crates/database/sqlx-data.json index b196860..bf0f025 100644 --- a/crates/database/sqlx-data.json +++ b/crates/database/sqlx-data.json @@ -510,6 +510,136 @@ }, "query": "select id,\n created_at as \"created_at: DateTime\",\n revoked_at as \"revoked_at: DateTime\",\n private_der,\n public_der\n\nfrom keys\nwhere revoked_at is null\norder by created_at desc\n" }, + "e22ba816faac0c17ca9f2c31fd1b4a5f13a09cece9ec78e0b6e018950c91facb": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "created_at: DateTime", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "updated_at: DateTime", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "label", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "redirect_uri", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "secret", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "is_confidential: bool", + "ordinal": 6, + "type_info": "Int64" + }, + { + "name": "is_archived: bool", + "ordinal": 7, + "type_info": "Int64" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false + ], + "parameters": { + "Right": 1 + } + }, + "query": "select id,\n created_at as \"created_at: DateTime\",\n updated_at as \"updated_at: DateTime\",\n label,\n redirect_uri,\n secret,\n is_confidential as \"is_confidential: bool\",\n is_archived as \"is_archived: bool\"\nfrom apps\n\nwhere id is (?)\n" + }, + "eb1a0153c88b0b2744ed1b71df04a91a129a0173fbbc3e2536f52d41e8dc99c4": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "created_at: DateTime", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "updated_at: DateTime", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "label", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "redirect_uri", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "secret", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "is_confidential: bool", + "ordinal": 6, + "type_info": "Int64" + }, + { + "name": "is_archived: bool", + "ordinal": 7, + "type_info": "Int64" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false + ], + "parameters": { + "Right": 2 + } + }, + "query": "select id,\n created_at as \"created_at: DateTime\",\n updated_at as \"updated_at: DateTime\",\n label,\n redirect_uri,\n secret,\n is_confidential as \"is_confidential: bool\",\n is_archived as \"is_archived: bool\"\nfrom apps\n\nwhere id is (?)\n and redirect_uri is (?)\n and is_archived is 0\n" + }, + "ed27954feb3e21b5c519ccd0312526e68fb3d88a1feb28bdafb414e990da55e8": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 5 + } + }, + "query": "insert into apps (id, label, redirect_uri, secret, is_confidential)\nvalues (?, ?, ?, ?, ?)\n" + }, "f4edf4567542eaead2e0db14b0d4197c5d3c1bc02da1897b571bf63bfcb4526a": { "describe": { "columns": [ diff --git a/crates/database/src/tables.rs b/crates/database/src/tables.rs index dcfaaf0..2381e4b 100644 --- a/crates/database/src/tables.rs +++ b/crates/database/src/tables.rs @@ -1,7 +1,9 @@ +mod apps; mod keys; mod settings; mod users; +pub use apps::Apps; pub use keys::Keys; pub use settings::Settings; pub use users::Users; diff --git a/crates/database/src/tables/apps.rs b/crates/database/src/tables/apps.rs new file mode 100644 index 0000000..2e81675 --- /dev/null +++ b/crates/database/src/tables/apps.rs @@ -0,0 +1,62 @@ +use crate::error::{handle_error, Error}; +use sqlx::sqlite::SqliteQueryResult; +use sqlx::types::chrono::{DateTime, Utc}; +use sqlx::{FromRow, SqliteExecutor}; + +#[derive(FromRow)] +pub struct Apps { + pub id: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub label: String, + pub redirect_uri: String, + pub secret: String, + pub is_confidential: bool, + pub is_archived: bool, +} + +impl Apps { + pub async fn insert( + conn: impl SqliteExecutor<'_>, + id: &str, + label: &str, + redirect_uri: &str, + secret: &str, + is_confidential: bool, + ) -> Result, Error> { + let query: SqliteQueryResult = sqlx::query_file!( + "queries/apps/insert.sql", + id, + label, + redirect_uri, + secret, + is_confidential + ) + .execute(conn) + .await + .map_err(handle_error)?; + + Ok((query.rows_affected() == 1).then_some(())) + } + + pub async fn get_one( + conn: impl SqliteExecutor<'_>, + id: &str, + redirect: &str, + ) -> Result, Error> { + sqlx::query_file_as!(Self, "queries/apps/get_one.sql", id, redirect) + .fetch_optional(conn) + .await + .map_err(handle_error) + } + + pub async fn get_one_by_id( + conn: impl SqliteExecutor<'_>, + id: &str, + ) -> Result, Error> { + sqlx::query_file_as!(Self, "queries/apps/get_one_by_id.sql", id) + .fetch_optional(conn) + .await + .map_err(handle_error) + } +} diff --git a/crates/ezidam/Cargo.toml b/crates/ezidam/Cargo.toml index c358745..b8ebbd6 100644 --- a/crates/ezidam/Cargo.toml +++ b/crates/ezidam/Cargo.toml @@ -21,3 +21,4 @@ id = { path = "../id" } hash = { path = "../hash" } openid = { path = "../openid" } jwt = { path = "../jwt" } +apps = { path = "../apps" } diff --git a/crates/hash/Cargo.toml b/crates/hash/Cargo.toml index 55b6ef1..aa81e1f 100644 --- a/crates/hash/Cargo.toml +++ b/crates/hash/Cargo.toml @@ -7,3 +7,5 @@ edition = "2021" argon2 = { version = "0.4.1" } rand_core = { version = "0.6.4", features = ["std"] } thiserror = { workspace = true } +nanoid = "0.4.0" +nanoid-dictionary = "0.4.3" diff --git a/crates/hash/src/lib.rs b/crates/hash/src/lib.rs index 769d4c5..2600be1 100644 --- a/crates/hash/src/lib.rs +++ b/crates/hash/src/lib.rs @@ -1,6 +1,8 @@ mod error; mod hash; mod password; +mod secret; pub use error::Error; pub use password::Password; +pub use secret::{Secret, SecretString}; diff --git a/crates/hash/src/secret.rs b/crates/hash/src/secret.rs new file mode 100644 index 0000000..424835c --- /dev/null +++ b/crates/hash/src/secret.rs @@ -0,0 +1,31 @@ +use crate::error::Error; +use crate::hash::{hash, Hash}; +use nanoid::nanoid; +use nanoid_dictionary::ALPHANUMERIC; + +// Struct to generate the secret +pub struct SecretString(String); +const LENGTH: usize = 64; +impl Default for SecretString { + fn default() -> Self { + Self(nanoid!(LENGTH, ALPHANUMERIC)) + } +} + +#[derive(Debug)] +pub struct Secret(Hash); + +impl Secret { + pub fn new(plain: SecretString) -> Result { + Ok(Self(Hash::from_hash(hash(&plain.0)?))) + } + pub fn from_hash(hash: impl Into) -> Self { + Self(Hash::from_hash(hash)) + } + pub fn hash(&self) -> &str { + self.0.hash() + } + pub fn compare(&self, plain: &str) -> Result { + self.0.compare(plain).map_err(Error::from) + } +} diff --git a/crates/id/src/app.rs b/crates/id/src/app.rs new file mode 100644 index 0000000..8845be5 --- /dev/null +++ b/crates/id/src/app.rs @@ -0,0 +1,80 @@ +use super::Error; +use nanoid::nanoid; +use nanoid_dictionary::NOLOOKALIKES; +use serde::{Deserialize, Serialize}; +use std::fmt::{Display, Formatter}; +use std::str::FromStr; + +const LENGTH: usize = 10; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct AppID(pub String); + +impl Display for AppID { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Default for AppID { + fn default() -> Self { + Self(nanoid!(LENGTH, NOLOOKALIKES)) + } +} + +impl AsRef for AppID { + fn as_ref(&self) -> &str { + self.0.as_ref() + } +} + +impl FromStr for AppID { + type Err = Error; + + fn from_str(s: &str) -> Result { + if s == "ezidam" || s.len() == LENGTH && s.chars().all(|c| NOLOOKALIKES.contains(&c)) { + Ok(Self(s.to_string())) + } else { + Err(Error::Invalid("App")) + } + } +} + +#[cfg(test)] +mod tests { + use super::{AppID, LENGTH}; + use std::str::FromStr; + + #[test] + fn invalid_length() { + assert!(AppID::from_str("test").is_err()); + } + + #[test] + fn invalid_characters() { + let value = "1I0ov5Ss2Z"; + assert_eq!(value.len(), LENGTH); + + assert!(AppID::from_str(value).is_err()); + } + + #[test] + fn valid_id() { + let value = "nqxTGaXUgn"; + let id = AppID::from_str(value); + assert!(id.is_ok()); + + let id = id.unwrap(); + assert_eq!(id.0, value); + } + + #[test] + fn valid_ezidam_id() { + let value = "ezidam"; + let id = AppID::from_str(value); + assert!(id.is_ok()); + + let id = id.unwrap(); + assert_eq!(id.0, value); + } +} diff --git a/crates/id/src/lib.rs b/crates/id/src/lib.rs index c427f20..3cbc4b3 100644 --- a/crates/id/src/lib.rs +++ b/crates/id/src/lib.rs @@ -1,3 +1,4 @@ +mod app; mod key; mod user; @@ -10,5 +11,6 @@ pub enum Error { Invalid(&'static str), } +pub use app::AppID; pub use key::KeyID; pub use user::UserID;