diff --git a/.env.example b/.env.example index 2a66e05..a8d05c8 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,6 @@ # shellcheck disable=2034,2148 + +# db app DB_MONGO_USER=mongo DB_MONGO_PASS=secret DB_MONGO_HOST=localhost @@ -20,10 +22,15 @@ DB_KEYS_HOST=localhost DB_KEYS_PORT=5433 DB_KEYS_NAME=api6_keys +# api app API_BYPASS_AUTH=false +# auth app +AUTH_APP_URL=localhost +AUTH_APP_PORT=3000 + AUTH_DB_POSTGRES_HOST=api6_postgres AUTH_DB_KEYS_HOST=api6_keys -AUTH_APP_PORT=3000 + AUTH_JWT_SECRET=secret AUTH_DEV_MODE=true diff --git a/Cargo.lock b/Cargo.lock index da8295e..f76cf0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -370,6 +370,48 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "askama" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" +dependencies = [ + "askama_derive", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" +dependencies = [ + "askama_parser", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn 2.0.100", +] + +[[package]] +name = "askama_parser" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" +dependencies = [ + "memchr", + "serde", + "serde_derive", + "winnow", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -421,6 +463,7 @@ dependencies = [ "actix-web", "actix-web-httpauth", "argon2", + "askama", "base64", "bcrypt", "dotenv", @@ -477,6 +520,15 @@ version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bcrypt" version = "0.17.0" @@ -2512,6 +2564,12 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustix" version = "1.0.5" diff --git a/apps/auth/Cargo.toml b/apps/auth/Cargo.toml index 50707e1..3f2e13a 100644 --- a/apps/auth/Cargo.toml +++ b/apps/auth/Cargo.toml @@ -37,5 +37,6 @@ thiserror = "2.0.12" fernet = "0.2.2" base64 = "0.22.1" actix-web-httpauth = "0.8.2" +askama = "0.14.0" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/apps/auth/src/entities/entity_key.rs b/apps/auth/src/entities/entity_key.rs new file mode 100644 index 0000000..f900991 --- /dev/null +++ b/apps/auth/src/entities/entity_key.rs @@ -0,0 +1,16 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, DeriveEntityModel)] +#[sea_orm(table_name = "entity_key")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = true)] + pub id: i64, + pub entity_id: i64, + pub entity_type: i64, + pub key: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/apps/auth/src/entities/keys.rs b/apps/auth/src/entities/entity_type.rs similarity index 81% rename from apps/auth/src/entities/keys.rs rename to apps/auth/src/entities/entity_type.rs index ac355a9..fad584b 100644 --- a/apps/auth/src/entities/keys.rs +++ b/apps/auth/src/entities/entity_type.rs @@ -1,11 +1,11 @@ use sea_orm::entity::prelude::*; #[derive(Clone, Debug, DeriveEntityModel)] -#[sea_orm(table_name = "user_key")] +#[sea_orm(table_name = "entity_type")] pub struct Model { #[sea_orm(primary_key)] pub id: i64, - pub key: String + pub name: String, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/apps/auth/src/entities/external_client.rs b/apps/auth/src/entities/external_client.rs new file mode 100644 index 0000000..6b804ae --- /dev/null +++ b/apps/auth/src/entities/external_client.rs @@ -0,0 +1,19 @@ +use sea_orm::entity::prelude::*; +use utoipa::ToSchema; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, ToSchema)] +#[sea_orm(table_name = "external_clients")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = true)] + pub id: i64, + pub name: String, + pub login: String, + pub password: String, + pub created_at: DateTime, + pub disabled_since: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/apps/auth/src/entities/mod.rs b/apps/auth/src/entities/mod.rs index c1e27f7..7201316 100644 --- a/apps/auth/src/entities/mod.rs +++ b/apps/auth/src/entities/mod.rs @@ -1,4 +1,6 @@ -pub mod keys; +pub mod entity_key; +pub mod entity_type; +pub mod external_client; pub mod permission; pub mod revoked_token; pub mod role; diff --git a/apps/auth/src/infra/config.rs b/apps/auth/src/infra/config.rs index d029411..a1f9b64 100644 --- a/apps/auth/src/infra/config.rs +++ b/apps/auth/src/infra/config.rs @@ -37,7 +37,9 @@ pub fn setup() -> Config { .parse() .unwrap(), - server_port: env::var("AUTH_APP_PORT") + app_host: env::var("AUTH_APP_HOST").unwrap_or_else(|_| "localhost".into()), + + app_port: env::var("AUTH_APP_PORT") .unwrap_or_else(|_| "3000".into()) .parse() .unwrap(), diff --git a/apps/auth/src/infra/server.rs b/apps/auth/src/infra/server.rs index c5ac8f6..0396199 100755 --- a/apps/auth/src/infra/server.rs +++ b/apps/auth/src/infra/server.rs @@ -23,7 +23,7 @@ pub struct DatabaseClientKeys { } pub async fn create_server(config: Config) -> std::io::Result { - let server_port = config.server_port; + let server_port = config.app_port; let db_postgres_client_data = web::Data::new(DatabaseClientPostgres { client: db::create_seaorm_connection( @@ -54,6 +54,9 @@ pub async fn create_server(config: Config) -> std::io::Result { ) .configure(routes::user) .configure(routes::auth) + .configure(routes::external_client) + .configure(routes::external_client_auth) + .configure(routes::portability) }) .bind(("0.0.0.0", server_port))?; diff --git a/apps/auth/src/infra/types.rs b/apps/auth/src/infra/types.rs index 63212a1..4bd416c 100644 --- a/apps/auth/src/infra/types.rs +++ b/apps/auth/src/infra/types.rs @@ -10,7 +10,8 @@ pub struct DatabaseConfig { pub struct Config { pub database_clients: HashMap, - pub server_port: u16, + pub app_host: String, + pub app_port: u16, pub jwt_secret: String, pub dev_mode: bool, } @@ -22,6 +23,13 @@ impl Config { None => panic!("Database config not found for `{}`", name), } } + + pub fn get_app_url(&self) -> String { + match self.app_host.as_str() { + "localhost" => format!("http://{}:{}", self.app_host, self.app_port), + _ => format!("https://{}", self.app_host), + } + } } pub struct PresetField { diff --git a/apps/auth/src/main.rs b/apps/auth/src/main.rs index 81f4672..1432b18 100644 --- a/apps/auth/src/main.rs +++ b/apps/auth/src/main.rs @@ -1,5 +1,6 @@ mod infra; mod service; +mod templates; mod entities; mod models; diff --git a/apps/auth/src/models/auth.rs b/apps/auth/src/models/auth.rs index 400302a..03fcd8a 100644 --- a/apps/auth/src/models/auth.rs +++ b/apps/auth/src/models/auth.rs @@ -3,7 +3,7 @@ use utoipa::ToSchema; #[derive(Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct RegisterRequest { +pub struct UserRegisterRequest { pub name: String, pub login: String, pub email: String, @@ -12,6 +12,14 @@ pub struct RegisterRequest { pub role_id: i64, } +#[derive(Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ExternalClientRegisterRequest { + pub name: String, + pub login: String, + pub password: String, +} + #[derive(Deserialize, ToSchema)] pub struct LoginRequest { pub login: String, @@ -19,13 +27,23 @@ pub struct LoginRequest { } #[derive(Serialize, ToSchema)] -pub struct LoginResponse { +pub struct UserLoginResponse { pub token: String, pub id: i64, pub permissions: Vec, } +#[derive(Serialize, ToSchema)] +pub struct ExternalClientLoginResponse { + pub token: String, +} + #[derive(Deserialize, ToSchema)] pub struct ValidateRequest { pub token: String, } + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct PortabilityScreenQuery { + pub token: String, +} diff --git a/apps/auth/src/models/entity_type.rs b/apps/auth/src/models/entity_type.rs new file mode 100644 index 0000000..29ce286 --- /dev/null +++ b/apps/auth/src/models/entity_type.rs @@ -0,0 +1,42 @@ +use sea_orm::entity::prelude::*; +use std::str::FromStr; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::Max)")] +pub enum EntityType { + #[sea_orm(string_value = "user")] + User, + #[sea_orm(string_value = "external_client")] + ExternalClient, + #[sea_orm(string_value = "authorized_client")] + AuthorizedClient, +} + +impl EntityType { + pub fn as_str(&self) -> &'static str { + match self { + EntityType::User => "user", + EntityType::ExternalClient => "external_client", + EntityType::AuthorizedClient => "authorized_client", + } + } +} + +impl FromStr for EntityType { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "user" => Ok(EntityType::User), + "external_client" => Ok(EntityType::ExternalClient), + "authorized_client" => Ok(EntityType::AuthorizedClient), + _ => Err(()), + } + } +} + +impl std::fmt::Display for EntityType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} diff --git a/apps/auth/src/models/external_client.rs b/apps/auth/src/models/external_client.rs new file mode 100644 index 0000000..9e49911 --- /dev/null +++ b/apps/auth/src/models/external_client.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use utoipa::ToSchema; + +#[derive(Debug, Serialize, Deserialize, FromRow, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ExternalClientPublic { + pub id: i64, + pub name: String, + pub login: String, + pub created_at: String, + pub disabled_since: Option, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ExternalClientUpdate { + pub name: Option, + pub login: Option, + pub password: Option, + pub disabled_since: Option>, +} diff --git a/apps/auth/src/models/jwt.rs b/apps/auth/src/models/jwt.rs index ae0ffcd..702b490 100644 --- a/apps/auth/src/models/jwt.rs +++ b/apps/auth/src/models/jwt.rs @@ -1,18 +1,22 @@ +use base64::{engine::general_purpose::STANDARD, Engine as _}; use jsonwebtoken::errors::Error as JwtError; use serde::{Deserialize, Serialize}; +use std::str::FromStr; use thiserror::Error; use utoipa::ToSchema; +use super::EntityType; + #[derive(Debug, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct Claims { - /// Subject (user ID or similar) + /// Subject (base64-encoded "entity_type:entity_id") pub sub: String, /// Expiration time as UNIX timestamp pub exp: usize, - /// Issued-at time as UNIX timestamp (optional, but recommended) + /// Issued-at time as UNIX timestamp #[schema(example = 1711670000)] pub iat: usize, @@ -21,6 +25,35 @@ pub struct Claims { pub jti: String, } +pub struct ClaimsSubject { + pub entity_type: EntityType, + pub id: String, +} + +impl Claims { + pub fn parse_subject(&self) -> Result { + let decoded = STANDARD + .decode(&self.sub) + .map_err(|_| VerificationError::InvalidSubjectFormat)?; + + let decoded_str = + std::str::from_utf8(&decoded).map_err(|_| VerificationError::InvalidSubjectFormat)?; + + let parts: Vec<&str> = decoded_str.splitn(2, ':').collect(); + if parts.len() != 2 { + return Err(VerificationError::InvalidSubjectFormat); + } + + let entity_type = EntityType::from_str(parts[0]) + .map_err(|_| VerificationError::InvalidEntityType(parts[0].to_string()))?; + + Ok(ClaimsSubject { + entity_type, + id: parts[1].to_string(), + }) + } +} + #[derive(Debug, Error)] pub enum VerificationError { #[error("JWT error: {0}")] @@ -28,5 +61,10 @@ pub enum VerificationError { #[error("Token has been revoked")] Revoked, #[error("Database error: {0}")] - Db(#[from] sea_orm::DbErr), + Database(#[from] sea_orm::DbErr), + + #[error("Invalid subject format")] + InvalidSubjectFormat, + #[error("Invalid entity type `{0}` for subject")] + InvalidEntityType(String), } diff --git a/apps/auth/src/models/mod.rs b/apps/auth/src/models/mod.rs index ba66e84..05e0920 100644 --- a/apps/auth/src/models/mod.rs +++ b/apps/auth/src/models/mod.rs @@ -1,7 +1,12 @@ pub mod auth; pub mod jwt; -mod user; + +mod entity_type; +mod external_client; mod pagination; +mod user; -pub use user::*; +pub use entity_type::EntityType; +pub use external_client::*; pub use pagination::*; +pub use user::*; diff --git a/apps/auth/src/models/user.rs b/apps/auth/src/models/user.rs index 81b3bea..e27bc36 100644 --- a/apps/auth/src/models/user.rs +++ b/apps/auth/src/models/user.rs @@ -25,3 +25,11 @@ pub struct UserUpdate { pub role_id: Option, pub disabled_since: Option>, } + +#[derive(Debug, Serialize, Deserialize, FromRow, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UserPortability { + pub name: String, + pub login: String, + pub email: String, +} diff --git a/apps/auth/src/routes/auth.rs b/apps/auth/src/routes/auth.rs index a4c1b7c..6f4c451 100644 --- a/apps/auth/src/routes/auth.rs +++ b/apps/auth/src/routes/auth.rs @@ -19,7 +19,7 @@ use sea_query::Query; use crate::{ entities::{ - keys as keys_entity, + entity_key as keys_entity, permission as permission_entity, role_permission as role_permission_entity, user as user_entity, // @@ -30,7 +30,8 @@ use crate::{ }, models::{ auth::*, - jwt::Claims, // + jwt::Claims, + EntityType, // }, service::{ fernet::*, @@ -48,16 +49,16 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .route("/logout", web::post().to(logout)), ) .service( - web::scope("") + web::scope("/register") .wrap(HttpAuthentication::bearer(validator)) - .route("/register", web::post().to(register)), + .route("/", web::post().to(register)), ); } #[utoipa::path( post, - path = "/register", - request_body = RegisterRequest, + path = "/register/", + request_body = UserRegisterRequest, responses( (status = 200, description = "User registered"), (status = 500, description = "Hashing or Database error") @@ -69,7 +70,7 @@ pub async fn register( postgres_client: web::Data, keys_client: web::Data, config: web::Data, - data: web::Json, + data: web::Json, ) -> impl Responder { let hashed = match hash(&data.password, DEFAULT_COST) { Ok(h) => h, @@ -99,7 +100,9 @@ pub async fn register( }; let new_user_key = keys_entity::ActiveModel { - id: Set(inserted_user.id), + id: NotSet, + entity_id: Set(inserted_user.id), + entity_type: Set(1), // TODO: select id instead key: Set(new_user_decryption_key.clone()), }; @@ -121,7 +124,7 @@ pub async fn register( path = "/auth/login", request_body = LoginRequest, responses( - (status = 200, description = "Login successful", body = LoginResponse), + (status = 200, description = "Login successful", body = UserLoginResponse), (status = 401, description = "Unauthorized"), (status = 500, description = "Database error") ), @@ -149,10 +152,11 @@ pub async fn login( return HttpResponse::Unauthorized().body("Inactive user"); } - let user_decryption_key = match get_user_key(user.id, &keys_client, &config).await { - GetUserKeyResult::Ok(key) => key, - GetUserKeyResult::Err(err) => return err, - }; + let user_decryption_key = + match get_entity_key(user.id, EntityType::User, &keys_client, &config).await { + GetKeyResult::Ok(key) => key, + GetKeyResult::Err(err) => return err, + }; let decrypted_password = match decrypt_field( &fernet::Fernet::new(&user_decryption_key).unwrap(), @@ -187,8 +191,8 @@ pub async fn login( }; if verify(&data.password, &decrypted_password).unwrap_or(false) { - let token = create_jwt(&user.id.to_string(), &config.jwt_secret); - HttpResponse::Ok().json(LoginResponse { + let token = create_jwt(&user.id.to_string(), EntityType::User, &config.jwt_secret); + HttpResponse::Ok().json(UserLoginResponse { token, id: user.id, permissions, @@ -214,7 +218,7 @@ pub async fn validate_token( keys_client: web::Data, config: web::Data, ) -> impl Responder { - match verify_jwt(&data.token, &config.jwt_secret, &keys_client.client).await { + match verify_jwt(&data.token, &config.jwt_secret, &keys_client).await { Ok(claims) => HttpResponse::Ok().json(claims.claims), Err(err) => handle_server_error_body("Invalid token", err, &config, None), } diff --git a/apps/auth/src/routes/common/error.rs b/apps/auth/src/routes/common/error.rs index cb7892b..9b05805 100644 --- a/apps/auth/src/routes/common/error.rs +++ b/apps/auth/src/routes/common/error.rs @@ -1,12 +1,19 @@ use actix_web::{web, HttpResponse}; use thiserror::Error; -use crate::service::fernet::DecryptionError; +use crate::{ + models::EntityType,// + service::fernet::DecryptionError, +}; #[derive(Debug, Error)] pub enum CustomError { - #[error("Decryption key not found in `user_key` table for user ID {0}")] - UserKeyNotFound(i64), + #[error("User with ID {0} not found")] + UserNotFound(i64), + #[error("External Client with ID {0} not found")] + ExternalClientNotFound(i64), + #[error("Decryption key not found in `entity_key` table for `{0}` entity of ID {1}")] + EntityKeyNotFound(EntityType, i64), #[error("Unsuccessful decryption of field `{0}`: {1}")] UnsuccessfulDecryption(String, #[source] DecryptionError), } diff --git a/apps/auth/src/routes/external_client.rs b/apps/auth/src/routes/external_client.rs new file mode 100644 index 0000000..6e87eac --- /dev/null +++ b/apps/auth/src/routes/external_client.rs @@ -0,0 +1,303 @@ +use actix_web::{web, HttpResponse, Responder}; +use actix_web_httpauth::middleware::HttpAuthentication; +use bcrypt::{hash, DEFAULT_COST}; +use fernet::Fernet; +use sea_orm::prelude::Date; +use sea_orm::ActiveValue::{NotSet, Set}; +use sea_orm::{ + ActiveModelTrait, // + EntityTrait, + IntoActiveModel, + PaginatorTrait, + QuerySelect, +}; + +use crate::entities::external_client as external_client_entity; +use crate::infra::server::{DatabaseClientKeys, DatabaseClientPostgres}; +use crate::models::{ + EntityType, // + ExternalClientPublic, + ExternalClientUpdate, + PaginatedRequest, + PaginatedResponse, +}; +use crate::service::fernet::{ + decrypt_database_external_client, // + encrypt_field, + get_entity_key, + GetKeyResult, +}; +use crate::service::jwt::validator; + +use super::common::{handle_server_error_body, ServerErrorType}; + +pub fn configure(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/external_clients") + .wrap(HttpAuthentication::bearer(validator)) + .route("/", web::post().to(get_external_clients)), // + ) + .service( + web::scope("/external_client") + .wrap(HttpAuthentication::bearer(validator)) + .route("/{id}", web::get().to(get_external_client)) + .route("/{id}", web::put().to(update_external_client)) + .route("/{id}", web::delete().to(delete_external_client)), + ); +} + +#[utoipa::path( + post, + path = "/external_clients/", + request_body = PaginatedRequest, + responses( + (status = 200, description = "ExternalClients found", body = PaginatedResponse), + (status = 404, description = "Not Found"), + (status = 400, description = "Invalid pagination parameters"), + (status = 500, description = "Server error") + ), + tags = ["External Client"] +)] + +async fn get_external_clients( + postgres_client: web::Data, + keys_client: web::Data, + config: web::Data, + pagination: web::Json, +) -> impl Responder { + let page = pagination.page.unwrap_or(1).max(1); + let size = pagination.size.unwrap_or(10).max(1); + + let offset = (page - 1) * size; + let result = external_client_entity::Entity::find() + .limit(size) + .offset(Some(offset)) + .all(&postgres_client.client) + .await; + + let raw_external_clients = match result { + Err(err) => return handle_server_error_body("Database Error", err, &config, None), + Ok(raw_external_clients) => raw_external_clients, + }; + + let mut external_clients_public = Vec::with_capacity(raw_external_clients.len()); + + for encrypted_external_client in raw_external_clients { + let external_client_decryption_key = match get_entity_key( + encrypted_external_client.id, + EntityType::ExternalClient, + &keys_client, + &config, + ) + .await + { + GetKeyResult::Ok(key) => key, + GetKeyResult::Err(err) => return err, + }; + + external_clients_public.push( + match decrypt_database_external_client( + &external_client_decryption_key, + encrypted_external_client, + ) { + Ok(decrypted_external_client) => decrypted_external_client, + Err(err) => return handle_server_error_body("Parse error", err, &config, None), + }, + ); + } + + let total_external_clients = external_client_entity::Entity::find() + .count(&postgres_client.client) + .await + .unwrap_or(0); + + let total_pages = (total_external_clients as f64 / size as f64).ceil() as u64; + + let external_clients_page: PaginatedResponse = PaginatedResponse { + total: external_clients_public.len() as u64, + page, + total_pages, + items: external_clients_public, + }; + + HttpResponse::Ok().json(external_clients_page) +} + +#[utoipa::path( + get, + path = "/external_client/{id}", + params( + ("id" = i64, Path, description = "ExternalClient ID") + ), + responses( + (status = 200, description = "ExternalClient found", body = ExternalClientPublic), + (status = 404, description = "ExternalClient not found"), + (status = 500, description = "Server error") + ), + tags = ["External Client"] +)] + +async fn get_external_client( + postgres_client: web::Data, + keys_client: web::Data, + config: web::Data, + external_client_id: web::Path, +) -> impl Responder { + let external_client_id = external_client_id.into_inner(); + + let result = external_client_entity::Entity::find_by_id(external_client_id) + .one(&postgres_client.client) + .await; + + let encrypted_external_client = match result { + Ok(Some(external_client)) => external_client, + Ok(None) => { + return handle_server_error_body( + "Database Error", + super::common::CustomError::ExternalClientNotFound(external_client_id), + &config, + Some(ServerErrorType::NotFound), + ) + } + Err(err) => return handle_server_error_body("Database Error", err, &config, None), + }; + + let external_client_decryption_key = match get_entity_key( + encrypted_external_client.id, + EntityType::ExternalClient, + &keys_client, + &config, + ) + .await + { + GetKeyResult::Ok(key) => key, + GetKeyResult::Err(err) => return err, + }; + + match decrypt_database_external_client( + &external_client_decryption_key, + encrypted_external_client, + ) { + Ok(decrypted_external_client) => HttpResponse::Ok().json(decrypted_external_client), + Err(err) => return handle_server_error_body("Parse error", err, &config, None), + } +} + +#[utoipa::path( + put, + path = "/external_client/{id}", + request_body = ExternalClientUpdate, + params( + ("id" = i64, Path, description = "ExternalClient ID") + ), + responses( + (status = 200, description = "ExternalClient updated"), + (status = 404, description = "ExternalClient not found"), + (status = 500, description = "Server error") + ), + tags = ["External Client"] +)] + +async fn update_external_client( + postgres_client: web::Data, + keys_client: web::Data, + config: web::Data, + external_client_id: web::Path, + external_client_update: web::Json, +) -> impl Responder { + let existing = external_client_entity::Entity::find_by_id(*external_client_id) + .one(&postgres_client.client) + .await; + + let mut external_client_update_model = match existing { + Ok(Some(model)) => model.into_active_model(), + Ok(None) => return HttpResponse::NotFound().body("ExternalClient not found"), + Err(err) => return handle_server_error_body("Database Error", err, &config, None), + }; + + let external_client_decryption_key = match get_entity_key( + *external_client_id, + EntityType::ExternalClient, + &keys_client, + &config, + ) + .await + { + GetKeyResult::Ok(key) => key, + GetKeyResult::Err(err) => return err, + }; + + external_client_update_model.disabled_since = match &external_client_update.disabled_since { + Some(dt) => match dt { + Some(dt) => match Date::parse_from_str(&dt, "%Y-%m-%d") { + Ok(valid_update_date) => Set(Some(valid_update_date.into())), + Err(err) => { + return handle_server_error_body( + "Parse error", + err, + &config, + Some(ServerErrorType::BadRequest), + ) + } + }, + None => Set(None), + }, + None => NotSet, + }; + + let fernet = Fernet::new(&external_client_decryption_key).unwrap(); + + external_client_update_model.name = match external_client_update.name { + Some(ref name) => Set(encrypt_field(&fernet, &name)), + None => NotSet, + }; + external_client_update_model.login = match external_client_update.login { + Some(ref login) => Set(encrypt_field(&fernet, &login)), + None => NotSet, + }; + external_client_update_model.password = match external_client_update.password { + Some(ref password) => match hash(password, DEFAULT_COST) { + Ok(ref hashed_password) => Set(encrypt_field(&fernet, hashed_password)), + Err(_) => return HttpResponse::InternalServerError().body("Hashing error"), + }, + None => NotSet, + }; + + match external_client_update_model + .update(&postgres_client.client) + .await + { + Ok(_) => HttpResponse::Ok().finish(), + Err(err) => handle_server_error_body("Database Error", err, &config, None), + } +} + +#[utoipa::path( + delete, + path = "/external_client/{id}", + params( + ("id" = i64, Path, description = "ExternalClient ID") + ), + responses( + (status = 200, description = "ExternalClient deleted"), + (status = 500, description = "Server error") + ), + tags = ["External Client"] +)] + +async fn delete_external_client( + postgres_client: web::Data, + config: web::Data, + external_client_id: web::Path, +) -> impl Responder { + // TODO: use `disabled_since` field of `external_clients` table + + let result = external_client_entity::Entity::delete_by_id(external_client_id.into_inner()) + .exec(&postgres_client.client) + .await; + + match result { + Ok(_) => HttpResponse::Ok().finish(), + Err(err) => handle_server_error_body("Database Error", err, &config, None), + } +} diff --git a/apps/auth/src/routes/external_client_auth.rs b/apps/auth/src/routes/external_client_auth.rs new file mode 100644 index 0000000..beb1e4e --- /dev/null +++ b/apps/auth/src/routes/external_client_auth.rs @@ -0,0 +1,237 @@ +use actix_web::{ + web, // + HttpRequest, + HttpResponse, + Responder, +}; +use bcrypt::{hash, verify, DEFAULT_COST}; +use fernet::Fernet; +use sea_orm::{ + ActiveModelTrait, // + ColumnTrait, + EntityTrait, + NotSet, + QueryFilter, + Set, +}; + +use crate::{ + entities::{ + entity_key as keys_entity, + external_client as external_client_entity, // + }, + infra::server::{ + DatabaseClientKeys, + DatabaseClientPostgres, // + }, + models::{ + auth::*, + jwt::Claims, + EntityType, // + }, + service::{ + fernet::*, + jwt::*, // + }, +}; + +use super::common::{handle_server_error_body, handle_server_error_string, CustomError}; + +pub fn configure(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/client_auth") + .route("/login", web::post().to(external_client_login)) + .route("/validate", web::post().to(external_client_validate_token)) + .route("/logout", web::post().to(external_client_logout)), + ) + .service( + web::scope("/client") // + .route("/register", web::post().to(external_client_register)), + ); +} + +#[utoipa::path( + post, + path = "/client/register", + request_body = ExternalClientRegisterRequest, + responses( + (status = 200, description = "ExternalClient registered"), + (status = 500, description = "Hashing or Database error") + ), + tags = ["External Client Auth"] +)] + +pub async fn external_client_register( + postgres_client: web::Data, + keys_client: web::Data, + config: web::Data, + data: web::Json, +) -> impl Responder { + let hashed = match hash(&data.password, DEFAULT_COST) { + Ok(h) => h, + Err(err) => handle_server_error_string("Hashing error", err, &config), + }; + + let new_external_client_decryption_key = Fernet::generate_key(); + + let fernet = Fernet::new(&new_external_client_decryption_key).unwrap(); + + let new_external_client = external_client_entity::ActiveModel { + name: Set(encrypt_field(&fernet, &data.name)), + login: Set(encrypt_field(&fernet, &data.login)), + password: Set(encrypt_field(&fernet, &hashed)), + disabled_since: NotSet, + ..Default::default() + }; + + let inserted_external_client = match new_external_client.insert(&postgres_client.client).await { + Ok(inserted_external_client) => inserted_external_client, + Err(err) => { + return handle_server_error_body( + "Failed to register external_client: {:?}", + err, + &config, + None, + ) + } + }; + + let new_external_client_key = keys_entity::ActiveModel { + id: NotSet, + entity_id: Set(inserted_external_client.id), + entity_type: Set(2), // TODO: select id instead + key: Set(new_external_client_decryption_key.clone()), + }; + + match new_external_client_key.insert(&keys_client.client).await { + Ok(_) => HttpResponse::Ok().body("ExternalClient registered"), + Err(err) => { + return handle_server_error_body( + "Failed to register external_client key: {:?}", + err, + &config, + None, + ) + } + } +} + +#[utoipa::path( + post, + path = "/client_auth/login", + request_body = LoginRequest, + responses( + (status = 200, description = "Login successful", body = ExternalClientLoginResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Database error") + ), + tags = ["External Client Auth"] +)] + +pub async fn external_client_login( + postgres_client: web::Data, + keys_client: web::Data, + config: web::Data, + data: web::Json, +) -> impl Responder { + let external_client_result = external_client_entity::Entity::find() + .filter(external_client_entity::Column::Login.eq(data.login.clone())) + .one(&postgres_client.client) + .await; + + let external_client = match external_client_result { + Ok(Some(external_client)) => external_client, + Ok(None) => return HttpResponse::Unauthorized().body("ExternalClient not found"), + Err(err) => return handle_server_error_body("Database Error", err, &config, None), + }; + + if external_client.disabled_since.is_some() { + return HttpResponse::Unauthorized().body("Inactive external_client"); + } + + let external_client_decryption_key = match get_entity_key( + external_client.id, + EntityType::ExternalClient, + &keys_client, + &config, + ) + .await + { + GetKeyResult::Ok(key) => key, + GetKeyResult::Err(err) => return err, + }; + + let decrypted_password = match decrypt_field( + &fernet::Fernet::new(&external_client_decryption_key).unwrap(), + &external_client.password, + ) { + Ok(p) => p, + Err(err) => { + return handle_server_error_body( + "Decryption error", + CustomError::UnsuccessfulDecryption("password".to_string(), err), + &config, + None, + ); + } + }; + + if verify(&data.password, &decrypted_password).unwrap_or(false) { + let token = create_jwt( + &external_client.id.to_string(), + EntityType::ExternalClient, + &config.jwt_secret, + ); + HttpResponse::Ok().json(ExternalClientLoginResponse { token }) + } else { + HttpResponse::Unauthorized().body("Invalid credentials") + } +} + +#[utoipa::path( + post, + path = "/client_auth/validate", + request_body = ValidateRequest, + responses( + (status = 200, description = "Token is valid", body = Claims), + (status = 401, description = "Invalid token") + ), + tags = ["External Client Auth"] +)] + +pub async fn external_client_validate_token( + data: web::Json, + keys_client: web::Data, + config: web::Data, +) -> impl Responder { + match verify_jwt(&data.token, &config.jwt_secret, &keys_client).await { + Ok(claims) => HttpResponse::Ok().json(claims.claims), + Err(err) => handle_server_error_body("Invalid token", err, &config, None), + } +} + +#[utoipa::path( + post, + path = "/client_auth/logout", + request_body = ValidateRequest, + responses( + (status = 200, description = "Token invalidated") + ), + tags = ["External Client Auth"] +)] + +pub async fn external_client_logout( + req: HttpRequest, + keys_client: web::Data, + config: web::Data, +) -> impl Responder { + let token = match extract_bearer(&req) { + Ok(t) => t, + Err(msg) => return HttpResponse::BadRequest().body(msg), + }; + + match revoke_token(token, &config.jwt_secret, &keys_client.client).await { + Ok(_) => HttpResponse::Ok().body("Token invalidated"), + Err(err) => handle_server_error_body("Token Invalidation error", err, &config, None), + } +} diff --git a/apps/auth/src/routes/mod.rs b/apps/auth/src/routes/mod.rs index 41eb0e7..83e2425 100644 --- a/apps/auth/src/routes/mod.rs +++ b/apps/auth/src/routes/mod.rs @@ -1,6 +1,12 @@ pub mod auth; pub mod common; +pub mod external_client; +pub mod external_client_auth; +pub mod portability; pub mod user; pub use auth::configure as auth; +pub use external_client::configure as external_client; +pub use external_client_auth::configure as external_client_auth; +pub use portability::configure as portability; pub use user::configure as user; diff --git a/apps/auth/src/routes/portability.rs b/apps/auth/src/routes/portability.rs new file mode 100644 index 0000000..3cf6b0a --- /dev/null +++ b/apps/auth/src/routes/portability.rs @@ -0,0 +1,342 @@ +use actix_web::{web, HttpMessage, HttpRequest, HttpResponse, Responder}; +use actix_web_httpauth::{extractors::bearer::BearerAuth, middleware::HttpAuthentication}; +use askama::Template; +use bcrypt::verify; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + +use crate::{ + entities::{ + external_client as external_client_entity, + user as user_entity, // + }, + infra::server::{ + DatabaseClientKeys, + DatabaseClientPostgres, // + }, + models::{ + auth::{ + ExternalClientLoginResponse, + LoginRequest, // + PortabilityScreenQuery, + }, + jwt::ClaimsSubject, // + EntityType, + UserPortability, + }, + routes::common::{ + handle_server_error_body, + CustomError, // + }, + service::{ + fernet::{ + decrypt_database_external_client, + decrypt_database_user, + decrypt_field, + get_entity_key, + GetKeyResult, // + }, + jwt::{ + create_jwt, + validate_entity_type, + validator_authorized_client, + validator_external_client, + verify_jwt, // + }, + }, + templates::{ + LoginButtonTemplate, + LoginFormTemplate, // + }, +}; + +pub fn configure(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/portability_button") // + .wrap(HttpAuthentication::bearer(validator_external_client)) + .route("/button", web::get().to(button)), + ) + .service( + web::scope("/portability/screen") // + .route("/", web::get().to(screen)), + ) + .service( + web::scope("/portability/auth") + .wrap(HttpAuthentication::bearer(validator_external_client)) + .route("/authorize", web::post().to(authorize)), + ) + .service( + web::scope("/portability/data") + .wrap(HttpAuthentication::bearer(validator_authorized_client)) + .route("/", web::get().to(portability)), + ); +} + +#[utoipa::path( + get, + path = "/portability_button", + responses( + (status = 200, description = "Returns the portability button HTML"), + (status = 500, description = "Database error") + ), + tags = ["Portability"] +)] + +pub async fn button(req: HttpRequest, credentials: BearerAuth) -> impl Responder { + let tmpl = LoginButtonTemplate { + popup_url: format!( + "{}/portability/screen/?token={}", + req.app_data::>() + .unwrap() + .get_app_url(), + credentials.token(), + ), + }; + HttpResponse::Ok().content_type("text/html").body( + tmpl.render() + .unwrap_or_else(|_| "

