OIDC login support (#222)

This commit is contained in:
Eugene 2022-08-05 20:04:40 +02:00 committed by GitHub
parent 83442ea350
commit f7bb12e44d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 1676 additions and 336 deletions

View file

@ -39,6 +39,10 @@ replace = version = "{new_version}"
search = version = "{current_version}" search = version = "{current_version}"
replace = version = "{new_version}" replace = version = "{new_version}"
[bumpversion:file:warpgate-sso/Cargo.toml]
search = version = "{current_version}"
replace = version = "{new_version}"
[bumpversion:file:warpgate-web/Cargo.toml] [bumpversion:file:warpgate-web/Cargo.toml]
search = version = "{current_version}" search = version = "{current_version}"
replace = version = "{new_version}" replace = version = "{new_version}"

103
Cargo.lock generated
View file

@ -1434,8 +1434,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9be70c98951c83b8d2f8f60d7065fa6d5146873094452a1008da8c2f1e4205ad" checksum = "9be70c98951c83b8d2f8f60d7065fa6d5146873094452a1008da8c2f1e4205ad"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi 0.10.0+wasi-snapshot-preview1", "wasi 0.10.0+wasi-snapshot-preview1",
"wasm-bindgen",
] ]
[[package]] [[package]]
@ -2320,6 +2322,26 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "oauth2"
version = "4.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d62c436394991641b970a92e23e8eeb4eb9bca74af4f5badc53bcd568daadbd"
dependencies = [
"base64 0.13.0",
"chrono",
"getrandom",
"http",
"rand",
"reqwest",
"serde",
"serde_json",
"serde_path_to_error",
"sha2 0.10.2",
"thiserror",
"url",
]
[[package]] [[package]]
name = "object" name = "object"
version = "0.28.3" version = "0.28.3"
@ -2341,6 +2363,30 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
[[package]]
name = "openidconnect"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e26afc60b2bf11b9a039db1f3a3c0d5fe201eebdbe646a8ecb8342c8240e3271"
dependencies = [
"base64 0.13.0",
"chrono",
"http",
"itertools",
"log",
"num-bigint",
"oauth2",
"rand",
"ring",
"serde",
"serde-value",
"serde_derive",
"serde_json",
"serde_path_to_error",
"thiserror",
"url",
]
[[package]] [[package]]
name = "openssl" name = "openssl"
version = "0.10.41" version = "0.10.41"
@ -2396,6 +2442,15 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "ordered-float"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7940cf2ca942593318d07fcf2596cdca60a85c9e7fab408a5e21a4f9dcd40d87"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "os_str_bytes" name = "os_str_bytes"
version = "6.0.0" version = "6.0.0"
@ -3058,6 +3113,7 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"web-sys", "web-sys",
"webpki-roots",
"winreg", "winreg",
] ]
@ -3526,6 +3582,16 @@ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "serde-value"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
dependencies = [
"ordered-float",
"serde",
]
[[package]] [[package]]
name = "serde_derive" name = "serde_derive"
version = "1.0.137" version = "1.0.137"
@ -3548,6 +3614,15 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "serde_path_to_error"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7868ad3b8196a8a0aea99a8220b124278ee5320a55e4fde97794b6f85b1a377"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@ -4393,6 +4468,7 @@ dependencies = [
"idna", "idna",
"matches", "matches",
"percent-encoding", "percent-encoding",
"serde",
] ]
[[package]] [[package]]
@ -4569,6 +4645,7 @@ dependencies = [
"uuid", "uuid",
"warpgate-db-entities", "warpgate-db-entities",
"warpgate-db-migrations", "warpgate-db-migrations",
"warpgate-sso",
"webpki", "webpki",
] ]
@ -4634,6 +4711,7 @@ dependencies = [
"warpgate-admin", "warpgate-admin",
"warpgate-common", "warpgate-common",
"warpgate-db-entities", "warpgate-db-entities",
"warpgate-sso",
"warpgate-web", "warpgate-web",
] ]
@ -4687,6 +4765,22 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "warpgate-sso"
version = "0.4.0"
dependencies = [
"async-trait",
"bytes 1.2.1",
"once_cell",
"openidconnect",
"serde",
"serde_json",
"thiserror",
"tokio",
"tracing",
"uuid",
]
[[package]] [[package]]
name = "warpgate-web" name = "warpgate-web"
version = "0.4.0" version = "0.4.0"
@ -4795,6 +4889,15 @@ dependencies = [
"untrusted", "untrusted",
] ]
[[package]]
name = "webpki-roots"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1c760f0d366a6c24a02ed7816e23e691f5d92291f94d15e836006fd11b04daf"
dependencies = [
"webpki",
]
[[package]] [[package]]
name = "wepoll-ffi" name = "wepoll-ffi"
version = "0.1.2" version = "0.1.2"

View file

@ -9,6 +9,7 @@ members = [
"warpgate-protocol-http", "warpgate-protocol-http",
"warpgate-protocol-mysql", "warpgate-protocol-mysql",
"warpgate-protocol-ssh", "warpgate-protocol-ssh",
"warpgate-sso",
"warpgate-web", "warpgate-web",
] ]
default-members = ["warpgate"] default-members = ["warpgate"]

View file

@ -13,7 +13,7 @@ Warpgate is a smart SSH, HTTPS and MySQL bastion host for Linux that doesn't nee
* Set it up in your DMZ, add user accounts and easily assign them to specific hosts and URLs within the network. * Set it up in your DMZ, add user accounts and easily assign them to specific hosts and URLs within the network.
* Warpgate will record every session for you to view (live) and replay later through a built-in admin web UI. * Warpgate will record every session for you to view (live) and replay later through a built-in admin web UI.
* Not a jump host - forwards your connections straight to the target instead. * Not a jump host - forwards your connections straight to the target instead.
* 2FA support * Native 2FA and SSO support
* Single binary with no dependencies. * Single binary with no dependencies.
* Written in 100% safe Rust. * Written in 100% safe Rust.

View file

@ -1,4 +1,4 @@
projects := "warpgate warpgate-admin warpgate-common warpgate-db-entities warpgate-db-migrations warpgate-database-protocols warpgate-protocol-ssh warpgate-protocol-mysql" projects := "warpgate warpgate-admin warpgate-common warpgate-db-entities warpgate-db-migrations warpgate-database-protocols warpgate-protocol-ssh warpgate-protocol-mysql warpgate-sso"
run *ARGS: run *ARGS:
RUST_BACKTRACE=1 RUST_LOG=warpgate cd warpgate && cargo run -- --config ../config.yaml {{ARGS}} RUST_BACKTRACE=1 RUST_LOG=warpgate cd warpgate && cargo run -- --config ../config.yaml {{ARGS}}

View file

@ -43,6 +43,7 @@ url = "2.2"
uuid = { version = "1.0", features = ["v4", "serde"] } uuid = { version = "1.0", features = ["v4", "serde"] }
warpgate-db-entities = { version = "*", path = "../warpgate-db-entities" } warpgate-db-entities = { version = "*", path = "../warpgate-db-entities" }
warpgate-db-migrations = { version = "*", path = "../warpgate-db-migrations" } warpgate-db-migrations = { version = "*", path = "../warpgate-db-migrations" }
warpgate-sso = { version = "*", path = "../warpgate-sso" }
rustls = { version = "0.20", features = ["dangerous_configuration"] } rustls = { version = "0.20", features = ["dangerous_configuration"] }
rustls-pemfile = "1.0" rustls-pemfile = "1.0"
webpki = "0.22" webpki = "0.22"

View file

@ -0,0 +1,41 @@
use bytes::Bytes;
use serde::{Deserialize, Serialize};
use crate::Secret;
#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CredentialKind {
#[serde(rename = "password")]
Password,
#[serde(rename = "publickey")]
PublicKey,
#[serde(rename = "otp")]
Otp,
#[serde(rename = "sso")]
Sso,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthCredential {
Otp(Secret<String>),
Password(Secret<String>),
PublicKey {
kind: String,
public_key_bytes: Bytes,
},
Sso {
provider: String,
email: String,
},
}
impl AuthCredential {
pub fn kind(&self) -> CredentialKind {
match self {
Self::Password { .. } => CredentialKind::Password,
Self::PublicKey { .. } => CredentialKind::PublicKey,
Self::Otp { .. } => CredentialKind::Otp,
Self::Sso { .. } => CredentialKind::Sso,
}
}
}

View file

@ -0,0 +1,10 @@
mod cred;
mod policy;
mod selector;
mod state;
mod store;
pub use cred::*;
pub use policy::*;
pub use selector::*;
pub use state::*;
pub use store::*;

View file

@ -0,0 +1,46 @@
use std::collections::HashSet;
use super::{AuthCredential, CredentialKind};
use crate::UserRequireCredentialsPolicy;
pub enum CredentialPolicyResponse {
Ok,
Need(CredentialKind),
}
pub trait CredentialPolicy {
fn is_sufficient(
&self,
protocol: &str,
valid_credentials: &[AuthCredential],
) -> CredentialPolicyResponse;
}
impl CredentialPolicy for UserRequireCredentialsPolicy {
fn is_sufficient(
&self,
protocol: &str,
valid_credentials: &[AuthCredential],
) -> CredentialPolicyResponse {
let required_kinds = match protocol {
"SSH" => &self.ssh,
"HTTP" => &self.http,
"MySQL" => &self.mysql,
_ => unreachable!(),
};
if let Some(required_kinds) = required_kinds {
let mut remaining_required_kinds = HashSet::<CredentialKind>::new();
remaining_required_kinds.extend(required_kinds);
for kind in required_kinds {
if valid_credentials.iter().any(|x| x.kind() == *kind) {
remaining_required_kinds.remove(kind);
}
}
if let Some(kind) = remaining_required_kinds.into_iter().next() {
return CredentialPolicyResponse::Need(kind);
}
}
CredentialPolicyResponse::Ok
}
}

View file

