diff --git a/Cargo.lock b/Cargo.lock index 3e145811..6514f58a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5682,6 +5682,7 @@ dependencies = [ "rand", "rand_chacha", "rand_core", + "russh-keys", "rustls 0.23.12", "rustls-native-certs 0.6.3", "rustls-pemfile 1.0.4", @@ -5778,6 +5779,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "tracing", "uuid", ] @@ -5799,6 +5801,7 @@ dependencies = [ "poem-openapi", "regex", "reqwest 0.12.7", + "sea-orm", "serde", "serde_json", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 92358424..cbc579f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ serde = "1.0" serde_json = "1.0" russh = { version = "0.46.0", features = ["legacy-ed25519-pkcs8-parser"] } russh-keys = { version = "0.46.0", features = ["legacy-ed25519-pkcs8-parser"] } +tracing = "0.1" [profile.release] lto = true diff --git a/warpgate-admin/Cargo.toml b/warpgate-admin/Cargo.toml index 30e94d9c..092b525f 100644 --- a/warpgate-admin/Cargo.toml +++ b/warpgate-admin/Cargo.toml @@ -34,7 +34,7 @@ serde.workspace = true serde_json.workspace = true thiserror = "1.0" tokio = { version = "1.20", features = ["tracing"] } -tracing = "0.1" +tracing.workspace = true uuid = { version = "1.3", features = ["v4", "serde"] } warpgate-common = { version = "*", path = "../warpgate-common" } warpgate-core = { version = "*", path = "../warpgate-core" } diff --git a/warpgate-admin/src/api/known_hosts_detail.rs b/warpgate-admin/src/api/known_hosts_detail.rs index 76d5b3b5..cca063d2 100644 --- a/warpgate-admin/src/api/known_hosts_detail.rs +++ b/warpgate-admin/src/api/known_hosts_detail.rs @@ -6,8 +6,9 @@ use poem_openapi::{ApiResponse, OpenApi}; use sea_orm::{DatabaseConnection, EntityTrait, ModelTrait}; use tokio::sync::Mutex; use uuid::Uuid; +use warpgate_common::WarpgateError; -use super::TokenSecurityScheme; +use super::AnySecurityScheme; pub struct Api; #[derive(ApiResponse)] @@ -30,22 +31,16 @@ impl Api { &self, db: Data<&Arc>>, id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { use warpgate_db_entities::KnownHost; let db = db.lock().await; - let known_host = KnownHost::Entity::find_by_id(id.0) - .one(&*db) - .await - .map_err(poem::error::InternalServerError)?; + let known_host = KnownHost::Entity::find_by_id(id.0).one(&*db).await?; match known_host { Some(known_host) => { - known_host - .delete(&*db) - .await - .map_err(poem::error::InternalServerError)?; + known_host.delete(&*db).await?; Ok(DeleteSSHKnownHostResponse::Deleted) } None => Ok(DeleteSSHKnownHostResponse::NotFound), diff --git a/warpgate-admin/src/api/known_hosts_list.rs b/warpgate-admin/src/api/known_hosts_list.rs index 5afce0dc..1db57b11 100644 --- a/warpgate-admin/src/api/known_hosts_list.rs +++ b/warpgate-admin/src/api/known_hosts_list.rs @@ -5,9 +5,10 @@ use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, OpenApi}; use sea_orm::{DatabaseConnection, EntityTrait}; use tokio::sync::Mutex; +use warpgate_common::WarpgateError; use warpgate_db_entities::KnownHost; -use super::TokenSecurityScheme; +use super::AnySecurityScheme; pub struct Api; @@ -27,15 +28,12 @@ impl Api { async fn api_ssh_get_all_known_hosts( &self, db: Data<&Arc>>, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { use warpgate_db_entities::KnownHost; let db = db.lock().await; - let hosts = KnownHost::Entity::find() - .all(&*db) - .await - .map_err(poem::error::InternalServerError)?; + let hosts = KnownHost::Entity::find().all(&*db).await?; Ok(GetSSHKnownHostsResponse::Ok(Json(hosts))) } } diff --git a/warpgate-admin/src/api/logs.rs b/warpgate-admin/src/api/logs.rs index 5689a5cf..f0bbdbd6 100644 --- a/warpgate-admin/src/api/logs.rs +++ b/warpgate-admin/src/api/logs.rs @@ -7,9 +7,10 @@ use poem_openapi::{ApiResponse, Object, OpenApi}; use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, QuerySelect}; use tokio::sync::Mutex; use uuid::Uuid; +use warpgate_common::WarpgateError; use warpgate_db_entities::LogEntry; -use super::TokenSecurityScheme; +use super::AnySecurityScheme; pub struct Api; @@ -36,8 +37,8 @@ impl Api { &self, db: Data<&Arc>>, body: Json, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { use warpgate_db_entities::LogEntry; let db = db.lock().await; @@ -67,10 +68,7 @@ impl Api { ); } - let logs = q - .all(&*db) - .await - .map_err(poem::error::InternalServerError)?; + let logs = q.all(&*db).await?; let logs = logs .into_iter() .map(Into::into) diff --git a/warpgate-admin/src/api/mod.rs b/warpgate-admin/src/api/mod.rs index 0e09da36..0dc0c0ca 100644 --- a/warpgate-admin/src/api/mod.rs +++ b/warpgate-admin/src/api/mod.rs @@ -17,13 +17,26 @@ mod sso_credentials; mod targets; mod tickets_detail; mod tickets_list; -mod users; +pub mod users; +mod parameters; #[derive(SecurityScheme)] #[oai(ty = "api_key", key_name = "X-Warpgate-Token", key_in = "header")] #[allow(dead_code)] pub struct TokenSecurityScheme(ApiKey); +#[derive(SecurityScheme)] +#[oai(ty = "api_key", key_name = "warpgate-http-session", key_in = "cookie")] +#[allow(dead_code)] +pub struct CookieSecurityScheme(ApiKey); + +#[derive(SecurityScheme)] +#[allow(dead_code)] +pub enum AnySecurityScheme { + Token(TokenSecurityScheme), + Cookie(CookieSecurityScheme), +} + pub fn get() -> impl OpenApi { ( (sessions_list::Api, sessions_detail::Api), @@ -45,5 +58,6 @@ pub fn get() -> impl OpenApi { public_key_credentials::DetailApi, ), (otp_credentials::ListApi, otp_credentials::DetailApi), + parameters::Api, ) } diff --git a/warpgate-admin/src/api/otp_credentials.rs b/warpgate-admin/src/api/otp_credentials.rs index b441fe1c..db862874 100644 --- a/warpgate-admin/src/api/otp_credentials.rs +++ b/warpgate-admin/src/api/otp_credentials.rs @@ -12,7 +12,7 @@ use uuid::Uuid; use warpgate_common::{UserTotpCredential, WarpgateError}; use warpgate_db_entities::OtpCredential; -use super::TokenSecurityScheme; +use super::AnySecurityScheme; #[derive(Object)] struct ExistingOtpCredential { @@ -63,15 +63,14 @@ impl ListApi { &self, db: Data<&Arc>>, user_id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; let objects = OtpCredential::Entity::find() .filter(OtpCredential::Column::UserId.eq(*user_id)) .all(&*db) - .await - .map_err(poem::error::InternalServerError)?; + .await?; Ok(GetOtpCredentialsResponse::Ok(Json( objects.into_iter().map(Into::into).collect(), @@ -88,8 +87,8 @@ impl ListApi { db: Data<&Arc>>, body: Json, user_id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; let object = OtpCredential::ActiveModel { @@ -127,22 +126,19 @@ impl DetailApi { db: Data<&Arc>>, user_id: Path, id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; let Some(role) = OtpCredential::Entity::find_by_id(id.0) .filter(OtpCredential::Column::UserId.eq(*user_id)) .one(&*db) - .await - .map_err(poem::error::InternalServerError)? + .await? else { return Ok(DeleteCredentialResponse::NotFound); }; - role.delete(&*db) - .await - .map_err(poem::error::InternalServerError)?; + role.delete(&*db).await?; Ok(DeleteCredentialResponse::Deleted) } } diff --git a/warpgate-admin/src/api/pagination.rs b/warpgate-admin/src/api/pagination.rs index b7e32c98..1f691a36 100644 --- a/warpgate-admin/src/api/pagination.rs +++ b/warpgate-admin/src/api/pagination.rs @@ -1,6 +1,7 @@ use poem_openapi::types::{ParseFromJSON, ToJSON}; use poem_openapi::Object; use sea_orm::{ConnectionTrait, EntityTrait, FromQueryResult, PaginatorTrait, QuerySelect, Select}; +use warpgate_common::WarpgateError; #[derive(Object)] pub struct PaginatedResponse { @@ -20,7 +21,7 @@ impl PaginatedResponse { params: PaginationParams, db: &'_ C, postprocess: P, - ) -> poem::Result> + ) -> Result, WarpgateError> where E: EntityTrait, C: ConnectionTrait, @@ -32,17 +33,11 @@ impl PaginatedResponse { let paginator = query.clone().paginate(db, limit); - let total = paginator - .num_items() - .await - .map_err(poem::error::InternalServerError)?; + let total = paginator.num_items().await?; let query = query.offset(offset).limit(limit); - let items = query - .all(db) - .await - .map_err(poem::error::InternalServerError)?; + let items = query.all(db).await?; let items = items.into_iter().map(postprocess).collect::>(); Ok(PaginatedResponse { diff --git a/warpgate-admin/src/api/parameters.rs b/warpgate-admin/src/api/parameters.rs new file mode 100644 index 00000000..2fd9c3fb --- /dev/null +++ b/warpgate-admin/src/api/parameters.rs @@ -0,0 +1,78 @@ +use poem::web::Data; +use poem_openapi::payload::Json; +use poem_openapi::{ApiResponse, Object, OpenApi}; +use sea_orm::{EntityTrait, Set}; +use serde::Serialize; +use warpgate_common::WarpgateError; +use warpgate_core::Services; +use warpgate_db_entities::Parameters; + +use super::AnySecurityScheme; + +pub struct Api; + +#[derive(Serialize, Object)] +struct ParameterValues { + pub allow_own_credential_management: bool, +} + +#[derive(Serialize, Object)] +struct ParameterUpdate { + pub allow_own_credential_management: Option, +} + +#[derive(ApiResponse)] +enum GetParametersResponse { + #[oai(status = 200)] + Ok(Json), +} + +#[derive(ApiResponse)] +enum UpdateParametersResponse { + #[oai(status = 201)] + Done, +} + +#[OpenApi] +impl Api { + #[oai(path = "/parameters", method = "get", operation_id = "get_parameters")] + async fn api_get( + &self, + services: Data<&Services>, + _auth: AnySecurityScheme, + ) -> Result { + let db = services.db.lock().await; + let parameters = Parameters::Entity::get(&db).await?; + + Ok(GetParametersResponse::Ok(Json(ParameterValues { + allow_own_credential_management: parameters.allow_own_credential_management, + }))) + } + + #[oai( + path = "/parameters", + method = "patch", + operation_id = "update_parameters" + )] + async fn api_update_parameters( + &self, + services: Data<&Services>, + body: Json, + _auth: AnySecurityScheme, + ) -> Result { + let db = services.db.lock().await; + + let mut am = Parameters::ActiveModel { + id: Set(Parameters::Entity::get(&db).await?.id), + ..Default::default() + }; + + if let Some(value) = body.allow_own_credential_management { + am.allow_own_credential_management = Set(value); + }; + + Parameters::Entity::update(am).exec(&*db).await?; + + Ok(UpdateParametersResponse::Done) + } +} diff --git a/warpgate-admin/src/api/password_credentials.rs b/warpgate-admin/src/api/password_credentials.rs index 5475a6de..816e8679 100644 --- a/warpgate-admin/src/api/password_credentials.rs +++ b/warpgate-admin/src/api/password_credentials.rs @@ -12,7 +12,7 @@ use uuid::Uuid; use warpgate_common::{Secret, UserPasswordCredential, WarpgateError}; use warpgate_db_entities::PasswordCredential; -use super::TokenSecurityScheme; +use super::AnySecurityScheme; #[derive(Object)] struct ExistingPasswordCredential { @@ -55,15 +55,14 @@ impl ListApi { &self, db: Data<&Arc>>, user_id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; let objects = PasswordCredential::Entity::find() .filter(PasswordCredential::Column::UserId.eq(*user_id)) .all(&*db) - .await - .map_err(poem::error::InternalServerError)?; + .await?; Ok(GetPasswordCredentialsResponse::Ok(Json( objects.into_iter().map(Into::into).collect(), @@ -80,8 +79,8 @@ impl ListApi { db: Data<&Arc>>, body: Json, user_id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; let object = PasswordCredential::ActiveModel { @@ -123,23 +122,19 @@ impl DetailApi { db: Data<&Arc>>, user_id: Path, id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; let Some(model) = PasswordCredential::Entity::find_by_id(id.0) .filter(PasswordCredential::Column::UserId.eq(*user_id)) .one(&*db) - .await - .map_err(poem::error::InternalServerError)? + .await? else { return Ok(DeleteCredentialResponse::NotFound); }; - model - .delete(&*db) - .await - .map_err(poem::error::InternalServerError)?; + model.delete(&*db).await?; Ok(DeleteCredentialResponse::Deleted) } } diff --git a/warpgate-admin/src/api/public_key_credentials.rs b/warpgate-admin/src/api/public_key_credentials.rs index 1b29bb1d..219a6936 100644 --- a/warpgate-admin/src/api/public_key_credentials.rs +++ b/warpgate-admin/src/api/public_key_credentials.rs @@ -13,7 +13,7 @@ use uuid::Uuid; use warpgate_common::{UserPublicKeyCredential, WarpgateError}; use warpgate_db_entities::PublicKeyCredential; -use super::TokenSecurityScheme; +use super::AnySecurityScheme; #[derive(Object)] struct ExistingPublicKeyCredential { @@ -76,15 +76,14 @@ impl ListApi { &self, db: Data<&Arc>>, user_id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; let objects = PublicKeyCredential::Entity::find() .filter(PublicKeyCredential::Column::UserId.eq(*user_id)) .all(&*db) - .await - .map_err(poem::error::InternalServerError)?; + .await?; Ok(GetPublicKeyCredentialsResponse::Ok(Json( objects.into_iter().map(Into::into).collect(), @@ -101,8 +100,8 @@ impl ListApi { db: Data<&Arc>>, body: Json, user_id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; let object = PublicKeyCredential::ActiveModel { @@ -143,8 +142,8 @@ impl DetailApi { body: Json, user_id: Path, id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; let model = PublicKeyCredential::ActiveModel { @@ -160,7 +159,7 @@ impl DetailApi { model.into(), ))), Err(DbErr::RecordNotFound(_)) => Ok(UpdatePublicKeyCredentialResponse::NotFound), - Err(e) => Err(poem::error::InternalServerError(e)), + Err(e) => Err(e.into()), } } @@ -174,22 +173,19 @@ impl DetailApi { db: Data<&Arc>>, user_id: Path, id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; - let Some(role) = PublicKeyCredential::Entity::find_by_id(id.0) + let Some(model) = PublicKeyCredential::Entity::find_by_id(id.0) .filter(PublicKeyCredential::Column::UserId.eq(*user_id)) .one(&*db) - .await - .map_err(poem::error::InternalServerError)? + .await? else { return Ok(DeleteCredentialResponse::NotFound); }; - role.delete(&*db) - .await - .map_err(poem::error::InternalServerError)?; + model.delete(&*db).await?; Ok(DeleteCredentialResponse::Deleted) } } diff --git a/warpgate-admin/src/api/recordings_detail.rs b/warpgate-admin/src/api/recordings_detail.rs index 23d87ed6..7dd10668 100644 --- a/warpgate-admin/src/api/recordings_detail.rs +++ b/warpgate-admin/src/api/recordings_detail.rs @@ -19,7 +19,7 @@ use uuid::Uuid; use warpgate_core::recordings::{AsciiCast, SessionRecordings, TerminalRecordingItem}; use warpgate_db_entities::Recording::{self, RecordingKind}; -use super::TokenSecurityScheme; +use super::AnySecurityScheme; pub struct Api; @@ -42,7 +42,7 @@ impl Api { &self, db: Data<&Arc>>, id: Path, - _auth: TokenSecurityScheme, + _auth: AnySecurityScheme, ) -> poem::Result { let db = db.lock().await; @@ -128,7 +128,7 @@ pub async fn api_get_recording_tcpdump( let recording = Recording::Entity::find_by_id(id.0) .one(&*db) .await - .map_err(poem::error::InternalServerError)?; + .map_err(InternalServerError)?; let Some(recording) = recording else { return Err(NotFoundError.into()); diff --git a/warpgate-admin/src/api/roles.rs b/warpgate-admin/src/api/roles.rs index e94a145a..7b3c530d 100644 --- a/warpgate-admin/src/api/roles.rs +++ b/warpgate-admin/src/api/roles.rs @@ -14,7 +14,7 @@ use warpgate_common::{Role as RoleConfig, WarpgateError}; use warpgate_core::consts::BUILTIN_ADMIN_ROLE_NAME; use warpgate_db_entities::Role; -use super::TokenSecurityScheme; +use super::AnySecurityScheme; #[derive(Object)] struct RoleDataRequest { @@ -44,8 +44,8 @@ impl ListApi { &self, db: Data<&Arc>>, search: Query>, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; let mut roles = Role::Entity::find().order_by_asc(Role::Column::Name); @@ -55,10 +55,7 @@ impl ListApi { roles = roles.filter(Role::Column::Name.like(search)); } - let roles = roles - .all(&*db) - .await - .map_err(poem::error::InternalServerError)?; + let roles = roles.all(&*db).await?; Ok(GetRolesResponse::Ok(Json( roles.into_iter().map(Into::into).collect(), @@ -70,8 +67,8 @@ impl ListApi { &self, db: Data<&Arc>>, body: Json, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { use warpgate_db_entities::Role; if body.name.is_empty() { @@ -128,14 +125,11 @@ impl DetailApi { &self, db: Data<&Arc>>, id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; - let role = Role::Entity::find_by_id(id.0) - .one(&*db) - .await - .map_err(poem::error::InternalServerError)?; + let role = Role::Entity::find_by_id(id.0).one(&*db).await?; Ok(match role { Some(role) => GetRoleResponse::Ok(Json(role.into())), @@ -149,15 +143,11 @@ impl DetailApi { db: Data<&Arc>>, body: Json, id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; - let Some(role) = Role::Entity::find_by_id(id.0) - .one(&*db) - .await - .map_err(poem::error::InternalServerError)? - else { + let Some(role) = Role::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(UpdateRoleResponse::NotFound); }; @@ -167,10 +157,7 @@ impl DetailApi { let mut model: Role::ActiveModel = role.into(); model.name = Set(body.name.clone()); - let role = model - .update(&*db) - .await - .map_err(poem::error::InternalServerError)?; + let role = model.update(&*db).await?; Ok(UpdateRoleResponse::Ok(Json(role.into()))) } @@ -180,15 +167,11 @@ impl DetailApi { &self, db: Data<&Arc>>, id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; - let Some(role) = Role::Entity::find_by_id(id.0) - .one(&*db) - .await - .map_err(poem::error::InternalServerError)? - else { + let Some(role) = Role::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(DeleteRoleResponse::NotFound); }; @@ -196,9 +179,7 @@ impl DetailApi { return Ok(DeleteRoleResponse::Forbidden); } - role.delete(&*db) - .await - .map_err(poem::error::InternalServerError)?; + role.delete(&*db).await?; Ok(DeleteRoleResponse::Deleted) } } diff --git a/warpgate-admin/src/api/sessions_detail.rs b/warpgate-admin/src/api/sessions_detail.rs index e42318ff..f302a86f 100644 --- a/warpgate-admin/src/api/sessions_detail.rs +++ b/warpgate-admin/src/api/sessions_detail.rs @@ -7,10 +7,11 @@ use poem_openapi::{ApiResponse, OpenApi}; use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder}; use tokio::sync::Mutex; use uuid::Uuid; +use warpgate_common::WarpgateError; use warpgate_core::{SessionSnapshot, State}; use warpgate_db_entities::{Recording, Session}; -use super::TokenSecurityScheme; +use super::AnySecurityScheme; pub struct Api; @@ -44,14 +45,11 @@ impl Api { &self, db: Data<&Arc>>, id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; - let session = Session::Entity::find_by_id(id.0) - .one(&*db) - .await - .map_err(poem::error::InternalServerError)?; + let session = Session::Entity::find_by_id(id.0).one(&*db).await?; match session { Some(session) => Ok(GetSessionResponse::Ok(Json(session.into()))), @@ -68,15 +66,14 @@ impl Api { &self, db: Data<&Arc>>, id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; let recordings: Vec = Recording::Entity::find() .order_by_desc(Recording::Column::Started) .filter(Recording::Column::SessionId.eq(id.0)) .all(&*db) - .await - .map_err(poem::error::InternalServerError)?; + .await?; Ok(GetSessionRecordingsResponse::Ok(Json(recordings))) } @@ -89,8 +86,8 @@ impl Api { &self, state: Data<&Arc>>, id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let state = state.lock().await; if let Some(s) = state.sessions.get(&id) { diff --git a/warpgate-admin/src/api/sessions_list.rs b/warpgate-admin/src/api/sessions_list.rs index dddc700a..b74a9a53 100644 --- a/warpgate-admin/src/api/sessions_list.rs +++ b/warpgate-admin/src/api/sessions_list.rs @@ -13,7 +13,7 @@ use tokio::sync::Mutex; use warpgate_core::{SessionSnapshot, State}; use super::pagination::{PaginatedResponse, PaginationParams}; -use super::TokenSecurityScheme; +use super::AnySecurityScheme; pub struct Api; @@ -39,7 +39,7 @@ impl Api { limit: Query>, active_only: Query>, logged_in_only: Query>, - _auth: TokenSecurityScheme, + _auth: AnySecurityScheme, ) -> poem::Result { use warpgate_db_entities::Session; @@ -76,7 +76,7 @@ impl Api { &self, state: Data<&Arc>>, session: &Session, - _auth: TokenSecurityScheme, + _auth: AnySecurityScheme, ) -> poem::Result { let state = state.lock().await; diff --git a/warpgate-admin/src/api/ssh_keys.rs b/warpgate-admin/src/api/ssh_keys.rs index 217659f0..4b80815a 100644 --- a/warpgate-admin/src/api/ssh_keys.rs +++ b/warpgate-admin/src/api/ssh_keys.rs @@ -6,9 +6,9 @@ use poem_openapi::{ApiResponse, Object, OpenApi}; use russh::keys::PublicKeyBase64; use serde::Serialize; use tokio::sync::Mutex; -use warpgate_common::WarpgateConfig; +use warpgate_common::{WarpgateConfig, WarpgateError}; -use super::TokenSecurityScheme; +use super::AnySecurityScheme; pub struct Api; @@ -34,11 +34,10 @@ impl Api { async fn api_ssh_get_own_keys( &self, config: Data<&Arc>>, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let config = config.lock().await; - let keys = warpgate_protocol_ssh::load_client_keys(&config) - .map_err(poem::error::InternalServerError)?; + let keys = warpgate_protocol_ssh::load_client_keys(&config)?; let keys = keys .into_iter() diff --git a/warpgate-admin/src/api/sso_credentials.rs b/warpgate-admin/src/api/sso_credentials.rs index 40c4034c..7d964045 100644 --- a/warpgate-admin/src/api/sso_credentials.rs +++ b/warpgate-admin/src/api/sso_credentials.rs @@ -13,7 +13,7 @@ use uuid::Uuid; use warpgate_common::{UserSsoCredential, WarpgateError}; use warpgate_db_entities::SsoCredential; -use super::TokenSecurityScheme; +use super::AnySecurityScheme; #[derive(Object)] struct ExistingSsoCredential { @@ -80,15 +80,14 @@ impl ListApi { &self, db: Data<&Arc>>, user_id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; let objects = SsoCredential::Entity::find() .filter(SsoCredential::Column::UserId.eq(*user_id)) .all(&*db) - .await - .map_err(poem::error::InternalServerError)?; + .await?; Ok(GetSsoCredentialsResponse::Ok(Json( objects.into_iter().map(Into::into).collect(), @@ -105,8 +104,8 @@ impl ListApi { db: Data<&Arc>>, body: Json, user_id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; let object = SsoCredential::ActiveModel { @@ -145,8 +144,8 @@ impl DetailApi { body: Json, user_id: Path, id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; let model = SsoCredential::ActiveModel { @@ -160,7 +159,7 @@ impl DetailApi { match model { Ok(model) => Ok(UpdateSsoCredentialResponse::Updated(Json(model.into()))), Err(DbErr::RecordNotFound(_)) => Ok(UpdateSsoCredentialResponse::NotFound), - Err(e) => Err(poem::error::InternalServerError(e)), + Err(e) => Err(e.into()), } } @@ -174,22 +173,19 @@ impl DetailApi { db: Data<&Arc>>, user_id: Path, id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; let Some(role) = SsoCredential::Entity::find_by_id(id.0) .filter(SsoCredential::Column::UserId.eq(*user_id)) .one(&*db) - .await - .map_err(poem::error::InternalServerError)? + .await? else { return Ok(DeleteCredentialResponse::NotFound); }; - role.delete(&*db) - .await - .map_err(poem::error::InternalServerError)?; + role.delete(&*db).await?; Ok(DeleteCredentialResponse::Deleted) } } diff --git a/warpgate-admin/src/api/targets.rs b/warpgate-admin/src/api/targets.rs index aeb70bc5..7d74a595 100644 --- a/warpgate-admin/src/api/targets.rs +++ b/warpgate-admin/src/api/targets.rs @@ -15,7 +15,7 @@ use warpgate_core::consts::BUILTIN_ADMIN_ROLE_NAME; use warpgate_db_entities::Target::TargetKind; use warpgate_db_entities::{Role, Target, TargetRoleAssignment}; -use super::TokenSecurityScheme; +use super::AnySecurityScheme; #[derive(Object)] struct TargetDataRequest { @@ -46,8 +46,8 @@ impl ListApi { &self, db: Data<&Arc>>, search: Query>, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; let mut targets = Target::Entity::find().order_by_asc(Target::Column::Name); @@ -71,8 +71,8 @@ impl ListApi { &self, db: Data<&Arc>>, body: Json, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { if body.name.is_empty() { return Ok(CreateTargetResponse::BadRequest(Json("name".into()))); } @@ -137,23 +137,15 @@ impl DetailApi { &self, db: Data<&Arc>>, id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; - let Some(target) = Target::Entity::find_by_id(id.0) - .one(&*db) - .await - .map_err(poem::error::InternalServerError)? - else { + let Some(target) = Target::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(GetTargetResponse::NotFound); }; - Ok(GetTargetResponse::Ok(Json( - target - .try_into() - .map_err(poem::error::InternalServerError)?, - ))) + Ok(GetTargetResponse::Ok(Json(target.try_into()?))) } #[oai(path = "/targets/:id", method = "put", operation_id = "update_target")] @@ -162,15 +154,11 @@ impl DetailApi { db: Data<&Arc>>, body: Json, id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; - let Some(target) = Target::Entity::find_by_id(id.0) - .one(&*db) - .await - .map_err(poem::error::InternalServerError)? - else { + let Some(target) = Target::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(UpdateTargetResponse::NotFound); }; @@ -182,10 +170,7 @@ impl DetailApi { model.name = Set(body.name.clone()); model.options = Set(serde_json::to_value(body.options.clone()).map_err(WarpgateError::from)?); - let target = model - .update(&*db) - .await - .map_err(poem::error::InternalServerError)?; + let target = model.update(&*db).await?; Ok(UpdateTargetResponse::Ok(Json( target.try_into().map_err(WarpgateError::from)?, @@ -201,15 +186,11 @@ impl DetailApi { &self, db: Data<&Arc>>, id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; - let Some(target) = Target::Entity::find_by_id(id.0) - .one(&*db) - .await - .map_err(poem::error::InternalServerError)? - else { + let Some(target) = Target::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(DeleteTargetResponse::NotFound); }; @@ -220,13 +201,9 @@ impl DetailApi { TargetRoleAssignment::Entity::delete_many() .filter(TargetRoleAssignment::Column::TargetId.eq(target.id)) .exec(&*db) - .await - .map_err(poem::error::InternalServerError)?; + .await?; - target - .delete(&*db) - .await - .map_err(poem::error::InternalServerError)?; + target.delete(&*db).await?; Ok(DeleteTargetResponse::Deleted) } } @@ -270,8 +247,8 @@ impl RolesApi { &self, db: Data<&Arc>>, id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; let Some((_, roles)) = Target::Entity::find_by_id(*id) @@ -299,8 +276,8 @@ impl RolesApi { db: Data<&Arc>>, id: Path, role_id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; if !TargetRoleAssignment::Entity::find() @@ -335,23 +312,15 @@ impl RolesApi { db: Data<&Arc>>, id: Path, role_id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; - let Some(target) = Target::Entity::find_by_id(id.0) - .one(&*db) - .await - .map_err(poem::error::InternalServerError)? - else { + let Some(target) = Target::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(DeleteTargetRoleResponse::NotFound); }; - let Some(role) = Role::Entity::find_by_id(role_id.0) - .one(&*db) - .await - .map_err(poem::error::InternalServerError)? - else { + let Some(role) = Role::Entity::find_by_id(role_id.0).one(&*db).await? else { return Ok(DeleteTargetRoleResponse::NotFound); }; diff --git a/warpgate-admin/src/api/tickets_detail.rs b/warpgate-admin/src/api/tickets_detail.rs index 10951453..0ec0290a 100644 --- a/warpgate-admin/src/api/tickets_detail.rs +++ b/warpgate-admin/src/api/tickets_detail.rs @@ -6,8 +6,9 @@ use poem_openapi::{ApiResponse, OpenApi}; use sea_orm::{DatabaseConnection, EntityTrait, ModelTrait}; use tokio::sync::Mutex; use uuid::Uuid; +use warpgate_common::WarpgateError; -use super::TokenSecurityScheme; +use super::AnySecurityScheme; pub struct Api; @@ -31,22 +32,16 @@ impl Api { &self, db: Data<&Arc>>, id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { use warpgate_db_entities::Ticket; let db = db.lock().await; - let ticket = Ticket::Entity::find_by_id(id.0) - .one(&*db) - .await - .map_err(poem::error::InternalServerError)?; + let ticket = Ticket::Entity::find_by_id(id.0).one(&*db).await?; match ticket { Some(ticket) => { - ticket - .delete(&*db) - .await - .map_err(poem::error::InternalServerError)?; + ticket.delete(&*db).await?; Ok(DeleteTicketResponse::Deleted) } None => Ok(DeleteTicketResponse::NotFound), diff --git a/warpgate-admin/src/api/tickets_list.rs b/warpgate-admin/src/api/tickets_list.rs index f2c09b8b..d32aad30 100644 --- a/warpgate-admin/src/api/tickets_list.rs +++ b/warpgate-admin/src/api/tickets_list.rs @@ -10,9 +10,10 @@ use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait}; use tokio::sync::Mutex; use uuid::Uuid; use warpgate_common::helpers::hash::generate_ticket_secret; +use warpgate_common::WarpgateError; use warpgate_db_entities::Ticket; -use super::TokenSecurityScheme; +use super::AnySecurityScheme; pub struct Api; @@ -51,15 +52,12 @@ impl Api { async fn api_get_all_tickets( &self, db: Data<&Arc>>, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { use warpgate_db_entities::Ticket; let db = db.lock().await; - let tickets = Ticket::Entity::find() - .all(&*db) - .await - .map_err(poem::error::InternalServerError)?; + let tickets = Ticket::Entity::find().all(&*db).await?; let tickets = tickets .into_iter() .map(Into::into) @@ -72,7 +70,7 @@ impl Api { &self, db: Data<&Arc>>, body: Json, - _auth: TokenSecurityScheme, + _auth: AnySecurityScheme, ) -> poem::Result { use warpgate_db_entities::Ticket; diff --git a/warpgate-admin/src/api/users.rs b/warpgate-admin/src/api/users.rs index bb526710..3d5d5f70 100644 --- a/warpgate-admin/src/api/users.rs +++ b/warpgate-admin/src/api/users.rs @@ -15,7 +15,7 @@ use warpgate_common::{ }; use warpgate_db_entities::{Role, User, UserRoleAssignment}; -use super::TokenSecurityScheme; +use super::AnySecurityScheme; #[derive(Object)] struct CreateUserRequest { @@ -50,8 +50,8 @@ impl ListApi { &self, db: Data<&Arc>>, search: Query>, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; let mut users = User::Entity::find().order_by_asc(User::Column::Username); @@ -74,8 +74,8 @@ impl ListApi { &self, db: Data<&Arc>>, body: Json, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { if body.username.is_empty() { return Ok(CreateUserResponse::BadRequest(Json("name".into()))); } @@ -133,21 +133,15 @@ impl DetailApi { &self, db: Data<&Arc>>, id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; - let Some(user) = User::Entity::find_by_id(id.0) - .one(&*db) - .await - .map_err(poem::error::InternalServerError)? - else { + let Some(user) = User::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(GetUserResponse::NotFound); }; - Ok(GetUserResponse::Ok(Json( - user.try_into().map_err(poem::error::InternalServerError)?, - ))) + Ok(GetUserResponse::Ok(Json(user.try_into()?))) } #[oai(path = "/users/:id", method = "put", operation_id = "update_user")] @@ -156,15 +150,11 @@ impl DetailApi { db: Data<&Arc>>, body: Json, id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; - let Some(user) = User::Entity::find_by_id(id.0) - .one(&*db) - .await - .map_err(poem::error::InternalServerError)? - else { + let Some(user) = User::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(UpdateUserResponse::NotFound); }; @@ -173,10 +163,7 @@ impl DetailApi { model.credential_policy = Set(serde_json::to_value(body.credential_policy.clone()) .map_err(WarpgateError::from)?); - let user = model - .update(&*db) - .await - .map_err(poem::error::InternalServerError)?; + let user = model.update(&*db).await?; Ok(UpdateUserResponse::Ok(Json( user.try_into().map_err(WarpgateError::from)?, @@ -188,27 +175,20 @@ impl DetailApi { &self, db: Data<&Arc>>, id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; - let Some(user) = User::Entity::find_by_id(id.0) - .one(&*db) - .await - .map_err(poem::error::InternalServerError)? - else { + let Some(user) = User::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(DeleteUserResponse::NotFound); }; UserRoleAssignment::Entity::delete_many() .filter(UserRoleAssignment::Column::UserId.eq(user.id)) .exec(&*db) - .await - .map_err(poem::error::InternalServerError)?; + .await?; - user.delete(&*db) - .await - .map_err(poem::error::InternalServerError)?; + user.delete(&*db).await?; Ok(DeleteUserResponse::Deleted) } } @@ -250,8 +230,8 @@ impl RolesApi { &self, db: Data<&Arc>>, id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; let Some((_, roles)) = User::Entity::find_by_id(*id) @@ -279,8 +259,8 @@ impl RolesApi { db: Data<&Arc>>, id: Path, role_id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; if !UserRoleAssignment::Entity::find() @@ -315,23 +295,15 @@ impl RolesApi { db: Data<&Arc>>, id: Path, role_id: Path, - _auth: TokenSecurityScheme, - ) -> poem::Result { + _auth: AnySecurityScheme, + ) -> Result { let db = db.lock().await; - let Some(_user) = User::Entity::find_by_id(id.0) - .one(&*db) - .await - .map_err(poem::error::InternalServerError)? - else { + let Some(_user) = User::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(DeleteUserRoleResponse::NotFound); }; - let Some(_role) = Role::Entity::find_by_id(role_id.0) - .one(&*db) - .await - .map_err(poem::error::InternalServerError)? - else { + let Some(_role) = Role::Entity::find_by_id(role_id.0).one(&*db).await? else { return Ok(DeleteUserRoleResponse::NotFound); }; diff --git a/warpgate-admin/src/lib.rs b/warpgate-admin/src/lib.rs index 2a3dc096..f966bd55 100644 --- a/warpgate-admin/src/lib.rs +++ b/warpgate-admin/src/lib.rs @@ -1,5 +1,5 @@ #![feature(decl_macro, proc_macro_hygiene)] -mod api; +pub mod api; use poem::{EndpointExt, IntoEndpoint, Route}; use poem_openapi::OpenApiService; use warpgate_core::Services; diff --git a/warpgate-common/Cargo.toml b/warpgate-common/Cargo.toml index 684c1a32..2e8a0e25 100644 --- a/warpgate-common/Cargo.toml +++ b/warpgate-common/Cargo.toml @@ -26,6 +26,7 @@ poem-openapi = { version = "5.1", features = [ rand = "0.8" rand_chacha = "0.3" rand_core = { version = "0.6", features = ["std"] } +russh-keys.workspace = true rustls-native-certs = "0.6" sea-orm = { version = "0.12.2", features = [ "runtime-tokio-rustls", @@ -37,7 +38,7 @@ thiserror = "1.0" tokio = { version = "1.20", features = ["tracing"] } tokio-rustls = "0.26" totp-rs = { version = "5.0", features = ["otpauth"] } -tracing = "0.1" +tracing.workspace = true tracing-core = "0.1" url = "2.2" uuid = { version = "1.3", features = ["v4", "serde"] } diff --git a/warpgate-common/src/config/mod.rs b/warpgate-common/src/config/mod.rs index c3a669e4..0a453e50 100644 --- a/warpgate-common/src/config/mod.rs +++ b/warpgate-common/src/config/mod.rs @@ -86,6 +86,51 @@ pub struct UserRequireCredentialsPolicy { pub postgres: Option>, } +impl UserRequireCredentialsPolicy { + #[must_use] + pub fn upgrade_to_otp(&self, with_existing_credentials: &[UserAuthCredential]) -> Self { + let mut copy = self.clone(); + + if let Some(policy) = &mut copy.http { + policy.push(CredentialKind::Totp); + } else { + // Upgrade to OTP only if there is a password credential + let mut kinds = vec![]; + if with_existing_credentials + .iter() + .any(|c| c.kind() == CredentialKind::Password) + { + kinds.push(CredentialKind::Password); + } + if !kinds.is_empty() { + kinds.push(CredentialKind::Totp); + copy.http = Some(kinds); + } + } + + if let Some(policy) = &mut copy.ssh { + policy.push(CredentialKind::Totp); + } else { + // Upgrade to OTP only if there is a password or public key credential + let mut kinds = vec![]; + if with_existing_credentials + .iter() + .find(|c| { + c.kind() == CredentialKind::Password || c.kind() == CredentialKind::PublicKey + }) + .is_some() + { + kinds.push(CredentialKind::Password); + } + if !kinds.is_empty() { + kinds.push(CredentialKind::Totp); + copy.ssh = Some(kinds); + } + } + copy + } +} + #[derive(Debug, Deserialize, Serialize, Clone, Object)] pub struct User { #[serde(default)] diff --git a/warpgate-common/src/error.rs b/warpgate-common/src/error.rs index f7527452..fe956850 100644 --- a/warpgate-common/src/error.rs +++ b/warpgate-common/src/error.rs @@ -1,7 +1,9 @@ use std::error::Error; use poem::error::ResponseError; +use poem_openapi::ApiResponse; use uuid::Uuid; +use warpgate_sso::SsoError; #[derive(thiserror::Error, Debug)] pub enum WarpgateError { @@ -31,6 +33,10 @@ pub enum WarpgateError { InconsistentState, #[error(transparent)] Anyhow(#[from] anyhow::Error), + #[error(transparent)] + Sso(#[from] SsoError), + #[error(transparent)] + RusshKeys(#[from] russh_keys::Error), #[error("Session end")] SessionEnd, @@ -47,3 +53,13 @@ impl WarpgateError { Self::Other(Box::new(err)) } } + +impl ApiResponse for WarpgateError { + fn meta() -> poem_openapi::registry::MetaResponses { + poem::error::Error::meta() + } + + fn register(registry: &mut poem_openapi::registry::Registry) { + poem::error::Error::register(registry) + } +} diff --git a/warpgate-core/Cargo.toml b/warpgate-core/Cargo.toml index 82383140..e1a380f8 100644 --- a/warpgate-core/Cargo.toml +++ b/warpgate-core/Cargo.toml @@ -39,7 +39,7 @@ serde_json.workspace = true thiserror = "1.0" tokio = { version = "1.20", features = ["tracing"] } totp-rs = { version = "5.0", features = ["otpauth"] } -tracing = "0.1" +tracing.workspace = true tracing-core = "0.1" tracing-subscriber = "0.3" url = "2.2" diff --git a/warpgate-db-entities/src/Parameters.rs b/warpgate-db-entities/src/Parameters.rs new file mode 100644 index 00000000..f2e69afc --- /dev/null +++ b/warpgate-db-entities/src/Parameters.rs @@ -0,0 +1,32 @@ +use sea_orm::entity::prelude::*; +use sea_orm::Set; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "parameters")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub allow_own_credential_management: bool, +} + +impl ActiveModelBehavior for ActiveModel {} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl Entity { + pub async fn get(db: &DatabaseConnection) -> Result { + match Self::find().one(db).await? { + Some(model) => Ok(model), + None => { + ActiveModel { + id: Set(Uuid::new_v4()), + allow_own_credential_management: Set(true), + } + .insert(db) + .await + } + } + } +} diff --git a/warpgate-db-entities/src/User.rs b/warpgate-db-entities/src/User.rs index 8d0323bd..5a9895e4 100644 --- a/warpgate-db-entities/src/User.rs +++ b/warpgate-db-entities/src/User.rs @@ -1,5 +1,6 @@ use poem_openapi::Object; use sea_orm::entity::prelude::*; +use sea_orm::Set; use serde::Serialize; use uuid::Uuid; use warpgate_common::{User, UserDetails, WarpgateError}; @@ -144,3 +145,15 @@ impl Model { }) } } + +impl TryFrom for ActiveModel { + type Error = WarpgateError; + + fn try_from(user: User) -> Result { + Ok(Self { + id: Set(user.id), + username: Set(user.username), + credential_policy: Set(serde_json::to_value(&user.credential_policy)?), + }) + } +} diff --git a/warpgate-db-entities/src/lib.rs b/warpgate-db-entities/src/lib.rs index e01ae597..cfcf480d 100644 --- a/warpgate-db-entities/src/lib.rs +++ b/warpgate-db-entities/src/lib.rs @@ -14,3 +14,4 @@ pub mod TargetRoleAssignment; pub mod Ticket; pub mod User; pub mod UserRoleAssignment; +pub mod Parameters; diff --git a/warpgate-db-migrations/Cargo.toml b/warpgate-db-migrations/Cargo.toml index 77993b81..a8ab05bc 100644 --- a/warpgate-db-migrations/Cargo.toml +++ b/warpgate-db-migrations/Cargo.toml @@ -21,6 +21,7 @@ sea-orm = { version = "0.12", features = [ sea-orm-migration = { version = "0.12", default-features = false, features = [ "cli", ] } +tracing.workspace = true uuid = { version = "1.3", features = ["v4", "serde"] } serde_json.workspace = true serde.workspace = true diff --git a/warpgate-db-migrations/src/lib.rs b/warpgate-db-migrations/src/lib.rs index ecb1a69c..544c4113 100644 --- a/warpgate-db-migrations/src/lib.rs +++ b/warpgate-db-migrations/src/lib.rs @@ -11,6 +11,7 @@ mod m00006_add_session_protocol; mod m00007_targets_and_roles; mod m00008_users; mod m00009_credential_models; +mod m00010_parameters; pub struct Migrator; @@ -27,6 +28,7 @@ impl MigratorTrait for Migrator { Box::new(m00007_targets_and_roles::Migration), Box::new(m00008_users::Migration), Box::new(m00009_credential_models::Migration), + Box::new(m00010_parameters::Migration), ] } } diff --git a/warpgate-db-migrations/src/m00009_credential_models.rs b/warpgate-db-migrations/src/m00009_credential_models.rs index f47ff72f..33a138f7 100644 --- a/warpgate-db-migrations/src/m00009_credential_models.rs +++ b/warpgate-db-migrations/src/m00009_credential_models.rs @@ -1,6 +1,7 @@ use credential_enum::UserAuthCredential; use sea_orm::{ActiveModelTrait, EntityTrait, Schema, Set}; use sea_orm_migration::prelude::*; +use tracing::error; use uuid::Uuid; use super::m00008_users::user as User; @@ -248,8 +249,15 @@ impl MigrationTrait for Migration { let users = User::Entity::find().all(db).await?; for user in users { #[allow(clippy::unwrap_used)] - let credentials: Vec = - serde_json::from_value(user.credentials).unwrap(); + let Ok(credentials) = + serde_json::from_value::>(user.credentials.clone()) + else { + error!( + "Failed to parse credentials for user {}, value was {:?}", + user.id, user.credentials + ); + continue; + }; for credential in credentials { match credential { UserAuthCredential::Password(password) => { diff --git a/warpgate-db-migrations/src/m00010_parameters.rs b/warpgate-db-migrations/src/m00010_parameters.rs new file mode 100644 index 00000000..0cbe6bf1 --- /dev/null +++ b/warpgate-db-migrations/src/m00010_parameters.rs @@ -0,0 +1,64 @@ +use sea_orm::Schema; +use sea_orm_migration::prelude::*; + +pub mod parameters { + use sea_orm::entity::prelude::*; + use sea_orm::Set; + use uuid::Uuid; + + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] + #[sea_orm(table_name = "parameters")] + pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub allow_own_credential_management: bool, + } + + impl ActiveModelBehavior for ActiveModel {} + + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] + pub enum Relation {} + + impl Model { + pub async fn get(db: &DatabaseConnection) -> Result { + match Entity::find().one(db).await? { + Some(model) => Ok(model), + None => { + ActiveModel { + id: Set(Uuid::new_v4()), + allow_own_credential_management: Set(true), + } + .insert(db) + .await + } + } + } + } +} + +pub struct Migration; + +impl MigrationName for Migration { + fn name(&self) -> &str { + "m00010_parameters" + } +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let builder = manager.get_database_backend(); + let schema = Schema::new(builder); + manager + .create_table(schema.create_table_from_entity(parameters::Entity)) + .await?; + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(parameters::Entity).to_owned()) + .await?; + Ok(()) + } +} diff --git a/warpgate-protocol-http/Cargo.toml b/warpgate-protocol-http/Cargo.toml index 41faae77..a6b7231a 100644 --- a/warpgate-protocol-http/Cargo.toml +++ b/warpgate-protocol-http/Cargo.toml @@ -28,11 +28,15 @@ reqwest = { version = "0.12", features = [ "rustls-tls-native-roots", "stream", ], default-features = false } +sea-orm = { version = "0.12", features = [ + "runtime-tokio-rustls", + "macros", +], default-features = false } serde.workspace = true serde_json.workspace = true tokio = { version = "1.20", features = ["tracing", "signal"] } tokio-tungstenite = { version = "0.24", features = ["rustls-tls-native-roots"] } -tracing = "0.1" +tracing.workspace = true warpgate-admin = { version = "*", path = "../warpgate-admin" } warpgate-common = { version = "*", path = "../warpgate-common" } warpgate-core = { version = "*", path = "../warpgate-core" } diff --git a/warpgate-protocol-http/src/api/credentials.rs b/warpgate-protocol-http/src/api/credentials.rs new file mode 100644 index 00000000..50837a4e --- /dev/null +++ b/warpgate-protocol-http/src/api/credentials.rs @@ -0,0 +1,406 @@ +use http::StatusCode; +use poem::web::Data; +use poem::{Endpoint, EndpointExt, FromRequest, IntoResponse}; +use poem_openapi::param::Path; +use poem_openapi::payload::Json; +use poem_openapi::{ApiResponse, Enum, Object, OpenApi}; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter, Set, +}; +use uuid::Uuid; +use warpgate_common::{User, UserPasswordCredential, UserRequireCredentialsPolicy, WarpgateError}; +use warpgate_core::Services; +use warpgate_db_entities::{self as entities, Parameters, PasswordCredential, PublicKeyCredential}; + +use crate::common::{endpoint_auth, RequestAuthorization}; + +pub struct Api; + +#[derive(Enum)] +enum PasswordState { + Unset, + Set, + MultipleSet, +} + +#[derive(Object)] +struct ExistingSsoCredential { + id: Uuid, + provider: Option, + email: String, +} + +impl From for ExistingSsoCredential { + fn from(credential: entities::SsoCredential::Model) -> Self { + Self { + id: credential.id, + provider: credential.provider, + email: credential.email, + } + } +} + +#[derive(Object)] +struct ChangePasswordRequest { + password: String, +} + +#[derive(ApiResponse)] +enum ChangePasswordResponse { + #[oai(status = 201)] + Done(Json), + #[oai(status = 401)] + Unauthorized, +} + +#[derive(Object)] +pub struct CredentialsState { + password: PasswordState, + otp: Vec, + public_keys: Vec, + sso: Vec, + credential_policy: UserRequireCredentialsPolicy, +} + +#[derive(ApiResponse)] +enum CredentialsStateResponse { + #[oai(status = 200)] + Ok(Json), + #[oai(status = 401)] + Unauthorized, +} + +#[derive(Object)] +struct NewPublicKeyCredential { + openssh_public_key: String, +} + +#[derive(Object)] +struct ExistingPublicKeyCredential { + id: Uuid, + label: String, +} + +fn abbreviate_public_key(k: &str) -> String { + let l = 10; + format!( + "{}...{}", + &k[..l.min(k.len())], + &k[(k.len() - l).max(l).min(k.len() - 1)..] + ) +} + +impl From for ExistingPublicKeyCredential { + fn from(credential: entities::PublicKeyCredential::Model) -> Self { + Self { + id: credential.id, + label: abbreviate_public_key(&credential.openssh_public_key), + } + } +} +#[derive(ApiResponse)] +enum CreatePublicKeyCredentialResponse { + #[oai(status = 201)] + Created(Json), + #[oai(status = 401)] + Unauthorized, +} + +#[derive(ApiResponse)] +enum DeleteCredentialResponse { + #[oai(status = 204)] + Deleted, + #[oai(status = 401)] + Unauthorized, + #[oai(status = 404)] + NotFound, +} + +#[derive(Object)] +struct NewOtpCredential { + secret_key: Vec, +} + +#[derive(Object)] +struct ExistingOtpCredential { + id: Uuid, +} + +impl From for ExistingOtpCredential { + fn from(credential: entities::OtpCredential::Model) -> Self { + Self { id: credential.id } + } +} + +#[derive(ApiResponse)] +enum CreateOtpCredentialResponse { + #[oai(status = 201)] + Created(Json), + #[oai(status = 401)] + Unauthorized, +} + +pub fn parameters_based_auth(e: E) -> impl Endpoint { + e.around(|ep, req| async move { + let services = Data::<&Services>::from_request_without_body(&req).await?; + let parameters = Parameters::Entity::get(&*services.db.lock().await) + .await + .map_err(WarpgateError::from)?; + if !parameters.allow_own_credential_management { + return Ok(poem::Response::builder() + .status(StatusCode::FORBIDDEN) + .body("Credential management is disabled") + .into_response()); + } + Ok(endpoint_auth(ep).call(req).await?.into_response()) + }) +} + +async fn get_user( + auth: &RequestAuthorization, + db: &DatabaseConnection, +) -> Result, WarpgateError> { + let Some(username) = auth.username() else { + return Ok(None); + }; + + let Some(user_model) = entities::User::Entity::find() + .filter(entities::User::Column::Username.eq(username)) + .one(&*db) + .await? + else { + return Ok(None); + }; + + Ok(Some(user_model)) +} + +#[OpenApi] +impl Api { + #[oai( + path = "/profile/credentials", + method = "get", + operation_id = "get_my_credentials", + transform = "parameters_based_auth" + )] + async fn api_get_credentials_state( + &self, + auth: Data<&RequestAuthorization>, + services: Data<&Services>, + ) -> Result { + let db = services.db.lock().await; + + let Some(user_model) = get_user(&*auth, &*db).await? else { + return Ok(CredentialsStateResponse::Unauthorized); + }; + + let user = User::try_from(user_model.clone())?; + + let otp_creds = user_model + .find_related(entities::OtpCredential::Entity) + .all(&*db) + .await?; + let password_creds = user_model + .find_related(entities::PasswordCredential::Entity) + .all(&*db) + .await?; + let sso_creds = user_model + .find_related(entities::SsoCredential::Entity) + .all(&*db) + .await?; + + let pk_creds = user_model + .find_related(entities::PublicKeyCredential::Entity) + .all(&*db) + .await?; + Ok(CredentialsStateResponse::Ok(Json(CredentialsState { + password: match password_creds.len() { + 0 => PasswordState::Unset, + 1 => PasswordState::Set, + _ => PasswordState::MultipleSet, + }, + otp: otp_creds.into_iter().map(Into::into).collect(), + public_keys: pk_creds.into_iter().map(Into::into).collect(), + sso: sso_creds.into_iter().map(Into::into).collect(), + credential_policy: user.credential_policy.unwrap_or_default(), + }))) + } + + #[oai( + path = "/profile/credentials/password", + method = "post", + operation_id = "change_my_password", + transform = "parameters_based_auth" + )] + async fn api_change_password( + &self, + auth: Data<&RequestAuthorization>, + services: Data<&Services>, + body: Json, + ) -> Result { + let db = services.db.lock().await; + + let Some(user_model) = get_user(&*auth, &*db).await? else { + return Ok(ChangePasswordResponse::Unauthorized); + }; + + let new_credential = entities::PasswordCredential::ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(user_model.id), + ..PasswordCredential::ActiveModel::from(UserPasswordCredential::from_password( + &body.password.clone().into(), + )) + } + .insert(&*db) + .await + .map_err(WarpgateError::from)?; + + entities::PasswordCredential::Entity::find() + .filter( + entities::PasswordCredential::Column::UserId + .eq(user_model.id) + .and(entities::PasswordCredential::Column::Id.ne(new_credential.id)), + ) + .all(&*db) + .await?; + + Ok(ChangePasswordResponse::Done(Json(PasswordState::Set))) + } + + #[oai( + path = "/profile/credentials/public-keys", + method = "post", + operation_id = "add_my_public_key", + transform = "parameters_based_auth" + )] + async fn api_create_pk( + &self, + auth: Data<&RequestAuthorization>, + services: Data<&Services>, + body: Json, + ) -> Result { + let db = services.db.lock().await; + + let Some(user_model) = get_user(&*auth, &*db).await? else { + return Ok(CreatePublicKeyCredentialResponse::Unauthorized); + }; + + let object = PublicKeyCredential::ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(user_model.id), + openssh_public_key: Set(body.openssh_public_key.clone()), + } + .insert(&*db) + .await + .map_err(WarpgateError::from)?; + + Ok(CreatePublicKeyCredentialResponse::Created(Json( + object.into(), + ))) + } + + #[oai( + path = "/profile/credentials/public-keys/:id", + method = "delete", + operation_id = "delete_my_public_key", + transform = "parameters_based_auth" + )] + async fn api_delete_pk( + &self, + auth: Data<&RequestAuthorization>, + services: Data<&Services>, + id: Path, + ) -> Result { + let db = services.db.lock().await; + + let Some(user_model) = get_user(&*auth, &*db).await? else { + return Ok(DeleteCredentialResponse::Unauthorized); + }; + + let Some(model) = user_model + .find_related(entities::PublicKeyCredential::Entity) + .filter(entities::PublicKeyCredential::Column::Id.eq(id.0)) + .one(&*db) + .await? + else { + return Ok(DeleteCredentialResponse::NotFound); + }; + + model.delete(&*db).await?; + Ok(DeleteCredentialResponse::Deleted) + } + + #[oai( + path = "/profile/credentials/otp", + method = "post", + operation_id = "add_my_otp", + transform = "parameters_based_auth" + )] + async fn api_create_otp( + &self, + auth: Data<&RequestAuthorization>, + services: Data<&Services>, + body: Json, + ) -> Result { + let db = services.db.lock().await; + + let Some(user_model) = get_user(&*auth, &*db).await? else { + return Ok(CreateOtpCredentialResponse::Unauthorized); + }; + + let mut user: User = user_model.clone().try_into()?; + + let object = entities::OtpCredential::ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(user_model.id), + secret_key: Set(body.secret_key.clone()), + } + .insert(&*db) + .await + .map_err(WarpgateError::from)?; + + let details = user_model.load_details(&*db).await?; + user.credential_policy = Some( + user.credential_policy + .unwrap_or_default() + .upgrade_to_otp(details.credentials.as_slice()), + ); + + entities::User::ActiveModel::try_from(user)? + .update(&*db) + .await?; + + Ok(CreateOtpCredentialResponse::Created(Json(object.into()))) + } + + #[oai( + path = "/profile/credentials/otp/:id", + method = "delete", + operation_id = "delete_my_otp", + transform = "parameters_based_auth" + )] + async fn api_delete_otp( + &self, + auth: Data<&RequestAuthorization>, + services: Data<&Services>, + id: Path, + ) -> Result { + let db = services.db.lock().await; + + let Some(user_model) = get_user(&*auth, &*db).await? else { + return Ok(DeleteCredentialResponse::Unauthorized); + }; + + let Some(model) = user_model + .find_related(entities::OtpCredential::Entity) + .filter(entities::OtpCredential::Column::Id.eq(id.0)) + .one(&*db) + .await? + else { + return Ok(DeleteCredentialResponse::NotFound); + }; + + model.delete(&*db).await?; + Ok(DeleteCredentialResponse::Deleted) + } +} diff --git a/warpgate-protocol-http/src/api/info.rs b/warpgate-protocol-http/src/api/info.rs index bd91cc63..f5c36ce8 100644 --- a/warpgate-protocol-http/src/api/info.rs +++ b/warpgate-protocol-http/src/api/info.rs @@ -4,7 +4,9 @@ use poem::Request; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, Object, OpenApi}; use serde::Serialize; +use warpgate_common::WarpgateError; use warpgate_core::Services; +use warpgate_db_entities::Parameters; use crate::common::{SessionAuthorization, SessionExt}; @@ -27,6 +29,7 @@ pub struct Info { ports: PortsInfo, authorized_via_ticket: bool, authorized_via_sso_with_single_logout: bool, + own_credential_management_allowed: bool, } #[derive(ApiResponse)] @@ -43,7 +46,7 @@ impl Api { req: &Request, session: &Session, services: Data<&Services>, - ) -> poem::Result { + ) -> Result { let config = services.config.lock().await; let external_host = config .construct_external_url(Some(req), None) @@ -52,6 +55,8 @@ impl Api { .and_then(|x| x.host()) .map(|x| x.to_string()); + let parameters = Parameters::Entity::get(&*services.db.lock().await).await?; + Ok(InstanceInfoResponse::Ok(Json(Info { version: env!("CARGO_PKG_VERSION").to_string(), username: session.get_username(), @@ -95,6 +100,7 @@ impl Api { postgres: None, } }, + own_credential_management_allowed: parameters.allow_own_credential_management, }))) } } diff --git a/warpgate-protocol-http/src/api/mod.rs b/warpgate-protocol-http/src/api/mod.rs index 9e467429..7839278a 100644 --- a/warpgate-protocol-http/src/api/mod.rs +++ b/warpgate-protocol-http/src/api/mod.rs @@ -3,6 +3,7 @@ use poem_openapi::{OpenApi, SecurityScheme}; pub mod auth; mod common; +mod credentials; pub mod info; pub mod sso_provider_detail; pub mod sso_provider_list; @@ -11,14 +12,14 @@ pub mod targets_list; #[derive(SecurityScheme)] #[oai(ty = "api_key", key_name = "X-Warpgate-Token", key_in = "header")] #[allow(dead_code)] -pub struct TokenSecurityScheme(ApiKey); +pub struct AnySecurityScheme(ApiKey); struct StubApi; #[OpenApi] impl StubApi { #[oai(path = "/__stub__", method = "get", operation_id = "__stub__")] - async fn stub(&self, _auth: TokenSecurityScheme) -> poem::Result<()> { + async fn stub(&self, _auth: AnySecurityScheme) -> poem::Result<()> { Ok(()) } } @@ -31,5 +32,6 @@ pub fn get() -> impl OpenApi { targets_list::Api, sso_provider_list::Api, sso_provider_detail::Api, + credentials::Api, ) } diff --git a/warpgate-protocol-http/src/api/sso_provider_detail.rs b/warpgate-protocol-http/src/api/sso_provider_detail.rs index dd465e29..13f2cbb3 100644 --- a/warpgate-protocol-http/src/api/sso_provider_detail.rs +++ b/warpgate-protocol-http/src/api/sso_provider_detail.rs @@ -6,6 +6,7 @@ use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, Object, OpenApi}; use serde::{Deserialize, Serialize}; use tracing::*; +use warpgate_common::WarpgateError; use warpgate_core::Services; use warpgate_sso::{SsoClient, SsoLoginRequest}; @@ -49,7 +50,7 @@ impl Api { services: Data<&Services>, name: Path, next: Query>, - ) -> poem::Result { + ) -> Result { let config = services.config.lock().await; let name = name.0; @@ -68,10 +69,7 @@ impl Api { let client = SsoClient::new(provider_config.provider.clone()); - let sso_req = client - .start_login(return_url.to_string()) - .await - .map_err(poem::error::InternalServerError)?; + let sso_req = client.start_login(return_url.to_string()).await?; let url = sso_req.auth_url().to_string(); session.set( @@ -80,10 +78,7 @@ impl Api { provider: name, request: sso_req, next_url: next.0.clone(), - supports_single_logout: client - .supports_single_logout() - .await - .map_err(poem::error::InternalServerError)?, + supports_single_logout: client.supports_single_logout().await?, }, ); diff --git a/warpgate-protocol-http/src/api/sso_provider_list.rs b/warpgate-protocol-http/src/api/sso_provider_list.rs index 26c68392..1461a3f5 100644 --- a/warpgate-protocol-http/src/api/sso_provider_list.rs +++ b/warpgate-protocol-http/src/api/sso_provider_list.rs @@ -11,6 +11,7 @@ use serde::Deserialize; use tokio::sync::Mutex; use tracing::*; use warpgate_common::auth::{AuthCredential, AuthResult}; +use warpgate_common::WarpgateError; use warpgate_core::Services; use warpgate_sso::{SsoClient, SsoInternalProviderConfig}; @@ -93,7 +94,7 @@ impl Api { async fn api_get_all_sso_providers( &self, services: Data<&Services>, - ) -> poem::Result { + ) -> Result { let mut providers = services.config.lock().await.store.sso_providers.clone(); providers.sort_by(|a, b| a.label().cmp(b.label())); Ok(GetSsoProvidersResponse::Ok(Json( @@ -120,7 +121,7 @@ impl Api { session: &Session, services: Data<&Services>, code: Query>, - ) -> poem::Result> { + ) -> Result, WarpgateError> { let url = self .api_return_to_sso_get_common(req, session, services, &code) .await? @@ -140,13 +141,12 @@ impl Api { session: &Session, services: Data<&Services>, data: Form, - ) -> poem::Result { + ) -> Result { let url = self .api_return_to_sso_get_common(req, session, services, &data.code) .await? .unwrap_or_else(|x| make_redirect_url(&x)); - let serialized_url = - serde_json::to_string(&url).map_err(poem::error::InternalServerError)?; + let serialized_url = serde_json::to_string(&url)?; Ok(ReturnToSsoPostResponse::Redirect( poem_openapi::payload::Html(format!( "\n @@ -169,7 +169,7 @@ impl Api { session: &Session, services: Data<&Services>, code: &Option, - ) -> poem::Result> { + ) -> Result, WarpgateError> { let Some(context) = session.get::(SSO_CONTEXT_SESSION_KEY) else { return Ok(Err("Not in an active SSO process".to_string())); }; @@ -180,11 +180,7 @@ impl Api { )); }; - let response = context - .request - .verify_code((*code).clone()) - .await - .map_err(poem::error::InternalServerError)?; + let response = context.request.verify_code((*code).clone()).await?; if !response.email_verified.unwrap_or(true) { return Ok(Err("The SSO account's e-mail is not verified".to_string())); @@ -293,7 +289,7 @@ impl Api { session: &Session, services: Data<&Services>, session_middleware: Data<&Arc>>, - ) -> poem::Result { + ) -> Result { let Some(state) = session.get_sso_login_state() else { return Ok(StartSloResponse::NotInSsoSession); }; @@ -313,10 +309,7 @@ impl Api { }; let client = SsoClient::new(provider_config.provider.clone()); - let logout_url = client - .logout(state.token, return_url) - .await - .map_err(poem::error::InternalServerError)?; + let logout_url = client.logout(state.token, return_url).await?; logout(session, session_middleware.lock().await.deref_mut()); diff --git a/warpgate-protocol-http/src/common.rs b/warpgate-protocol-http/src/common.rs index 88f6a5d4..c7cd9f0e 100644 --- a/warpgate-protocol-http/src/common.rs +++ b/warpgate-protocol-http/src/common.rs @@ -1,6 +1,7 @@ use core::str; use std::sync::Arc; +use anyhow::Context; use http::{HeaderName, StatusCode}; use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; use poem::session::Session; @@ -117,6 +118,15 @@ pub enum RequestAuthorization { AdminToken, } +impl RequestAuthorization { + pub fn username(&self) -> Option<&String> { + match self { + Self::Session(auth) => Some(auth.username()), + Self::AdminToken => None, + } + } +} + async fn is_user_admin(req: &Request, auth: &RequestAuthorization) -> poem::Result { let services = Data::<&Services>::from_request_without_body(req).await?; @@ -261,16 +271,20 @@ pub async fn get_auth_state_for_request( Ok(state) } -pub async fn authorize_session(req: &Request, username: String) -> poem::Result<()> { - let session_middleware = - Data::<&Arc>>::from_request_without_body(req).await?; - let session = <&Session>::from_request_without_body(req).await?; +pub async fn authorize_session(req: &Request, username: String) -> Result<(), WarpgateError> { + let session_middleware = Data::<&Arc>>::from_request_without_body(req) + .await + .context("SessionStore not in request")?; + let session = <&Session>::from_request_without_body(req) + .await + .context("Session not in request")?; let server_handle = session_middleware .lock() .await .create_handle_for(req) - .await?; + .await + .context("create_handle_for")?; server_handle .lock() .await diff --git a/warpgate-protocol-http/src/lib.rs b/warpgate-protocol-http/src/lib.rs index 960f9283..3c6d4ed2 100644 --- a/warpgate-protocol-http/src/lib.rs +++ b/warpgate-protocol-http/src/lib.rs @@ -72,6 +72,7 @@ impl ProtocolServer for HTTPProtocolServer { let session_storage = make_session_storage(); let session_store = SessionStore::new(); + let db = self.services.db.clone(); let cache_bust = || { SetHeader::new().overriding( @@ -170,7 +171,8 @@ impl ProtocolServer for HTTPProtocolServer { .with(CookieHostMiddleware::new()) .data(self.services.clone()) .data(session_store.clone()) - .data(session_storage); + .data(session_storage) + .data(db); tokio::spawn(async move { loop { diff --git a/warpgate-protocol-mysql/Cargo.toml b/warpgate-protocol-mysql/Cargo.toml index c62efcd2..9b73f9f9 100644 --- a/warpgate-protocol-mysql/Cargo.toml +++ b/warpgate-protocol-mysql/Cargo.toml @@ -12,7 +12,7 @@ warpgate-database-protocols = { version = "*", path = "../warpgate-database-prot anyhow = { version = "1.0", features = ["std"] } async-trait = "0.1" tokio = { version = "1.20", features = ["tracing", "signal"] } -tracing = "0.1" +tracing.workspace = true uuid = { version = "1.3", features = ["v4"] } bytes.workspace = true mysql_common = { version = "0.29", default-features = false } diff --git a/warpgate-protocol-postgres/Cargo.toml b/warpgate-protocol-postgres/Cargo.toml index d85fe1d7..e13668ba 100644 --- a/warpgate-protocol-postgres/Cargo.toml +++ b/warpgate-protocol-postgres/Cargo.toml @@ -10,7 +10,7 @@ warpgate-core = { version = "*", path = "../warpgate-core" } anyhow = { version = "1.0", features = ["std"] } async-trait = "0.1" tokio = { version = "1.20", features = ["tracing", "signal"] } -tracing = "0.1" +tracing.workspace = true uuid = { version = "1.2" } bytes.workspace = true rustls = "0.23" diff --git a/warpgate-protocol-ssh/Cargo.toml b/warpgate-protocol-ssh/Cargo.toml index 9e791a51..1264204b 100644 --- a/warpgate-protocol-ssh/Cargo.toml +++ b/warpgate-protocol-ssh/Cargo.toml @@ -21,7 +21,7 @@ sea-orm = { version = "0.12", features = [ thiserror = "1.0" time = "0.3" tokio = { version = "1.20", features = ["tracing", "signal"] } -tracing = "0.1" +tracing.workspace = true uuid = { version = "1.3", features = ["v4"] } warpgate-common = { version = "*", path = "../warpgate-common" } warpgate-core = { version = "*", path = "../warpgate-core" } diff --git a/warpgate-sso/Cargo.toml b/warpgate-sso/Cargo.toml index a5f2ff5a..2b4f2134 100644 --- a/warpgate-sso/Cargo.toml +++ b/warpgate-sso/Cargo.toml @@ -8,7 +8,7 @@ version = "0.11.0" bytes.workspace = true thiserror = "1.0" tokio = { version = "1.20", features = ["tracing", "macros"] } -tracing = "0.1" +tracing.workspace = true openidconnect = { version = "3.5", features = ["reqwest", "rustls-tls", "accept-string-booleans"] } serde.workspace = true serde_json.workspace = true diff --git a/warpgate-web/src/admin/App.svelte b/warpgate-web/src/admin/App.svelte index 9ca5c802..38dfed3d 100644 --- a/warpgate-web/src/admin/App.svelte +++ b/warpgate-web/src/admin/App.svelte @@ -58,6 +58,9 @@ const routes = { '/log': wrap({ asyncComponent: () => import('./Log.svelte') as any, }), + '/parameters': wrap({ + asyncComponent: () => import('./Parameters.svelte') as any, + }), } diff --git a/warpgate-web/src/admin/Config.svelte b/warpgate-web/src/admin/Config.svelte index e76aeae6..f79c93a7 100644 --- a/warpgate-web/src/admin/Config.svelte +++ b/warpgate-web/src/admin/Config.svelte @@ -37,6 +37,16 @@
+ +

Targets

Math.floor(Math.random() * 255)) } - /** - * Copies the TOTP URI to the system clipboard if it is defined. - * - * @return {Promise} A promise that resolves when the TOTP URI has been copied to the clipboard. - */ - async function copyTotpUri () : Promise { - if (totpUri === undefined) { - return - } - - const { clipboard } = navigator - return clipboard.writeText(totpUri) - } - /** * Generates a TOTP (Time-based One-Time Password) secret key encoded in base32. * @@ -122,27 +109,24 @@ field?.focus()}> -
+ { + _save() + e.preventDefault() + }}> - Password + One-time password -
-
- OTP QR code -
-
- - -
+ OTP QR code + +
+ +
- + @@ -158,7 +143,7 @@
@@ -173,3 +158,12 @@ + + diff --git a/warpgate-web/src/admin/CreatePasswordModal.svelte b/warpgate-web/src/admin/CreatePasswordModal.svelte index cc659a3c..a990583b 100644 --- a/warpgate-web/src/admin/CreatePasswordModal.svelte +++ b/warpgate-web/src/admin/CreatePasswordModal.svelte @@ -40,7 +40,10 @@ field?.focus()}> -
+ { + _save() + e.preventDefault() + }}> Password diff --git a/warpgate-web/src/admin/CredentialEditor.svelte b/warpgate-web/src/admin/CredentialEditor.svelte index 2889ba24..24062363 100644 --- a/warpgate-web/src/admin/CredentialEditor.svelte +++ b/warpgate-web/src/admin/CredentialEditor.svelte @@ -350,7 +350,7 @@ {#if editingPublicKeyCredential} {/if} diff --git a/warpgate-web/src/admin/Parameters.svelte b/warpgate-web/src/admin/Parameters.svelte new file mode 100644 index 00000000..4aba438b --- /dev/null +++ b/warpgate-web/src/admin/Parameters.svelte @@ -0,0 +1,53 @@ + + +
+

Global parameters

+
+ +{#await load()} + +{:then} +{#if parameters} + +{/if} +{/await} + +{#if error} + {error} +{/if} diff --git a/warpgate-web/src/admin/PublicKeyCredentialModal.svelte b/warpgate-web/src/admin/PublicKeyCredentialModal.svelte index b1002fdf..b0554680 100644 --- a/warpgate-web/src/admin/PublicKeyCredentialModal.svelte +++ b/warpgate-web/src/admin/PublicKeyCredentialModal.svelte @@ -14,7 +14,7 @@ interface Props { isOpen: boolean - instance: ExistingPublicKeyCredential|null + instance?: ExistingPublicKeyCredential save: (opensshPublicKey: string) => void } @@ -51,7 +51,10 @@ } field?.focus() }}> - + { + _save() + e.preventDefault() + }}> Public key diff --git a/warpgate-web/src/admin/SsoCredentialModal.svelte b/warpgate-web/src/admin/SsoCredentialModal.svelte index 8bc21c44..cf8078aa 100644 --- a/warpgate-web/src/admin/SsoCredentialModal.svelte +++ b/warpgate-web/src/admin/SsoCredentialModal.svelte @@ -49,7 +49,10 @@ email = instance.email } }}> - + { + _save() + e.preventDefault() + }}> Single sign-on diff --git a/warpgate-web/src/admin/User.svelte b/warpgate-web/src/admin/User.svelte index 85ab3328..f3cccf3b 100644 --- a/warpgate-web/src/admin/User.svelte +++ b/warpgate-web/src/admin/User.svelte @@ -68,7 +68,7 @@ {#await load()} - + {:then} {#if user}
@@ -96,11 +96,11 @@ class="list-group-item list-group-item-action d-flex align-items-center" > toggleRole(role)} - checked={roleIsAllowed[role.id]} /> + id="role-{role.id}" + class="mb-0 me-2" + type="switch" + on:change={() => toggleRole(role)} + checked={roleIsAllowed[role.id]} />
{role.name}
{/each} diff --git a/warpgate-web/src/admin/lib/openapi-schema.json b/warpgate-web/src/admin/lib/openapi-schema.json index dfde1975..d943c4ac 100644 --- a/warpgate-web/src/admin/lib/openapi-schema.json +++ b/warpgate-web/src/admin/lib/openapi-schema.json @@ -72,6 +72,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "get_sessions" @@ -85,6 +88,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "close_all_sessions" @@ -123,6 +129,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "get_session" @@ -161,6 +170,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "get_session_recordings" @@ -192,6 +204,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "close_session" @@ -230,6 +245,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "get_recording" @@ -267,6 +285,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "get_roles" @@ -307,6 +328,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "create_role" @@ -345,6 +369,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "get_role" @@ -394,6 +421,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "update_role" @@ -426,6 +456,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "delete_role" @@ -451,6 +484,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "get_tickets" @@ -491,6 +527,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "create_ticket" @@ -522,6 +561,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "delete_ticket" @@ -547,6 +589,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "get_ssh_known_hosts" @@ -578,6 +623,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "delete_ssh_known_host" @@ -603,6 +651,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "get_ssh_own_keys" @@ -638,6 +689,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "get_logs" @@ -675,6 +729,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "get_targets" @@ -715,6 +772,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "create_target" @@ -753,6 +813,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "get_target" @@ -802,6 +865,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "update_target" @@ -834,6 +900,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "delete_target" @@ -875,6 +944,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "get_target_roles" @@ -917,6 +989,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "add_target_role" @@ -960,6 +1035,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "delete_target_role" @@ -997,6 +1075,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "get_users" @@ -1037,6 +1118,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "create_user" @@ -1075,6 +1159,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "get_user" @@ -1121,6 +1208,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "update_user" @@ -1150,6 +1240,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "delete_user" @@ -1191,6 +1284,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "get_user_roles" @@ -1233,6 +1329,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "add_user_role" @@ -1273,6 +1372,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "delete_user_role" @@ -1311,6 +1413,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "get_password_credentials" @@ -1354,6 +1459,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "create_password_credential" @@ -1396,6 +1504,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "delete_password_credential" @@ -1434,6 +1545,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "get_sso_credentials" @@ -1477,6 +1591,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "create_sso_credential" @@ -1536,6 +1653,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "update_sso_credential" @@ -1576,6 +1696,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "delete_sso_credential" @@ -1614,6 +1737,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "get_public_key_credentials" @@ -1657,6 +1783,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "create_public_key_credential" @@ -1716,6 +1845,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "update_public_key_credential" @@ -1756,6 +1888,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "delete_public_key_credential" @@ -1794,6 +1929,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "get_otp_credentials" @@ -1837,6 +1975,9 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "create_otp_credential" @@ -1879,10 +2020,64 @@ "security": [ { "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "delete_otp_credential" } + }, + "/parameters": { + "get": { + "responses": { + "200": { + "description": "", + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ParameterValues" + } + } + } + } + }, + "security": [ + { + "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] + } + ], + "operationId": "get_parameters" + }, + "patch": { + "requestBody": { + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ParameterUpdate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] + } + ], + "operationId": "update_parameters" + } } }, "components": { @@ -2123,6 +2318,25 @@ } } }, + "ParameterUpdate": { + "type": "object", + "properties": { + "allow_own_credential_management": { + "type": "boolean" + } + } + }, + "ParameterValues": { + "type": "object", + "required": [ + "allow_own_credential_management" + ], + "properties": { + "allow_own_credential_management": { + "type": "boolean" + } + } + }, "Recording": { "type": "object", "required": [ @@ -2771,6 +2985,11 @@ } }, "securitySchemes": { + "CookieSecurityScheme": { + "type": "apiKey", + "name": "warpgate-http-session", + "in": "cookie" + }, "TokenSecurityScheme": { "type": "apiKey", "name": "X-Warpgate-Token", diff --git a/warpgate-web/src/common/AuthBar.svelte b/warpgate-web/src/common/AuthBar.svelte index 18c903ff..7187b5ac 100644 --- a/warpgate-web/src/common/AuthBar.svelte +++ b/warpgate-web/src/common/AuthBar.svelte @@ -20,7 +20,9 @@ async function singleLogout () { {#if $serverInfo?.username}
- {$serverInfo.username} + + {$serverInfo.username} + {#if $serverInfo.authorizedViaTicket} (ticket auth) {/if} diff --git a/warpgate-web/src/common/CopyButton.svelte b/warpgate-web/src/common/CopyButton.svelte index 9603aec4..cf336919 100644 --- a/warpgate-web/src/common/CopyButton.svelte +++ b/warpgate-web/src/common/CopyButton.svelte @@ -5,13 +5,14 @@ import copyTextToClipboard from 'copy-text-to-clipboard' interface Props { - text: string; - disabled?: boolean; - outline?: boolean; - link?: boolean; - color?: Color | 'link'; - class?: string; - children?: import('svelte').Snippet; + text: string + disabled?: boolean + outline?: boolean + link?: boolean + color?: Color | 'link' + class?: string + label?: string + children?: () => any } let { @@ -20,6 +21,7 @@ outline = false, link = false, color = 'link', + label, 'class': className = '', children, }: Props = $props() @@ -74,6 +76,9 @@ {:else} {/if} + {#if label} + {label} + {/if} {/if} {/if} diff --git a/warpgate-web/src/common/ThemeSwitcher.svelte b/warpgate-web/src/common/ThemeSwitcher.svelte index a3c33655..fdca073f 100644 --- a/warpgate-web/src/common/ThemeSwitcher.svelte +++ b/warpgate-web/src/common/ThemeSwitcher.svelte @@ -1,37 +1,38 @@ {#if $currentTheme === 'dark'} - Dark theme + Dark theme {:else if $currentTheme === 'light'} - Light theme + Light theme {:else} - Automatic theme + Automatic theme {/if} diff --git a/warpgate-web/src/common/Tooltip.svelte b/warpgate-web/src/common/Tooltip.svelte new file mode 100644 index 00000000..c59a2ecf --- /dev/null +++ b/warpgate-web/src/common/Tooltip.svelte @@ -0,0 +1,202 @@ + + + +{#if isOpen} +{@const SvelteComponent = outer} + + + +{/if} diff --git a/warpgate-web/src/common/_sveltestrapUtils.ts b/warpgate-web/src/common/_sveltestrapUtils.ts index 7232f937..73f203c2 100644 --- a/warpgate-web/src/common/_sveltestrapUtils.ts +++ b/warpgate-web/src/common/_sveltestrapUtils.ts @@ -23,3 +23,12 @@ export function toClassName(value: any) { // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export const classnames = (...args: any[]) => args.map(toClassName).filter(Boolean).join(' ') + + +export function uuid(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0 + const v = c === 'x' ? r : (r & 0x3) | 0x8 + return v.toString(16) + }) +} diff --git a/warpgate-web/src/gateway/App.svelte b/warpgate-web/src/gateway/App.svelte index 49172ce3..bd9b1500 100644 --- a/warpgate-web/src/gateway/App.svelte +++ b/warpgate-web/src/gateway/App.svelte @@ -42,6 +42,10 @@ const routes = { }, conditions: [requireLogin], }), + '/profile': wrap({ + asyncComponent: () => import('./Profile.svelte') as any, + conditions: [requireLogin], + }), '/login': wrap({ asyncComponent: () => import('./Login.svelte') as any, }), diff --git a/warpgate-web/src/gateway/CredentialManager.svelte b/warpgate-web/src/gateway/CredentialManager.svelte new file mode 100644 index 00000000..77f571bd --- /dev/null +++ b/warpgate-web/src/gateway/CredentialManager.svelte @@ -0,0 +1,243 @@ + + +{#await load()} + +{:then} +{#if creds} +
+

Password

+
+ +
+
+ {#if creds.password === PasswordState.Unset} + Your account has no password set + {/if} + {#if creds.password === PasswordState.Set} + + Password set + {/if} + {#if creds.password === PasswordState.MultipleSet} + + Multiple passwords set + {/if} + + + { + changingPassword = true + e.preventDefault() + }}> + {#if creds.password === PasswordState.Unset} + Set password + {/if} + {#if creds.password === PasswordState.Set} + Change + {/if} + {#if creds.password === PasswordState.MultipleSet} + Reset password + {/if} + +
+
+ + {#if creds.publicKeys.length === 0 && Object.values(creds.credentialPolicy).some(l => l?.includes(CredentialKind.Password))} + + Your credential policy requires using a password for authentication. Without one, you won't be able to log in. + + {/if} + +
+

One-time passwords

+ + +
+ +
+ {#each creds.otp as credential} + + {/each} +
+ + {#if creds.otp.length === 0 && Object.values(creds.credentialPolicy).some(l => l?.includes(CredentialKind.Totp))} + + Your credential policy requires using a one-time password for authentication. Without one, you won't be able to log in. + + {/if} + +
+

Public keys

+ + +
+ +
+ {#each creds.publicKeys as credential} + + {/each} +
+ + {#if creds.publicKeys.length === 0 && creds.credentialPolicy.ssh?.includes(CredentialKind.PublicKey)} + + Your credential policy requires using a public key for authentication. Without one, you won't be able to log in. + + {/if} + + {#if creds.sso.length > 0} +
+

Single sign-on

+
+ +
+ {#each creds.sso as credential} +
+ + + {credential.email} + {#if credential.provider} ({credential.provider}){/if} + +
+ {/each} +
+ {/if} +{/if} +{/await} + +{#if error} +{error} +{/if} + +{#if changingPassword} + +{/if} + +{#if creatingPublicKeyCredential} + +{/if} + +{#if creatingOtpCredential} + +{/if} + + diff --git a/warpgate-web/src/gateway/Login.svelte b/warpgate-web/src/gateway/Login.svelte index 19fc420d..a8acd177 100644 --- a/warpgate-web/src/gateway/Login.svelte +++ b/warpgate-web/src/gateway/Login.svelte @@ -154,6 +154,7 @@ async function startSSO (provider: SsoProviderDescription) { onkeypress={onInputKey} name="otp" autofocus + inputmode="numeric" disabled={busy} class="form-control" /> diff --git a/warpgate-web/src/gateway/Profile.svelte b/warpgate-web/src/gateway/Profile.svelte new file mode 100644 index 00000000..44752b1c --- /dev/null +++ b/warpgate-web/src/gateway/Profile.svelte @@ -0,0 +1,22 @@ + + +
+
+

{$serverInfo!.username}

+
User
+
+
+ +{#if $serverInfo} + {#if $serverInfo.ownCredentialManagementAllowed} + + {:else} + + Credential management is disabled by your administrator + + {/if} +{/if} diff --git a/warpgate-web/src/gateway/lib/openapi-schema.json b/warpgate-web/src/gateway/lib/openapi-schema.json index e8f4839e..b5ac3810 100644 --- a/warpgate-web/src/gateway/lib/openapi-schema.json +++ b/warpgate-web/src/gateway/lib/openapi-schema.json @@ -20,7 +20,7 @@ }, "security": [ { - "TokenSecurityScheme": [] + "AnySecurityScheme": [] } ], "operationId": "__stub__" @@ -403,6 +403,174 @@ }, "operationId": "start_sso" } + }, + "/profile/credentials": { + "get": { + "responses": { + "200": { + "description": "", + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/CredentialsState" + } + } + } + }, + "401": { + "description": "" + } + }, + "operationId": "get_my_credentials" + } + }, + "/profile/credentials/password": { + "post": { + "requestBody": { + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ChangePasswordRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/PasswordState" + } + } + } + }, + "401": { + "description": "" + } + }, + "operationId": "change_my_password" + } + }, + "/profile/credentials/public-keys": { + "post": { + "requestBody": { + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewPublicKeyCredential" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ExistingPublicKeyCredential" + } + } + } + }, + "401": { + "description": "" + } + }, + "operationId": "add_my_public_key" + } + }, + "/profile/credentials/public-keys/{id}": { + "delete": { + "parameters": [ + { + "name": "id", + "schema": { + "type": "string", + "format": "uuid" + }, + "in": "path", + "required": true, + "deprecated": false, + "explode": true + } + ], + "responses": { + "204": { + "description": "" + }, + "401": { + "description": "" + }, + "404": { + "description": "" + } + }, + "operationId": "delete_my_public_key" + } + }, + "/profile/credentials/otp": { + "post": { + "requestBody": { + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewOtpCredential" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json; charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ExistingOtpCredential" + } + } + } + }, + "401": { + "description": "" + } + }, + "operationId": "add_my_otp" + } + }, + "/profile/credentials/otp/{id}": { + "delete": { + "parameters": [ + { + "name": "id", + "schema": { + "type": "string", + "format": "uuid" + }, + "in": "path", + "required": true, + "deprecated": false, + "explode": true + } + ], + "responses": { + "204": { + "description": "" + }, + "401": { + "description": "" + }, + "404": { + "description": "" + } + }, + "operationId": "delete_my_otp" + } } }, "components": { @@ -447,13 +615,118 @@ } } }, + "ChangePasswordRequest": { + "type": "object", + "required": [ + "password" + ], + "properties": { + "password": { + "type": "string" + } + } + }, + "CredentialKind": { + "type": "string", + "enum": [ + "Password", + "PublicKey", + "Totp", + "Sso", + "WebUserApproval" + ] + }, + "CredentialsState": { + "type": "object", + "required": [ + "password", + "otp", + "public_keys", + "sso", + "credential_policy" + ], + "properties": { + "password": { + "$ref": "#/components/schemas/PasswordState" + }, + "otp": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExistingOtpCredential" + } + }, + "public_keys": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExistingPublicKeyCredential" + } + }, + "sso": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExistingSsoCredential" + } + }, + "credential_policy": { + "$ref": "#/components/schemas/UserRequireCredentialsPolicy" + } + } + }, + "ExistingOtpCredential": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + } + }, + "ExistingPublicKeyCredential": { + "type": "object", + "required": [ + "id", + "label" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "label": { + "type": "string" + } + } + }, + "ExistingSsoCredential": { + "type": "object", + "required": [ + "id", + "email" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "provider": { + "type": "string" + }, + "email": { + "type": "string" + } + } + }, "Info": { "type": "object", "required": [ "version", "ports", "authorized_via_ticket", - "authorized_via_sso_with_single_logout" + "authorized_via_sso_with_single_logout", + "own_credential_management_allowed" ], "properties": { "version": { @@ -476,6 +749,9 @@ }, "authorized_via_sso_with_single_logout": { "type": "boolean" + }, + "own_credential_management_allowed": { + "type": "boolean" } } }, @@ -505,6 +781,32 @@ } } }, + "NewOtpCredential": { + "type": "object", + "required": [ + "secret_key" + ], + "properties": { + "secret_key": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8" + } + } + } + }, + "NewPublicKeyCredential": { + "type": "object", + "required": [ + "openssh_public_key" + ], + "properties": { + "openssh_public_key": { + "type": "string" + } + } + }, "OtpLoginRequest": { "type": "object", "required": [ @@ -516,6 +818,14 @@ } } }, + "PasswordState": { + "type": "string", + "enum": [ + "Unset", + "Set", + "MultipleSet" + ] + }, "PortsInfo": { "type": "object", "properties": { @@ -614,10 +924,39 @@ "type": "string" } } + }, + "UserRequireCredentialsPolicy": { + "type": "object", + "properties": { + "http": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CredentialKind" + } + }, + "ssh": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CredentialKind" + } + }, + "mysql": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CredentialKind" + } + }, + "postgres": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CredentialKind" + } + } + } } }, "securitySchemes": { - "TokenSecurityScheme": { + "AnySecurityScheme": { "type": "apiKey", "name": "X-Warpgate-Token", "in": "header" diff --git a/warpgate/Cargo.toml b/warpgate/Cargo.toml index 46cfe83b..6750ca50 100644 --- a/warpgate/Cargo.toml +++ b/warpgate/Cargo.toml @@ -24,7 +24,7 @@ serde_yaml = "0.9" sea-orm = { version = "0.12.2", default-features = false } time = "0.3" tokio = { version = "1.20", features = ["tracing", "signal", "macros"] } -tracing = "0.1" +tracing.workspace = true tracing-subscriber = { version = "0.3", features = [ "env-filter", "local-time",