An error occurred

".to_string()), + ) +} + +#[utoipa::path( + get, + path = "/portability/screen", + responses( + (status = 200, description = "Returns the authorization screen HTML"), + (status = 500, description = "Database error") + ), + tags = ["Portability"] +)] + +pub async fn screen( + postgres_client: web::Data, + keys_client: web::Data, + config: web::Data, + query: web::Query, +) -> impl Responder { + let token = &query.token; + + let claims = match verify_jwt(&token, &config.jwt_secret, &keys_client).await { + Ok(token_data) => token_data.claims, + Err(err) => { + return handle_server_error_body("Invalid or expired token", err, &config, None) + } + }; + + let client_id = match validate_entity_type(&claims, EntityType::ExternalClient).await { + Ok(subject) => subject.id, + Err(err) => return handle_server_error_body("Invalid token", err, &config, None), + }; + + dbg!(&client_id); + + let external_client_result = + external_client_entity::Entity::find_by_id(client_id.parse::().unwrap()) + .one(&postgres_client.client) + .await; + + let encrypted_external_client = match external_client_result { + Ok(Some(client)) => client, + Ok(None) => return HttpResponse::NotFound().body("ExternalClient not found"), + Err(err) => return handle_server_error_body("Database Error", err, &config, None), + }; + + if encrypted_external_client.disabled_since.is_some() { + return HttpResponse::Unauthorized().body("Inactive external_client"); + } + + let external_client_decryption_key = match get_entity_key( + encrypted_external_client.id, + EntityType::ExternalClient, + &keys_client, + &config, + ) + .await + { + GetKeyResult::Ok(key) => key, + GetKeyResult::Err(err) => return err, + }; + + let external_client = match decrypt_database_external_client( + &external_client_decryption_key, + encrypted_external_client, + ) { + Ok(client) => client, + Err(err) => return handle_server_error_body("Parse error", err, &config, None), + }; + + let tmpl = LoginFormTemplate { + auth_url: format!( + "{}/portability/auth/authorize", + config.get_app_url() + ), + client_name: external_client.name, + }; + HttpResponse::Ok().content_type("text/html").body( + tmpl.render() + .unwrap_or_else(|_| "

