From cfc1c7468da500c116661dab787735cb49c447a1 Mon Sep 17 00:00:00 2001 From: Paulo Granthon Date: Thu, 29 May 2025 20:45:00 -0300 Subject: [PATCH 1/8] chore(auth): rename `GetUserKeyResult` enum to `GetKeyResult` in `fernet` service --- apps/auth/src/routes/auth.rs | 4 ++-- apps/auth/src/routes/user.rs | 14 +++++++------- apps/auth/src/service/fernet.rs | 10 +++++----- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/auth/src/routes/auth.rs b/apps/auth/src/routes/auth.rs index a4c1b7c..fc2b3be 100644 --- a/apps/auth/src/routes/auth.rs +++ b/apps/auth/src/routes/auth.rs @@ -150,8 +150,8 @@ pub async fn login( } let user_decryption_key = match get_user_key(user.id, &keys_client, &config).await { - GetUserKeyResult::Ok(key) => key, - GetUserKeyResult::Err(err) => return err, + GetKeyResult::Ok(key) => key, + GetKeyResult::Err(err) => return err, }; let decrypted_password = match decrypt_field( diff --git a/apps/auth/src/routes/user.rs b/apps/auth/src/routes/user.rs index f08eb5a..ec1d85d 100644 --- a/apps/auth/src/routes/user.rs +++ b/apps/auth/src/routes/user.rs @@ -10,7 +10,7 @@ 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, + decrypt_database_user, encrypt_field, get_user_key, GetKeyResult, }; use crate::service::jwt::validator; @@ -70,8 +70,8 @@ async fn get_users( for encrypted_user in raw_users { let user_decryption_key = match get_user_key(encrypted_user.id, &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( @@ -140,8 +140,8 @@ async fn get_user( }; let user_decryption_key = match get_user_key(encrypted_user.id, &keys_client, &config).await { - GetUserKeyResult::Ok(key) => key, - GetUserKeyResult::Err(err) => return err, + GetKeyResult::Ok(key) => key, + GetKeyResult::Err(err) => return err, }; match decrypt_database_user(&user_decryption_key, encrypted_user) { @@ -183,8 +183,8 @@ async fn update_user( }; let user_decryption_key = match get_user_key(*user_id, &keys_client, &config).await { - GetUserKeyResult::Ok(key) => key, - GetUserKeyResult::Err(err) => return err, + GetKeyResult::Ok(key) => key, + GetKeyResult::Err(err) => return err, }; user_update_model.disabled_since = match &user_update.disabled_since { diff --git a/apps/auth/src/service/fernet.rs b/apps/auth/src/service/fernet.rs index 600c86b..6e991e3 100644 --- a/apps/auth/src/service/fernet.rs +++ b/apps/auth/src/service/fernet.rs @@ -19,7 +19,7 @@ use crate::{ }, }; -pub enum GetUserKeyResult { +pub enum GetKeyResult { Ok(String), Err(HttpResponse), } @@ -28,25 +28,25 @@ pub async fn get_user_key( user_id: i64, keys_client: &web::Data, config: &web::Data, -) -> GetUserKeyResult { +) -> GetKeyResult { let user_key_result = keys_entity::Entity::find_by_id(user_id) .one(&keys_client.client) .await; match user_key_result { - Err(err) => GetUserKeyResult::Err(handle_server_error_body( + 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), &config, Some(ServerErrorType::NotFound), )), - Ok(Some(user_key)) => GetUserKeyResult::Ok(user_key.key), + Ok(Some(user_key)) => GetKeyResult::Ok(user_key.key), } } From 8a21290880ecc925e4a7d16da8d7229959838420 Mon Sep 17 00:00:00 2001 From: Paulo Granthon Date: Thu, 29 May 2025 21:15:29 -0300 Subject: [PATCH 2/8] feat(db): support `external_client` entity in definitions --- apps/db/seeds/postgres_seeds.py | 123 +++++++++++++++++++++++++++----- apps/db/src/db/keys.py | 21 +++++- apps/db/src/db/postgres.py | 45 +++++++----- 3 files changed, 150 insertions(+), 39 deletions(-) diff --git a/apps/db/seeds/postgres_seeds.py b/apps/db/seeds/postgres_seeds.py index fd9eb37..4392271 100644 --- a/apps/db/seeds/postgres_seeds.py +++ b/apps/db/seeds/postgres_seeds.py @@ -5,12 +5,13 @@ import random from db.keys import ( get_keys_engine, - UserKey, + EntityKey, ) from db.postgres import ( get_engine, test_connection, User, + ExternalClient, Permission, Role ) @@ -23,14 +24,23 @@ 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_permissions(session): - permission_names = ["dashboard", "register", "analitic", "terms"] + permission_names = [ + "dashboard", + "register", + "analitic", + "terms", + ] + permissions = [ Permission(name=name, description=fake.sentence()) for name in permission_names @@ -75,12 +85,6 @@ def insert_users(session, roles): 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, user: User, @@ -121,13 +125,11 @@ def add_user( session.add(user) 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( + id=user.id, + key=key.decode(), + entity_type="user", + )) add_user( # default user for easy login name="Alice", @@ -141,7 +143,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( @@ -165,6 +167,87 @@ def hash_password(password: str) -> str: return users +def insert_external_clients(session): + external_clients = [] + keys_session = sessionmaker(bind=get_keys_engine())() + + 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) + session.add(external_client) + session.flush() # get external_client.id + + keys_session.add(EntityKey( + id=external_client.id, + key=key.decode(), + entity_type="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.external_client_name(), + password=hash_password(fake.password()), + disabled_since=disabled_date + ) + + session.add_all(external_clients) + print(f"{NUM_USERS} usuários inseridos.") + + session.commit() + keys_session.commit() + 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) @@ -176,6 +259,8 @@ def insert_seeds(): roles = insert_roles(session, permissions) insert_users(session, roles) + insert_external_clients(session) + print("Seed finalizada com sucesso.") diff --git a/apps/db/src/db/keys.py b/apps/db/src/db/keys.py index 1fdfeca..25db148 100644 --- a/apps/db/src/db/keys.py +++ b/apps/db/src/db/keys.py @@ -2,8 +2,8 @@ import os from dotenv import load_dotenv from sqlalchemy import ( - Date, DateTime, + ForeignKey, create_engine, text, Column, @@ -33,13 +33,28 @@ 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, + autoincrement=True + ) + name = Column(String, unique=True, nullable=False) + + +class EntityKey(Base): + __tablename__ = "entity_key" id = Column( BigInteger, primary_key=True ) key = Column(String, unique=True, nullable=False) + entity_type = Column( + BigInteger, + ForeignKey("entity_type.id"), + 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): From 065d99360a4a7b66c3f1e5bd9fa3e635ee82bb8b Mon Sep 17 00:00:00 2001 From: Paulo Granthon Date: Fri, 30 May 2025 01:41:07 -0300 Subject: [PATCH 3/8] chore(db): add `init-keys` as dependency of `postgres-seeds` --- apps/db/project.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" } From 180c80bc93989769cfa77e4cb876e2dc10be7aff Mon Sep 17 00:00:00 2001 From: Paulo Granthon Date: Fri, 30 May 2025 02:59:41 -0300 Subject: [PATCH 4/8] feat: implement external client support --- apps/auth/src/entities/entity_key.rs | 18 ++ .../src/entities/{keys.rs => entity_type.rs} | 4 +- apps/auth/src/entities/external_client.rs | 19 ++ apps/auth/src/entities/mod.rs | 4 +- apps/auth/src/infra/server.rs | 2 + apps/auth/src/models/auth.rs | 17 +- apps/auth/src/models/entity_type.rs | 38 +++ apps/auth/src/models/external_client.rs | 22 ++ apps/auth/src/models/jwt.rs | 36 ++- apps/auth/src/models/mod.rs | 9 +- apps/auth/src/routes/auth.rs | 32 +- apps/auth/src/routes/common/error.rs | 13 +- apps/auth/src/routes/external_client.rs | 303 ++++++++++++++++++ apps/auth/src/routes/external_client_auth.rs | 239 ++++++++++++++ apps/auth/src/routes/mod.rs | 4 + apps/auth/src/routes/user.rs | 57 ++-- apps/auth/src/service/fernet.rs | 50 ++- apps/auth/src/service/jwt.rs | 66 ++-- apps/auth/src/swagger.rs | 48 ++- apps/db/seeds/postgres_seeds.py | 117 ++++--- apps/db/src/db/keys.py | 7 +- 21 files changed, 982 insertions(+), 123 deletions(-) create mode 100644 apps/auth/src/entities/entity_key.rs rename apps/auth/src/entities/{keys.rs => entity_type.rs} (82%) create mode 100644 apps/auth/src/entities/external_client.rs create mode 100644 apps/auth/src/models/entity_type.rs create mode 100644 apps/auth/src/models/external_client.rs create mode 100644 apps/auth/src/routes/external_client.rs create mode 100644 apps/auth/src/routes/external_client_auth.rs diff --git a/apps/auth/src/entities/entity_key.rs b/apps/auth/src/entities/entity_key.rs new file mode 100644 index 0000000..1d8c504 --- /dev/null +++ b/apps/auth/src/entities/entity_key.rs @@ -0,0 +1,18 @@ +use sea_orm::entity::prelude::*; + +use crate::models::EntityType; + +#[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: EntityType, + 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 82% rename from apps/auth/src/entities/keys.rs rename to apps/auth/src/entities/entity_type.rs index ac355a9..4ee8634 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: i64, } #[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/server.rs b/apps/auth/src/infra/server.rs index c5ac8f6..c511f98 100755 --- a/apps/auth/src/infra/server.rs +++ b/apps/auth/src/infra/server.rs @@ -54,6 +54,8 @@ 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) }) .bind(("0.0.0.0", server_port))?; diff --git a/apps/auth/src/models/auth.rs b/apps/auth/src/models/auth.rs index 400302a..b9e9334 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,12 +27,17 @@ 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, diff --git a/apps/auth/src/models/entity_type.rs b/apps/auth/src/models/entity_type.rs new file mode 100644 index 0000000..8c96a6c --- /dev/null +++ b/apps/auth/src/models/entity_type.rs @@ -0,0 +1,38 @@ +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, +} + +impl EntityType { + pub fn as_str(&self) -> &'static str { + match self { + EntityType::User => "user", + EntityType::ExternalClient => "external_client", + } + } +} + +impl FromStr for EntityType { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "user" => Ok(EntityType::User), + "external_client" => Ok(EntityType::ExternalClient), + _ => 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..f15f263 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,27 @@ pub struct Claims { pub jti: String, } +impl Claims { + pub fn parse_subject(&self) -> Result<(EntityType, String), VerificationError> { + 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((entity_type, parts[1].to_string())) + } +} + #[derive(Debug, Error)] pub enum VerificationError { #[error("JWT error: {0}")] @@ -28,5 +53,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/routes/auth.rs b/apps/auth/src/routes/auth.rs index fc2b3be..9c393a8 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::*, @@ -57,7 +58,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) { #[utoipa::path( post, path = "/register", - request_body = RegisterRequest, + 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(EntityType::User), 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 { - GetKeyResult::Ok(key) => key, - GetKeyResult::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,12 @@ 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, 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..85cf9a0 --- /dev/null +++ b/apps/auth/src/routes/external_client_auth.rs @@ -0,0 +1,239 @@ +use actix_web::{ + web, // + HttpRequest, + HttpResponse, + Responder, +}; +use actix_web_httpauth::middleware::HttpAuthentication; +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(login)) + .route("/validate", web::post().to(validate_token)) + .route("/logout", web::post().to(logout)), + ) + .service( + web::scope("/client") + .wrap(HttpAuthentication::bearer(validator)) + .route("/register", web::post().to(register)), + ); +} + +#[utoipa::path( + post, + path = "/register", + request_body = ExternalClientRegisterRequest, + responses( + (status = 200, description = "ExternalClient registered"), + (status = 500, description = "Hashing or Database error") + ), + tags = ["Auth"] +)] + +pub async fn 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(EntityType::ExternalClient), + 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 = "/auth/login", + request_body = LoginRequest, + responses( + (status = 200, description = "Login successful", body = ExternalClientLoginResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Database error") + ), + tags = ["Auth"] +)] + +pub async fn 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 = "/auth/validate", + request_body = ValidateRequest, + responses( + (status = 200, description = "Token is valid", body = Claims), + (status = 401, description = "Invalid token") + ), + tags = ["Auth"] +)] + +pub async fn validate_token( + data: web::Json, + keys_client: web::Data, + config: web::Data, +) -> impl Responder { + match verify_jwt(&data.token, &config.jwt_secret, &keys_client.client).await { + Ok(claims) => HttpResponse::Ok().json(claims.claims), + Err(err) => handle_server_error_body("Invalid token", err, &config, None), + } +} + +#[utoipa::path( + post, + path = "/auth/logout", + request_body = ValidateRequest, + responses( + (status = 200, description = "Token invalidated") + ), + tags = ["Auth"] +)] + +pub async fn 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..85aba1b 100644 --- a/apps/auth/src/routes/mod.rs +++ b/apps/auth/src/routes/mod.rs @@ -1,6 +1,10 @@ pub mod auth; pub mod common; +pub mod external_client; +pub mod external_client_auth; 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 user::configure as user; diff --git a/apps/auth/src/routes/user.rs b/apps/auth/src/routes/user.rs index ec1d85d..3b3f259 100644 --- a/apps/auth/src/routes/user.rs +++ b/apps/auth/src/routes/user.rs @@ -4,13 +4,28 @@ 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 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::models::{ + EntityType, // + PaginatedRequest, + PaginatedResponse, + UserPublic, + UserUpdate, +}; use crate::service::fernet::{ - decrypt_database_user, encrypt_field, get_user_key, GetKeyResult, + decrypt_database_user, // + encrypt_field, + get_entity_key, + GetKeyResult, }; use crate::service::jwt::validator; @@ -68,7 +83,13 @@ 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 { GetKeyResult::Ok(key) => key, GetKeyResult::Err(err) => return err, @@ -119,7 +140,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 +151,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 +159,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 { - GetKeyResult::Ok(key) => key, - GetKeyResult::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 +203,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 { - GetKeyResult::Ok(key) => key, - GetKeyResult::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 +282,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 6e991e3..57a3719 100644 --- a/apps/auth/src/service/fernet.rs +++ b/apps/auth/src/service/fernet.rs @@ -3,15 +3,20 @@ 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, // + 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, @@ -24,16 +29,19 @@ pub enum GetKeyResult { 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, ) -> GetKeyResult { - let user_key_result = keys_entity::Entity::find_by_id(user_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)) .one(&keys_client.client) .await; - match user_key_result { + match entity_key_result { Err(err) => GetKeyResult::Err(handle_server_error_body( "Database Error", err, @@ -42,11 +50,11 @@ pub async fn get_user_key( )), 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)) => GetKeyResult::Ok(user_key.key), + Ok(Some(entity_key)) => GetKeyResult::Ok(entity_key.key), } } @@ -107,3 +115,27 @@ 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))?; + + let login = decrypt_field(&fernet, &external_client.login) + .map_err(|err| CustomError::UnsuccessfulDecryption("login".to_string(), err))?; + + Ok(ExternalClientPublic { + id: external_client.id, + name, + 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..70e5878 100644 --- a/apps/auth/src/service/jwt.rs +++ b/apps/auth/src/service/jwt.rs @@ -1,9 +1,19 @@ -use crate::models::jwt::*; +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; @@ -16,8 +26,8 @@ pub async fn validator( req: ServiceRequest, credentials: BearerAuth, ) -> Result { - let jwt_secret = match req.app_data::>() { - Some(data) => data.get_ref().clone(), + let jwt_secret = match req.app_data::>() { + Some(data) => data.get_ref().jwt_secret.clone(), None => { return Err(( actix_web::error::ErrorInternalServerError("JWT secret not configured"), @@ -68,15 +78,24 @@ pub async fn verify_jwt( 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 +118,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 now = Utc::now().naive_utc(); - let am = revoked_token::ActiveModel { + let jti = token_data.jti; + + 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..210ddb3 100644 --- a/apps/auth/src/swagger.rs +++ b/apps/auth/src/swagger.rs @@ -1,40 +1,68 @@ -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::register, + routes::external_client_auth::login, + routes::external_client_auth::validate_token, + routes::external_client_auth::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, + ), 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::UserPublic, models::UserUpdate, + + // External Client models + 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") + ) )] diff --git a/apps/db/seeds/postgres_seeds.py b/apps/db/seeds/postgres_seeds.py index 4392271..e96c333 100644 --- a/apps/db/seeds/postgres_seeds.py +++ b/apps/db/seeds/postgres_seeds.py @@ -1,10 +1,15 @@ -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, + EntityType, EntityKey, ) from db.postgres import ( @@ -15,8 +20,6 @@ Permission, Role ) -from sqlalchemy.orm import sessionmaker -from base64 import b64encode fake = Faker() Faker.seed(42) @@ -33,7 +36,28 @@ NUM_EXTERNAL_CLIENTS_DISABLED = 0 -def insert_permissions(session): +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(postgres_session): permission_names = [ "dashboard", "register", @@ -46,13 +70,13 @@ def insert_permissions(session): 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 @@ -74,16 +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_user( fernet: Fernet, @@ -122,13 +150,13 @@ 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(EntityKey( - id=user.id, + entity_id=user.id, key=key.decode(), - entity_type="user", + entity_type=entity_types["user"] )) add_user( # default user for easy login @@ -156,20 +184,23 @@ def add_user( 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(session): +def insert_external_clients( + postgres_session, + keys_session, + entity_types, +): external_clients = [] - keys_session = sessionmaker(bind=get_keys_engine())() def encrypt_external_client( fernet: Fernet, @@ -196,13 +227,13 @@ def add_external_client( disabled_since=disabled_since, )) external_clients.append(external_client) - session.add(external_client) - session.flush() # get external_client.id + postgres_session.add(external_client) + postgres_session.flush() # get external_client.id keys_session.add(EntityKey( - id=external_client.id, + entity_id=external_client.id, key=key.decode(), - entity_type="external_client", + entity_type=entity_types["external_client"], )) add_external_client( # default external_client for easy login @@ -218,16 +249,16 @@ def add_external_client( add_external_client( name=fake.name(), - login=fake.unique.external_client_name(), + login=fake.unique.company(), password=hash_password(fake.password()), disabled_since=disabled_date ) - session.add_all(external_clients) + postgres_session.add_all(external_clients) 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.") @@ -249,17 +280,27 @@ def hash_password(password: str) -> str: 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) - insert_external_clients(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 25db148..fa455a2 100644 --- a/apps/db/src/db/keys.py +++ b/apps/db/src/db/keys.py @@ -47,7 +47,12 @@ class EntityKey(Base): __tablename__ = "entity_key" id = Column( BigInteger, - primary_key=True + primary_key=True, + autoincrement=True + ) + entity_id = Column( + BigInteger, + nullable=False ) key = Column(String, unique=True, nullable=False) entity_type = Column( From 6689f920fb9b60818358a328327e5ffbfebf0382 Mon Sep 17 00:00:00 2001 From: Paulo Granthon Date: Fri, 30 May 2025 03:26:00 -0300 Subject: [PATCH 5/8] fix(db): remove `unique` from `key` column of `EntityKey` entity --- apps/db/src/db/keys.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/db/src/db/keys.py b/apps/db/src/db/keys.py index fa455a2..a42c096 100644 --- a/apps/db/src/db/keys.py +++ b/apps/db/src/db/keys.py @@ -54,7 +54,7 @@ class EntityKey(Base): BigInteger, nullable=False ) - key = Column(String, unique=True, nullable=False) + key = Column(String, nullable=False) entity_type = Column( BigInteger, ForeignKey("entity_type.id"), From e95854d1a4ce6fa505e60081eb6a795e6299f49b Mon Sep 17 00:00:00 2001 From: Paulo Granthon Date: Fri, 30 May 2025 03:26:27 -0300 Subject: [PATCH 6/8] feat(db): add `entity_type`+`entity_id` unique combination constraint in `EntityKey` entity --- apps/db/src/db/keys.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/db/src/db/keys.py b/apps/db/src/db/keys.py index a42c096..cb63261 100644 --- a/apps/db/src/db/keys.py +++ b/apps/db/src/db/keys.py @@ -4,6 +4,7 @@ from sqlalchemy import ( DateTime, ForeignKey, + UniqueConstraint, create_engine, text, Column, @@ -60,6 +61,13 @@ class EntityKey(Base): ForeignKey("entity_type.id"), nullable=False ) + __table_args__ = ( + UniqueConstraint( + 'entity_type', + 'entity_id', + name='uq_entity_type_entity_id' + ), + ) class RevokedToken(Base): From ccdceb7e6dc00697b567bebcbef9b4bba6c8133a Mon Sep 17 00:00:00 2001 From: Paulo Granthon Date: Fri, 6 Jun 2025 01:36:50 -0300 Subject: [PATCH 7/8] feat(auth): portability --- Cargo.lock | 58 ++++ apps/auth/Cargo.toml | 1 + apps/auth/src/infra/server.rs | 1 + apps/auth/src/main.rs | 1 + apps/auth/src/models/entity_type.rs | 4 + apps/auth/src/models/jwt.rs | 12 +- apps/auth/src/routes/mod.rs | 2 + apps/auth/src/routes/portability.rs | 309 ++++++++++++++++++ apps/auth/src/routes/user.rs | 54 +-- apps/auth/src/service/jwt.rs | 95 ++++-- apps/auth/src/templates/engine.rs | 14 + apps/auth/src/templates/mod.rs | 1 + .../portability/authorization_button.html | 9 + .../portability/authorization_form.html | 49 +++ 14 files changed, 567 insertions(+), 43 deletions(-) create mode 100644 apps/auth/src/routes/portability.rs create mode 100644 apps/auth/src/templates/engine.rs create mode 100644 apps/auth/src/templates/mod.rs create mode 100644 apps/auth/src/templates/portability/authorization_button.html create mode 100644 apps/auth/src/templates/portability/authorization_form.html 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/infra/server.rs b/apps/auth/src/infra/server.rs index c511f98..4dd7e63 100755 --- a/apps/auth/src/infra/server.rs +++ b/apps/auth/src/infra/server.rs @@ -56,6 +56,7 @@ pub async fn create_server(config: Config) -> std::io::Result { .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/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/entity_type.rs b/apps/auth/src/models/entity_type.rs index 8c96a6c..29ce286 100644 --- a/apps/auth/src/models/entity_type.rs +++ b/apps/auth/src/models/entity_type.rs @@ -8,6 +8,8 @@ pub enum EntityType { User, #[sea_orm(string_value = "external_client")] ExternalClient, + #[sea_orm(string_value = "authorized_client")] + AuthorizedClient, } impl EntityType { @@ -15,6 +17,7 @@ impl EntityType { match self { EntityType::User => "user", EntityType::ExternalClient => "external_client", + EntityType::AuthorizedClient => "authorized_client", } } } @@ -26,6 +29,7 @@ impl FromStr for EntityType { match s { "user" => Ok(EntityType::User), "external_client" => Ok(EntityType::ExternalClient), + "authorized_client" => Ok(EntityType::AuthorizedClient), _ => Err(()), } } diff --git a/apps/auth/src/models/jwt.rs b/apps/auth/src/models/jwt.rs index f15f263..702b490 100644 --- a/apps/auth/src/models/jwt.rs +++ b/apps/auth/src/models/jwt.rs @@ -25,8 +25,13 @@ pub struct Claims { pub jti: String, } +pub struct ClaimsSubject { + pub entity_type: EntityType, + pub id: String, +} + impl Claims { - pub fn parse_subject(&self) -> Result<(EntityType, String), VerificationError> { + pub fn parse_subject(&self) -> Result { let decoded = STANDARD .decode(&self.sub) .map_err(|_| VerificationError::InvalidSubjectFormat)?; @@ -42,7 +47,10 @@ impl Claims { let entity_type = EntityType::from_str(parts[0]) .map_err(|_| VerificationError::InvalidEntityType(parts[0].to_string()))?; - Ok((entity_type, parts[1].to_string())) + Ok(ClaimsSubject { + entity_type, + id: parts[1].to_string(), + }) } } diff --git a/apps/auth/src/routes/mod.rs b/apps/auth/src/routes/mod.rs index 85aba1b..83e2425 100644 --- a/apps/auth/src/routes/mod.rs +++ b/apps/auth/src/routes/mod.rs @@ -2,9 +2,11 @@ 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..ac94a7e --- /dev/null +++ b/apps/auth/src/routes/portability.rs @@ -0,0 +1,309 @@ +use actix_web::{web, HttpMessage, HttpRequest, HttpResponse, Responder}; +use actix_web_httpauth::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}, + jwt::ClaimsSubject, + EntityType, + }, + 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, // + validator_authorized_client, + validator_external_client, + }, + }, + templates::engine::{LoginButtonTemplate, LoginFormTemplate}, +}; + +pub fn configure(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/portability/button") // + .route("/", web::get().to(button)), + ) + .service( + web::scope("/portability/auth") + .wrap(HttpAuthentication::bearer(validator_external_client)) + .route("/screen", web::get().to(screen)) + .route("/authorize", web::post().to(authorize)), + ) + .service( + web::scope("/portability/data") + .wrap(HttpAuthentication::bearer(validator_authorized_client)) + .route("/", web::post().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) -> impl Responder { + let client_id = match req.extensions().get::() { + Some(subject) => subject.id.to_string(), + None => return HttpResponse::Unauthorized().body("Unauthorized"), + }; + + let tmpl = LoginButtonTemplate { + popup_url: format!("/login-form/{}", client_id), + }; + HttpResponse::Ok().content_type("text/html").body( + tmpl.render() + .unwrap_or_else(|_| "

An error occurred

".to_string()), + ) +} + +#[utoipa::path( + get, + path = "/portability/auth/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, + 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 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 { + client_name: external_client.name, + client_id: external_client.id, + }; + 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(decrypted_user), + 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 3b3f259..5f5b914 100644 --- a/apps/auth/src/routes/user.rs +++ b/apps/auth/src/routes/user.rs @@ -2,34 +2,46 @@ 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, // + prelude::Date, + ActiveModelTrait, + ActiveValue::{ + NotSet, + Set, // + }, EntityTrait, IntoActiveModel, PaginatorTrait, - QuerySelect, + QuerySelect, // }; -use crate::entities::user as user_entity; -use crate::infra::server::{DatabaseClientKeys, DatabaseClientPostgres}; -use crate::models::{ - EntityType, // - PaginatedRequest, - PaginatedResponse, - UserPublic, - UserUpdate, -}; -use crate::service::fernet::{ - decrypt_database_user, // - encrypt_field, - get_entity_key, - GetKeyResult, +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, + }, }; -use crate::service::jwt::validator; - -use super::common::{handle_server_error_body, ServerErrorType}; pub fn configure(cfg: &mut web::ServiceConfig) { cfg.service( diff --git a/apps/auth/src/service/jwt.rs b/apps/auth/src/service/jwt.rs index 70e5878..4bd3c25 100644 --- a/apps/auth/src/service/jwt.rs +++ b/apps/auth/src/service/jwt.rs @@ -22,40 +22,95 @@ 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 db = req + .app_data::>() + .ok_or_else(|| { + actix_web::error::ErrorInternalServerError("Database connection not configured") + })? + .get_ref() + .clone(); + + let token_data = verify_jwt(credentials.token(), &jwt_secret, &db) + .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().jwt_secret.clone(), - None => { - return Err(( - actix_web::error::ErrorInternalServerError("JWT secret not configured"), - req, - )) - } + let claims = match extract_claims(&req, &credentials).await { + Ok(claims) => claims, + 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 validator_entity_type( + req: ServiceRequest, + credentials: BearerAuth, + entity_type: EntityType, +) -> Result { + let claims = match extract_claims(&req, &credentials).await { + Ok(claims) => claims, + Err(err) => return Err((err, req)), + }; + + let subject = match claims.parse_subject() { + Ok(subject) => subject, + Err(_) => { return Err(( - actix_web::error::ErrorInternalServerError("Database connection not configured"), + actix_web::error::ErrorUnauthorized("Invalid token subject"), 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"), + if subject.entity_type != entity_type { + return Err(( + actix_web::error::ErrorUnauthorized("Invalid token entity type"), 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( diff --git a/apps/auth/src/templates/engine.rs b/apps/auth/src/templates/engine.rs new file mode 100644 index 0000000..eec94ad --- /dev/null +++ b/apps/auth/src/templates/engine.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 client_name: String, + pub client_id: i64, +} diff --git a/apps/auth/src/templates/mod.rs b/apps/auth/src/templates/mod.rs new file mode 100644 index 0000000..702e611 --- /dev/null +++ b/apps/auth/src/templates/mod.rs @@ -0,0 +1 @@ +pub mod engine; diff --git a/apps/auth/src/templates/portability/authorization_button.html b/apps/auth/src/templates/portability/authorization_button.html new file mode 100644 index 0000000..3bcc782 --- /dev/null +++ b/apps/auth/src/templates/portability/authorization_button.html @@ -0,0 +1,9 @@ + diff --git a/apps/auth/src/templates/portability/authorization_form.html b/apps/auth/src/templates/portability/authorization_form.html new file mode 100644 index 0000000..1ff9065 --- /dev/null +++ b/apps/auth/src/templates/portability/authorization_form.html @@ -0,0 +1,49 @@ + + + + Authorize {{ client_name }} via Khali + + + + +

Login to {{ client_name }}

+
+ +
+
+ +
+ + + + From add57aa191974cb6976551e403e39f0b9246726d Mon Sep 17 00:00:00 2001 From: Paulo Granthon Date: Mon, 9 Jun 2025 07:11:33 -0300 Subject: [PATCH 8/8] feat: `portability_example` app --- .env.example | 9 +- apps/auth/src/entities/entity_key.rs | 4 +- apps/auth/src/entities/entity_type.rs | 2 +- apps/auth/src/infra/config.rs | 4 +- apps/auth/src/infra/server.rs | 2 +- apps/auth/src/infra/types.rs | 10 +- apps/auth/src/models/auth.rs | 5 + apps/auth/src/models/user.rs | 8 ++ apps/auth/src/routes/auth.rs | 16 +-- apps/auth/src/routes/external_client_auth.rs | 42 +++---- apps/auth/src/routes/portability.rs | 81 ++++++++---- apps/auth/src/routes/user.rs | 6 +- apps/auth/src/service/fernet.rs | 39 +++++- apps/auth/src/service/jwt.rs | 59 +++++---- apps/auth/src/swagger.rs | 24 ++-- .../src/{templates/engine.rs => templates.rs} | 2 +- apps/auth/src/templates/mod.rs | 1 - .../portability/authorization_button.html | 0 .../portability/authorization_form.html | 28 +++-- apps/portability_example/eslint.config.mjs | 12 ++ apps/portability_example/index.html | 16 +++ apps/portability_example/jest.config.ts | 12 ++ apps/portability_example/jest.setup.js | 17 +++ apps/portability_example/project.json | 16 +++ apps/portability_example/public/favicon.ico | Bin 0 -> 15086 bytes apps/portability_example/src/app.tsx | 115 ++++++++++++++++++ apps/portability_example/src/main.tsx | 13 ++ apps/portability_example/src/schemas/auth.ts | 8 ++ apps/portability_example/src/schemas/user.ts | 5 + apps/portability_example/src/service/auth.ts | 33 +++++ .../src/service/portability.ts | 18 +++ .../src/service/service.ts | 52 ++++++++ apps/portability_example/src/storage.ts | 28 +++++ apps/portability_example/src/styles.css | 12 ++ .../portability_example/tests/example.test.ts | 5 + apps/portability_example/tsconfig.app.json | 24 ++++ apps/portability_example/tsconfig.json | 27 ++++ apps/portability_example/tsconfig.spec.json | 27 ++++ apps/portability_example/vite.config.ts | 31 +++++ apps/web/src/schemas/UserSchema.ts | 2 +- apps/web/src/service/UserService.ts | 2 +- package.json | 2 +- 42 files changed, 701 insertions(+), 118 deletions(-) rename apps/auth/src/{templates/engine.rs => templates.rs} (92%) delete mode 100644 apps/auth/src/templates/mod.rs rename apps/auth/{src => }/templates/portability/authorization_button.html (100%) rename apps/auth/{src => }/templates/portability/authorization_form.html (54%) create mode 100644 apps/portability_example/eslint.config.mjs create mode 100644 apps/portability_example/index.html create mode 100644 apps/portability_example/jest.config.ts create mode 100644 apps/portability_example/jest.setup.js create mode 100644 apps/portability_example/project.json create mode 100644 apps/portability_example/public/favicon.ico create mode 100644 apps/portability_example/src/app.tsx create mode 100644 apps/portability_example/src/main.tsx create mode 100644 apps/portability_example/src/schemas/auth.ts create mode 100644 apps/portability_example/src/schemas/user.ts create mode 100644 apps/portability_example/src/service/auth.ts create mode 100644 apps/portability_example/src/service/portability.ts create mode 100644 apps/portability_example/src/service/service.ts create mode 100644 apps/portability_example/src/storage.ts create mode 100644 apps/portability_example/src/styles.css create mode 100644 apps/portability_example/tests/example.test.ts create mode 100644 apps/portability_example/tsconfig.app.json create mode 100644 apps/portability_example/tsconfig.json create mode 100644 apps/portability_example/tsconfig.spec.json create mode 100644 apps/portability_example/vite.config.ts 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/apps/auth/src/entities/entity_key.rs b/apps/auth/src/entities/entity_key.rs index 1d8c504..f900991 100644 --- a/apps/auth/src/entities/entity_key.rs +++ b/apps/auth/src/entities/entity_key.rs @@ -1,14 +1,12 @@ use sea_orm::entity::prelude::*; -use crate::models::EntityType; - #[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: EntityType, + pub entity_type: i64, pub key: String, } diff --git a/apps/auth/src/entities/entity_type.rs b/apps/auth/src/entities/entity_type.rs index 4ee8634..fad584b 100644 --- a/apps/auth/src/entities/entity_type.rs +++ b/apps/auth/src/entities/entity_type.rs @@ -5,7 +5,7 @@ use sea_orm::entity::prelude::*; pub struct Model { #[sea_orm(primary_key)] pub id: i64, - pub name: i64, + pub name: String, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 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 4dd7e63..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( 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/models/auth.rs b/apps/auth/src/models/auth.rs index b9e9334..03fcd8a 100644 --- a/apps/auth/src/models/auth.rs +++ b/apps/auth/src/models/auth.rs @@ -42,3 +42,8 @@ pub struct ExternalClientLoginResponse { pub struct ValidateRequest { pub token: String, } + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct PortabilityScreenQuery { + pub token: String, +} 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 9c393a8..6f4c451 100644 --- a/apps/auth/src/routes/auth.rs +++ b/apps/auth/src/routes/auth.rs @@ -49,15 +49,15 @@ 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", + path = "/register/", request_body = UserRegisterRequest, responses( (status = 200, description = "User registered"), @@ -102,7 +102,7 @@ pub async fn register( let new_user_key = keys_entity::ActiveModel { id: NotSet, entity_id: Set(inserted_user.id), - entity_type: Set(EntityType::User), + entity_type: Set(1), // TODO: select id instead key: Set(new_user_decryption_key.clone()), }; @@ -191,11 +191,7 @@ pub async fn login( }; if verify(&data.password, &decrypted_password).unwrap_or(false) { - let token = create_jwt( - &user.id.to_string(), - EntityType::User, - &config.jwt_secret, - ); + let token = create_jwt(&user.id.to_string(), EntityType::User, &config.jwt_secret); HttpResponse::Ok().json(UserLoginResponse { token, id: user.id, @@ -222,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/external_client_auth.rs b/apps/auth/src/routes/external_client_auth.rs index 85cf9a0..beb1e4e 100644 --- a/apps/auth/src/routes/external_client_auth.rs +++ b/apps/auth/src/routes/external_client_auth.rs @@ -4,7 +4,6 @@ use actix_web::{ HttpResponse, Responder, }; -use actix_web_httpauth::middleware::HttpAuthentication; use bcrypt::{hash, verify, DEFAULT_COST}; use fernet::Fernet; use sea_orm::{ @@ -40,30 +39,29 @@ use super::common::{handle_server_error_body, handle_server_error_string, Custom pub fn configure(cfg: &mut web::ServiceConfig) { cfg.service( - web::scope("/client/auth") - .route("/login", web::post().to(login)) - .route("/validate", web::post().to(validate_token)) - .route("/logout", web::post().to(logout)), + 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") - .wrap(HttpAuthentication::bearer(validator)) - .route("/register", web::post().to(register)), + web::scope("/client") // + .route("/register", web::post().to(external_client_register)), ); } #[utoipa::path( post, - path = "/register", + path = "/client/register", request_body = ExternalClientRegisterRequest, responses( (status = 200, description = "ExternalClient registered"), (status = 500, description = "Hashing or Database error") ), - tags = ["Auth"] + tags = ["External Client Auth"] )] -pub async fn register( +pub async fn external_client_register( postgres_client: web::Data, keys_client: web::Data, config: web::Data, @@ -101,7 +99,7 @@ pub async fn register( let new_external_client_key = keys_entity::ActiveModel { id: NotSet, entity_id: Set(inserted_external_client.id), - entity_type: Set(EntityType::ExternalClient), + entity_type: Set(2), // TODO: select id instead key: Set(new_external_client_decryption_key.clone()), }; @@ -120,17 +118,17 @@ pub async fn register( #[utoipa::path( post, - path = "/auth/login", + 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 = ["Auth"] + tags = ["External Client Auth"] )] -pub async fn login( +pub async fn external_client_login( postgres_client: web::Data, keys_client: web::Data, config: web::Data, @@ -192,21 +190,21 @@ pub async fn login( #[utoipa::path( post, - path = "/auth/validate", + path = "/client_auth/validate", request_body = ValidateRequest, responses( (status = 200, description = "Token is valid", body = Claims), (status = 401, description = "Invalid token") ), - tags = ["Auth"] + tags = ["External Client Auth"] )] -pub async fn validate_token( +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.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), } @@ -214,15 +212,15 @@ pub async fn validate_token( #[utoipa::path( post, - path = "/auth/logout", + path = "/client_auth/logout", request_body = ValidateRequest, responses( (status = 200, description = "Token invalidated") ), - tags = ["Auth"] + tags = ["External Client Auth"] )] -pub async fn logout( +pub async fn external_client_logout( req: HttpRequest, keys_client: web::Data, config: web::Data, diff --git a/apps/auth/src/routes/portability.rs b/apps/auth/src/routes/portability.rs index ac94a7e..3cf6b0a 100644 --- a/apps/auth/src/routes/portability.rs +++ b/apps/auth/src/routes/portability.rs @@ -1,5 +1,5 @@ use actix_web::{web, HttpMessage, HttpRequest, HttpResponse, Responder}; -use actix_web_httpauth::middleware::HttpAuthentication; +use actix_web_httpauth::{extractors::bearer::BearerAuth, middleware::HttpAuthentication}; use askama::Template; use bcrypt::verify; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; @@ -14,9 +14,14 @@ use crate::{ DatabaseClientPostgres, // }, models::{ - auth::{ExternalClientLoginResponse, LoginRequest}, - jwt::ClaimsSubject, + auth::{ + ExternalClientLoginResponse, + LoginRequest, // + PortabilityScreenQuery, + }, + jwt::ClaimsSubject, // EntityType, + UserPortability, }, routes::common::{ handle_server_error_body, @@ -31,35 +36,44 @@ use crate::{ GetKeyResult, // }, jwt::{ - create_jwt, // + create_jwt, + validate_entity_type, validator_authorized_client, validator_external_client, + verify_jwt, // }, }, - templates::engine::{LoginButtonTemplate, LoginFormTemplate}, + templates::{ + LoginButtonTemplate, + LoginFormTemplate, // + }, }; pub fn configure(cfg: &mut web::ServiceConfig) { cfg.service( - web::scope("/portability/button") // - .route("/", web::get().to(button)), + 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("/screen", web::get().to(screen)) .route("/authorize", web::post().to(authorize)), ) .service( web::scope("/portability/data") .wrap(HttpAuthentication::bearer(validator_authorized_client)) - .route("/", web::post().to(portability)), + .route("/", web::get().to(portability)), ); } #[utoipa::path( get, - path = "/portability/button", + path = "/portability_button", responses( (status = 200, description = "Returns the portability button HTML"), (status = 500, description = "Database error") @@ -67,14 +81,15 @@ pub fn configure(cfg: &mut web::ServiceConfig) { tags = ["Portability"] )] -pub async fn button(req: HttpRequest) -> impl Responder { - let client_id = match req.extensions().get::() { - Some(subject) => subject.id.to_string(), - None => return HttpResponse::Unauthorized().body("Unauthorized"), - }; - +pub async fn button(req: HttpRequest, credentials: BearerAuth) -> impl Responder { let tmpl = LoginButtonTemplate { - popup_url: format!("/login-form/{}", client_id), + popup_url: format!( + "{}/portability/screen/?token={}", + req.app_data::>() + .unwrap() + .get_app_url(), + credentials.token(), + ), }; HttpResponse::Ok().content_type("text/html").body( tmpl.render() @@ -84,7 +99,7 @@ pub async fn button(req: HttpRequest) -> impl Responder { #[utoipa::path( get, - path = "/portability/auth/screen", + path = "/portability/screen", responses( (status = 200, description = "Returns the authorization screen HTML"), (status = 500, description = "Database error") @@ -96,13 +111,24 @@ pub async fn screen( postgres_client: web::Data, keys_client: web::Data, config: web::Data, - req: HttpRequest, + query: web::Query, ) -> impl Responder { - let client_id = match req.extensions().get::() { - Some(subject) => subject.id.to_string(), - None => return HttpResponse::Unauthorized().body("Unauthorized"), + 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) @@ -139,8 +165,11 @@ pub async fn screen( }; let tmpl = LoginFormTemplate { + auth_url: format!( + "{}/portability/auth/authorize", + config.get_app_url() + ), client_name: external_client.name, - client_id: external_client.id, }; HttpResponse::Ok().content_type("text/html").body( tmpl.render() @@ -303,7 +332,11 @@ pub async fn portability( }; match decrypt_database_user(&user_decryption_key, encrypted_user) { - Ok(decrypted_user) => HttpResponse::Ok().json(decrypted_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 5f5b914..9c3508b 100644 --- a/apps/auth/src/routes/user.rs +++ b/apps/auth/src/routes/user.rs @@ -39,19 +39,19 @@ use crate::{ get_entity_key, GetKeyResult, }, - jwt::validator, + 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)), diff --git a/apps/auth/src/service/fernet.rs b/apps/auth/src/service/fernet.rs index 57a3719..dc711fd 100644 --- a/apps/auth/src/service/fernet.rs +++ b/apps/auth/src/service/fernet.rs @@ -8,7 +8,8 @@ use thiserror::Error; use crate::{ entities::{ - entity_key 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, }, @@ -35,9 +36,38 @@ pub async fn get_entity_key( keys_client: &web::Data, config: &web::Data, ) -> 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; + + 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)) + .filter(keys_entity::Column::EntityType.eq(entity_type_id)) .one(&keys_client.client) .await; @@ -125,13 +155,10 @@ pub fn decrypt_database_external_client( let name = decrypt_field(&fernet, &external_client.name) .map_err(|err| CustomError::UnsuccessfulDecryption("name".to_string(), err))?; - let login = decrypt_field(&fernet, &external_client.login) - .map_err(|err| CustomError::UnsuccessfulDecryption("login".to_string(), err))?; - Ok(ExternalClientPublic { id: external_client.id, name, - login, + 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()), diff --git a/apps/auth/src/service/jwt.rs b/apps/auth/src/service/jwt.rs index 4bd3c25..6fc0097 100644 --- a/apps/auth/src/service/jwt.rs +++ b/apps/auth/src/service/jwt.rs @@ -1,3 +1,4 @@ +use crate::infra::server::DatabaseClientKeys; use crate::infra::types::Config; use crate::models::{jwt::*, EntityType}; use actix_web::dev::ServiceRequest; @@ -33,15 +34,14 @@ async fn extract_claims( .jwt_secret .clone(); - let db = req - .app_data::>() + let keys_client = req + .app_data::>() .ok_or_else(|| { actix_web::error::ErrorInternalServerError("Database connection not configured") })? - .get_ref() - .clone(); + .get_ref(); - let token_data = verify_jwt(credentials.token(), &jwt_secret, &db) + let token_data = verify_jwt(credentials.token(), &jwt_secret, &keys_client) .await .map_err(|_| actix_web::error::ErrorUnauthorized("Invalid or revoked token"))?; @@ -54,13 +54,35 @@ pub async fn validator( ) -> Result { let claims = match extract_claims(&req, &credentials).await { Ok(claims) => claims, - Err(err) => return Err((err, req)), + Err(_) => { + return Err(( + actix_web::error::ErrorUnauthorized("Error extracting claims"), + req, + )) + } // Err(err) => return Err((err, req)), }; 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, @@ -68,25 +90,18 @@ pub async fn validator_entity_type( ) -> Result { let claims = match extract_claims(&req, &credentials).await { Ok(claims) => claims, - Err(err) => return Err((err, req)), - }; - - let subject = match claims.parse_subject() { - Ok(subject) => subject, Err(_) => { return Err(( - actix_web::error::ErrorUnauthorized("Invalid token subject"), + actix_web::error::ErrorUnauthorized("Error extracting claims"), req, - )) - } + )); + } // Err(err) => return Err((err, req)), }; - if subject.entity_type != entity_type { - return Err(( - actix_web::error::ErrorUnauthorized("Invalid token entity type"), - 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) @@ -116,7 +131,7 @@ pub async fn validator_authorized_client( pub async fn verify_jwt( token: &str, jwt_secret: &str, - db: &DatabaseConnection, + keys_client: &DatabaseClientKeys, ) -> Result, VerificationError> { let token_data = decode::( token, @@ -126,7 +141,7 @@ 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() { diff --git a/apps/auth/src/swagger.rs b/apps/auth/src/swagger.rs index 210ddb3..8ccad61 100644 --- a/apps/auth/src/swagger.rs +++ b/apps/auth/src/swagger.rs @@ -19,10 +19,10 @@ use utoipa::OpenApi; routes::user::delete_user, // External Client Authentication routes - routes::external_client_auth::register, - routes::external_client_auth::login, - routes::external_client_auth::validate_token, - routes::external_client_auth::logout, + 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, @@ -30,6 +30,11 @@ use utoipa::OpenApi; 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( @@ -43,15 +48,16 @@ use utoipa::OpenApi; models::auth::ExternalClientLoginResponse, models::auth::ValidateRequest, + models::auth::PortabilityScreenQuery, - // User models + // models models::UserPublic, models::UserUpdate, - - // External Client models + models::UserPortability, models::ExternalClientPublic, models::ExternalClientUpdate, + // Entities entities::user::Model, entities::external_client::Model, @@ -61,7 +67,9 @@ use utoipa::OpenApi; (name = "Auth", description = "Authentication endpoints"), (name = "User", description = "User management endpoints"), - (name = "External Client", description = "External Client 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/engine.rs b/apps/auth/src/templates.rs similarity index 92% rename from apps/auth/src/templates/engine.rs rename to apps/auth/src/templates.rs index eec94ad..b0268ae 100644 --- a/apps/auth/src/templates/engine.rs +++ b/apps/auth/src/templates.rs @@ -9,6 +9,6 @@ pub struct LoginButtonTemplate { #[derive(Template)] #[template(path = "portability/authorization_form.html")] pub struct LoginFormTemplate { + pub auth_url: String, pub client_name: String, - pub client_id: i64, } diff --git a/apps/auth/src/templates/mod.rs b/apps/auth/src/templates/mod.rs deleted file mode 100644 index 702e611..0000000 --- a/apps/auth/src/templates/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod engine; diff --git a/apps/auth/src/templates/portability/authorization_button.html b/apps/auth/templates/portability/authorization_button.html similarity index 100% rename from apps/auth/src/templates/portability/authorization_button.html rename to apps/auth/templates/portability/authorization_button.html diff --git a/apps/auth/src/templates/portability/authorization_form.html b/apps/auth/templates/portability/authorization_form.html similarity index 54% rename from apps/auth/src/templates/portability/authorization_form.html rename to apps/auth/templates/portability/authorization_form.html index 1ff9065..5700e26 100644 --- a/apps/auth/src/templates/portability/authorization_form.html +++ b/apps/auth/templates/portability/authorization_form.html @@ -3,9 +3,9 @@ Authorize {{ client_name }} via Khali + + 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 0000000000000000000000000000000000000000..317ebcb2336e0833a22dddf0ab287849f26fda57 GIT binary patch literal 15086 zcmeI332;U^%p|z7g|#(P)qFEA@4f!_@qOK2 z_lJl}!lhL!VT_U|uN7%8B2iKH??xhDa;*`g{yjTFWHvXn;2s{4R7kH|pKGdy(7z!K zgftM+Ku7~24TLlh(!g)gz|foI94G^t2^IO$uvX$3(OR0<_5L2sB)lMAMy|+`xodJ{ z_Uh_1m)~h?a;2W{dmhM;u!YGo=)OdmId_B<%^V^{ovI@y`7^g1_V9G}*f# zNzAtvou}I!W1#{M^@ROc(BZ! z+F!!_aR&Px3_reO(EW+TwlW~tv*2zr?iP7(d~a~yA|@*a89IUke+c472NXM0wiX{- zl`UrZC^1XYyf%1u)-Y)jj9;MZ!SLfd2Hl?o|80Su%Z?To_=^g_Jt0oa#CT*tjx>BI z16wec&AOWNK<#i0Qd=1O$fymLRoUR*%;h@*@v7}wApDl^w*h}!sYq%kw+DKDY)@&A z@9$ULEB3qkR#85`lb8#WZw=@})#kQig9oqy^I$dj&k4jU&^2(M3q{n1AKeGUKPFbr z1^<)aH;VsG@J|B&l>UtU#Ejv3GIqERzYgL@UOAWtW<{p#zy`WyJgpCy8$c_e%wYJL zyGHRRx38)HyjU3y{-4z6)pzb>&Q1pR)B&u01F-|&Gx4EZWK$nkUkOI|(D4UHOXg_- zw{OBf!oWQUn)Pe(=f=nt=zkmdjpO^o8ZZ9o_|4tW1ni+Un9iCW47*-ut$KQOww!;u z`0q)$s6IZO!~9$e_P9X!hqLxu`fpcL|2f^I5d4*a@Dq28;@2271v_N+5HqYZ>x;&O z05*7JT)mUe&%S0@UD)@&8SmQrMtsDfZT;fkdA!r(S=}Oz>iP)w=W508=Rc#nNn7ym z1;42c|8($ALY8#a({%1#IXbWn9-Y|0eDY$_L&j{63?{?AH{);EzcqfydD$@-B`Y3<%IIj7S7rK_N}je^=dEk%JQ4c z!tBdTPE3Tse;oYF>cnrapWq*o)m47X1`~6@(!Y29#>-#8zm&LXrXa(3=7Z)ElaQqj z-#0JJy3Fi(C#Rx(`=VXtJ63E2_bZGCz+QRa{W0e2(m3sI?LOcUBx)~^YCqZ{XEPX)C>G>U4tfqeH8L(3|pQR*zbL1 zT9e~4Tb5p9_G}$y4t`i*4t_Mr9QYvL9C&Ah*}t`q*}S+VYh0M6GxTTSXI)hMpMpIq zD1ImYqJLzbj0}~EpE-aH#VCH_udYEW#`P2zYmi&xSPs_{n6tBj=MY|-XrA;SGA_>y zGtU$?HXm$gYj*!N)_nQ59%lQdXtQZS3*#PC-{iB_sm+ytD*7j`D*k(P&IH2GHT}Eh z5697eQECVIGQAUe#eU2I!yI&%0CP#>%6MWV z@zS!p@+Y1i1b^QuuEF*13CuB zu69dve5k7&Wgb+^s|UB08Dr3u`h@yM0NTj4h7MnHo-4@xmyr7(*4$rpPwsCDZ@2be zRz9V^GnV;;?^Lk%ynzq&K(Aix`mWmW`^152Hoy$CTYVehpD-S1-W^#k#{0^L`V6CN+E z!w+xte;2vu4AmVNEFUOBmrBL>6MK@!O2*N|2=d|Y;oN&A&qv=qKn73lDD zI(+oJAdgv>Yr}8(&@ZuAZE%XUXmX(U!N+Z_sjL<1vjy1R+1IeHt`79fnYdOL{$ci7 z%3f0A*;Zt@ED&Gjm|OFTYBDe%bbo*xXAQsFz+Q`fVBH!N2)kaxN8P$c>sp~QXnv>b zwq=W3&Mtmih7xkR$YA)1Yi?avHNR6C99!u6fh=cL|KQ&PwF!n@ud^n(HNIImHD!h87!i*t?G|p0o+eelJ?B@A64_9%SBhNaJ64EvKgD&%LjLCYnNfc; znj?%*p@*?dq#NqcQFmmX($wms@CSAr9#>hUR^=I+=0B)vvGX%T&#h$kmX*s=^M2E!@N9#m?LhMvz}YB+kd zG~mbP|D(;{s_#;hsKK9lbVK&Lo734x7SIFJ9V_}2$@q?zm^7?*XH94w5Qae{7zOMUF z^?%F%)c1Y)Q?Iy?I>knw*8gYW#ok|2gdS=YYZLiD=CW|Nj;n^x!=S#iJ#`~Ld79+xXpVmUK^B(xO_vO!btA9y7w3L3-0j-y4 z?M-V{%z;JI`bk7yFDcP}OcCd*{Q9S5$iGA7*E1@tfkyjAi!;wP^O71cZ^Ep)qrQ)N z#wqw0_HS;T7x3y|`P==i3hEwK%|>fZ)c&@kgKO1~5<5xBSk?iZV?KI6&i72H6S9A* z=U(*e)EqEs?Oc04)V-~K5AUmh|62H4*`UAtItO$O(q5?6jj+K^oD!04r=6#dsxp?~}{`?&sXn#q2 zGuY~7>O2=!u@@Kfu7q=W*4egu@qPMRM>(eyYyaIE<|j%d=iWNdGsx%c!902v#ngNg z@#U-O_4xN$s_9?(`{>{>7~-6FgWpBpqXb`Ydc3OFL#&I}Irse9F_8R@4zSS*Y*o*B zXL?6*Aw!AfkNCgcr#*yj&p3ZDe2y>v$>FUdKIy_2N~}6AbHc7gA3`6$g@1o|dE>vz z4pl(j9;kyMsjaw}lO?(?Xg%4k!5%^t#@5n=WVc&JRa+XT$~#@rldvN3S1rEpU$;XgxVny7mki3 z-Hh|jUCHrUXuLr!)`w>wgO0N%KTB-1di>cj(x3Bav`7v z3G7EIbU$z>`Nad7Rk_&OT-W{;qg)-GXV-aJT#(ozdmnA~Rq3GQ_3mby(>q6Ocb-RgTUhTN)))x>m&eD;$J5Bg zo&DhY36Yg=J=$Z>t}RJ>o|@hAcwWzN#r(WJ52^g$lh^!63@hh+dR$&_dEGu&^CR*< z!oFqSqO@>xZ*nC2oiOd0eS*F^IL~W-rsrO`J`ej{=ou_q^_(<$&-3f^J z&L^MSYWIe{&pYq&9eGaArA~*kA { + 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 +}