From e054860091ae5a9da33dbeeb9f7af636c8992e2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Wo=C5=BAniak?= Date: Thu, 28 Dec 2023 21:16:32 +0100 Subject: [PATCH] Add agents --- bins/identity-agent/src/migration/mod.rs | 10 + bins/sessions-agent/Cargo.toml | 12 + bins/sessions-agent/src/main.rs | 3 + bins/sessions-agent/src/migration/mod.rs | 10 + bins/sessions-agent/src/utils.rs | 294 +++++++++++++++++++++++ crates/agent/Cargo.toml | 16 ++ crates/agent/src/lib.rs | 4 + crates/web/Cargo.toml | 12 + crates/web/src/lib.rs | 4 + 9 files changed, 365 insertions(+) create mode 100644 bins/identity-agent/src/migration/mod.rs create mode 100644 bins/sessions-agent/Cargo.toml create mode 100644 bins/sessions-agent/src/main.rs create mode 100644 bins/sessions-agent/src/migration/mod.rs create mode 100644 bins/sessions-agent/src/utils.rs create mode 100644 crates/agent/Cargo.toml create mode 100644 crates/agent/src/lib.rs create mode 100644 crates/web/Cargo.toml create mode 100644 crates/web/src/lib.rs diff --git a/bins/identity-agent/src/migration/mod.rs b/bins/identity-agent/src/migration/mod.rs new file mode 100644 index 0000000..3532b7f --- /dev/null +++ b/bins/identity-agent/src/migration/mod.rs @@ -0,0 +1,10 @@ +pub use database::sea_orm_migration::prelude::*; + +pub struct Migrator; + +#[async_trait::async_trait] +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![] + } +} diff --git a/bins/sessions-agent/Cargo.toml b/bins/sessions-agent/Cargo.toml new file mode 100644 index 0000000..8d4e584 --- /dev/null +++ b/bins/sessions-agent/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "sessions-agent" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +tokio = { version = "1.27.0", features = ["full"] } +database = { workspace = true } +async-trait = "0.1.75" +events = { workspace = true } +actix = "0.13.1" diff --git a/bins/sessions-agent/src/main.rs b/bins/sessions-agent/src/main.rs new file mode 100644 index 0000000..ce4ffdc --- /dev/null +++ b/bins/sessions-agent/src/main.rs @@ -0,0 +1,3 @@ +#[actix::main] +async fn main() { +} diff --git a/bins/sessions-agent/src/migration/mod.rs b/bins/sessions-agent/src/migration/mod.rs new file mode 100644 index 0000000..3532b7f --- /dev/null +++ b/bins/sessions-agent/src/migration/mod.rs @@ -0,0 +1,10 @@ +pub use database::sea_orm_migration::prelude::*; + +pub struct Migrator; + +#[async_trait::async_trait] +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![] + } +} diff --git a/bins/sessions-agent/src/utils.rs b/bins/sessions-agent/src/utils.rs new file mode 100644 index 0000000..b7beb2a --- /dev/null +++ b/bins/sessions-agent/src/utils.rs @@ -0,0 +1,294 @@ +use std::collections::HashSet; +use std::hash::{DefaultHasher, Hasher}; +use std::num::TryFromIntError; +use std::path::PathBuf; +use std::sync::Arc; + +use chrono::NaiveDateTime; +use database::chrono::Utc; +use database::sea_orm::ActiveValue::*; +use database::{chrono, sessions, uuid}; +use jsonwebtoken::*; +use serde::{Deserialize, Serialize}; +use tracing::*; + +pub static KEYS_PATH: &str = "./config"; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + #[serde(rename = "sum")] + pub hash_sum: i64, + #[serde(rename = "sub")] + pub subject: i32, + #[serde(rename = "aud")] + pub audience: String, + pub role: String, + #[serde(rename = "iss")] + pub issuer: String, + #[serde(rename = "nbt")] + pub not_before_time: NaiveDateTime, + #[serde(rename = "exp")] + pub exp: NaiveDateTime, + #[serde(rename = "iat")] + pub issued_at: NaiveDateTime, + #[serde(rename = "jti")] + pub jwt_unique_identifier: uuid::Uuid, +} + +impl Claims { + pub async fn new( + subject: i32, + audience: String, + role: String, + issuer: Option, + not_before_time: chrono::NaiveDateTime, + issued_at: chrono::NaiveDateTime, + expiration_duration: Option, + ) -> Result { + let issuer = issuer.unwrap_or_else(|| "ergokeyboard".to_owned()); + let mut claims = Claims { + subject, + audience, + role, + issuer, + hash_sum: 0, + not_before_time, + exp: issued_at + expiration_duration.unwrap_or(chrono::Duration::days(365)), + issued_at, + jwt_unique_identifier: uuid::Uuid::new_v4(), + }; + let hash_sum = generate_hash_sum(&claims).await as i64; + claims.hash_sum = hash_sum; + Ok(claims) + } +} + +macro_rules! cmp_both { + ($l: expr, $r: expr, $($field: ident),+) => { + $( + $l.$field == $r.$field && + )+ true + } +} +impl PartialEq for Claims { + fn eq(&self, s: &sessions::Model) -> bool { + cmp_both!( + self, + s, + hash_sum, + subject, + audience, + role, + issuer, + not_before_time, + issued_at, + jwt_unique_identifier + ) && self.exp == s.expiration_time + } +} + +#[derive(Debug, thiserror::Error)] +pub enum KeysError { + #[error("Decode key failed on file system error: {0}")] + DecodeKeyIo(std::io::Error), + #[error("Decode key failed on file system error: {0}")] + EncodeKeyIo(std::io::Error), + #[error("Decode key failed to parse ed25519 key: {0}")] + DecodeKeyParsing(jsonwebtoken::errors::Error), + #[error("Encode key failed to parse ed25519 key: {0}")] + EncodeKeyParsing(jsonwebtoken::errors::Error), +} + +pub struct JwtKeysInner { + decode: DecodingKey, + encode: EncodingKey, +} + +const DECODE_KEY_NAME: &str = "public.pem"; +const ENCODE_KEY_NAME: &str = "private.pem"; + +#[derive(Clone, derive_more::Deref)] +pub struct JwtKeys(Arc); + +impl JwtKeys { + pub fn load(config_path: PathBuf) -> Result { + Ok(Self(Arc::new(JwtKeysInner { + decode: DecodingKey::from_ed_pem( + &std::fs::read(config_path.join(DECODE_KEY_NAME)) + .map_err(KeysError::DecodeKeyIo)?, + ) + .map_err(KeysError::DecodeKeyParsing)?, + encode: EncodingKey::from_ed_pem( + &std::fs::read(config_path.join(ENCODE_KEY_NAME)) + .map_err(KeysError::EncodeKeyIo)?, + ) + .map_err(KeysError::EncodeKeyParsing)?, + }))) + } +} + +pub async fn generate_token( + subject: i32, + audience: String, + role: String, + issuer: Option, + not_before_time: chrono::NaiveDateTime, + issued_at: chrono::NaiveDateTime, + expiration_duration: Option, +) -> Result { + let claims = Claims::new( + subject, + audience, + role, + issuer.clone(), + not_before_time, + issued_at, + expiration_duration, + ) + .await?; + Ok(database::sessions::ActiveModel { + hash_sum: Set(claims.hash_sum), + subject: Set(claims.subject), + audience: Set(claims.audience), + role: Set(claims.role), + issuer: Set(claims.issuer), + not_before_time: Set(claims.not_before_time), + expiration_time: Set(claims.exp), + issued_at: Set(claims.issued_at), + jwt_unique_identifier: Set(claims.jwt_unique_identifier), + ..Default::default() + }) +} + +pub async fn generate_hash_sum(claims: &Claims) -> i64 { + let Claims { + subject, + hash_sum: _, + audience, + role, + issuer, + not_before_time, + exp: expiration_time, + issued_at, + jwt_unique_identifier, + } = claims; + let mut hasher = DefaultHasher::default(); + hasher.write_i32(*subject); + hasher.write(audience.as_bytes()); + hasher.write(role.as_bytes()); + hasher.write(issuer.as_bytes()); + hasher.write_i64(not_before_time.timestamp_nanos_opt().expect("invalid NBT")); + hasher.write_i64(expiration_time.timestamp_nanos_opt().expect("invalid EXP")); + hasher.write_i64(issued_at.timestamp_nanos_opt().expect("invalid IAT")); + hasher.write(jwt_unique_identifier.as_bytes()); + hasher.finish() as i64 +} + +#[derive(Debug, thiserror::Error)] +pub enum ValidationError { + #[error("Given JWT text is not valid")] + InvalidString, + #[error("Can't load accounts from database")] + FetchAccounts, + #[error("Can't load sessions from database")] + FetchSessions, + #[error("Account for given ID does not exists")] + NoAccount, + #[error("Given token does not exists")] + UnknownToken, + #[error("Given token expired")] + Expired, +} + +pub async fn validate( + db_client: database::DatabaseConnection, + keys: JwtKeys, + token: &str, +) -> Result { + use database::*; + + let mut validation = jsonwebtoken::Validation::new(Algorithm::EdDSA); + validation.validate_exp = false; + validation.required_spec_claims = HashSet::new(); + validation.set_audience(&["Web"]); + validation.set_issuer(&["ergokeyboard"]); + + tracing::info!("decoding token: {token:?}"); + let token = match jsonwebtoken::decode::( + &token, + &keys.decode, + &validation, + ) { + Err(e) => { + warn!("Failed to decode token: {e}"); + return Err(ValidationError::InvalidString); + } + Ok(token) => token.claims, + }; + tracing::trace!("claims are: {token:?}"); + let hash = generate_hash_sum(&token).await as i64; + let Ok(mut rows) = Sessions::find() + .filter(entities::sessions::Column::HashSum.eq(hash)) + .all(&db_client) + .await + else { + return Err(ValidationError::FetchSessions); + }; + + let Some(found_idx) = rows.iter().position(|row| token == *row).clone() else { + return Err(ValidationError::UnknownToken); + }; + let found = rows.remove(found_idx); + if found.expiration_time < Utc::now().naive_utc() { + return Err(ValidationError::Expired); + } + + match Accounts::find() + .filter(database::accounts::Column::Id.eq(found.subject)) + .one(&db_client) + .await + { + Err(e) => { + error!("Failed to load account for {found:?}: {e}"); + Err(ValidationError::FetchAccounts) + } + Ok(None) => Err(ValidationError::NoAccount), + Ok(Some(account)) => Ok(account), + } +} + +pub async fn create_jwt_string( + keys: JwtKeys, + claims: &Claims, +) -> Result { + jsonwebtoken::encode( + &jsonwebtoken::Header::new(Algorithm::EdDSA), + claims, + &keys.encode, + ) +} + +#[cfg(test)] +mod tests { + use database::chrono::Utc; + + use super::*; + use std::path::Path; + + #[tokio::test] + async fn create_string() { + let keys = JwtKeys::load(Path::new("./config").to_owned()).unwrap(); + let claims = Claims::new( + 234, + "jaosidf".into(), + "User".into(), + None, + Utc::now().naive_utc(), + Utc::now().naive_utc(), + None, + ) + .await + .unwrap(); + let _text = create_jwt_string(keys, &claims).await.unwrap(); + } +} diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml new file mode 100644 index 0000000..ab62035 --- /dev/null +++ b/crates/agent/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "agent" +version = "0.1.0" +edition = "2021" + +[features] +with-web = ["web"] +with-database = ["database"] +with-events = ["events"] + +[dependencies] +futures = { version = "0.3.30", default-features = false, features = ["async-await", "futures-executor", "executor", "compat", "std", "thread-pool"] } +tokio = { version = "1.35.1", features = ["full", "tracing"] } +web = { workspace = true, optional = true } +events = { workspace = true, optional = true } +database = { workspace = true, optional = true } diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs new file mode 100644 index 0000000..b941b60 --- /dev/null +++ b/crates/agent/src/lib.rs @@ -0,0 +1,4 @@ +pub struct SharedResources { + #[cfg(feature = "web")] + web: web::Web, +} diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml new file mode 100644 index 0000000..ee858d4 --- /dev/null +++ b/crates/web/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "web" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +actix = "0.13.1" +actix-web = "4.4.1" +futures = { version = "0.3.30", default-features = false, features = ["async-await", "futures-executor", "executor", "compat", "std", "thread-pool"] } +tokio = { version = "1.35.1", features = ["full", "tracing"] } diff --git a/crates/web/src/lib.rs b/crates/web/src/lib.rs new file mode 100644 index 0000000..0e673e4 --- /dev/null +++ b/crates/web/src/lib.rs @@ -0,0 +1,4 @@ +pub struct Web {} + +impl Web { +}