An error occurred

".to_string()), + ) +} + +#[utoipa::path( + post, + path = "/portability/auth/authorize", + request_body = LoginRequest, + responses( + (status = 200, description = "Authorization successful", body = ExternalClientLoginResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Database error") + ), + tags = ["Portability"] +)] + +pub async fn authorize( + postgres_client: web::Data, + keys_client: web::Data, + config: web::Data, + data: web::Json, + req: HttpRequest, +) -> impl Responder { + let client_id = match req.extensions().get::() { + Some(subject) => subject.id.to_string(), + None => return HttpResponse::Unauthorized().body("Unauthorized"), + }; + + let external_client_result = + external_client_entity::Entity::find_by_id(client_id.parse::().unwrap()) + .one(&postgres_client.client) + .await; + + let external_client = match external_client_result { + Ok(Some(client)) => client, + Ok(None) => return HttpResponse::NotFound().body("ExternalClient not found"), + Err(err) => return handle_server_error_body("Database Error", err, &config, None), + }; + + if external_client.disabled_since.is_some() { + return HttpResponse::Unauthorized().body("Inactive external_client"); + } + + if data.login.is_empty() || data.password.is_empty() { + return HttpResponse::Unauthorized().body("Login and password are required"); + } + + let user_result = user_entity::Entity::find() + .filter(user_entity::Column::Login.eq(data.login.clone())) + .one(&postgres_client.client) + .await; + + let user = match user_result { + Ok(Some(user)) => user, + Ok(None) => return HttpResponse::Unauthorized().body("User not found"), + Err(err) => return handle_server_error_body("Database Error", err, &config, None), + }; + + if user.disabled_since.is_some() { + return HttpResponse::Unauthorized().body("Inactive user"); + } + + let user_decryption_key = + match get_entity_key(user.id, EntityType::User, &keys_client, &config).await { + GetKeyResult::Ok(key) => key, + GetKeyResult::Err(err) => return err, + }; + + let decrypted_password = match decrypt_field( + &fernet::Fernet::new(&user_decryption_key).unwrap(), + &user.password, + ) { + Ok(p) => p, + Err(err) => { + return handle_server_error_body( + "Decryption error", + CustomError::UnsuccessfulDecryption("password".to_string(), err), + &config, + None, + ); + } + }; + + if verify(&data.password, &decrypted_password).unwrap_or(false) { + let token = create_jwt( + format!("{}-{}", external_client.id, user.id,).as_str(), + EntityType::AuthorizedClient, + &config.jwt_secret, + ); + HttpResponse::Ok().json(ExternalClientLoginResponse { token }) + } else { + HttpResponse::Unauthorized().body("Invalid credentials") + } +} + +#[utoipa::path( + post, + path = "/portability/data", + request_body = String, + responses( + (status = 200, description = "Data portability successful"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Database error") + ), + tags = ["Portability"] +)] + +pub async fn portability( + postgres_client: web::Data, + keys_client: web::Data, + config: web::Data, + req: HttpRequest, +) -> impl Responder { + let authorized_client_id = match req.extensions().get::() { + Some(subject) => subject.id.to_string(), + None => return HttpResponse::Unauthorized().body("Unauthorized"), + }; + + let (external_client_id, user_id) = match authorized_client_id.split_once('-') { + Some((client_id, user_id)) => (client_id.to_string(), user_id.to_string()), + None => return HttpResponse::Unauthorized().body("Invalid token format"), + }; + + let external_client_result = + external_client_entity::Entity::find_by_id(external_client_id.parse::().unwrap()) + .one(&postgres_client.client) + .await; + + let external_client = match external_client_result { + Ok(Some(client)) => client, + Ok(None) => return HttpResponse::NotFound().body("ExternalClient not found"), + Err(err) => return handle_server_error_body("Database Error", err, &config, None), + }; + + if external_client.disabled_since.is_some() { + return HttpResponse::Unauthorized().body("Inactive external_client"); + } + + let user_result = user_entity::Entity::find_by_id(user_id.parse::().unwrap()) + .one(&postgres_client.client) + .await; + + let encrypted_user = match user_result { + Ok(Some(user)) => user, + Ok(None) => return HttpResponse::NotFound().body("User not found"), + Err(err) => return handle_server_error_body("Database Error", err, &config, None), + }; + + if encrypted_user.disabled_since.is_some() { + return HttpResponse::Unauthorized().body("Inactive user"); + } + + let user_decryption_key = + match get_entity_key(encrypted_user.id, EntityType::User, &keys_client, &config).await { + GetKeyResult::Ok(key) => key, + GetKeyResult::Err(err) => return err, + }; + + match decrypt_database_user(&user_decryption_key, encrypted_user) { + Ok(decrypted_user) => HttpResponse::Ok().json(UserPortability { + name: decrypted_user.name, + login: decrypted_user.login, + email: decrypted_user.email, + }), + Err(err) => handle_server_error_body("Parse error", err, &config, None), + } +} diff --git a/apps/auth/src/routes/user.rs b/apps/auth/src/routes/user.rs index f08eb5a..9c3508b 100644 --- a/apps/auth/src/routes/user.rs +++ b/apps/auth/src/routes/user.rs @@ -2,29 +2,56 @@ use actix_web::{web, HttpResponse, Responder}; use actix_web_httpauth::middleware::HttpAuthentication; use bcrypt::{hash, DEFAULT_COST}; use fernet::Fernet; -use sea_orm::prelude::Date; -use sea_orm::ActiveValue::{NotSet, Set}; -use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QuerySelect}; - -use crate::entities::user as user_entity; -use crate::infra::server::{DatabaseClientKeys, DatabaseClientPostgres}; -use crate::models::{PaginatedRequest, PaginatedResponse, UserPublic, UserUpdate}; -use crate::service::fernet::{ - decrypt_database_user, encrypt_field, get_user_key, GetUserKeyResult, +use sea_orm::{ + prelude::Date, + ActiveModelTrait, + ActiveValue::{ + NotSet, + Set, // + }, + EntityTrait, + IntoActiveModel, + PaginatorTrait, + QuerySelect, // }; -use crate::service::jwt::validator; -use super::common::{handle_server_error_body, ServerErrorType}; +use crate::{ + entities::user as user_entity, + infra::server::{ + DatabaseClientKeys, // + DatabaseClientPostgres, + }, + models::{ + EntityType, // + PaginatedRequest, + PaginatedResponse, + UserPublic, + UserUpdate, + }, + routes::common::{ + handle_server_error_body, + ServerErrorType, // + }, + service::{ + fernet::{ + decrypt_database_user, // + encrypt_field, + get_entity_key, + GetKeyResult, + }, + jwt::validator_user, + }, +}; pub fn configure(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/users") - .wrap(HttpAuthentication::bearer(validator)) + .wrap(HttpAuthentication::bearer(validator_user)) .route("/", web::post().to(get_users)), // ) .service( web::scope("/user") - .wrap(HttpAuthentication::bearer(validator)) + .wrap(HttpAuthentication::bearer(validator_user)) .route("/{id}", web::get().to(get_user)) .route("/{id}", web::put().to(update_user)) .route("/{id}", web::delete().to(delete_user)), @@ -68,10 +95,16 @@ async fn get_users( let mut users_public = Vec::with_capacity(raw_users.len()); for encrypted_user in raw_users { - let user_decryption_key = match get_user_key(encrypted_user.id, &keys_client, &config).await + let user_decryption_key = match get_entity_key( + encrypted_user.id, + EntityType::User, + &keys_client, + &config, + ) + .await { - GetUserKeyResult::Ok(key) => key, - GetUserKeyResult::Err(err) => return err, + GetKeyResult::Ok(key) => key, + GetKeyResult::Err(err) => return err, }; users_public.push( @@ -119,7 +152,9 @@ async fn get_user( config: web::Data, user_id: web::Path, ) -> impl Responder { - let result = user_entity::Entity::find_by_id(user_id.into_inner()) + let user_id = user_id.into_inner(); + + let result = user_entity::Entity::find_by_id(user_id) .one(&postgres_client.client) .await; @@ -128,10 +163,7 @@ async fn get_user( Ok(None) => { return handle_server_error_body( "Database Error", - std::io::Error::new( - std::io::ErrorKind::NotFound, - "User not found in `users` table", - ), + super::common::CustomError::UserNotFound(user_id), &config, Some(ServerErrorType::NotFound), ) @@ -139,10 +171,11 @@ async fn get_user( Err(err) => return handle_server_error_body("Database Error", err, &config, None), }; - let user_decryption_key = match get_user_key(encrypted_user.id, &keys_client, &config).await { - GetUserKeyResult::Ok(key) => key, - GetUserKeyResult::Err(err) => return err, - }; + let user_decryption_key = + match get_entity_key(encrypted_user.id, EntityType::User, &keys_client, &config).await { + GetKeyResult::Ok(key) => key, + GetKeyResult::Err(err) => return err, + }; match decrypt_database_user(&user_decryption_key, encrypted_user) { Ok(decrypted_user) => HttpResponse::Ok().json(decrypted_user), @@ -182,10 +215,11 @@ async fn update_user( Err(err) => return handle_server_error_body("Database Error", err, &config, None), }; - let user_decryption_key = match get_user_key(*user_id, &keys_client, &config).await { - GetUserKeyResult::Ok(key) => key, - GetUserKeyResult::Err(err) => return err, - }; + let user_decryption_key = + match get_entity_key(*user_id, EntityType::User, &keys_client, &config).await { + GetKeyResult::Ok(key) => key, + GetKeyResult::Err(err) => return err, + }; user_update_model.disabled_since = match &user_update.disabled_since { Some(dt) => match dt { @@ -260,7 +294,6 @@ async fn delete_user( user_id: web::Path, ) -> impl Responder { // TODO: use `disabled_since` field of `users` table - // OR insert user into `deleted_users` table let result = user_entity::Entity::delete_by_id(user_id.into_inner()) .exec(&postgres_client.client) diff --git a/apps/auth/src/service/fernet.rs b/apps/auth/src/service/fernet.rs index 600c86b..dc711fd 100644 --- a/apps/auth/src/service/fernet.rs +++ b/apps/auth/src/service/fernet.rs @@ -3,15 +3,21 @@ use fernet::Fernet; use actix_web::{web, HttpResponse}; -use sea_orm::EntityTrait; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use thiserror::Error; use crate::{ entities::{ - keys as keys_entity, // + entity_key as keys_entity, // + entity_type as key_type_entity, // + external_client::Model as external_client_model, user::Model as user_model, }, - models::UserPublic, + models::{ + EntityType, // + ExternalClientPublic, + UserPublic, + }, routes::common::{ handle_server_error_body, // CustomError, @@ -19,34 +25,66 @@ use crate::{ }, }; -pub enum GetUserKeyResult { +pub enum GetKeyResult { Ok(String), Err(HttpResponse), } -pub async fn get_user_key( - user_id: i64, +pub async fn get_entity_key( + entity_id: i64, + entity_type: EntityType, keys_client: &web::Data, config: &web::Data, -) -> GetUserKeyResult { - let user_key_result = keys_entity::Entity::find_by_id(user_id) +) -> GetKeyResult { + let entity_type_str = entity_type.to_string(); + + dbg!(&entity_type_str); + + let entity_type_result = key_type_entity::Entity::find() + .filter(key_type_entity::Column::Name.eq(entity_type_str)) .one(&keys_client.client) .await; - match user_key_result { - Err(err) => GetUserKeyResult::Err(handle_server_error_body( + let entity_type_id = match entity_type_result { + Err(err) => { + return GetKeyResult::Err(handle_server_error_body( + "Database Error", + err, + &config, + None, + )) + } + Ok(None) => { + return GetKeyResult::Err(handle_server_error_body( + "Database Error", + CustomError::EntityKeyNotFound(entity_type, entity_id), + &config, + Some(ServerErrorType::NotFound), + )) + } + Ok(Some(entity_type)) => entity_type.id, + }; + + let entity_key_result = keys_entity::Entity::find() + .filter(keys_entity::Column::EntityId.eq(entity_id)) + .filter(keys_entity::Column::EntityType.eq(entity_type_id)) + .one(&keys_client.client) + .await; + + match entity_key_result { + Err(err) => GetKeyResult::Err(handle_server_error_body( "Database Error", err, &config, None, )), - Ok(None) => GetUserKeyResult::Err(handle_server_error_body( + Ok(None) => GetKeyResult::Err(handle_server_error_body( "Database Error", - CustomError::UserKeyNotFound(user_id), + CustomError::EntityKeyNotFound(entity_type, entity_id), &config, Some(ServerErrorType::NotFound), )), - Ok(Some(user_key)) => GetUserKeyResult::Ok(user_key.key), + Ok(Some(entity_key)) => GetKeyResult::Ok(entity_key.key), } } @@ -107,3 +145,24 @@ pub fn decrypt_database_user( }, }) } + +pub fn decrypt_database_external_client( + external_client_decryption_key: &str, + external_client: external_client_model, +) -> Result { + let fernet = Fernet::new(&external_client_decryption_key).unwrap(); + + let name = decrypt_field(&fernet, &external_client.name) + .map_err(|err| CustomError::UnsuccessfulDecryption("name".to_string(), err))?; + + Ok(ExternalClientPublic { + id: external_client.id, + name, + login: external_client.login, + created_at: external_client.created_at.format("%Y-%m-%d").to_string(), + disabled_since: match external_client.disabled_since { + Some(dt) => Some(dt.format("%Y-%m-%d").to_string()), + None => None, + }, + }) +} diff --git a/apps/auth/src/service/jwt.rs b/apps/auth/src/service/jwt.rs index a9a8265..6fc0097 100644 --- a/apps/auth/src/service/jwt.rs +++ b/apps/auth/src/service/jwt.rs @@ -1,9 +1,20 @@ -use crate::models::jwt::*; +use crate::infra::server::DatabaseClientKeys; +use crate::infra::types::Config; +use crate::models::{jwt::*, EntityType}; use actix_web::dev::ServiceRequest; use actix_web::{http::header, HttpRequest}; use actix_web::{web, HttpMessage}; use actix_web_httpauth::extractors::bearer::BearerAuth; -use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, TokenData, Validation}; +use base64::Engine; +use jsonwebtoken::{ + decode, // + encode, + DecodingKey, + EncodingKey, + Header, + TokenData, + Validation, +}; use sea_orm::EntityTrait; use sea_orm::{ActiveModelTrait, ActiveValue::Set, DatabaseConnection}; use sqlx::types::chrono::Utc; @@ -12,46 +23,115 @@ use uuid::Uuid; use crate::entities::revoked_token; +async fn extract_claims( + req: &ServiceRequest, + credentials: &BearerAuth, +) -> Result { + let jwt_secret = req + .app_data::>() + .ok_or_else(|| actix_web::error::ErrorInternalServerError("JWT secret not configured"))? + .get_ref() + .jwt_secret + .clone(); + + let keys_client = req + .app_data::>() + .ok_or_else(|| { + actix_web::error::ErrorInternalServerError("Database connection not configured") + })? + .get_ref(); + + let token_data = verify_jwt(credentials.token(), &jwt_secret, &keys_client) + .await + .map_err(|_| actix_web::error::ErrorUnauthorized("Invalid or revoked token"))?; + + Ok(token_data.claims) +} + pub async fn validator( req: ServiceRequest, credentials: BearerAuth, ) -> Result { - let jwt_secret = match req.app_data::>() { - Some(data) => data.get_ref().clone(), - None => { + let claims = match extract_claims(&req, &credentials).await { + Ok(claims) => claims, + Err(_) => { return Err(( - actix_web::error::ErrorInternalServerError("JWT secret not configured"), + actix_web::error::ErrorUnauthorized("Error extracting claims"), req, )) - } + } // Err(err) => return Err((err, req)), }; - let db = match req.app_data::>() { - Some(data) => data.get_ref().clone(), - None => { + req.extensions_mut().insert(claims); + Ok(req) +} + +pub async fn validate_entity_type( + claims: &Claims, + entity_type: EntityType, +) -> Result { + let subject = claims + .parse_subject() + .map_err(|_| actix_web::error::ErrorUnauthorized("Invalid token subject"))?; + + if subject.entity_type != entity_type { + return Err(actix_web::error::ErrorUnauthorized( + "Invalid token entity type", + )); + } + + Ok(subject) +} + +pub async fn validator_entity_type( + req: ServiceRequest, + credentials: BearerAuth, + entity_type: EntityType, +) -> Result { + let claims = match extract_claims(&req, &credentials).await { + Ok(claims) => claims, + Err(_) => { return Err(( - actix_web::error::ErrorInternalServerError("Database connection not configured"), + actix_web::error::ErrorUnauthorized("Error extracting claims"), req, - )) - } + )); + } // Err(err) => return Err((err, req)), }; - match verify_jwt(credentials.token(), &jwt_secret, &db).await { - Ok(token_data) => { - req.extensions_mut().insert(token_data.claims); - Ok(req) - } - Err(_) => Err(( - actix_web::error::ErrorUnauthorized("Invalid or revoked token"), - req, - )), - } + let subject = match validate_entity_type(&claims, entity_type).await { + Ok(subject) => subject, + Err(err) => return Err((err, req)), + }; + + req.extensions_mut().insert(subject); + Ok(req) +} + +pub async fn validator_user( + req: ServiceRequest, + credentials: BearerAuth, +) -> Result { + validator_entity_type(req, credentials, EntityType::User).await +} + +pub async fn validator_external_client( + req: ServiceRequest, + credentials: BearerAuth, +) -> Result { + validator_entity_type(req, credentials, EntityType::ExternalClient).await +} + +pub async fn validator_authorized_client( + req: ServiceRequest, + credentials: BearerAuth, +) -> Result { + validator_entity_type(req, credentials, EntityType::AuthorizedClient).await } pub async fn verify_jwt( token: &str, jwt_secret: &str, - db: &DatabaseConnection, + keys_client: &DatabaseClientKeys, ) -> Result, VerificationError> { let token_data = decode::( token, @@ -61,22 +141,31 @@ pub async fn verify_jwt( let jti = &token_data.claims.jti; if revoked_token::Entity::find_by_id(jti.clone()) - .one(db) + .one(&keys_client.client) .await? .is_some() { return Err(VerificationError::Revoked); } + token_data.claims.parse_subject()?; + Ok(token_data) } -pub fn create_jwt(user_id: &str, jwt_secret: &str) -> String { +pub fn create_jwt(entity_id: &str, entity_type: EntityType, jwt_secret: &str) -> String { let now = OffsetDateTime::now_utc(); let exp = (now + Duration::hours(24)).unix_timestamp() as usize; let claims = Claims { - sub: user_id.to_owned(), + sub: base64::engine::general_purpose::STANDARD.encode( + format!( + "{}:{}", // + entity_type.to_string(), + entity_id, + ) + .as_bytes(), + ), iat: now.unix_timestamp() as usize, exp, jti: Uuid::new_v4().to_string(), @@ -99,30 +188,33 @@ pub fn extract_bearer(req: &HttpRequest) -> Result<&str, &'static str> { hdr.strip_prefix("Bearer ").ok_or("Malformed Bearer token") } -pub fn decode_claims( - token: &str, - secret: &str, -) -> Result, jsonwebtoken::errors::Error> { - decode::( +pub fn decode_claims(token: &str, jwt_secret: &str) -> Result { + let token_data = decode::( token, - &DecodingKey::from_secret(secret.as_ref()), + &DecodingKey::from_secret(jwt_secret.as_bytes()), &Validation::default(), - ) + )?; + + token_data.claims.parse_subject()?; + + Ok(token_data.claims) } pub async fn revoke_token( token: &str, - secret: &str, + jwt_secret: &str, db: &DatabaseConnection, -) -> Result<(), sea_orm::DbErr> { - let data = - decode_claims(token, secret).map_err(|_| sea_orm::DbErr::Custom("Invalid token".into()))?; - let jti = data.claims.jti; +) -> Result<(), VerificationError> { + let token_data = decode_claims(token, jwt_secret)?; + + let jti = token_data.jti; - let now = Utc::now().naive_utc(); - let am = revoked_token::ActiveModel { + let revoked = revoked_token::ActiveModel { jti: Set(jti), - revoked_at: Set(now), + revoked_at: Set(Utc::now().naive_utc()), }; - am.insert(db).await.map(|_| ()) + + revoked.insert(db).await?; + + Ok(()) } diff --git a/apps/auth/src/swagger.rs b/apps/auth/src/swagger.rs index f18ea69..8ccad61 100644 --- a/apps/auth/src/swagger.rs +++ b/apps/auth/src/swagger.rs @@ -1,40 +1,76 @@ -use crate::{ - routes, - models, - entities -}; +use crate::{entities, models, routes}; use utoipa::OpenApi; // Swagger Schema #[derive(OpenApi)] #[openapi( paths( - // Authentication routes + + // User Authentication routes routes::auth::register, routes::auth::login, routes::auth::validate_token, routes::auth::logout, - // User routes + + // User _RUD routes routes::user::get_users, routes::user::get_user, routes::user::update_user, routes::user::delete_user, + + // External Client Authentication routes + routes::external_client_auth::external_client_register, + routes::external_client_auth::external_client_login, + routes::external_client_auth::external_client_validate_token, + routes::external_client_auth::external_client_logout, + + // External Client _RUD routes + routes::external_client::get_external_clients, + routes::external_client::get_external_client, + routes::external_client::update_external_client, + routes::external_client::delete_external_client, + + // Portability routes + routes::portability::button, + routes::portability::screen, + routes::portability::authorize, + routes::portability::portability, ), components(schemas( + // Authentication models - models::auth::RegisterRequest, + models::auth::UserRegisterRequest, + models::auth::ExternalClientRegisterRequest, + models::auth::LoginRequest, - models::auth::LoginResponse, + + models::auth::UserLoginResponse, + models::auth::ExternalClientLoginResponse, + models::auth::ValidateRequest, - // User models + models::auth::PortabilityScreenQuery, + + // models models::UserPublic, models::UserUpdate, + models::UserPortability, + models::ExternalClientPublic, + models::ExternalClientUpdate, + + // Entities entities::user::Model, + entities::external_client::Model, + )), tags( + (name = "Auth", description = "Authentication endpoints"), - (name = "User", description = "User management endpoints") + (name = "User", description = "User management endpoints"), + (name = "External Client", description = "External Client management endpoints"), + (name = "External Client Auth", description = "External client authentication endpoints"), + (name = "Portability", description = "Portability related endpoints"), + ) )] diff --git a/apps/auth/src/templates.rs b/apps/auth/src/templates.rs new file mode 100644 index 0000000..b0268ae --- /dev/null +++ b/apps/auth/src/templates.rs @@ -0,0 +1,14 @@ +use askama::Template; + +#[derive(Template)] +#[template(path = "portability/authorization_button.html")] +pub struct LoginButtonTemplate { + pub popup_url: String, +} + +#[derive(Template)] +#[template(path = "portability/authorization_form.html")] +pub struct LoginFormTemplate { + pub auth_url: String, + pub client_name: String, +} diff --git a/apps/auth/templates/portability/authorization_button.html b/apps/auth/templates/portability/authorization_button.html new file mode 100644 index 0000000..3bcc782 --- /dev/null +++ b/apps/auth/templates/portability/authorization_button.html @@ -0,0 +1,9 @@ + diff --git a/apps/auth/templates/portability/authorization_form.html b/apps/auth/templates/portability/authorization_form.html new file mode 100644 index 0000000..5700e26 --- /dev/null +++ b/apps/auth/templates/portability/authorization_form.html @@ -0,0 +1,57 @@ + + + + Authorize {{ client_name }} via Khali + + + + +