@ -0,0 +1,68 @@
use std::time::{Duration, Instant};
use once_cell::sync::Lazy;
use tracing::*;
use super::{AuthCredential, CredentialPolicy, CredentialPolicyResponse};
use crate::AuthResult;
#[allow(clippy::unwrap_used)]
pub static TIMEOUT: Lazy<Duration> = Lazy::new(|| Duration::from_secs(60 * 10));
pub struct AuthState {
username: String,
protocol: String,
policy: Option<Box<dyn CredentialPolicy + Sync + Send>>,
valid_credentials: Vec<AuthCredential>,
started_at: Instant,
}
impl AuthState {
pub fn new(
username: String,
protocol: String,
policy: Option<Box<dyn CredentialPolicy + Sync + Send>>,
) -> Self {
Self {
username,
protocol,
policy,
valid_credentials: vec![],
started_at: Instant::now(),
}
}
pub fn username(&self) -> &str {
&self.username
}
pub fn add_valid_credential(&mut self, credential: AuthCredential) {
self.valid_credentials.push(credential);
}
pub fn is_expired(&self) -> bool {
self.started_at.elapsed() > *TIMEOUT
}
pub fn verify(&self) -> AuthResult {
if self.valid_credentials.is_empty() {
warn!(
username=%self.username,
"No matching valid credentials"
);
return AuthResult::Rejected;
}
if let Some(ref policy) = self.policy {
match policy.is_sufficient(&self.protocol, &self.valid_credentials[..]) {
CredentialPolicyResponse::Ok => {}
CredentialPolicyResponse::Need(kind) => {
return AuthResult::Need(kind);
}
}
}
AuthResult::Accepted {
username: self.username.clone(),
}
}
}

View file

@ -0,0 +1,63 @@
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
use uuid::Uuid;
use super::AuthState;
use crate::{ConfigProvider, WarpgateError};
pub struct AuthStateStore {
config_provider: Arc<Mutex<dyn ConfigProvider + Send + 'static>>,
store: HashMap<Uuid, AuthState>,
}
impl AuthStateStore {
pub fn new(config_provider: Arc<Mutex<dyn ConfigProvider + Send + 'static>>) -> Self {
Self {
store: HashMap::new(),
config_provider,
}
}
pub fn contains_key(&mut self, id: &Uuid) -> bool {
self.store.contains_key(id)
}
pub fn get_mut(&mut self, id: &Uuid) -> Option<&mut AuthState> {
self.store.get_mut(id)
}
pub async fn create(
&mut self,
username: &str,
protocol: &str,
) -> Result<(Uuid, &mut AuthState), WarpgateError> {
let id = Uuid::new_v4();
let state = AuthState::new(
username.to_string(),
protocol.to_string(),
self.config_provider
.lock()
.await
.get_credential_policy(username)
.await?,
);
self.store.insert(id, state);
#[allow(clippy::unwrap_used)]
Ok((id, self.store.get_mut(&id).unwrap()))
}
pub async fn vacuum(&mut self) {
let mut to_remove = vec![];
for (id, state) in self.store.iter() {
if state.is_expired() {
to_remove.push(*id);
}
}
for id in to_remove {
self.store.remove(&id);
}
}
}

View file

@ -5,7 +5,9 @@ use std::time::Duration;
use poem_openapi::{Enum, Object, Union}; use poem_openapi::{Enum, Object, Union};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use warpgate_sso::SsoProviderConfig;
use crate::auth::CredentialKind;
use crate::helpers::otp::OtpSecretKey; use crate::helpers::otp::OtpSecretKey;
use crate::{ListenEndpoint, Secret}; use crate::{ListenEndpoint, Secret};
@ -194,16 +196,32 @@ pub enum UserAuthCredential {
#[serde(with = "crate::helpers::serde_base64_secret")] #[serde(with = "crate::helpers::serde_base64_secret")]
key: OtpSecretKey, key: OtpSecretKey,
}, },
#[serde(rename = "sso")]
Sso {
provider: Option<String>,
email: String,
},
}
impl UserAuthCredential {
pub fn kind(&self) -> CredentialKind {
match self {
Self::Password { .. } => CredentialKind::Password,
Self::PublicKey { .. } => CredentialKind::PublicKey,
Self::Totp { .. } => CredentialKind::Otp,
Self::Sso { .. } => CredentialKind::Sso,
}
}
} }
#[derive(Debug, Deserialize, Serialize, Clone)] #[derive(Debug, Deserialize, Serialize, Clone)]
pub struct UserRequireCredentialsPolicy { pub struct UserRequireCredentialsPolicy {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub http: Option<Vec<String>>, pub http: Option<Vec<CredentialKind>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub ssh: Option<Vec<String>>, pub ssh: Option<Vec<CredentialKind>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub mysql: Option<Vec<String>>, pub mysql: Option<Vec<CredentialKind>>,
} }
#[derive(Debug, Deserialize, Serialize, Clone)] #[derive(Debug, Deserialize, Serialize, Clone)]
@ -359,6 +377,7 @@ pub struct WarpgateConfigStore {
pub targets: Vec<Target>, pub targets: Vec<Target>,
pub users: Vec<User>, pub users: Vec<User>,
pub roles: Vec<Role>, pub roles: Vec<Role>,
pub sso_providers: Vec<SsoProviderConfig>,
#[serde(default)] #[serde(default)]
pub recordings: RecordingsConfig, pub recordings: RecordingsConfig,
@ -388,6 +407,7 @@ impl Default for WarpgateConfigStore {
targets: vec![], targets: vec![],
users: vec![], users: vec![],
roles: vec![], roles: vec![],
sso_providers: vec![],
recordings: RecordingsConfig::default(), recordings: RecordingsConfig::default(),
external_host: None, external_host: None,
database_url: _default_database_url(), database_url: _default_database_url(),

View file

@ -11,12 +11,10 @@ use uuid::Uuid;
use warpgate_db_entities::Ticket; use warpgate_db_entities::Ticket;
use super::ConfigProvider; use super::ConfigProvider;
use crate::auth::{AuthCredential, CredentialPolicy};
use crate::helpers::hash::verify_password_hash; use crate::helpers::hash::verify_password_hash;
use crate::helpers::otp::verify_totp; use crate::helpers::otp::verify_totp;
use crate::{ use crate::{Target, User, UserAuthCredential, UserSnapshot, WarpgateConfig, WarpgateError};
AuthCredential, AuthResult, ProtocolName, Target, User, UserAuthCredential, UserSnapshot,
WarpgateConfig, WarpgateError,
};
pub struct FileConfigProvider { pub struct FileConfigProvider {
db: Arc<Mutex<DatabaseConnection>>, db: Arc<Mutex<DatabaseConnection>>,
@ -35,14 +33,6 @@ impl FileConfigProvider {
} }
} }
fn credential_is_type(c: &UserAuthCredential, k: &str) -> bool {
match c {
UserAuthCredential::Password { .. } => k == "password",
UserAuthCredential::PublicKey { .. } => k == "publickey",
UserAuthCredential::Totp { .. } => k == "otp",
}
}
#[async_trait] #[async_trait]
impl ConfigProvider for FileConfigProvider { impl ConfigProvider for FileConfigProvider {
async fn list_users(&mut self) -> Result<Vec<UserSnapshot>, WarpgateError> { async fn list_users(&mut self) -> Result<Vec<UserSnapshot>, WarpgateError> {
@ -69,16 +59,10 @@ impl ConfigProvider for FileConfigProvider {
.collect::<Vec<_>>()) .collect::<Vec<_>>())
} }
async fn authorize( async fn get_credential_policy(
&mut self, &mut self,
username: &str, username: &str,
credentials: &[AuthCredential], ) -> Result<Option<Box<dyn CredentialPolicy + Sync + Send>>, WarpgateError> {
protocol: ProtocolName,
) -> Result<AuthResult, WarpgateError> {
if credentials.is_empty() {
return Ok(AuthResult::Rejected);
}
let user = { let user = {
self.config self.config
.lock() .lock()
@ -91,115 +75,125 @@ impl ConfigProvider for FileConfigProvider {
}; };
let Some(user) = user else { let Some(user) = user else {
error!("Selected user not found: {}", username); error!("Selected user not found: {}", username);
return Ok(AuthResult::Rejected); return Ok(None);
}; };
let mut valid_credentials = vec![]; Ok(user
.require
.map(|r| Box::new(r) as Box<dyn CredentialPolicy + Sync + Send>))
}
for client_credential in credentials { async fn username_for_sso_credential(
match client_credential { &mut self,
AuthCredential::PublicKey { client_credential: &AuthCredential,
kind, ) -> Result<Option<String>, WarpgateError> {
public_key_bytes, let AuthCredential::Sso { provider: client_provider, email : client_email} = client_credential else {
} => { return Ok(None);
let mut base64_bytes = BASE64.encode(public_key_bytes); };
let client_key = format!("{} {}", kind, base64_bytes); Ok(self
debug!(username = &user.username[..], "Client key: {}", client_key); .config
.lock()
.await
.store
.users
.iter()
.find(|x| {
for cred in x.credentials.iter() {
if let UserAuthCredential::Sso { provider, email } = cred {
if provider.as_ref().unwrap_or(client_provider) == client_provider
&& email == client_email
{
return true;
}
}
}
false
})
.map(|x| x.username.clone()))
}
if let Some(credential) = async fn validate_credential(
user.credentials.iter().find(|credential| match credential { &mut self,
UserAuthCredential::PublicKey { key: ref user_key } => { username: &str,
&client_key == user_key.expose_secret() client_credential: &AuthCredential,
} ) -> Result<bool, WarpgateError> {
_ => false, let user = {
}) self.config
.lock()
.await
.store
.users
.iter()
.find(|x| x.username == username)
.map(User::to_owned)
};
let Some(user) = user else {
error!("Selected user not found: {}", username);
return Ok(false);
};
match client_credential {
AuthCredential::PublicKey {
kind,
public_key_bytes,
} => {
let base64_bytes = BASE64.encode(public_key_bytes);
let client_key = format!("{} {}", kind, base64_bytes);
debug!(username = &user.username[..], "Client key: {}", client_key);
return Ok(user.credentials.iter().any(|credential| match credential {
UserAuthCredential::PublicKey { key: ref user_key } => {
&client_key == user_key.expose_secret()
}
_ => false,
}));
}
AuthCredential::Password(client_password) => {
return Ok(user.credentials.iter().any(|credential| match credential {
UserAuthCredential::Password {
hash: ref user_password_hash,
} => verify_password_hash(
client_password.expose_secret(),
user_password_hash.expose_secret(),
)
.unwrap_or_else(|e| {
error!(
username = &user.username[..],
"Error verifying password hash: {}", e
);
false
}),
_ => false,
}))
}
AuthCredential::Otp(client_otp) => {
return Ok(user.credentials.iter().any(|credential| match credential {
UserAuthCredential::Totp {
key: ref user_otp_key,
} => verify_totp(client_otp.expose_secret(), user_otp_key),
_ => false,
}))
}
AuthCredential::Sso {
provider: client_provider,
email: client_email,
} => {
for credential in user.credentials.iter() {
if let UserAuthCredential::Sso {
ref provider,
ref email,
} = credential
{ {
valid_credentials.push(credential) if provider.as_ref().unwrap_or(client_provider) == client_provider {
} return Ok(email == client_email);
} }
AuthCredential::Password(client_password) => {
match user.credentials.iter().find(|credential| match credential {
UserAuthCredential::Password {
hash: ref user_password_hash,
} => verify_password_hash(
client_password.expose_secret(),
user_password_hash.expose_secret(),
)
.unwrap_or_else(|e| {
error!(
username = &user.username[..],
"Error verifying password hash: {}", e
);
false
}),
_ => false,
}) {
Some(credential) => valid_credentials.push(credential),
None => return Ok(AuthResult::Rejected),
}
}
AuthCredential::Otp(client_otp) => {
match user.credentials.iter().find(|credential| match credential {
UserAuthCredential::Totp {
key: ref user_otp_key,
} => verify_totp(client_otp.expose_secret(), user_otp_key),
_ => false,
}) {
Some(credential) => valid_credentials.push(credential),
None => return Ok(AuthResult::Rejected),
} }
} }
return Ok(false);
} }
} }
if valid_credentials.is_empty() {
warn!(
username = &user.username[..],
"Client credentials did not match"
);
}
if let Some(ref policy) = user.require {
let required_kinds = match protocol {
"SSH" => &policy.ssh,
"HTTP" => &policy.http,
"MySQL" => &policy.mysql,
_ => {
error!(%protocol, "Unkown protocol");
return Ok(AuthResult::Rejected);
}
};
if let Some(required_kinds) = required_kinds {
let mut remaining_required_kinds = HashSet::new();
remaining_required_kinds.extend(required_kinds);
for kind in required_kinds {
if valid_credentials
.iter()
.any(|x| credential_is_type(x, kind))
{
remaining_required_kinds.remove(kind);
}
}
if remaining_required_kinds.is_empty() {
return Ok(AuthResult::Accepted {
username: user.username.clone(),
});
} else if remaining_required_kinds.contains(&"otp".to_string()) {
return Ok(AuthResult::OtpNeeded);
} else {
return Ok(AuthResult::Rejected);
}
}
}
Ok(if !valid_credentials.is_empty() {
AuthResult::Accepted {
username: user.username.clone(),
}
} else {
AuthResult::Rejected
})
} }
async fn authorize_target( async fn authorize_target(

View file

@ -2,7 +2,6 @@ mod file;
use std::sync::Arc; use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use bytes::Bytes;
pub use file::FileConfigProvider; pub use file::FileConfigProvider;
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter}; use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
use tokio::sync::Mutex; use tokio::sync::Mutex;
@ -10,35 +9,37 @@ use tracing::*;
use uuid::Uuid; use uuid::Uuid;
use warpgate_db_entities::Ticket; use warpgate_db_entities::Ticket;
use crate::{ProtocolName, Secret, Target, UserSnapshot, WarpgateError}; use crate::auth::{AuthCredential, CredentialKind, CredentialPolicy};
use crate::{Secret, Target, UserSnapshot, WarpgateError};
#[derive(Debug)]
pub enum AuthResult { pub enum AuthResult {
Accepted { username: String }, Accepted { username: String },
OtpNeeded, Need(CredentialKind),
Rejected, Rejected,
} }
pub enum AuthCredential {
Otp(Secret<String>),
Password(Secret<String>),
PublicKey {
kind: String,
public_key_bytes: Bytes,
},
}
#[async_trait] #[async_trait]
pub trait ConfigProvider { pub trait ConfigProvider {
async fn list_users(&mut self) -> Result<Vec<UserSnapshot>, WarpgateError>; async fn list_users(&mut self) -> Result<Vec<UserSnapshot>, WarpgateError>;
async fn list_targets(&mut self) -> Result<Vec<Target>, WarpgateError>; async fn list_targets(&mut self) -> Result<Vec<Target>, WarpgateError>;
async fn authorize( async fn validate_credential(
&mut self, &mut self,
username: &str, username: &str,
credentials: &[AuthCredential], client_credential: &AuthCredential,
protocol: ProtocolName, ) -> Result<bool, WarpgateError>;
) -> Result<AuthResult, WarpgateError>;
async fn username_for_sso_credential(
&mut self,
client_credential: &AuthCredential,
) -> Result<Option<String>, WarpgateError>;
async fn get_credential_policy(
&mut self,
username: &str,
) -> Result<Option<Box<dyn CredentialPolicy + Sync + Send>>, WarpgateError>;
async fn authorize_target( async fn authorize_target(
&mut self, &mut self,

View file

@ -1,9 +1,11 @@
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use anyhow::Result; use anyhow::Result;
use sea_orm::DatabaseConnection; use sea_orm::DatabaseConnection;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::auth::AuthStateStore;
use crate::db::{connect_to_db, sanitize_db}; use crate::db::{connect_to_db, sanitize_db};
use crate::recordings::SessionRecordings; use crate::recordings::SessionRecordings;
use crate::{ConfigProvider, FileConfigProvider, State, WarpgateConfig}; use crate::{ConfigProvider, FileConfigProvider, State, WarpgateConfig};
@ -15,6 +17,7 @@ pub struct Services {
pub config: Arc<Mutex<WarpgateConfig>>, pub config: Arc<Mutex<WarpgateConfig>>,
pub state: Arc<Mutex<State>>, pub state: Arc<Mutex<State>>,
pub config_provider: Arc<Mutex<dyn ConfigProvider + Send + 'static>>, pub config_provider: Arc<Mutex<dyn ConfigProvider + Send + 'static>>,
pub auth_state_store: Arc<Mutex<AuthStateStore>>,
} }
impl Services { impl Services {
@ -29,12 +32,25 @@ impl Services {
let config = Arc::new(Mutex::new(config)); let config = Arc::new(Mutex::new(config));
let config_provider = Arc::new(Mutex::new(FileConfigProvider::new(&db, &config).await)); let config_provider = Arc::new(Mutex::new(FileConfigProvider::new(&db, &config).await));
let auth_state_store = Arc::new(Mutex::new(AuthStateStore::new(config_provider.clone())));
tokio::spawn({
let auth_state_store = auth_state_store.clone();
async move {
loop {
auth_state_store.lock().await.vacuum().await;
tokio::time::sleep(Duration::from_secs(60)).await;
}
}
});
Ok(Self { Ok(Self {
db: db.clone(), db: db.clone(),
recordings, recordings,
config: config.clone(), config: config.clone(),
state: State::new(&db), state: State::new(&db),
config_provider, config_provider,
auth_state_store,
}) })
} }
} }

View file

@ -25,6 +25,7 @@ warpgate-admin = {version = "*", path = "../warpgate-admin"}
warpgate-common = {version = "*", path = "../warpgate-common"} warpgate-common = {version = "*", path = "../warpgate-common"}
warpgate-db-entities = {version = "*", path = "../warpgate-db-entities"} warpgate-db-entities = {version = "*", path = "../warpgate-db-entities"}
warpgate-web = {version = "*", path = "../warpgate-web"} warpgate-web = {version = "*", path = "../warpgate-web"}
warpgate-sso = {version = "*", path = "../warpgate-sso"}
percent-encoding = "2.1" percent-encoding = "2.1"
uuid = {version = "1.0", features = ["v4"]} uuid = {version = "1.0", features = ["v4"]}
regex = "1.6" regex = "1.6"

View file

@ -1,15 +1,17 @@
use crate::common::{SessionExt, SessionAuthorization}; use std::sync::Arc;
use crate::session::SessionStore;
use anyhow::Context;
use poem::session::Session; use poem::session::Session;
use poem::web::Data; use poem::web::Data;
use poem::Request; use poem::Request;
use poem_openapi::payload::Json; use poem_openapi::payload::Json;
use poem_openapi::{ApiResponse, Enum, Object, OpenApi}; use poem_openapi::{ApiResponse, Enum, Object, OpenApi};
use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tracing::*; use tracing::*;
use warpgate_common::{AuthCredential, AuthResult, Secret, Services}; use warpgate_common::auth::{AuthCredential, CredentialKind};
use warpgate_common::{AuthResult, Secret, Services};
use crate::common::{authorize_session, get_auth_state_for_request, SessionExt};
use crate::session::SessionStore;
pub struct Api; pub struct Api;
@ -17,18 +19,26 @@ pub struct Api;
struct LoginRequest { struct LoginRequest {
username: String, username: String,
password: String, password: String,
otp: Option<String>, }
#[derive(Object)]
struct OtpLoginRequest {
otp: String,
} }
#[derive(Enum)] #[derive(Enum)]
enum LoginFailureReason { enum ApiAuthState {
InvalidCredentials, NotStarted,
Failed,
PasswordNeeded,
OtpNeeded, OtpNeeded,
SsoNeeded,
Success,
} }
#[derive(Object)] #[derive(Object)]
struct LoginFailureResponse { struct LoginFailureResponse {
reason: LoginFailureReason, state: ApiAuthState,
} }
#[derive(ApiResponse)] #[derive(ApiResponse)]
@ -46,6 +56,30 @@ enum LogoutResponse {
Success, Success,
} }
#[derive(Object)]
struct AuthStateResponseInternal {
pub state: ApiAuthState,
}
#[derive(ApiResponse)]
enum AuthStateResponse {
#[oai(status = 200)]
Ok(Json<AuthStateResponseInternal>),
}
impl From<AuthResult> for ApiAuthState {
fn from(state: AuthResult) -> Self {
match state {
AuthResult::Rejected => ApiAuthState::Failed,
AuthResult::Need(CredentialKind::Password) => ApiAuthState::PasswordNeeded,
AuthResult::Need(CredentialKind::Otp) => ApiAuthState::OtpNeeded,
AuthResult::Need(CredentialKind::Sso) => ApiAuthState::SsoNeeded,
AuthResult::Need(CredentialKind::PublicKey) => ApiAuthState::Failed,
AuthResult::Accepted { .. } => ApiAuthState::Success,
}
}
}
#[OpenApi] #[OpenApi]
impl Api { impl Api {
#[oai(path = "/auth/login", method = "post", operation_id = "login")] #[oai(path = "/auth/login", method = "post", operation_id = "login")]
@ -54,51 +88,93 @@ impl Api {
req: &Request, req: &Request,
session: &Session, session: &Session,
services: Data<&Services>, services: Data<&Services>,
session_middleware: Data<&Arc<Mutex<SessionStore>>>,
body: Json<LoginRequest>, body: Json<LoginRequest>,
) -> poem::Result<LoginResponse> { ) -> poem::Result<LoginResponse> {
let mut credentials = vec![AuthCredential::Password(Secret::new(body.password.clone()))]; let mut auth_state_store = services.auth_state_store.lock().await;
if let Some(ref otp) = body.otp { let state =
credentials.push(AuthCredential::Otp(otp.clone().into())); get_auth_state_for_request(&body.username, session, &mut auth_state_store).await?;
let mut cp = services.config_provider.lock().await;
let password_cred = AuthCredential::Password(Secret::new(body.password.clone()));
if cp
.validate_credential(&body.username, &password_cred)
.await?
{
state.add_valid_credential(password_cred);
} }
let result = { match state.verify() {
let mut config_provider = services.config_provider.lock().await;
config_provider
.authorize(&body.username, &credentials, crate::common::PROTOCOL_NAME)
.await
.context("Failed to authorize user")?
};
match result {
AuthResult::Accepted { username } => { AuthResult::Accepted { username } => {
let server_handle = session_middleware authorize_session(req, username).await?;
.lock()
.await
.create_handle_for(&req)
.await?;
server_handle
.lock()
.await
.set_username(username.clone())
.await?;
info!(%username, "Authenticated");
session.set_auth(SessionAuthorization::User(username));
Ok(LoginResponse::Success) Ok(LoginResponse::Success)
} }
x => { x => {
error!("Auth rejected"); error!("Auth rejected");
Ok(LoginResponse::Failure(Json(LoginFailureResponse { Ok(LoginResponse::Failure(Json(LoginFailureResponse {
reason: match x { state: x.into(),
AuthResult::Accepted { .. } => unreachable!(),
AuthResult::Rejected => LoginFailureReason::InvalidCredentials,
AuthResult::OtpNeeded => LoginFailureReason::OtpNeeded,
},
}))) })))
} }
} }
} }
#[oai(path = "/auth/otp", method = "post", operation_id = "otpLogin")]
async fn api_auth_otp_login(
&self,
req: &Request,
session: &Session,
services: Data<&Services>,
body: Json<OtpLoginRequest>,
) -> poem::Result<LoginResponse> {
let state_id = session.get_auth_state_id();
let mut auth_state_store = services.auth_state_store.lock().await;
let Some(state) = state_id.and_then(|id| auth_state_store.get_mut(&id.0)) else {
return Ok(LoginResponse::Failure(Json(LoginFailureResponse {
state: ApiAuthState::NotStarted,
})))
};
let mut cp = services.config_provider.lock().await;
let otp_cred = AuthCredential::Otp(body.otp.clone().into());
if cp.validate_credential(state.username(), &otp_cred).await? {
state.add_valid_credential(otp_cred);
}
match state.verify() {
AuthResult::Accepted { username } => {
authorize_session(req, username).await?;
Ok(LoginResponse::Success)
}
x => Ok(LoginResponse::Failure(Json(LoginFailureResponse {
state: x.into(),
}))),
}
}
#[oai(path = "/auth/state", method = "get", operation_id = "getAuthState")]
async fn api_auth_state(
&self,
session: &Session,
services: Data<&Services>,
) -> poem::Result<AuthStateResponse> {
let state_id = session.get_auth_state_id();
let mut auth_state_store = services.auth_state_store.lock().await;
let Some(state) = state_id.and_then(|id| auth_state_store.get_mut(&id.0)) else {
return Ok(AuthStateResponse::Ok(Json(AuthStateResponseInternal {
state: ApiAuthState::NotStarted,
})));
};
Ok(AuthStateResponse::Ok(Json(AuthStateResponseInternal {
state: state.verify().into(),
})))
}
#[oai(path = "/auth/logout", method = "post", operation_id = "logout")] #[oai(path = "/auth/logout", method = "post", operation_id = "logout")]
async fn api_auth_logout( async fn api_auth_logout(
&self, &self,

View file

@ -2,8 +2,16 @@ use poem_openapi::OpenApi;
pub mod auth; pub mod auth;
pub mod info; pub mod info;
pub mod sso_provider_detail;
pub mod sso_provider_list;
pub mod targets_list; pub mod targets_list;
pub fn get() -> impl OpenApi { pub fn get() -> impl OpenApi {
(auth::Api, info::Api, targets_list::Api) (
auth::Api,
info::Api,
targets_list::Api,
sso_provider_list::Api,
sso_provider_detail::Api,
)
} }

View file

@ -0,0 +1,94 @@
use poem::session::Session;
use poem::web::Data;
use poem::Request;
use poem_openapi::param::Path;
use poem_openapi::payload::Json;
use poem_openapi::{ApiResponse, Object, OpenApi};
use reqwest::Url;
use serde::{Deserialize, Serialize};
use warpgate_common::Services;
use warpgate_sso::{SsoClient, SsoLoginRequest};
pub struct Api;
#[derive(Object)]
struct StartSsoResponseParams {
url: String,
}
#[allow(clippy::large_enum_variant)]
#[derive(ApiResponse)]
enum StartSsoResponse {
#[oai(status = 200)]
Ok(Json<StartSsoResponseParams>),
#[oai(status = 404)]
NotFound,
}
pub static SSO_CONTEXT_SESSION_KEY: &str = "sso_request";
#[derive(Debug, Serialize, Deserialize)]
pub struct SsoContext {
pub provider: String,
pub request: SsoLoginRequest,
}
#[OpenApi]
impl Api {
#[oai(
path = "/sso/providers/:name/start",
method = "get",
operation_id = "start_sso"
)]
async fn api_start_sso(
&self,
req: &Request,
session: &Session,
services: Data<&Services>,
name: Path<String>,
) -> poem::Result<StartSsoResponse> {
let config = services.config.lock().await;
let name = name.0;
let ext_host = config
.store
.external_host
.as_deref()
.or_else(|| req.original_uri().host());
let Some(ext_host) = ext_host else {
return Err(poem::Error::from_string("external_host config option is required for SSO", http::status::StatusCode::INTERNAL_SERVER_ERROR));
};
let ext_port = config.store.http.listen.port();
let mut return_url = Url::parse(&format!("https://{ext_host}/@warpgate/api/sso/return"))
.map_err(|e| {
poem::Error::from_string(
format!("failed to construct the return URL: {e}"),
http::status::StatusCode::INTERNAL_SERVER_ERROR,
)
})?;
if ext_port != 443 {
let _ = return_url.set_port(Some(ext_port));
}
let Some(provider_config) = config.store.sso_providers.iter().find(|p| p.name == *name) else {
return Ok(StartSsoResponse::NotFound);
};
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 url = sso_req.auth_url().to_string();
session.set(SSO_CONTEXT_SESSION_KEY, SsoContext {
provider: name,
request: sso_req,
});
Ok(StartSsoResponse::Ok(Json(StartSsoResponseParams { url })))
}
}

View file

@ -0,0 +1,140 @@
use poem::session::Session;
use poem::web::Data;
use poem::Request;
use poem_openapi::param::Query;
use poem_openapi::payload::{Json, Response};
use poem_openapi::{ApiResponse, Enum, Object, OpenApi};
use tracing::*;
use warpgate_common::auth::AuthCredential;
use warpgate_common::{AuthResult, Services};
use warpgate_sso::SsoInternalProviderConfig;
use super::sso_provider_detail::{SsoContext, SSO_CONTEXT_SESSION_KEY};
use crate::common::{authorize_session, get_auth_state_for_request};
pub struct Api;
#[derive(Enum)]
pub enum SsoProviderKind {
Google,
Apple,
Azure,
Custom,
}
#[derive(Object)]
pub struct SsoProviderDescription {
pub name: String,
pub label: String,
pub kind: SsoProviderKind,
}
#[derive(ApiResponse)]
enum GetSsoProvidersResponse {
#[oai(status = 200)]
Ok(Json<Vec<SsoProviderDescription>>),
}
#[allow(clippy::large_enum_variant)]
#[derive(ApiResponse)]
enum ReturnToSsoResponse {
#[oai(status = 307)]
Ok,
}
#[OpenApi]
impl Api {
#[oai(
path = "/sso/providers",
method = "get",
operation_id = "get_sso_providers"
)]
async fn api_get_all_sso_providers(
&self,
services: Data<&Services>,
) -> poem::Result<GetSsoProvidersResponse> {
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(
providers
.into_iter()
.map(|p| SsoProviderDescription {
name: p.name.clone(),
label: p.label().to_string(),
kind: match p.provider {
SsoInternalProviderConfig::Google { .. } => SsoProviderKind::Google,
SsoInternalProviderConfig::Apple { .. } => SsoProviderKind::Apple,
SsoInternalProviderConfig::Azure { .. } => SsoProviderKind::Azure,
SsoInternalProviderConfig::Custom { .. } => SsoProviderKind::Custom,
},
})
.collect(),
)))
}
#[oai(path = "/sso/return", method = "get", operation_id = "return_to_sso")]
async fn api_return_to_sso(
&self,
req: &Request,
session: &Session,
services: Data<&Services>,
code: Query<Option<String>>,
) -> poem::Result<Response<ReturnToSsoResponse>> {
fn make_err_response(err: &str) -> poem::Result<Response<ReturnToSsoResponse>> {
error!("SSO error: {err}");
Ok(Response::new(ReturnToSsoResponse::Ok)
.header("Location", format!("/@warpgate?login_error={err}")))
}
let Some(context) = session.get::<SsoContext>(SSO_CONTEXT_SESSION_KEY) else {
return make_err_response("Not in an active SSO process");
};
let Some(ref code) = *code else {
return make_err_response("No authorization code in the return URL request");
};
let response = context
.request
.verify_code((*code).clone())
.await
.map_err(poem::error::InternalServerError)?;
if !response.email_verified.unwrap_or(true) {
return make_err_response("The SSO account's e-mail is not verified");
}
let Some(email) = response.email else {
return make_err_response("No e-mail information in the SSO response");
};
info!("SSO login as {email}");
let cred = AuthCredential::Sso {
provider: context.provider,
email: email.clone(),
};
let Some(username) = services.config_provider.lock().await.username_for_sso_credential(&cred).await? else {
return make_err_response(&format!("No user matching {email}"));
};
let mut auth_state_store = services.auth_state_store.lock().await;
let state = get_auth_state_for_request(&username, session, &mut auth_state_store).await?;
let mut cp = services.config_provider.lock().await;
if cp.validate_credential(&username, &cred).await? {
state.add_valid_credential(cred);
}
match state.verify() {
AuthResult::Accepted { username } => {
authorize_session(req, username).await?;
}
_ => ()
}
Ok(Response::new(ReturnToSsoResponse::Ok).header("Location", "/@warpgate"))
}
}

View file

@ -7,11 +7,18 @@ use poem::session::Session;
use poem::web::{Data, Redirect}; use poem::web::{Data, Redirect};
use poem::{Endpoint, EndpointExt, FromRequest, IntoResponse, Request, Response}; use poem::{Endpoint, EndpointExt, FromRequest, IntoResponse, Request, Response};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use warpgate_common::{ProtocolName, Services, TargetOptions}; use tokio::sync::Mutex;
use tracing::*;
use uuid::Uuid;
use warpgate_common::auth::{AuthState, AuthStateStore};
use warpgate_common::{ProtocolName, Services, TargetOptions, WarpgateError};
use crate::session::SessionStore;
pub const PROTOCOL_NAME: ProtocolName = "HTTP"; pub const PROTOCOL_NAME: ProtocolName = "HTTP";
static TARGET_SESSION_KEY: &str = "target_name"; static TARGET_SESSION_KEY: &str = "target_name";
static AUTH_SESSION_KEY: &str = "auth"; static AUTH_SESSION_KEY: &str = "auth";
static AUTH_STATE_ID_SESSION_KEY: &str = "auth_state_id";
pub static SESSION_MAX_AGE: Duration = Duration::from_secs(60 * 30); pub static SESSION_MAX_AGE: Duration = Duration::from_secs(60 * 30);
pub static COOKIE_MAX_AGE: Duration = Duration::from_secs(60 * 60 * 24); pub static COOKIE_MAX_AGE: Duration = Duration::from_secs(60 * 60 * 24);
@ -23,6 +30,7 @@ pub trait SessionExt {
fn get_username(&self) -> Option<String>; fn get_username(&self) -> Option<String>;
fn get_auth(&self) -> Option<SessionAuthorization>; fn get_auth(&self) -> Option<SessionAuthorization>;
fn set_auth(&self, auth: SessionAuthorization); fn set_auth(&self, auth: SessionAuthorization);
fn get_auth_state_id(&self) -> Option<AuthStateId>;
} }
impl SessionExt for Session { impl SessionExt for Session {
@ -53,8 +61,15 @@ impl SessionExt for Session {
fn set_auth(&self, auth: SessionAuthorization) { fn set_auth(&self, auth: SessionAuthorization) {
self.set(AUTH_SESSION_KEY, auth); self.set(AUTH_SESSION_KEY, auth);
} }
fn get_auth_state_id(&self) -> Option<AuthStateId> {
self.get(AUTH_STATE_ID_SESSION_KEY)
}
} }
#[derive(Clone, Serialize, Deserialize)]
pub struct AuthStateId(pub Uuid);
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
pub enum SessionAuthorization { pub enum SessionAuthorization {
User(String), User(String),
@ -161,3 +176,50 @@ pub fn gateway_redirect(req: &Request) -> Response {
Redirect::temporary(path).into_response() Redirect::temporary(path).into_response()
} }
pub async fn get_auth_state_for_request<'a>(
username: &str,
session: &Session,
store: &'a mut AuthStateStore,
) -> Result<&'a mut AuthState, WarpgateError> {
match session.get_auth_state_id() {
Some(id) => {
if !store.contains_key(&id.0) {
session.remove(AUTH_STATE_ID_SESSION_KEY)
}
}
None => (),
};
match session.get_auth_state_id() {
Some(id) => Ok(store.get_mut(&id.0).unwrap()),
None => {
let (id, state) = store
.create(&username, crate::common::PROTOCOL_NAME)
.await?;
session.set(AUTH_STATE_ID_SESSION_KEY, AuthStateId(id));
Ok(state)
}
}
}
pub async fn authorize_session(req: &Request, username: String) -> poem::Result<()> {
let session_middleware: Data<&Arc<Mutex<SessionStore>>> =
<_>::from_request_without_body(&req).await?;
let session: &Session = <_>::from_request_without_body(&req).await?;
let server_handle = session_middleware
.lock()
.await
.create_handle_for(&req)
.await?;
server_handle
.lock()
.await
.set_username(username.clone())
.await?;
info!(%username, "Authenticated");
session.set_auth(SessionAuthorization::User(username));
Ok(())
}

View file

@ -1,5 +1,5 @@
#![feature(type_alias_impl_trait, let_else, try_blocks)] #![feature(type_alias_impl_trait, let_else, try_blocks)]
mod api; pub mod api;
mod catchall; mod catchall;
mod common; mod common;
mod error; mod error;

View file

@ -1,9 +1,6 @@
#![feature(type_alias_impl_trait, let_else, try_blocks)] #![feature(type_alias_impl_trait, let_else, try_blocks)]
mod api; use warpgate_protocol_http::api;
use regex::Regex; use regex::Regex;
mod common;
mod session;
mod session_handle;
use poem_openapi::OpenApiService; use poem_openapi::OpenApiService;
#[allow(clippy::unwrap_used)] #[allow(clippy::unwrap_used)]

View file

@ -7,11 +7,11 @@ use tokio::net::TcpStream;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tracing::*; use tracing::*;
use uuid::Uuid; use uuid::Uuid;
use warpgate_common::auth::AuthSelector; use warpgate_common::auth::{AuthCredential, AuthSelector, AuthState};
use warpgate_common::helpers::rng::get_crypto_rng; use warpgate_common::helpers::rng::get_crypto_rng;
use warpgate_common::{ use warpgate_common::{
authorize_ticket, AuthCredential, AuthResult, Secret, Services, TargetMySqlOptions, authorize_ticket, AuthResult, Secret, Services, TargetMySqlOptions, TargetOptions,
TargetOptions, WarpgateServerHandle, WarpgateServerHandle,
}; };
use warpgate_database_protocols::io::{BufExt, Decode}; use warpgate_database_protocols::io::{BufExt, Decode};
use warpgate_database_protocols::mysql::protocol::auth::AuthPlugin; use warpgate_database_protocols::mysql::protocol::auth::AuthPlugin;
@ -175,20 +175,25 @@ impl MySqlSession {
Ok(()) Ok(())
} }
let credentials = vec![AuthCredential::Password(password)];
match selector { match selector {
AuthSelector::User { AuthSelector::User {
username, username,
target_name, target_name,
} => { } => {
let user_auth_result: AuthResult = { let user_auth_result = {
self.services let mut cp = self.services.config_provider.lock().await;
.config_provider
.lock() let credential = AuthCredential::Password(password);
.await let mut state = AuthState::new(
.authorize(&username, &credentials, crate::common::PROTOCOL_NAME) username.clone(),
.await crate::common::PROTOCOL_NAME.to_string(),
.map_err(MySqlError::other)? cp.get_credential_policy(&username).await?,
);
if cp.validate_credential(&username, &credential).await? {
state.add_valid_credential(credential);
}
state.verify()
}; };
match user_auth_result { match user_auth_result {
@ -211,7 +216,7 @@ impl MySqlSession {
} }
self.run_authorized(handshake, username, target_name).await self.run_authorized(handshake, username, target_name).await
} }
AuthResult::Rejected | AuthResult::OtpNeeded => fail(&mut self).await, AuthResult::Rejected | AuthResult::Need(_) => fail(&mut self).await, // TODO SSO
} }
} }
AuthSelector::Ticket { secret } => { AuthSelector::Ticket { secret } => {

View file

@ -17,15 +17,15 @@ use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
use tokio::sync::{oneshot, Mutex}; use tokio::sync::{oneshot, Mutex};
use tracing::*; use tracing::*;
use uuid::Uuid; use uuid::Uuid;
use warpgate_common::auth::AuthSelector; use warpgate_common::auth::{AuthCredential, AuthSelector, AuthState, CredentialKind};
use warpgate_common::eventhub::{EventHub, EventSender}; use warpgate_common::eventhub::{EventHub, EventSender};
use warpgate_common::recordings::{ use warpgate_common::recordings::{
self, ConnectionRecorder, TerminalRecorder, TerminalRecordingStreamId, TrafficConnectionParams, self, ConnectionRecorder, TerminalRecorder, TerminalRecordingStreamId, TrafficConnectionParams,
TrafficRecorder, TrafficRecorder,
}; };
use warpgate_common::{ use warpgate_common::{
authorize_ticket, AuthCredential, AuthResult, Secret, Services, SessionId, authorize_ticket, AuthResult, Secret, Services, SessionId, SshHostKeyVerificationMode, Target,
SshHostKeyVerificationMode, Target, TargetOptions, TargetSSHOptions, WarpgateServerHandle, TargetOptions, TargetSSHOptions, WarpgateServerHandle,
}; };
use super::service_output::ServiceOutput; use super::service_output::ServiceOutput;
@ -71,10 +71,10 @@ pub struct ServerSession {
target: TargetSelection, target: TargetSelection,
traffic_recorders: HashMap<(String, u32), TrafficRecorder>, traffic_recorders: HashMap<(String, u32), TrafficRecorder>,
traffic_connection_recorders: HashMap<Uuid, ConnectionRecorder>, traffic_connection_recorders: HashMap<Uuid, ConnectionRecorder>,
credentials: Vec<AuthCredential>,
hub: EventHub<Event>, hub: EventHub<Event>,
event_sender: EventSender<Event>, event_sender: EventSender<Event>,
service_output: ServiceOutput, service_output: ServiceOutput,
auth_state: Option<AuthState>,
} }
fn session_debug_tag(id: &SessionId, remote_address: &SocketAddr) -> String { fn session_debug_tag(id: &SessionId, remote_address: &SocketAddr) -> String {
@ -136,12 +136,12 @@ impl ServerSession {
target: TargetSelection::None, target: TargetSelection::None,
traffic_recorders: HashMap::new(), traffic_recorders: HashMap::new(),
traffic_connection_recorders: HashMap::new(), traffic_connection_recorders: HashMap::new(),
credentials: vec![],
hub, hub,
event_sender: event_sender.clone(), event_sender: event_sender.clone(),
service_output: ServiceOutput::new(Box::new(move |data| { service_output: ServiceOutput::new(Box::new(move |data| {
so_tx.send(BytesMut::from(data).freeze()).context("x") so_tx.send(BytesMut::from(data).freeze()).context("x")
})), })),
auth_state: None,
}; };
let this = Arc::new(Mutex::new(this)); let this = Arc::new(Mutex::new(this));
@ -217,6 +217,20 @@ impl ServerSession {
Ok(this) Ok(this)
} }
async fn get_auth_state(&mut self, username: &str) -> Result<&mut AuthState> {
#[allow(clippy::unwrap_used)]
if self.auth_state.is_none() || self.auth_state.as_ref().unwrap().username() != username {
let mut cp = self.services.config_provider.lock().await;
self.auth_state = Some(AuthState::new(
username.to_string(),
crate::PROTOCOL_NAME.to_string(),
cp.get_credential_policy(username).await?,
));
}
#[allow(clippy::unwrap_used)]
Ok(self.auth_state.as_mut().unwrap())
}
pub fn make_logging_span(&self) -> tracing::Span { pub fn make_logging_span(&self) -> tracing::Span {
match self.username { match self.username {
Some(ref username) => info_span!("SSH", session=%self.id, session_username=%username), Some(ref username) => info_span!("SSH", session=%self.id, session_username=%username),
@ -913,15 +927,19 @@ impl ServerSession {
key.fingerprint() key.fingerprint()
); );
self.credentials.push(AuthCredential::PublicKey { match self
kind: key.name().to_string(), .try_auth(
public_key_bytes: Bytes::from(key.public_key_bytes()), &selector,
}); AuthCredential::PublicKey {
kind: key.name().to_string(),
match self.try_auth(&selector).await { public_key_bytes: Bytes::from(key.public_key_bytes()),
},
)
.await
{
Ok(AuthResult::Accepted { .. }) => russh::server::Auth::Accept, Ok(AuthResult::Accepted { .. }) => russh::server::Auth::Accept,
Ok(AuthResult::Rejected) => russh::server::Auth::Reject, Ok(AuthResult::Rejected) => russh::server::Auth::Reject,
Ok(AuthResult::OtpNeeded) => russh::server::Auth::Reject, Ok(AuthResult::Need(_)) => russh::server::Auth::Reject,
Err(error) => { Err(error) => {
error!(?error, "Failed to verify credentials"); error!(?error, "Failed to verify credentials");
russh::server::Auth::Reject russh::server::Auth::Reject
@ -937,12 +955,13 @@ impl ServerSession {
let selector: AuthSelector = ssh_username.expose_secret().into(); let selector: AuthSelector = ssh_username.expose_secret().into();
info!("Password key auth as {:?}", selector); info!("Password key auth as {:?}", selector);
self.credentials.push(AuthCredential::Password(password)); match self
.try_auth(&selector, AuthCredential::Password(password))
match self.try_auth(&selector).await { .await
{
Ok(AuthResult::Accepted { .. }) => russh::server::Auth::Accept, Ok(AuthResult::Accepted { .. }) => russh::server::Auth::Accept,
Ok(AuthResult::Rejected) => russh::server::Auth::Reject, Ok(AuthResult::Rejected) => russh::server::Auth::Reject,
Ok(AuthResult::OtpNeeded) => russh::server::Auth::Reject, Ok(AuthResult::Need(_)) => russh::server::Auth::Reject,
Err(error) => { Err(error) => {
error!(?error, "Failed to verify credentials"); error!(?error, "Failed to verify credentials");
russh::server::Auth::Reject russh::server::Auth::Reject
@ -958,18 +977,19 @@ impl ServerSession {
let selector: AuthSelector = ssh_username.expose_secret().into(); let selector: AuthSelector = ssh_username.expose_secret().into();
info!("Keyboard-interactive auth as {:?}", selector); info!("Keyboard-interactive auth as {:?}", selector);
if let Some(otp) = response { let Some(otp) = response else {
self.credentials.push(AuthCredential::Otp(otp)); return russh::server::Auth::Reject
} };
match self.try_auth(&selector).await { match self.try_auth(&selector, AuthCredential::Otp(otp)).await {
Ok(AuthResult::Accepted { .. }) => russh::server::Auth::Accept, Ok(AuthResult::Accepted { .. }) => russh::server::Auth::Accept,
Ok(AuthResult::Rejected) => russh::server::Auth::Reject, Ok(AuthResult::Rejected) => russh::server::Auth::Reject,
Ok(AuthResult::OtpNeeded) => russh::server::Auth::Partial { Ok(AuthResult::Need(CredentialKind::Otp)) => russh::server::Auth::Partial {
name: Cow::Borrowed("Two-factor authentication"), name: Cow::Borrowed("Two-factor authentication"),
instructions: Cow::Borrowed(""), instructions: Cow::Borrowed(""),
prompts: Cow::Owned(vec![(Cow::Borrowed("One-time password: "), true)]), prompts: Cow::Owned(vec![(Cow::Borrowed("One-time password: "), true)]),
}, },
Ok(AuthResult::Need(_)) => russh::server::Auth::Reject, // TODO SSO
Err(error) => { Err(error) => {
error!(?error, "Failed to verify credentials"); error!(?error, "Failed to verify credentials");
russh::server::Auth::Reject russh::server::Auth::Reject
@ -977,20 +997,28 @@ impl ServerSession {
} }
} }
async fn try_auth(&mut self, selector: &AuthSelector) -> Result<AuthResult> { async fn try_auth(
&mut self,
selector: &AuthSelector,
credential: AuthCredential,
) -> Result<AuthResult> {
match selector { match selector {
AuthSelector::User { AuthSelector::User {
username, username,
target_name, target_name,
} => { } => {
let user_auth_result: AuthResult = { let cp = self.services.config_provider.clone();
self.services let state = self.get_auth_state(username).await?;
.config_provider if cp
.lock() .lock()
.await .await
.authorize(username, &self.credentials, crate::PROTOCOL_NAME) .validate_credential(username, &credential)
.await? .await?
}; {
state.add_valid_credential(credential);
}
let user_auth_result = state.verify();
match user_auth_result { match user_auth_result {
AuthResult::Accepted { username } => { AuthResult::Accepted { username } => {

17
warpgate-sso/Cargo.toml Normal file
View file

@ -0,0 +1,17 @@
[package]
edition = "2021"
license = "Apache-2.0"
name = "warpgate-sso"
version = "0.4.0"
[dependencies]
async-trait = "0.1"
bytes = "1.2"
thiserror = "1.0"
tokio = { version = "1.20", features = ["tracing", "macros"] }
tracing = "0.1"
uuid = { version = "1.0", features = ["v4"] }
openidconnect = { version = "2.3", features = ["reqwest", "rustls-tls"] }
serde = "1.0"
serde_json = "1.0"
once_cell = "1.13"

113
warpgate-sso/src/config.rs Normal file
View file

@ -0,0 +1,113 @@
use once_cell::sync::Lazy;
use openidconnect::{ClientId, ClientSecret, IssuerUrl};
use serde::{Deserialize, Serialize};
use crate::SsoError;
#[allow(clippy::unwrap_used)]
pub static GOOGLE_ISSUER_URL: Lazy<IssuerUrl> =
Lazy::new(|| IssuerUrl::new("https://accounts.google.com".to_string()).unwrap());
#[allow(clippy::unwrap_used)]
pub static APPLE_ISSUER_URL: Lazy<IssuerUrl> =
Lazy::new(|| IssuerUrl::new("https://appleid.apple.com".to_string()).unwrap());
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SsoProviderConfig {
pub name: String,
pub label: Option<String>,
pub provider: SsoInternalProviderConfig,
}
impl SsoProviderConfig {
pub fn label(&self) -> &str {
return self
.label
.as_deref()
.unwrap_or_else(|| self.provider.label());
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum SsoInternalProviderConfig {
#[serde(rename = "google")]
Google {
client_id: ClientId,
client_secret: ClientSecret,
},
#[serde(rename = "apple")]
Apple {
client_id: ClientId,
client_secret: ClientSecret,
},
#[serde(rename = "azure")]
Azure {
client_id: ClientId,
client_secret: ClientSecret,
tenant: String,
},
#[serde(rename = "custom")]
Custom {
name: String,
label: String,
client_id: ClientId,
client_secret: ClientSecret,
issuer_url: IssuerUrl,
scopes: Vec<String>,
},
}
impl SsoInternalProviderConfig {
#[inline]
pub fn label(&self) -> &'static str {
match self {
SsoInternalProviderConfig::Google { .. } => "Google",
SsoInternalProviderConfig::Apple { .. } => "Apple",
SsoInternalProviderConfig::Azure { .. } => "Azure",
SsoInternalProviderConfig::Custom { .. } => "SSO",
}
}
#[inline]
pub fn client_id(&self) -> &ClientId {
match self {
SsoInternalProviderConfig::Google { client_id, .. }
| SsoInternalProviderConfig::Apple { client_id, .. }
| SsoInternalProviderConfig::Azure { client_id, .. }
| SsoInternalProviderConfig::Custom { client_id, .. } => client_id,
}
}
#[inline]
pub fn client_secret(&self) -> &ClientSecret {
match self {
SsoInternalProviderConfig::Google { client_secret, .. }
| SsoInternalProviderConfig::Apple { client_secret, .. }
| SsoInternalProviderConfig::Azure { client_secret, .. }
| SsoInternalProviderConfig::Custom { client_secret, .. } => client_secret,
}
}
#[inline]
pub fn issuer_url(&self) -> Result<IssuerUrl, SsoError> {
Ok(match self {
SsoInternalProviderConfig::Google { .. } => GOOGLE_ISSUER_URL.clone(),
SsoInternalProviderConfig::Apple { .. } => APPLE_ISSUER_URL.clone(),
SsoInternalProviderConfig::Azure { tenant, .. } => {
IssuerUrl::new(format!("https://login.microsoftonline.com/{tenant}/v2.0"))?
}
SsoInternalProviderConfig::Custom { issuer_url, .. } => issuer_url.clone(),
})
}
#[inline]
pub fn scopes(&self) -> Vec<String> {
match self {
SsoInternalProviderConfig::Google { .. }
| SsoInternalProviderConfig::Apple { .. }
| SsoInternalProviderConfig::Azure { .. } => vec!["email".to_string()],
SsoInternalProviderConfig::Custom { scopes, .. } => scopes.clone(),
}
}
}

25
warpgate-sso/src/error.rs Normal file
View file

@ -0,0 +1,25 @@
use std::error::Error;
use openidconnect::{ClaimsVerificationError, SigningError};
#[derive(thiserror::Error, Debug)]
pub enum SsoError {
#[error("provider is OAuth2, not OIDC")]
NotOidc,
#[error("the token was replaced in flight")]
Mitm,
#[error("config parse error: {0}")]
UrlParse(#[from] openidconnect::url::ParseError),
#[error("provider discovery error: {0}")]
Discovery(String),
#[error("code verification error: {0}")]
Verification(String),
#[error("claims verification error: {0}")]
ClaimsVerification(#[from] ClaimsVerificationError),
#[error("signing error: {0}")]
Signing(#[from] SigningError),
#[error("I/O: {0}")]
Io(#[from] std::io::Error),
#[error(transparent)]
Other(Box<dyn Error + Send + Sync>),
}

11
warpgate-sso/src/lib.rs Normal file
View file

@ -0,0 +1,11 @@
mod config;
mod error;
mod request;
mod response;
mod sso;
pub use config::*;
pub use error::*;
pub use request::*;
pub use response::*;
pub use sso::*;

View file

@ -0,0 +1,65 @@
use openidconnect::reqwest::async_http_client;
use openidconnect::url::Url;
use openidconnect::{
AccessTokenHash, AuthorizationCode, CsrfToken, Nonce, OAuth2TokenResponse, PkceCodeVerifier,
RedirectUrl, TokenResponse,
};
use serde::{Deserialize, Serialize};
use crate::{make_client, SsoError, SsoInternalProviderConfig, SsoLoginResponse};
#[derive(Serialize, Deserialize, Debug)]
pub struct SsoLoginRequest {
pub(crate) auth_url: Url,
pub(crate) csrf_token: CsrfToken,
pub(crate) nonce: Nonce,
pub(crate) redirect_url: RedirectUrl,
pub(crate) pkce_verifier: PkceCodeVerifier,
pub(crate) config: SsoInternalProviderConfig,
}
impl SsoLoginRequest {
pub fn auth_url(&self) -> &Url {
&self.auth_url
}
pub fn csrf_token(&self) -> &CsrfToken {
&self.csrf_token
}
pub async fn verify_code(self, code: String) -> Result<SsoLoginResponse, SsoError> {
let client = make_client(&self.config)
.await?
.set_redirect_uri(self.redirect_url.clone());
let token_response = client
.exchange_code(AuthorizationCode::new(code))
.set_pkce_verifier(self.pkce_verifier)
.request_async(async_http_client)
.await
.map_err(|e| SsoError::Verification(format!("{e}")))?;
let id_token = token_response.id_token().ok_or(SsoError::NotOidc)?;
let claims = id_token.claims(&client.id_token_verifier(), &self.nonce)?;
if let Some(expected_access_token_hash) = claims.access_token_hash() {
let actual_access_token_hash = AccessTokenHash::from_token(
token_response.access_token(),
&id_token.signing_alg()?,
)?;
if actual_access_token_hash != *expected_access_token_hash {
return Err(SsoError::Mitm);
}
}
Ok(SsoLoginResponse {
name: claims
.name()
.and_then(|x| x.get(None))
.map(|x| x.as_str())
.map(ToString::to_string),
email: claims.email().map(|x| x.as_str()).map(ToString::to_string),
email_verified: claims.email_verified(),
})
}
}

View file

@ -0,0 +1,6 @@
#[derive(Clone, Debug)]
pub struct SsoLoginResponse {
pub name: Option<String>,
pub email: Option<String>,
pub email_verified: Option<bool>,
}

59
warpgate-sso/src/sso.rs Normal file
View file

@ -0,0 +1,59 @@
use std::borrow::Cow;
use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata};
use openidconnect::reqwest::async_http_client;
use openidconnect::{CsrfToken, Nonce, PkceCodeChallenge, RedirectUrl, Scope};
use crate::config::SsoInternalProviderConfig;
use crate::request::SsoLoginRequest;
use crate::SsoError;
pub struct SsoClient {
config: SsoInternalProviderConfig,
}
pub async fn make_client(config: &SsoInternalProviderConfig) -> Result<CoreClient, SsoError> {
let metadata = CoreProviderMetadata::discover_async(config.issuer_url()?, async_http_client)
.await
.map_err(|e| SsoError::Discovery(format!("{e}")))?;
Ok(CoreClient::from_provider_metadata(
metadata,
config.client_id().clone(),
Some(config.client_secret().clone()),
))
}
impl SsoClient {
pub fn new(config: SsoInternalProviderConfig) -> Self {
Self { config }
}
pub async fn start_login(&self, redirect_url: String) -> Result<SsoLoginRequest, SsoError> {
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let redirect_url = RedirectUrl::new(redirect_url)?;
let client = make_client(&self.config).await?;
let mut auth_req = client
.authorize_url(
CoreAuthenticationFlow::AuthorizationCode,
CsrfToken::new_random,
Nonce::new_random,
)
.set_redirect_uri(Cow::Owned(redirect_url.clone()));
for scope in self.config.scopes() {
auth_req = auth_req.add_scope(Scope::new(scope.to_string()));
}
let (auth_url, csrf_token, nonce) = auth_req.set_pkce_challenge(pkce_challenge).url();
Ok(SsoLoginRequest {
auth_url,
csrf_token,
nonce,
pkce_verifier,
redirect_url,
config: self.config.clone(),
})
}
}

View file

@ -19,8 +19,9 @@
}, },
"devDependencies": { "devDependencies": {
"@fontsource/work-sans": "^4.5.7", "@fontsource/work-sans": "^4.5.7",
"@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-brands-svg-icons": "^6.1.2",
"@fortawesome/free-solid-svg-icons": "^6.1.1", "@fortawesome/free-regular-svg-icons": "^6.1.2",
"@fortawesome/free-solid-svg-icons": "^6.1.2",
"@openapitools/openapi-generator-cli": "^2.5.1", "@openapitools/openapi-generator-cli": "^2.5.1",
"@sveltejs/vite-plugin-svelte": "^1.0.0-next.49", "@sveltejs/vite-plugin-svelte": "^1.0.0-next.49",
"@tsconfig/svelte": "^3.0.0", "@tsconfig/svelte": "^3.0.0",

View file

@ -22,6 +22,7 @@ async function _click () {
<Button <Button
on:click={_click} on:click={_click}
class={$$props.class}
outline={outline} outline={outline}
color={color} color={color}
type={type} type={type}

View file

@ -1,7 +1,11 @@
<script lang="ts"> <script lang="ts">
import { replace } from 'svelte-spa-router' import { replace } from 'svelte-spa-router'
import { Alert, FormGroup } from 'sveltestrap' import { Alert, FormGroup, Spinner } from 'sveltestrap'
import { api, LoginFailureReason, LoginFailureResponseFromJSON } from 'gateway/lib/api' import Fa from 'svelte-fa'
import { faArrowRight } from '@fortawesome/free-solid-svg-icons'
import { faGoogle, faMicrosoft, faApple } from '@fortawesome/free-brands-svg-icons'
import { api, ApiAuthState, LoginFailureResponseFromJSON, SsoProviderDescription, SsoProviderKind } from 'gateway/lib/api'
import { reloadServerInfo } from 'gateway/lib/store' import { reloadServerInfo } from 'gateway/lib/store'
import AsyncButton from 'common/AsyncButton.svelte' import AsyncButton from 'common/AsyncButton.svelte'
@ -9,10 +13,32 @@ let error: Error|null = null
let username = '' let username = ''
let password = '' let password = ''
let otp = '' let otp = ''
let incorrectCredentials = false
let otpInputVisible = false
let busy = false let busy = false
let authState = ApiAuthState.NotStarted
let ssoProvidersPromise = api.getSsoProviders()
const nextURL = new URLSearchParams(location.search).get('next')
const serverErrorMessage = new URLSearchParams(location.search).get('login_error')
async function init () {
authState = (await api.getAuthState()).state
continueWithState()
}
async function continueWithState () {
if (authState === ApiAuthState.SsoNeeded) {
const providers = await ssoProvidersPromise
if (!providers.length) {
// todo
}
if (providers.length === 1) {
startSSO(providers[0])
}
}
}
async function login () { async function login () {
busy = true busy = true
try { try {
@ -24,18 +50,23 @@ async function login () {
async function _login () { async function _login () {
error = null error = null
incorrectCredentials = false
try { try {
await api.login({ if (authState === ApiAuthState.OtpNeeded) {
loginRequest: { await api.otpLogin({
username, otpLoginRequest: {
password, otp,
otp: otp || undefined, },
}, })
}) } else {
let next = new URLSearchParams(location.search).get('next') await api.login({
if (next) { loginRequest: {
location.href = next username,
password,
},
})
}
if (nextURL) {
location.href = nextURL
} else { } else {
await reloadServerInfo() await reloadServerInfo()
replace('/') replace('/')
@ -45,11 +76,8 @@ async function _login () {
const response = err as Response const response = err as Response
if (response.status === 401) { if (response.status === 401) {
const failure = LoginFailureResponseFromJSON(await response.json()) const failure = LoginFailureResponseFromJSON(await response.json())
if (failure.reason === LoginFailureReason.InvalidCredentials) { authState = failure.state
incorrectCredentials = true continueWithState()
} else if (failure.reason === LoginFailureReason.OtpNeeded) {
presentOTPInput()
}
} else { } else {
error = new Error(await response.text()) error = new Error(await response.text())
} }
@ -59,71 +87,117 @@ async function _login () {
} }
} }
function presentOTPInput () {
otpInputVisible = true
}
function onInputKey (event: KeyboardEvent) { function onInputKey (event: KeyboardEvent) {
if (event.key === 'Enter') { if (event.key === 'Enter') {
login() login()
} }
} }
async function startSSO (provider: SsoProviderDescription) {
busy = true
try {
const params = await api.startSso(provider)
location.href = params.url
} catch {
busy = false
}
}
</script> </script>
<form class="mt-5" autocomplete="on"> {#await init()}
<div class="page-summary-bar"> <Spinner />
<h1>Welcome</h1> {:then}
</div> <form class="mt-5" autocomplete="on">
<div class="page-summary-bar">
{#if authState === ApiAuthState.NotStarted || authState === ApiAuthState.Failed}
<h1>Welcome</h1>
{:else}
<h1>Continue login</h1>
{/if}
</div>
{#if authState === ApiAuthState.OtpNeeded}
<FormGroup floating label="One-time password">
<!-- svelte-ignore a11y-autofocus -->
<input
bind:value={otp}
on:keypress={onInputKey}
name="otp"
autofocus
disabled={busy}
class="form-control" />
</FormGroup>
{/if}
{#if authState === ApiAuthState.NotStarted || authState === ApiAuthState.PasswordNeeded || authState === ApiAuthState.Failed}
<FormGroup floating label="Username">
<!-- svelte-ignore a11y-autofocus -->
<input
bind:value={username}
on:keypress={onInputKey}
name="username"
autocomplete="username"
disabled={busy}
class="form-control"
autofocus />
</FormGroup>
{#if !otpInputVisible} <FormGroup floating label="Password">
<FormGroup floating label="Username"> <input
<!-- svelte-ignore a11y-autofocus --> bind:value={password}
<input on:keypress={onInputKey}
bind:value={username} name="password"
on:keypress={onInputKey} type="password"
name="username" autocomplete="current-password"
autocomplete="username" disabled={busy}
disabled={busy} class="form-control" />
class="form-control" </FormGroup>
autofocus />
</FormGroup>
<FormGroup floating label="Password"> <AsyncButton
<input outline
bind:value={password} class="d-flex align-items-center"
on:keypress={onInputKey} type="submit"
name="password" disabled={busy}
type="password" click={login}
autocomplete="current-password" >
disabled={busy} Login
class="form-control" /> <Fa class="ms-2" icon={faArrowRight} />
</FormGroup> </AsyncButton>
{/if}
{#if otpInputVisible} {#if authState === ApiAuthState.Failed}
<FormGroup floating label="One-time password"> <Alert color="danger">Incorrect credentials</Alert>
<!-- svelte-ignore a11y-autofocus --> {/if}
<input {#if serverErrorMessage}
bind:value={otp} <Alert color="danger">{serverErrorMessage}</Alert>
on:keypress={onInputKey} {/if}
name="otp" {#if error}
autofocus <Alert color="danger">{error}</Alert>
disabled={busy} {/if}
class="form-control" /> {/if}
</FormGroup> </form>
{/if}
<AsyncButton {#await ssoProvidersPromise then ssoProviders}
outline {#if authState === ApiAuthState.SsoNeeded || authState === ApiAuthState.NotStarted || authState === ApiAuthState.Failed}
type="submit" <div class="mt-5">
disabled={busy} {#each ssoProviders as ssoProvider}
click={login} <button
>Login</AsyncButton> class="btn d-flex align-items-center w-100 mb-2 btn-outline-primary"
disabled={busy}
{#if incorrectCredentials} on:click={() => startSSO(ssoProvider)}
<Alert color="danger">Incorrect credentials</Alert> >
{/if} <span class="m-auto">
{#if error} {#if ssoProvider.kind === SsoProviderKind.Google}
<Alert color="danger">{error}</Alert> <Fa fw class="me-2" icon={faGoogle} />
{/if} {/if}
</form> {#if ssoProvider.kind === SsoProviderKind.Microsoft || ssoProvider.kind === SsoProviderKind.Azure}
<Fa fw class="me-2" icon={faMicrosoft} />
{/if}
{#if ssoProvider.kind === SsoProviderKind.Apple}
<Fa fw class="me-2" icon={faApple} />
{/if}
{ssoProvider.label}
</span>
</button>
{/each}
</div>
{/if}
{/await}
{/await}

View file

@ -41,6 +41,53 @@
"operationId": "login" "operationId": "login"
} }
}, },
"/auth/otp": {
"post": {
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OtpLoginRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"description": ""
},
"401": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LoginFailureResponse"
}
}
}
}
},
"operationId": "otpLogin"
}
},
"/auth/state": {
"get": {
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuthStateResponseInternal"
}
}
}
}
},
"operationId": "getAuthState"
}
},
"/auth/logout": { "/auth/logout": {
"post": { "post": {
"responses": { "responses": {
@ -87,10 +134,104 @@
}, },
"operationId": "get_targets" "operationId": "get_targets"
} }
},
"/sso/providers": {
"get": {
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SsoProviderDescription"
}
}
}
}
}
},
"operationId": "get_sso_providers"
}
},
"/sso/return": {
"get": {
"parameters": [
{
"name": "code",
"schema": {
"type": "string"
},
"in": "query",
"required": false,
"deprecated": false
}
],
"responses": {
"307": {
"description": ""
}
},
"operationId": "return_to_sso"
}
},
"/sso/providers/{name}/start": {
"get": {
"parameters": [
{
"name": "name",
"schema": {
"type": "string"
},
"in": "path",
"required": true,
"deprecated": false
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StartSsoResponseParams"
}
}
}
},
"404": {
"description": ""
}
},
"operationId": "start_sso"
}
} }
}, },
"components": { "components": {
"schemas": { "schemas": {
"ApiAuthState": {
"type": "string",
"enum": [
"NotStarted",
"Failed",
"PasswordNeeded",
"OtpNeeded",
"SsoNeeded",
"Success"
]
},
"AuthStateResponseInternal": {
"type": "object",
"required": [
"state"
],
"properties": {
"state": {
"$ref": "#/components/schemas/ApiAuthState"
}
}
},
"Info": { "Info": {
"type": "object", "type": "object",
"required": [ "required": [
@ -119,21 +260,14 @@
} }
} }
}, },
"LoginFailureReason": {
"type": "string",
"enum": [
"InvalidCredentials",
"OtpNeeded"
]
},
"LoginFailureResponse": { "LoginFailureResponse": {
"type": "object", "type": "object",
"required": [ "required": [
"reason" "state"
], ],
"properties": { "properties": {
"reason": { "state": {
"$ref": "#/components/schemas/LoginFailureReason" "$ref": "#/components/schemas/ApiAuthState"
} }
} }
}, },
@ -149,7 +283,15 @@
}, },
"password": { "password": {
"type": "string" "type": "string"
}, }
}
},
"OtpLoginRequest": {
"type": "object",
"required": [
"otp"
],
"properties": {
"otp": { "otp": {
"type": "string" "type": "string"
} }
@ -172,6 +314,46 @@
} }
} }
}, },
"SsoProviderDescription": {
"type": "object",
"required": [
"name",
"label",
"kind"
],
"properties": {
"name": {
"type": "string"
},
"label": {
"type": "string"
},
"kind": {
"$ref": "#/components/schemas/SsoProviderKind"
}
}
},
"SsoProviderKind": {
"type": "string",
"enum": [
"Google",
"Apple",
"Microsoft",
"Azure",
"Custom"
]
},
"StartSsoResponseParams": {
"type": "object",
"required": [
"url"
],
"properties": {
"url": {
"type": "string"
}
}
},
"Target": { "Target": {
"type": "object", "type": "object",
"required": [ "required": [

View file

@ -48,24 +48,31 @@
resolved "https://registry.yarnpkg.com/@fontsource/work-sans/-/work-sans-4.5.7.tgz#e8d070896af8d751ca4064e9b0dd134faad3536b" resolved "https://registry.yarnpkg.com/@fontsource/work-sans/-/work-sans-4.5.7.tgz#e8d070896af8d751ca4064e9b0dd134faad3536b"
integrity sha512-DlVEYsShbL0ZUV96yPhie6rJN3eeCta4iI6UbLdbLptlLnkoryfbMIqeQLe+o7OsIoMNWqHTunKMW0x1BmUNpw== integrity sha512-DlVEYsShbL0ZUV96yPhie6rJN3eeCta4iI6UbLdbLptlLnkoryfbMIqeQLe+o7OsIoMNWqHTunKMW0x1BmUNpw==
"@fortawesome/fontawesome-common-types@6.1.1": "@fortawesome/fontawesome-common-types@6.1.2":
version "6.1.1" version "6.1.2"
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.1.1.tgz#7dc996042d21fc1ae850e3173b5c67b0549f9105" resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.1.2.tgz#c1095b1bbabf19f37f9ff0719db38d92a410bcfe"
integrity sha512-wVn5WJPirFTnzN6tR95abCx+ocH+3IFLXAgyavnf9hUmN0CfWoDjPT/BAWsUVwSlYYVBeCLJxaqi7ZGe4uSjBA== integrity sha512-wBaAPGz1Awxg05e0PBRkDRuTsy4B3dpBm+zreTTyd9TH4uUM27cAL4xWyWR0rLJCrRwzVsQ4hF3FvM6rqydKPA==
"@fortawesome/free-regular-svg-icons@^6.1.1": "@fortawesome/free-brands-svg-icons@^6.1.2":
version "6.1.1" version "6.1.2"
resolved "https://registry.yarnpkg.com/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.1.1.tgz#3f2f58262a839edf0643cbacee7a8a8230061c98" resolved "https://registry.yarnpkg.com/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.1.2.tgz#14160348b8ad5986b3805797dc4377a96e0014d9"
integrity sha512-xXiW7hcpgwmWtndKPOzG+43fPH7ZjxOaoeyooptSztGmJxCAflHZxXNK0GcT0uEsR4jTGQAfGklDZE5NHoBhKg== integrity sha512-b2eMfXQBsSxh52pcPtYchURQs6BWNh3zVTG8XH8Lv6V4kDhEg7D0kHN+K1SZniDiPb/e5tBlaygsinMUvetITA==
dependencies: dependencies:
"@fortawesome/fontawesome-common-types" "6.1.1" "@fortawesome/fontawesome-common-types" "6.1.2"
"@fortawesome/free-solid-svg-icons@^6.1.1": "@fortawesome/free-regular-svg-icons@^6.1.2":
version "6.1.1" version "6.1.2"
resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.1.1.tgz#3369e673f8fe8be2fba30b1ec274d47490a830a6" resolved "https://registry.yarnpkg.com/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.1.2.tgz#9f04009098addcc11d0d185126f058ed042c3099"
integrity sha512-0/5exxavOhI/D4Ovm2r3vxNojGZioPwmFrKg0ZUH69Q68uFhFPs6+dhAToh6VEQBntxPRYPuT5Cg1tpNa9JUPg== integrity sha512-xR4hA+tAwsaTHGfb+25H1gVU/aJ0Rzu+xIUfnyrhaL13yNQ7TWiI2RvzniAaB+VGHDU2a+Pk96Ve+pkN3/+TTQ==
dependencies: dependencies:
"@fortawesome/fontawesome-common-types" "6.1.1" "@fortawesome/fontawesome-common-types" "6.1.2"
"@fortawesome/free-solid-svg-icons@^6.1.2":
version "6.1.2"
resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.1.2.tgz#491d668b8a6603698d0ce1ac620f66fd22b74c84"
integrity sha512-lTgZz+cMpzjkHmCwOG3E1ilUZrnINYdqMmrkv30EC3XbRsGlbIOL8H9LaNp5SV4g0pNJDfQ4EdTWWaMvdwyLiQ==
dependencies:
"@fortawesome/fontawesome-common-types" "6.1.2"
"@humanwhocodes/config-array@^0.9.2": "@humanwhocodes/config-array@^0.9.2":
version "0.9.3" version "0.9.3"
@ -2258,9 +2265,9 @@ svelte-check@^2.7.2:
typescript "*" typescript "*"
svelte-fa@^3.0.1: svelte-fa@^3.0.1:
version "3.0.1" version "3.0.3"
resolved "https://registry.yarnpkg.com/svelte-fa/-/svelte-fa-3.0.1.tgz#a8f64031c25e13bf09670152fb631f274ad127a1" resolved "https://registry.yarnpkg.com/svelte-fa/-/svelte-fa-3.0.3.tgz#49aa8627725b2533fb4d109b39cd8e64f7e54b7c"
integrity sha512-p0+sp0Zzcjzqi/3cg4TsGG8MO1DE6vgNxUqcELZXMvkMgad/1VFTSKZ9QRkQMWBcIHsu3IEr16HldQFU4bJQ2g== integrity sha512-GIikJjcVCD+5Y/x9hZc2R4gvuA0gVftacuWu1a+zVQWSFjFYZ+hhU825x+QNs2slsppfrgmFiUyU9Sz9gj4Rdw==
svelte-hmr@^0.14.12: svelte-hmr@^0.14.12:
version "0.14.12" version "0.14.12"