Login to {{ client_name }}

+
+ + + +
+ + + + diff --git a/apps/db/project.json b/apps/db/project.json index de9b099..2393739 100644 --- a/apps/db/project.json +++ b/apps/db/project.json @@ -45,7 +45,7 @@ }, "postgres-seeds": { "executor": "nx:run-commands", - "dependsOn": ["init-postgres"], + "dependsOn": ["init-postgres", "init-keys"], "options": { "command": "poetry run python {projectRoot}/seeds/postgres_seeds.py" } diff --git a/apps/db/seeds/postgres_seeds.py b/apps/db/seeds/postgres_seeds.py index fd9eb37..e96c333 100644 --- a/apps/db/seeds/postgres_seeds.py +++ b/apps/db/seeds/postgres_seeds.py @@ -1,21 +1,25 @@ -import bcrypt -from cryptography.fernet import Fernet -from faker import Faker -from datetime import datetime, timedelta import random +from base64 import b64encode +from datetime import datetime, timedelta + +from sqlalchemy.orm import sessionmaker +from faker import Faker +from cryptography.fernet import Fernet +import bcrypt + from db.keys import ( get_keys_engine, - UserKey, + EntityType, + EntityKey, ) from db.postgres import ( get_engine, test_connection, User, + ExternalClient, Permission, Role ) -from sqlalchemy.orm import sessionmaker -from base64 import b64encode fake = Faker() Faker.seed(42) @@ -23,26 +27,56 @@ DEFAULT_HASH_COST = 12 -NUM_USERS = 10 NUM_PERMISSIONS = 2 -NUM_SOFT_DELETED = 0 -NUM_HARD_DELETED = 0 + +NUM_USERS = 10 +NUM_USERS_DISABLED = 0 + +NUM_EXTERNAL_CLIENTS = 9 +NUM_EXTERNAL_CLIENTS_DISABLED = 0 + + +def insert_entity_types(keys_session): + entity_types = { + inserted.name: inserted.id + for inserted in [ + ( + lambda entity_type: None + or keys_session.add(entity_type) + or keys_session.flush() + or entity_type + )(EntityType(name=name)) + for name in [ + "user", + "external_client", + ] + ] + } + + keys_session.flush() + return entity_types -def insert_permissions(session): - permission_names = ["dashboard", "register", "analitic", "terms"] +def insert_permissions(postgres_session): + permission_names = [ + "dashboard", + "register", + "analitic", + "terms", + ] + permissions = [ Permission(name=name, description=fake.sentence()) for name in permission_names ] - session.add_all(permissions) - session.commit() + postgres_session.add_all(permissions) + postgres_session.flush() print(f"{len(permissions)} permissões criadas: {', '.join(permission_names)}.") return permissions -def insert_roles(session, permissions): +def insert_roles(postgres_session, permissions): permission_dict = {perm.name: perm for perm in permissions} # ADM - todas as permissões @@ -64,22 +98,20 @@ def insert_roles(session, permissions): ) roles = [adm_role, agent_role] - session.add_all(roles) - session.commit() + postgres_session.add_all(roles) + postgres_session.flush() print("Roles criadas: adm (todas permissões), agent (dashboard, analitic, terms).") return roles -def insert_users(session, roles): +def insert_users( + postgres_session, + keys_session, + roles, + entity_types, +): users = [] - keys_session = sessionmaker(bind=get_keys_engine())() - - def encrypt_field( - fernet: Fernet, - field: str, - ): - return b64encode(fernet.encrypt(field.encode())).decode() def encrypt_user( fernet: Fernet, @@ -118,16 +150,14 @@ def add_user( disabled_since=disabled_since, )) users.append(user) - session.add(user) - session.flush() # get user.id + postgres_session.add(user) + postgres_session.flush() # get user.id - keys_session.add(UserKey(id=user.id, key=key.decode())) - - def hash_password(password: str) -> str: - return bcrypt.hashpw( - password.encode("utf-8"), - bcrypt.gensalt(rounds=DEFAULT_HASH_COST) - ).decode("utf-8") + keys_session.add(EntityKey( + entity_id=user.id, + key=key.decode(), + entity_type=entity_types["user"] + )) add_user( # default user for easy login name="Alice", @@ -141,7 +171,7 @@ def hash_password(password: str) -> str: for i in range(NUM_USERS): # Decide se o usuário será soft-deletado disabled_date = None - if i < NUM_SOFT_DELETED: + if i < NUM_USERS_DISABLED: disabled_date = datetime.now() - timedelta(days=random.randint(31, 365)) add_user( @@ -154,27 +184,123 @@ def hash_password(password: str) -> str: disabled_since=disabled_date, ) - session.add_all(users) + postgres_session.add_all(users) print(f"{NUM_USERS} usuários inseridos.") - session.commit() - keys_session.commit() + postgres_session.flush() + keys_session.flush() print(f"{NUM_USERS + 1} usuários inseridos e encriptados.") print(f"{NUM_USERS + 1} chaves de usuário inseridas.") return users +def insert_external_clients( + postgres_session, + keys_session, + entity_types, +): + external_clients = [] + + def encrypt_external_client( + fernet: Fernet, + external_client: ExternalClient, + ): + return ExternalClient( + name=encrypt_field(fernet, external_client.name), + login=external_client.login, + password=encrypt_field(fernet, external_client.password), + ) + + def add_external_client( + name: str, + login: str, + password: str, + disabled_since: datetime | None = None + ): + key = Fernet.generate_key() + fernet = Fernet(key) + external_client = encrypt_external_client(fernet, ExternalClient( + name=name, + login=login, + password=password, + disabled_since=disabled_since, + )) + external_clients.append(external_client) + postgres_session.add(external_client) + postgres_session.flush() # get external_client.id + + keys_session.add(EntityKey( + entity_id=external_client.id, + key=key.decode(), + entity_type=entity_types["external_client"], + )) + + add_external_client( # default external_client for easy login + name="Canva", + login="canva", + password=hash_password("secret"), + ) + + for i in range(NUM_EXTERNAL_CLIENTS): + disabled_date = None + if i < NUM_EXTERNAL_CLIENTS_DISABLED: + disabled_date = datetime.now() - timedelta(days=random.randint(31, 365)) + + add_external_client( + name=fake.name(), + login=fake.unique.company(), + password=hash_password(fake.password()), + disabled_since=disabled_date + ) + + postgres_session.add_all(external_clients) + print(f"{NUM_USERS} usuários inseridos.") + + postgres_session.flush() + keys_session.flush() + print(f"{NUM_USERS + 1} usuários inseridos e encriptados.") + print(f"{NUM_USERS + 1} chaves de usuário inseridas.") + + return external_clients + + +def encrypt_field( + fernet: Fernet, + field: str, +): + return b64encode(fernet.encrypt(field.encode())).decode() + + +def hash_password(password: str) -> str: + return bcrypt.hashpw( + password.encode("utf-8"), + bcrypt.gensalt(rounds=DEFAULT_HASH_COST) + ).decode("utf-8") + + def insert_seeds(): - engine = get_engine() - test_connection(engine) - session = sessionmaker(bind=engine)() + postgres_engine = get_engine() + keys_engine = get_keys_engine() + + test_connection(postgres_engine) + test_connection(keys_engine) + + postgres_session = sessionmaker(bind=postgres_engine)() + keys_session = sessionmaker(bind=keys_engine)() print("Iniciando seeds...") - permissions = insert_permissions(session) - roles = insert_roles(session, permissions) - insert_users(session, roles) + entity_types = insert_entity_types(keys_session) + + permissions = insert_permissions(postgres_session) + roles = insert_roles(postgres_session, permissions) + insert_users(postgres_session, keys_session, roles, entity_types) + + insert_external_clients(postgres_session, keys_session, entity_types) + + postgres_session.commit() + keys_session.commit() print("Seed finalizada com sucesso.") diff --git a/apps/db/src/db/keys.py b/apps/db/src/db/keys.py index 1fdfeca..cb63261 100644 --- a/apps/db/src/db/keys.py +++ b/apps/db/src/db/keys.py @@ -2,8 +2,9 @@ import os from dotenv import load_dotenv from sqlalchemy import ( - Date, DateTime, + ForeignKey, + UniqueConstraint, create_engine, text, Column, @@ -33,13 +34,40 @@ def get_keys_engine(): Base = declarative_base() -class UserKey(Base): - __tablename__ = "user_key" +class EntityType(Base): + __tablename__ = "entity_type" id = Column( BigInteger, - primary_key=True + primary_key=True, + autoincrement=True + ) + name = Column(String, unique=True, nullable=False) + + +class EntityKey(Base): + __tablename__ = "entity_key" + id = Column( + BigInteger, + primary_key=True, + autoincrement=True + ) + entity_id = Column( + BigInteger, + nullable=False + ) + key = Column(String, nullable=False) + entity_type = Column( + BigInteger, + ForeignKey("entity_type.id"), + nullable=False + ) + __table_args__ = ( + UniqueConstraint( + 'entity_type', + 'entity_id', + name='uq_entity_type_entity_id' + ), ) - key = Column(String, unique=True, nullable=False) class RevokedToken(Base): diff --git a/apps/db/src/db/postgres.py b/apps/db/src/db/postgres.py index 64e0141..5a01a6b 100644 --- a/apps/db/src/db/postgres.py +++ b/apps/db/src/db/postgres.py @@ -43,8 +43,12 @@ def get_engine(): "role_permissions", Base.metadata, Column("role_id", BigInteger, ForeignKey("roles.id"), primary_key=True), - Column("permission_id", BigInteger, ForeignKey( - "permissions.id"), primary_key=True), + Column( + "permission_id", + BigInteger, + ForeignKey("permissions.id"), + primary_key=True + ), ) @@ -55,7 +59,10 @@ class Role(Base): description = Column(Text) permissions = relationship( - "Permission", secondary=role_permissions, back_populates="roles") + "Permission", + secondary=role_permissions, + back_populates="roles" + ) users = relationship("User", back_populates="role") @@ -65,8 +72,11 @@ class Permission(Base): name = Column(String, unique=True, nullable=False) description = Column(Text, nullable=False) - roles = relationship("Role", secondary=role_permissions, - back_populates="permissions") + roles = relationship( + "Role", + secondary=role_permissions, + back_populates="permissions" + ) class User(Base): @@ -79,21 +89,22 @@ class User(Base): version_terms_agreement = Column(String) disabled_since = Column(DateTime, nullable=True) - role_id = Column(BigInteger, ForeignKey("roles.id"), nullable=False) + role_id = Column( + BigInteger, + ForeignKey("roles.id"), + nullable=False + ) role = relationship("Role", back_populates="users") -class UserKey(Base): - __tablename__ = "user_key" - id = Column(BigInteger, primary_key=True) - key = Column(String, unique=True, nullable=False) - - -class RevokedToken(Base): - __tablename__ = "revoked_tokens" - jti = Column(String, primary_key=True) - revoked_at = Column(DateTime, nullable=False, - default=datetime.datetime.utcnow) +class ExternalClient(Base): + __tablename__ = "external_clients" + id = Column(BigInteger, primary_key=True, autoincrement=True) + name = Column(String, nullable=False) + login = Column(String, index=True, unique=True, nullable=False) + password = Column(Text, nullable=False) + created_at = Column(DateTime, default=datetime.datetime.utcnow) + disabled_since = Column(DateTime, nullable=True) def create_tables(engine): diff --git a/apps/portability_example/eslint.config.mjs b/apps/portability_example/eslint.config.mjs new file mode 100644 index 0000000..e8e4590 --- /dev/null +++ b/apps/portability_example/eslint.config.mjs @@ -0,0 +1,12 @@ +import nx from '@nx/eslint-plugin'; +import baseConfig from '../../eslint.config.mjs'; + +export default [ + ...baseConfig, + ...nx.configs['flat/react'], + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + // Override or add rules here + rules: {}, + }, +]; diff --git a/apps/portability_example/index.html b/apps/portability_example/index.html new file mode 100644 index 0000000..2d14817 --- /dev/null +++ b/apps/portability_example/index.html @@ -0,0 +1,16 @@ + + + + + Web + + + + + + + +
+ + + diff --git a/apps/portability_example/jest.config.ts b/apps/portability_example/jest.config.ts new file mode 100644 index 0000000..1f86108 --- /dev/null +++ b/apps/portability_example/jest.config.ts @@ -0,0 +1,12 @@ +export default { + displayName: 'portability_example', + preset: '../../jest.preset.js', + transform: { + '^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nx/react/plugins/jest', + '^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/react/babel'] }], + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + coverageDirectory: '../../coverage/portability_example', + setupFiles: ['./jest.setup.js'], + coveragePathIgnorePatterns: ['/node_modules/', '/test/'], +}; diff --git a/apps/portability_example/jest.setup.js b/apps/portability_example/jest.setup.js new file mode 100644 index 0000000..30b5d0b --- /dev/null +++ b/apps/portability_example/jest.setup.js @@ -0,0 +1,17 @@ +import 'jest-canvas-mock'; + +import fetchMock from 'jest-fetch-mock'; +fetchMock.enableMocks(); + +Object.defineProperties(window.HTMLElement.prototype, { + clientWidth: { + get() { + return 800; + }, + }, + clientHeight: { + get() { + return 600; + }, + }, +}); diff --git a/apps/portability_example/project.json b/apps/portability_example/project.json new file mode 100644 index 0000000..edd94e3 --- /dev/null +++ b/apps/portability_example/project.json @@ -0,0 +1,16 @@ +{ + "name": "portability_example", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "portability_example/src", + "projectType": "application", + "tags": [], + "// targets": "to see all targets run: nx show project portability_example --web", + "targets": { + "add": { + "executor": "nx:run-commands", + "options": { + "command": "npm install package-name" + } + } + } +} diff --git a/apps/portability_example/public/favicon.ico b/apps/portability_example/public/favicon.ico new file mode 100644 index 0000000..317ebcb Binary files /dev/null and b/apps/portability_example/public/favicon.ico differ diff --git a/apps/portability_example/src/app.tsx b/apps/portability_example/src/app.tsx new file mode 100644 index 0000000..3859ec4 --- /dev/null +++ b/apps/portability_example/src/app.tsx @@ -0,0 +1,115 @@ +import { useEffect, useState, useRef } from 'react'; +import { login } from './service/auth'; +import { + getPortabilityButton, + getPortabilityData, +} from './service/portability'; +import { User } from './schemas/user'; + +const App = () => { + const [clientLoggedIn, setClientLoggedIn] = useState(''); + const [buttonHtml, setButtonHtml] = useState(null); + const [portabilityToken, setPortabilityToken] = useState(null); + const [user, setUser] = useState(null); + const containerRef = useRef(null); + + // Step 1: Authenticate external client + useEffect(() => { + const authenticate = async () => { + try { + setClientLoggedIn( + await login({ + login: 'canva', + password: 'secret', + }) + ); + } catch (err) { + console.error('Failed to authenticate external client', err); + } + }; + + authenticate(); + }, []); + + // Step 2: Fetch login button HTML + useEffect(() => { + if (!clientLoggedIn) return; + + const fetchButton = async () => { + try { + const html = await getPortabilityButton(clientLoggedIn); + setButtonHtml(html); + } catch (err) { + console.error('Failed to fetch login button', err); + } + }; + + fetchButton(); + }, [clientLoggedIn]); + + // Step 3: Capture portability_token via postMessage + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if (typeof event.data === 'object' && event.data?.portability_token) { + setPortabilityToken(event.data.portability_token); + } + }; + + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, []); + + // Step 4: Fetch data using portability_token + const fetchPortabilityData = async () => { + if (!portabilityToken) return; + + try { + const userData = await getPortabilityData(portabilityToken); + setUser(userData); + } catch (err) { + console.error('Failed to fetch portability data', err); + } + }; + + return ( +
+

Auth Integration Demo

+ + {/* Step 2: Render fetched button HTML */} + {buttonHtml && ( +
+ )} + + {/* Step 4: Button to fetch data */} + {portabilityToken && !user && ( + + )} + + {/* Step 5: Display user data */} + {user && ( +
+

User Data

+

+ Name: {user.name} +

+

+ Username: {user.login} +

+

+ Email: {user.email} +

+
+ )} +
+ ); +}; + +export default App; diff --git a/apps/portability_example/src/main.tsx b/apps/portability_example/src/main.tsx new file mode 100644 index 0000000..85c08bb --- /dev/null +++ b/apps/portability_example/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from 'react'; +import * as ReactDOM from 'react-dom/client'; +import App from './app'; + +const root = ReactDOM.createRoot( + document.getElementById('root') as HTMLElement +); + +root.render( + + + +); diff --git a/apps/portability_example/src/schemas/auth.ts b/apps/portability_example/src/schemas/auth.ts new file mode 100644 index 0000000..4f6b4c7 --- /dev/null +++ b/apps/portability_example/src/schemas/auth.ts @@ -0,0 +1,8 @@ +export type LoginRequest = { + login: string; + password: string; +}; + +export type LoginResponse = { + token?: string; +}; diff --git a/apps/portability_example/src/schemas/user.ts b/apps/portability_example/src/schemas/user.ts new file mode 100644 index 0000000..076b95d --- /dev/null +++ b/apps/portability_example/src/schemas/user.ts @@ -0,0 +1,5 @@ +export interface User { + name: string; + login: string; + email: string; +} diff --git a/apps/portability_example/src/service/auth.ts b/apps/portability_example/src/service/auth.ts new file mode 100644 index 0000000..b60f3fb --- /dev/null +++ b/apps/portability_example/src/service/auth.ts @@ -0,0 +1,33 @@ +import { LoginRequest, LoginResponse } from '../schemas/auth'; + +import { getLocalStorageData, putLocalStorageData } from '../storage'; +import { processPOST } from './service'; + +export const login = async (params: LoginRequest): Promise => { + const result = await processPOST({ + path: '/client_auth/login', + body: params, + }); + + if (!result?.token) { + return ''; + } + + putLocalStorageData({ + token: result.token, + }); + return result.token; +}; + +export const logout = async (): Promise => { + const result = await processPOST({ + path: '/client_auth/logout', + body: { + token: getLocalStorageData()?.token, + }, + }); + + if (result) { + localStorage.removeItem('utoken'); + } +}; diff --git a/apps/portability_example/src/service/portability.ts b/apps/portability_example/src/service/portability.ts new file mode 100644 index 0000000..efb23c8 --- /dev/null +++ b/apps/portability_example/src/service/portability.ts @@ -0,0 +1,18 @@ +import { User } from '../schemas/user'; +import { getLocalStorageData } from '../storage'; + +import { processGET } from './service'; + +export const getPortabilityButton = async (token: string): Promise => { + return await processGET({ + path: '/portability_button/button', + tokenOverride: token, + }); +}; + +export const getPortabilityData = async (token): Promise => { + return await processGET({ + path: '/portability/data/', + tokenOverride: token, + }); +}; diff --git a/apps/portability_example/src/service/service.ts b/apps/portability_example/src/service/service.ts new file mode 100644 index 0000000..0463d2a --- /dev/null +++ b/apps/portability_example/src/service/service.ts @@ -0,0 +1,52 @@ +import axios from 'axios'; +import { getLocalStorageData } from '../storage'; + +export const AUTH_BASE_URL = 'http://127.0.0.1:3000'; + +const headers = { + headers: { + 'Content-Type': 'application/json', + }, +}; + +type Method = 'GET' | 'POST' | 'PUT' | 'DELETE'; + +type RequestParamsBase = { + path: string; + tokenOverride?: string; +}; + +type WithBody = { + body: T; +}; + +type RequestParams = RequestParamsBase & Partial>; +type GetParams = RequestParamsBase; +type PostParams = RequestParamsBase & WithBody; + +export const processRequest = async ( + method: Method, + params?: RequestParams +): Promise => { + const { path, body, tokenOverride } = params || {}; + const token = getLocalStorageData()?.token || tokenOverride; + + const response = await axios.request({ + url: `${AUTH_BASE_URL}${path}`, + method, + headers: { + ...headers.headers, + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + ...(body && { data: body }), + }); + + return response.data; +}; + +export const processGET = async ( + params: GetParams +): Promise => await processRequest('GET', params); + +export const processPOST = async (params: PostParams): Promise => + await processRequest('POST', params); diff --git a/apps/portability_example/src/storage.ts b/apps/portability_example/src/storage.ts new file mode 100644 index 0000000..d0c505a --- /dev/null +++ b/apps/portability_example/src/storage.ts @@ -0,0 +1,28 @@ +export type StorageData = { + token: string; + portabilityToken: string; +}; + +export const setLocalStorageData = (userData: StorageData): void => { + localStorage.setItem( + 'khali_api6_portability_example:user', + JSON.stringify(userData) + ); +}; + +export const getLocalStorageData = (): StorageData | null => { + const userData = localStorage.getItem('khali_api6_portability_example:user'); + return userData ? JSON.parse(userData) : null; +}; + +export const putLocalStorageData = (userData: Partial): void => { + const currentData = getLocalStorageData(); + if (currentData) { + const updatedData = { ...currentData, ...userData }; + setLocalStorageData(updatedData); + } +}; + +export const clearLocalStorageData = (): void => { + localStorage.removeItem('khali_api6_portability_example:user'); +}; diff --git a/apps/portability_example/src/styles.css b/apps/portability_example/src/styles.css new file mode 100644 index 0000000..fee1e53 --- /dev/null +++ b/apps/portability_example/src/styles.css @@ -0,0 +1,12 @@ +html, +body, +#root { + height: 100%; + width: 100vw; + max-width: 100vw; + margin: 0; + padding: 0; + overflow-y: auto; + overflow-x: hidden; + font-family: 'Mulish', sans-serif; +} diff --git a/apps/portability_example/tests/example.test.ts b/apps/portability_example/tests/example.test.ts new file mode 100644 index 0000000..03c2f47 --- /dev/null +++ b/apps/portability_example/tests/example.test.ts @@ -0,0 +1,5 @@ +describe('should test', () => { + it('should pass', async () => { + expect(true).toBeTruthy(); + }); +}); diff --git a/apps/portability_example/tsconfig.app.json b/apps/portability_example/tsconfig.app.json new file mode 100644 index 0000000..db420c0 --- /dev/null +++ b/apps/portability_example/tsconfig.app.json @@ -0,0 +1,24 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [ + "node", + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts", + "vite/client" + ] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/**/*.spec.tsx", + "src/**/*.test.tsx", + "src/**/*.spec.js", + "src/**/*.test.js", + "src/**/*.spec.jsx", + "src/**/*.test.jsx", + "jest.config.ts" + ], + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] +} diff --git a/apps/portability_example/tsconfig.json b/apps/portability_example/tsconfig.json new file mode 100644 index 0000000..c1438c8 --- /dev/null +++ b/apps/portability_example/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "types": [ + "vite/client" + ] + }, + "files": [], + "include": [], + "exclude": [ + "node_modules", + "dist" + ], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../tsconfig.base.json" +} diff --git a/apps/portability_example/tsconfig.spec.json b/apps/portability_example/tsconfig.spec.json new file mode 100644 index 0000000..9c28e60 --- /dev/null +++ b/apps/portability_example/tsconfig.spec.json @@ -0,0 +1,27 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "jsx": "react-jsx", + "types": [ + "jest", + "node", + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts" + ] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] +} diff --git a/apps/portability_example/vite.config.ts b/apps/portability_example/vite.config.ts new file mode 100644 index 0000000..7970db9 --- /dev/null +++ b/apps/portability_example/vite.config.ts @@ -0,0 +1,31 @@ +/// +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin'; + +export default defineConfig({ + root: __dirname, + cacheDir: '../../node_modules/.vite/portability_example', + server: { + port: 4201, + host: 'localhost', + }, + preview: { + port: 4301, + host: 'localhost', + }, + plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])], + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + build: { + outDir: '../../dist/portability_example', + emptyOutDir: true, + reportCompressedSize: true, + commonjsOptions: { + transformMixedEsModules: true, + }, + }, +}); diff --git a/apps/web/src/schemas/UserSchema.ts b/apps/web/src/schemas/UserSchema.ts index 02a97df..10d733f 100644 --- a/apps/web/src/schemas/UserSchema.ts +++ b/apps/web/src/schemas/UserSchema.ts @@ -4,7 +4,7 @@ export interface User { login: string; email: string; versionTerms: string; - permissionId: number; + roleId: number; disabledSince?: string; } diff --git a/apps/web/src/service/UserService.ts b/apps/web/src/service/UserService.ts index b00115b..83e31e8 100644 --- a/apps/web/src/service/UserService.ts +++ b/apps/web/src/service/UserService.ts @@ -29,7 +29,7 @@ export const getUser = async (id: number): Promise => { export const createUser = async (newUser: NewUser): Promise => { return await processPOST({ - path: `/register`, + path: `/register/`, body: newUser, overrideURL: AUTH_BASE_URL, }); diff --git a/package.json b/package.json index 72b0ac0..32ada1a 100644 --- a/package.json +++ b/package.json @@ -66,4 +66,4 @@ "web": "^0.0.2" }, "nx": {} -} \ No newline at end of file +}