From e2605b6930e591bc23654d8407100deee2005b48 Mon Sep 17 00:00:00 2001 From: Wei Zhang Date: Mon, 23 Dec 2024 18:33:31 +0800 Subject: [PATCH 01/12] feat(graphQL): add ldap related apis Signed-off-by: Wei Zhang --- Cargo.lock | 35 +++++++++ ee/tabby-schema/graphql/schema.graphql | 50 +++++++++++++ ee/tabby-schema/src/schema/auth.rs | 51 +++++++++++++ ee/tabby-schema/src/schema/mod.rs | 33 ++++++++- ee/tabby-webserver/Cargo.toml | 1 + ee/tabby-webserver/src/ldap.rs | 99 ++++++++++++++++++++++++++ ee/tabby-webserver/src/lib.rs | 1 + ee/tabby-webserver/src/service/mod.rs | 3 + 8 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 ee/tabby-webserver/src/ldap.rs diff --git a/Cargo.lock b/Cargo.lock index c4c4a20d2f1e..9e6d6087c6e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2409,6 +2409,40 @@ dependencies = [ "spin 0.5.2", ] +[[package]] +name = "lber" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2df7f9fd9f64cf8f59e1a4a0753fe7d575a5b38d3d7ac5758dcee9357d83ef0a" +dependencies = [ + "bytes", + "nom", +] + +[[package]] +name = "ldap3" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "166199a8207874a275144c8a94ff6eed5fcbf5c52303e4d9b4d53a0c7ac76554" +dependencies = [ + "async-trait", + "bytes", + "futures", + "futures-util", + "lazy_static", + "lber", + "log", + "native-tls", + "nom", + "percent-encoding", + "thiserror", + "tokio", + "tokio-native-tls", + "tokio-stream", + "tokio-util", + "url", +] + [[package]] name = "leaky-bucket" version = "1.1.2" @@ -5487,6 +5521,7 @@ dependencies = [ "juniper_axum", "juniper_graphql_ws", "lazy_static", + "ldap3", "lettre", "logkit", "mime_guess", diff --git a/ee/tabby-schema/graphql/schema.graphql b/ee/tabby-schema/graphql/schema.graphql index fef920d952c2..238f536e59f2 100644 --- a/ee/tabby-schema/graphql/schema.graphql +++ b/ee/tabby-schema/graphql/schema.graphql @@ -10,6 +10,13 @@ enum AuthMethod { LOGIN } +enum AuthProviderKind { + OAUTH_GITHUB + OAUTH_GOOGLE + OAUTH_GITLAB + LDAP +} + "Represents the kind of context source." enum ContextSourceKind { GIT @@ -61,6 +68,12 @@ enum Language { OTHER } +enum LdapEncryptionKind { + NONE + START_TLS + LDAPS +} + enum LicenseStatus { OK EXPIRED @@ -231,6 +244,19 @@ input UpdateIntegrationInput { kind: IntegrationKind! } +input UpdateLdapCredentialInput { + host: String! + port: Int! + bindDn: String! + bindPassword: String! + baseDn: String! + userFilter: String! + encryption: LdapEncryptionKind! + skipTlsVerify: Boolean! + emailAttribute: String! + nameAttribute: String! +} + input UpdateMessageInput { id: ID! threadId: ID! @@ -288,6 +314,10 @@ interface User { """ scalar DateTime +type AuthProvider { + kind: AuthProviderKind! +} + type CompletionStats { start: DateTime! end: DateTime! @@ -464,6 +494,21 @@ type JobStats { pending: Int! } +type LdapCredential { + host: String! + port: Int! + bindDn: String! + bindPassword: String! + baseDn: String! + userFilter: String! + encryption: LdapEncryptionKind! + skipTlsVerify: Boolean! + emailAttribute: String! + nameAttribute: String! + createdAt: DateTime! + updatedAt: DateTime! +} + type LicenseInfo { type: LicenseType! status: LicenseStatus! @@ -585,6 +630,8 @@ type Mutation { deleteInvitation(id: ID!): ID! updateOauthCredential(input: UpdateOAuthCredentialInput!): Boolean! deleteOauthCredential(provider: OAuthProvider!): Boolean! + updateLdapCredential(input: UpdateLdapCredentialInput!): Boolean! + deleteLdapCredential: Boolean! updateEmailSetting(input: EmailSettingInput!): Boolean! updateSecuritySetting(input: SecuritySettingInput!): Boolean! updateNetworkSetting(input: NetworkSettingInput!): Boolean! @@ -716,8 +763,11 @@ type Query { * `func_name lang:go` """ repositoryGrep(kind: RepositoryKind!, id: ID!, rev: String, query: String!): RepositoryGrepOutput! + authProviders: [AuthProvider!]! oauthCredential(provider: OAuthProvider!): OAuthCredential oauthCallbackUrl(provider: OAuthProvider!): String! + ldapCredential: LdapCredential + testLdapCredential: Boolean! serverInfo: ServerInfo! license: LicenseInfo! jobs: [String!]! diff --git a/ee/tabby-schema/src/schema/auth.rs b/ee/tabby-schema/src/schema/auth.rs index 404da5d72a8c..7ea2f78a2f1f 100644 --- a/ee/tabby-schema/src/schema/auth.rs +++ b/ee/tabby-schema/src/schema/auth.rs @@ -322,6 +322,19 @@ pub enum OAuthProvider { Gitlab, } +#[derive(GraphQLEnum, Clone, Serialize, Deserialize, PartialEq, Debug)] +pub enum AuthProviderKind { + OAuthGithub, + OAuthGoogle, + OAuthGitlab, + Ldap, +} + +#[derive(GraphQLObject)] +pub struct AuthProvider { + pub kind: AuthProviderKind, +} + #[derive(GraphQLObject)] pub struct OAuthCredential { pub provider: OAuthProvider, @@ -348,6 +361,44 @@ pub struct UpdateOAuthCredentialInput { pub client_secret: Option, } +#[derive(GraphQLEnum)] +pub enum LdapEncryptionKind { + None, + StartTLS, + LDAPS, +} + +#[derive(GraphQLInputObject, Validate)] +pub struct UpdateLdapCredentialInput { + host: String, + port: i32, + bind_dn: String, + bind_password: String, + base_dn: String, + user_filter: String, + encryption: LdapEncryptionKind, + skip_tls_verify: bool, + email_attribute: String, + name_attribute: String, +} + +#[derive(GraphQLObject)] +pub struct LdapCredential { + host: String, + port: i32, + bind_dn: String, + bind_password: String, + base_dn: String, + user_filter: String, + encryption: LdapEncryptionKind, + skip_tls_verify: bool, + email_attribute: String, + name_attribute: String, + + pub created_at: DateTime, + pub updated_at: DateTime, +} + #[async_trait] pub trait AuthenticationService: Send + Sync { async fn register( diff --git a/ee/tabby-schema/src/schema/mod.rs b/ee/tabby-schema/src/schema/mod.rs index c6c75e6e954d..6a926a5b2942 100644 --- a/ee/tabby-schema/src/schema/mod.rs +++ b/ee/tabby-schema/src/schema/mod.rs @@ -28,8 +28,8 @@ use async_openai::{ }, }; use auth::{ - AuthenticationService, Invitation, RefreshTokenResponse, RegisterResponse, TokenAuthResponse, - UserSecured, + AuthProvider, AuthenticationService, Invitation, LdapCredential, RefreshTokenResponse, + RegisterResponse, TokenAuthResponse, UpdateLdapCredentialInput, UserSecured, }; use base64::Engine; use chrono::{DateTime, Utc}; @@ -429,6 +429,10 @@ impl Query { Ok(RepositoryGrepOutput { files, elapsed_ms }) } + async fn auth_providers(ctx: &Context) -> Result> { + Ok(vec![]) + } + async fn oauth_credential( ctx: &Context, provider: OAuthProvider, @@ -442,6 +446,16 @@ impl Query { ctx.locator.auth().oauth_callback_url(provider).await } + async fn ldap_credential(ctx: &Context) -> Result> { + check_admin(ctx).await?; + Ok(None) + } + + async fn test_ldap_credential(ctx: &Context) -> Result { + check_admin(ctx).await?; + Ok(false) + } + async fn server_info(ctx: &Context) -> Result { Ok(ServerInfo { is_admin_initialized: ctx.locator.auth().is_admin_initialized().await?, @@ -1058,6 +1072,21 @@ impl Mutation { Ok(true) } + async fn update_ldap_credential( + ctx: &Context, + input: UpdateLdapCredentialInput, + ) -> Result { + check_admin(ctx).await?; + check_license(ctx, &[LicenseType::Enterprise]).await?; + input.validate()?; + Ok(true) + } + + async fn delete_ldap_credential(ctx: &Context) -> Result { + check_admin(ctx).await?; + Ok(true) + } + async fn update_email_setting(ctx: &Context, input: EmailSettingInput) -> Result { check_admin(ctx).await?; input.validate()?; diff --git a/ee/tabby-webserver/Cargo.toml b/ee/tabby-webserver/Cargo.toml index 75afa8fae5bf..9f30a82b2471 100644 --- a/ee/tabby-webserver/Cargo.toml +++ b/ee/tabby-webserver/Cargo.toml @@ -23,6 +23,7 @@ juniper.workspace = true juniper_axum = { version = "0.1", features = ["subscriptions"] } juniper_graphql_ws = "0.4" lazy_static.workspace = true +ldap3 = "0.11.0" lettre = { version = "0.11.3", features = ["tokio1", "tokio1-native-tls"] } mime_guess.workspace = true pin-project = "1.1.3" diff --git a/ee/tabby-webserver/src/ldap.rs b/ee/tabby-webserver/src/ldap.rs new file mode 100644 index 000000000000..76e127ec5e12 --- /dev/null +++ b/ee/tabby-webserver/src/ldap.rs @@ -0,0 +1,99 @@ +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use ldap3::{drive, result::Result, LdapConnAsync, Scope, SearchEntry}; +use tabby_schema::auth::AuthenticationService; + +#[async_trait] +pub trait LdapClient: Send + Sync { + async fn validate(&mut self, user: &str, password: &str) -> Result; +} + +pub async fn new_ldap_client(auth: Arc) -> Arc> { + Arc::new(Mutex::new(LdapClientImpl { auth })) +} + +pub struct LdapClientImpl { + auth: Arc, +} + +pub struct LdapUser { + pub email: String, + pub name: String, +} + +#[async_trait] +impl LdapClient for LdapClientImpl { + async fn validate(&mut self, user: &str, password: &str) -> Result { + let (connection, mut client) = LdapConnAsync::new("ldap://localhost:3890").await.unwrap(); + drive!(connection); + + // use bind_dn to search + let res = client + .simple_bind("cn=admin,ou=people,dc=ikw,dc=app", "password") + .await? + .success()?; + println!("Bind successful {:?}", res); + + let searched = client + .search( + "dc=ikw,dc=app", + Scope::OneLevel, + format!("(uid={})", user).as_ref(), + vec!["cn", "mail"], + ) + .await?; + + println!("Search result {:?}", searched); + + if let Some(entry) = searched.0.into_iter().next() { + let entry = SearchEntry::construct(entry); + let user_dn = entry.dn; + let email = entry + .attrs + .get("mail") + .and_then(|v| v.get(0)) + .cloned() + .unwrap_or_default(); + let name = entry + .attrs + .get("cn") + .and_then(|v| v.get(0)) + .cloned() + .unwrap_or_default(); + + client.simple_bind(&user_dn, password).await?.success()?; + + println!("Search result, email {} name: {}", email, name); + + Ok(LdapUser { email, name }) + } else { + Err(ldap3::LdapResult { + rc: 32, + matched: "".to_string(), + text: "User not found".to_string(), + refs: vec![], + ctrls: vec![], + } + .into()) + } + } +} + +#[cfg(test)] +pub mod test_client { + use super::*; + use crate::service::FakeAuthService; + + #[tokio::test] + async fn test_ldap_client() { + let auth = FakeAuthService::new(vec![]); + let client = new_ldap_client(Arc::new(auth)).await; + client + .lock() + .unwrap() + .validate("kw", "password") + .await + .unwrap(); + } +} diff --git a/ee/tabby-webserver/src/lib.rs b/ee/tabby-webserver/src/lib.rs index 06560ebd4d16..17e692577d85 100644 --- a/ee/tabby-webserver/src/lib.rs +++ b/ee/tabby-webserver/src/lib.rs @@ -2,6 +2,7 @@ mod axum; mod hub; mod jwt; +mod ldap; mod oauth; mod path; mod rate_limit; diff --git a/ee/tabby-webserver/src/service/mod.rs b/ee/tabby-webserver/src/service/mod.rs index f67734392f57..b2e0bdb0f203 100644 --- a/ee/tabby-webserver/src/service/mod.rs +++ b/ee/tabby-webserver/src/service/mod.rs @@ -20,6 +20,9 @@ pub mod web_documents; use std::sync::Arc; +#[cfg(test)] +pub use auth::testutils::FakeAuthService; + use answer::AnswerService; use anyhow::Context; use async_trait::async_trait; From 7ada1e815e6f2b29edfb976fc7f3d6cd4b0a110d Mon Sep 17 00:00:00 2001 From: Wei Zhang Date: Thu, 26 Dec 2024 19:11:29 +0800 Subject: [PATCH 02/12] feat(graphQL): add login with ldap support Signed-off-by: Wei Zhang --- Cargo.lock | 1 + Cargo.toml | 1 + ee/tabby-schema/Cargo.toml | 1 + ee/tabby-schema/graphql/schema.graphql | 1 + ee/tabby-schema/src/schema/auth.rs | 11 +++ ee/tabby-schema/src/schema/mod.rs | 23 +++++ ee/tabby-webserver/src/ldap.rs | 86 +++++++++++-------- ee/tabby-webserver/src/service/auth.rs | 66 ++++++++++++-- .../src/service/auth/testutils.rs | 7 ++ 9 files changed, 153 insertions(+), 44 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9e6d6087c6e5..7983da26c257 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5482,6 +5482,7 @@ dependencies = [ "hash-ids", "juniper", "lazy_static", + "ldap3", "regex", "serde", "strum 0.24.1", diff --git a/Cargo.toml b/Cargo.toml index 9488a6eca551..1d8a9d22ef9c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,7 @@ mime_guess = "2.0.4" assert_matches = "1.5" insta = "1.34.0" logkit = "0.3" +ldap3 = "0.11.0" async-openai = "0.20" tracing-test = "0.2" clap = "4.3.0" diff --git a/ee/tabby-schema/Cargo.toml b/ee/tabby-schema/Cargo.toml index a6849542d615..30eb999a675a 100644 --- a/ee/tabby-schema/Cargo.toml +++ b/ee/tabby-schema/Cargo.toml @@ -30,6 +30,7 @@ validator = { version = "0.18.1", features = ["derive"] } regex.workspace = true hash-ids.workspace = true url.workspace = true +ldap3.workspace = true [dev-dependencies] tabby-db = { path = "../../ee/tabby-db", features = ["testutils"]} diff --git a/ee/tabby-schema/graphql/schema.graphql b/ee/tabby-schema/graphql/schema.graphql index 238f536e59f2..650e6cb67997 100644 --- a/ee/tabby-schema/graphql/schema.graphql +++ b/ee/tabby-schema/graphql/schema.graphql @@ -619,6 +619,7 @@ type Mutation { updateUserName(id: ID!, name: String!): Boolean! register(email: String!, password1: String!, password2: String!, invitationCode: String, name: String!): RegisterResponse! tokenAuth(email: String!, password: String!): TokenAuthResponse! + tokenAuthLdap(userId: String!, password: String!): TokenAuthResponse! verifyToken(token: String!): Boolean! refreshToken(refreshToken: String!): RefreshTokenResponse! createInvitation(email: String!): ID! diff --git a/ee/tabby-schema/src/schema/auth.rs b/ee/tabby-schema/src/schema/auth.rs index 7ea2f78a2f1f..0e60f6e6e3de 100644 --- a/ee/tabby-schema/src/schema/auth.rs +++ b/ee/tabby-schema/src/schema/auth.rs @@ -67,6 +67,15 @@ pub struct TokenAuthInput { pub password: String, } +/// Input parameters for token_auth_ldap mutation +#[derive(Validate)] +pub struct TokenAuthLdapInput<'a> { + #[validate(length(min = 1, code = "user_id", message = "User ID should not be empty"))] + pub user_id: &'a str, + #[validate(length(min = 1, code = "password", message = "Password should not be empty"))] + pub password: &'a str, +} + /// Input parameters for register mutation /// `validate` attribute is used to validate the input parameters /// - `code` argument specifies which parameter causes the failure @@ -412,6 +421,8 @@ pub trait AuthenticationService: Send + Sync { async fn token_auth(&self, email: String, password: String) -> Result; + async fn token_auth_ldap(&self, email: &str, password: &str) -> Result; + async fn refresh_token(&self, refresh_token: String) -> Result; async fn verify_access_token(&self, access_token: &str) -> Result; async fn verify_auth_token(&self, token: &str) -> Result; diff --git a/ee/tabby-schema/src/schema/mod.rs b/ee/tabby-schema/src/schema/mod.rs index 6a926a5b2942..4e31ea67f024 100644 --- a/ee/tabby-schema/src/schema/mod.rs +++ b/ee/tabby-schema/src/schema/mod.rs @@ -41,6 +41,7 @@ use juniper::{ graphql_object, graphql_subscription, graphql_value, FieldError, GraphQLEnum, GraphQLObject, IntoFieldError, Object, RootNode, ScalarValue, Value, ID, }; +use ldap3::result::LdapError; use notification::NotificationService; use repository::RepositoryGrepOutput; use tabby_common::{ @@ -145,6 +146,12 @@ pub enum CoreError { Other(#[from] anyhow::Error), } +impl From for CoreError { + fn from(err: LdapError) -> Self { + Self::Other(err.into()) + } +} + impl IntoFieldError for CoreError { fn into_field_error(self) -> FieldError { match self { @@ -989,6 +996,22 @@ impl Mutation { .await } + async fn token_auth_ldap( + ctx: &Context, + user_id: String, + password: String, + ) -> Result { + let input = auth::TokenAuthLdapInput { + user_id: &user_id, + password: &password, + }; + input.validate()?; + ctx.locator + .auth() + .token_auth_ldap(&user_id, &password) + .await + } + async fn verify_token(ctx: &Context, token: String) -> Result { ctx.locator.auth().verify_access_token(&token).await?; Ok(true) diff --git a/ee/tabby-webserver/src/ldap.rs b/ee/tabby-webserver/src/ldap.rs index 76e127ec5e12..4ee147355be0 100644 --- a/ee/tabby-webserver/src/ldap.rs +++ b/ee/tabby-webserver/src/ldap.rs @@ -1,20 +1,42 @@ -use std::sync::{Arc, Mutex}; - +use anyhow::anyhow; use async_trait::async_trait; -use ldap3::{drive, result::Result, LdapConnAsync, Scope, SearchEntry}; -use tabby_schema::auth::AuthenticationService; +use ldap3::{drive, LdapConnAsync, Scope, SearchEntry}; +use tabby_schema::{CoreError, Result}; #[async_trait] pub trait LdapClient: Send + Sync { async fn validate(&mut self, user: &str, password: &str) -> Result; } -pub async fn new_ldap_client(auth: Arc) -> Arc> { - Arc::new(Mutex::new(LdapClientImpl { auth })) +pub fn new_ldap_client( + address: String, + bind_dn: String, + bind_password: String, + base_dn: String, + user_filter: String, + email_attr: String, + name_attr: String, +) -> impl LdapClient { + LdapClientImpl { + address, + bind_dn, + bind_password, + base_dn, + user_filter, + email_attr, + name_attr, + } } pub struct LdapClientImpl { - auth: Arc, + address: String, + bind_dn: String, + bind_password: String, + base_dn: String, + user_filter: String, + + email_attr: String, + name_attr: String, } pub struct LdapUser { @@ -25,55 +47,52 @@ pub struct LdapUser { #[async_trait] impl LdapClient for LdapClientImpl { async fn validate(&mut self, user: &str, password: &str) -> Result { - let (connection, mut client) = LdapConnAsync::new("ldap://localhost:3890").await.unwrap(); + let (connection, mut client) = LdapConnAsync::new(&self.address).await?; drive!(connection); // use bind_dn to search - let res = client - .simple_bind("cn=admin,ou=people,dc=ikw,dc=app", "password") + let _res = client + .simple_bind(&self.bind_dn, &self.bind_password) .await? .success()?; - println!("Bind successful {:?}", res); let searched = client .search( - "dc=ikw,dc=app", + &self.base_dn, Scope::OneLevel, - format!("(uid={})", user).as_ref(), - vec!["cn", "mail"], + &self.user_filter.replace("%s", user), + vec![&self.name_attr, &self.email_attr], ) .await?; - println!("Search result {:?}", searched); - if let Some(entry) = searched.0.into_iter().next() { let entry = SearchEntry::construct(entry); let user_dn = entry.dn; let email = entry .attrs - .get("mail") + .get(&self.email_attr) .and_then(|v| v.get(0)) .cloned() - .unwrap_or_default(); + .ok_or_else(|| CoreError::Other(anyhow!("email not found for user")))?; let name = entry .attrs - .get("cn") + .get(&self.name_attr) .and_then(|v| v.get(0)) .cloned() - .unwrap_or_default(); + .ok_or_else(|| CoreError::Other(anyhow!("name not found for user")))?; client.simple_bind(&user_dn, password).await?.success()?; - println!("Search result, email {} name: {}", email, name); - Ok(LdapUser { email, name }) } else { - Err(ldap3::LdapResult { - rc: 32, - matched: "".to_string(), - text: "User not found".to_string(), - refs: vec![], - ctrls: vec![], + Err(ldap3::LdapError::LdapResult { + result: ldap3::LdapResult { + rc: 32, + matched: user.to_string(), + text: "User not found".to_string(), + refs: vec![], + ctrls: vec![], + }, } .into()) } @@ -83,17 +102,10 @@ impl LdapClient for LdapClientImpl { #[cfg(test)] pub mod test_client { use super::*; - use crate::service::FakeAuthService; #[tokio::test] async fn test_ldap_client() { - let auth = FakeAuthService::new(vec![]); - let client = new_ldap_client(Arc::new(auth)).await; - client - .lock() - .unwrap() - .validate("kw", "password") - .await - .unwrap(); + let mut client = new_ldap_client(); + client.validate("kw", "password").await.unwrap(); } } diff --git a/ee/tabby-webserver/src/service/auth.rs b/ee/tabby-webserver/src/service/auth.rs index e8d13c17dacf..30b8c0d25ea3 100644 --- a/ee/tabby-webserver/src/service/auth.rs +++ b/ee/tabby-webserver/src/service/auth.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use tokio::sync::Mutex; use anyhow::{anyhow, Context}; use argon2::{ @@ -29,6 +30,7 @@ use super::{graphql_pagination_to_filter, UserSecuredExt}; use crate::{ bail, jwt::{generate_jwt, validate_jwt}, + ldap::{self, LdapClient}, oauth::{self, OAuthClient}, }; @@ -320,6 +322,34 @@ impl AuthenticationService for AuthenticationServiceImpl { Ok(resp) } + async fn token_auth_ldap(&self, user_id: &str, password: &str) -> Result { + let client = ldap::new_ldap_client( + "ldap://localhost:3890".to_string(), + "cn=admin,ou=people,dc=ikw,dc=app".to_string(), + "password".to_string(), + "ou=people,dc=ikw,dc=app".to_string(), + "(&(objectClass=inetOrgPerson)(uid=%s))".to_string(), + "mail".to_string(), + "cn".to_string(), + ); + let license = self + .license + .read() + .await + .context("Failed to read license info")?; + + ldap_login( + Arc::new(Mutex::new(client)), + &self.db, + &*self.setting, + &license, + &*self.mail, + user_id, + password, + ) + .await + } + async fn refresh_token(&self, token: String) -> Result { let Some(refresh_token) = self.db.get_refresh_token(&token).await? else { bail!("Invalid refresh token"); @@ -580,6 +610,28 @@ impl AuthenticationService for AuthenticationServiceImpl { } } +async fn ldap_login( + client: Arc>, + db: &DbConn, + setting: &dyn SettingService, + license: &LicenseInfo, + mail: &dyn EmailService, + user_id: &str, + password: &str, +) -> Result { + let user = client.lock().await.validate(user_id, password).await?; + let user_id = get_or_create_sso_user(license, db, setting, mail, &user.email, &user.name) + .await + .map_err(|e| CoreError::Other(anyhow!("fail to get or create ldap user: {}", e)))?; + + let refresh_token = db.create_refresh_token(user_id).await?; + let access_token = generate_jwt(user_id.as_id()) + .map_err(|e| CoreError::Other(anyhow!("fail to create access_token: {}", e)))?; + + let resp = TokenAuthResponse::new(access_token, refresh_token); + Ok(resp) +} + async fn oauth_login( client: Arc, code: String, @@ -591,7 +643,7 @@ async fn oauth_login( let access_token = client.exchange_code_for_token(code).await?; let email = client.fetch_user_email(&access_token).await?; let name = client.fetch_user_full_name(&access_token).await?; - let user_id = get_or_create_oauth_user(license, db, setting, mail, &email, &name).await?; + let user_id = get_or_create_sso_user(license, db, setting, mail, &email, &name).await?; let refresh_token = db.create_refresh_token(user_id).await?; @@ -604,7 +656,7 @@ async fn oauth_login( Ok(resp) } -async fn get_or_create_oauth_user( +async fn get_or_create_sso_user( license: &LicenseInfo, db: &DbConn, setting: &dyn SettingService, @@ -638,7 +690,7 @@ async fn get_or_create_oauth_user( // it's ok to set password to null here, because // 1. both `register` & `token_auth` mutation will do input validation, so empty password won't be accepted // 2. `password_verify` will always return false for empty password hash read from user table - // so user created here is only able to login by github oauth, normal login won't work + // so user created here is only able to login by github oauth, or ldap, normal login won't work let res = db.create_user(email.to_owned(), None, false, name).await?; if let Err(e) = mail.send_signup(email.to_string()).await { @@ -1039,7 +1091,7 @@ mod tests { service.db.update_user_active(id, false).await.unwrap(); let setting = service.setting; - let res = get_or_create_oauth_user( + let res = get_or_create_sso_user( &license, &service.db, &*setting, @@ -1056,7 +1108,7 @@ mod tests { .await .unwrap(); - let res = get_or_create_oauth_user( + let res = get_or_create_sso_user( &license, &service.db, &*setting, @@ -1074,7 +1126,7 @@ mod tests { tokio::time::sleep(Duration::milliseconds(50).to_std().unwrap()).await; assert_eq!(mail.list_mail().await[0].subject, "Welcome to Tabby!"); - let res = get_or_create_oauth_user( + let res = get_or_create_sso_user( &license, &service.db, &*setting, @@ -1091,7 +1143,7 @@ mod tests { .await .unwrap(); - let res = get_or_create_oauth_user( + let res = get_or_create_sso_user( &license, &service.db, &*setting, diff --git a/ee/tabby-webserver/src/service/auth/testutils.rs b/ee/tabby-webserver/src/service/auth/testutils.rs index eca153cee36a..c49e4057a3a6 100644 --- a/ee/tabby-webserver/src/service/auth/testutils.rs +++ b/ee/tabby-webserver/src/service/auth/testutils.rs @@ -80,6 +80,13 @@ impl AuthenticationService for FakeAuthService { )) } + async fn token_auth_ldap(&self, _user_id: &str, _password: &str) -> Result { + Ok(TokenAuthResponse::new( + "access_token".to_string(), + "refresh_token".to_string(), + )) + } + async fn refresh_token(&self, _token: String) -> Result { Ok(RefreshTokenResponse::new( "access_token".to_string(), From 10beeecf5fd091e90efc4e0fd8b40c9cd9847fdf Mon Sep 17 00:00:00 2001 From: Wei Zhang Date: Fri, 27 Dec 2024 15:38:29 +0800 Subject: [PATCH 03/12] feat(graphQL): add ldap crendential related apis Signed-off-by: Wei Zhang --- ee/tabby-db/src/lib.rs | 1 + ee/tabby-schema/graphql/schema.graphql | 2 +- ee/tabby-schema/src/dao.rs | 51 +++++++++++++-- ee/tabby-schema/src/schema/auth.rs | 78 ++++++++++++++++------ ee/tabby-schema/src/schema/mod.rs | 16 +++-- ee/tabby-webserver/src/ldap.rs | 5 +- ee/tabby-webserver/src/service/auth.rs | 91 +++++++++++++++++++++----- 7 files changed, 194 insertions(+), 50 deletions(-) diff --git a/ee/tabby-db/src/lib.rs b/ee/tabby-db/src/lib.rs index f40196ddf903..a73465aff58a 100644 --- a/ee/tabby-db/src/lib.rs +++ b/ee/tabby-db/src/lib.rs @@ -8,6 +8,7 @@ pub use email_setting::EmailSettingDAO; pub use integrations::IntegrationDAO; pub use invitations::InvitationDAO; pub use job_runs::JobRunDAO; +pub use ldap_credential::LdapCredentialDAO; pub use notifications::NotificationDAO; pub use oauth_credential::OAuthCredentialDAO; pub use provided_repositories::ProvidedRepositoryDAO; diff --git a/ee/tabby-schema/graphql/schema.graphql b/ee/tabby-schema/graphql/schema.graphql index 650e6cb67997..1e367426877a 100644 --- a/ee/tabby-schema/graphql/schema.graphql +++ b/ee/tabby-schema/graphql/schema.graphql @@ -631,6 +631,7 @@ type Mutation { deleteInvitation(id: ID!): ID! updateOauthCredential(input: UpdateOAuthCredentialInput!): Boolean! deleteOauthCredential(provider: OAuthProvider!): Boolean! + testLdapCredential(input: UpdateLdapCredentialInput!): Boolean! updateLdapCredential(input: UpdateLdapCredentialInput!): Boolean! deleteLdapCredential: Boolean! updateEmailSetting(input: EmailSettingInput!): Boolean! @@ -768,7 +769,6 @@ type Query { oauthCredential(provider: OAuthProvider!): OAuthCredential oauthCallbackUrl(provider: OAuthProvider!): String! ldapCredential: LdapCredential - testLdapCredential: Boolean! serverInfo: ServerInfo! license: LicenseInfo! jobs: [String!]! diff --git a/ee/tabby-schema/src/dao.rs b/ee/tabby-schema/src/dao.rs index 60b7c64c8073..ebc6d5748fe0 100644 --- a/ee/tabby-schema/src/dao.rs +++ b/ee/tabby-schema/src/dao.rs @@ -2,19 +2,20 @@ use anyhow::bail; use hash_ids::HashIds; use lazy_static::lazy_static; use tabby_db::{ - EmailSettingDAO, IntegrationDAO, InvitationDAO, JobRunDAO, NotificationDAO, OAuthCredentialDAO, - ServerSettingDAO, ThreadDAO, ThreadMessageAttachmentClientCode, ThreadMessageAttachmentCode, - ThreadMessageAttachmentDoc, ThreadMessageAttachmentIssueDoc, ThreadMessageAttachmentPullDoc, - ThreadMessageAttachmentWebDoc, UserEventDAO, + EmailSettingDAO, IntegrationDAO, InvitationDAO, JobRunDAO, LdapCredentialDAO, NotificationDAO, + OAuthCredentialDAO, ServerSettingDAO, ThreadDAO, ThreadMessageAttachmentClientCode, + ThreadMessageAttachmentCode, ThreadMessageAttachmentDoc, ThreadMessageAttachmentIssueDoc, + ThreadMessageAttachmentPullDoc, ThreadMessageAttachmentWebDoc, UserEventDAO, }; use crate::{ + auth::LdapEncryptionKind, integration::{Integration, IntegrationKind, IntegrationStatus}, interface::UserValue, notification::{Notification, NotificationRecipient}, repository::RepositoryKind, schema::{ - auth::{self, OAuthCredential, OAuthProvider}, + auth::{self, LdapCredential, OAuthCredential, OAuthProvider}, email::{AuthMethod, EmailSetting, Encryption}, job, repository::{ @@ -67,6 +68,27 @@ impl TryFrom for OAuthCredential { } } +impl TryFrom for LdapCredential { + type Error = anyhow::Error; + + fn try_from(val: LdapCredentialDAO) -> Result { + Ok(LdapCredential { + host: val.host, + port: val.port as i32, + bind_dn: val.bind_dn, + bind_password: val.bind_password, + base_dn: val.base_dn, + user_filter: val.user_filter, + encryption: LdapEncryptionKind::from_enum_str(&val.encryption)?, + skip_tls_verify: val.skip_tls_verify, + email_attribute: val.email_attribute, + name_attribute: val.name_attribute, + created_at: val.created_at, + updated_at: val.updated_at, + }) + } +} + impl TryFrom for EmailSetting { type Error = anyhow::Error; @@ -447,6 +469,25 @@ impl DbEnum for OAuthProvider { } } +impl DbEnum for LdapEncryptionKind { + fn as_enum_str(&self) -> &'static str { + match self { + LdapEncryptionKind::None => "none", + LdapEncryptionKind::StartTLS => "starttls", + LdapEncryptionKind::LDAPS => "ldaps", + } + } + + fn from_enum_str(s: &str) -> anyhow::Result { + match s { + "none" => Ok(LdapEncryptionKind::None), + "starttls" => Ok(LdapEncryptionKind::StartTLS), + "ldaps" => Ok(LdapEncryptionKind::LDAPS), + _ => bail!("Invalid Ldap encryption kind"), + } + } +} + impl DbEnum for AuthMethod { fn as_enum_str(&self) -> &'static str { match self { diff --git a/ee/tabby-schema/src/schema/auth.rs b/ee/tabby-schema/src/schema/auth.rs index 0e60f6e6e3de..2ff2e836e595 100644 --- a/ee/tabby-schema/src/schema/auth.rs +++ b/ee/tabby-schema/src/schema/auth.rs @@ -379,30 +379,61 @@ pub enum LdapEncryptionKind { #[derive(GraphQLInputObject, Validate)] pub struct UpdateLdapCredentialInput { - host: String, - port: i32, - bind_dn: String, - bind_password: String, - base_dn: String, - user_filter: String, - encryption: LdapEncryptionKind, - skip_tls_verify: bool, - email_attribute: String, - name_attribute: String, + #[validate(length( + min = 1, + code = "host", + message = "host should not be empty and should be a valid hostname or IP address" + ))] + pub host: String, + pub port: i32, + + #[validate(length(min = 1, code = "bind_dn", message = "bind_dn cannot be empty"))] + pub bind_dn: String, + #[validate(length( + min = 1, + code = "bind_password", + message = "bind_password cannot be empty" + ))] + pub bind_password: String, + + #[validate(length(min = 1, code = "base_dn", message = "base_dn cannot be empty"))] + pub base_dn: String, + #[validate(length( + min = 1, + code = "user_filter", + message = "user_filter cannot be empty, and should be in the format of `(uid=%s)`" + ))] + pub user_filter: String, + + pub encryption: LdapEncryptionKind, + pub skip_tls_verify: bool, + + #[validate(length( + min = 1, + code = "email_attribute", + message = "email_attribute cannot be empty" + ))] + pub email_attribute: String, + #[validate(length( + min = 1, + code = "name_attribute", + message = "name_attribute cannot be empty" + ))] + pub name_attribute: String, } #[derive(GraphQLObject)] pub struct LdapCredential { - host: String, - port: i32, - bind_dn: String, - bind_password: String, - base_dn: String, - user_filter: String, - encryption: LdapEncryptionKind, - skip_tls_verify: bool, - email_attribute: String, - name_attribute: String, + pub host: String, + pub port: i32, + pub bind_dn: String, + pub bind_password: String, + pub base_dn: String, + pub user_filter: String, + pub encryption: LdapEncryptionKind, + pub skip_tls_verify: bool, + pub email_attribute: String, + pub name_attribute: String, pub created_at: DateTime, pub updated_at: DateTime, @@ -476,8 +507,13 @@ pub trait AuthenticationService: Send + Sync { ) -> Result>; async fn update_oauth_credential(&self, input: UpdateOAuthCredentialInput) -> Result<()>; - async fn delete_oauth_credential(&self, provider: OAuthProvider) -> Result<()>; + + async fn read_ldap_credential(&self) -> Result>; + async fn test_ldap_credential(&self, input: UpdateLdapCredentialInput) -> Result<()>; + async fn update_ldap_credential(&self, input: UpdateLdapCredentialInput) -> Result<()>; + async fn delete_ldap_credential(&self) -> Result<()>; + async fn update_user_active(&self, id: &ID, active: bool) -> Result<()>; async fn update_user_role(&self, id: &ID, is_admin: bool) -> Result<()>; async fn update_user_avatar(&self, id: &ID, avatar: Option>) -> Result<()>; diff --git a/ee/tabby-schema/src/schema/mod.rs b/ee/tabby-schema/src/schema/mod.rs index 4e31ea67f024..6e2c024e87fb 100644 --- a/ee/tabby-schema/src/schema/mod.rs +++ b/ee/tabby-schema/src/schema/mod.rs @@ -455,12 +455,7 @@ impl Query { async fn ldap_credential(ctx: &Context) -> Result> { check_admin(ctx).await?; - Ok(None) - } - - async fn test_ldap_credential(ctx: &Context) -> Result { - check_admin(ctx).await?; - Ok(false) + ctx.locator.auth().read_ldap_credential().await } async fn server_info(ctx: &Context) -> Result { @@ -1095,6 +1090,12 @@ impl Mutation { Ok(true) } + async fn test_ldap_credential(ctx: &Context, input: UpdateLdapCredentialInput) -> Result { + check_admin(ctx).await?; + ctx.locator.auth().test_ldap_credential(input).await?; + Ok(true) + } + async fn update_ldap_credential( ctx: &Context, input: UpdateLdapCredentialInput, @@ -1102,11 +1103,14 @@ impl Mutation { check_admin(ctx).await?; check_license(ctx, &[LicenseType::Enterprise]).await?; input.validate()?; + + ctx.locator.auth().update_ldap_credential(input).await?; Ok(true) } async fn delete_ldap_credential(ctx: &Context) -> Result { check_admin(ctx).await?; + ctx.locator.auth().delete_ldap_credential().await?; Ok(true) } diff --git a/ee/tabby-webserver/src/ldap.rs b/ee/tabby-webserver/src/ldap.rs index 4ee147355be0..6bccde03f3a3 100644 --- a/ee/tabby-webserver/src/ldap.rs +++ b/ee/tabby-webserver/src/ldap.rs @@ -9,7 +9,8 @@ pub trait LdapClient: Send + Sync { } pub fn new_ldap_client( - address: String, + host: String, + port: i64, bind_dn: String, bind_password: String, base_dn: String, @@ -18,7 +19,7 @@ pub fn new_ldap_client( name_attr: String, ) -> impl LdapClient { LdapClientImpl { - address, + address: format!("ldap://{}:{}", host, port), bind_dn, bind_password, base_dn, diff --git a/ee/tabby-webserver/src/service/auth.rs b/ee/tabby-webserver/src/service/auth.rs index 30b8c0d25ea3..0312cf595b86 100644 --- a/ee/tabby-webserver/src/service/auth.rs +++ b/ee/tabby-webserver/src/service/auth.rs @@ -13,9 +13,10 @@ use juniper::ID; use tabby_db::{DbConn, InvitationDAO}; use tabby_schema::{ auth::{ - AuthenticationService, Invitation, JWTPayload, OAuthCredential, OAuthError, OAuthProvider, - OAuthResponse, RefreshTokenResponse, RegisterResponse, RequestInvitationInput, - TokenAuthResponse, UpdateOAuthCredentialInput, UserSecured, + AuthenticationService, Invitation, JWTPayload, LdapCredential, OAuthCredential, OAuthError, + OAuthProvider, OAuthResponse, RefreshTokenResponse, RegisterResponse, + RequestInvitationInput, TokenAuthResponse, UpdateLdapCredentialInput, + UpdateOAuthCredentialInput, UserSecured, }, email::EmailService, is_demo_mode, @@ -323,15 +324,6 @@ impl AuthenticationService for AuthenticationServiceImpl { } async fn token_auth_ldap(&self, user_id: &str, password: &str) -> Result { - let client = ldap::new_ldap_client( - "ldap://localhost:3890".to_string(), - "cn=admin,ou=people,dc=ikw,dc=app".to_string(), - "password".to_string(), - "ou=people,dc=ikw,dc=app".to_string(), - "(&(objectClass=inetOrgPerson)(uid=%s))".to_string(), - "mail".to_string(), - "cn".to_string(), - ); let license = self .license .read() @@ -339,7 +331,6 @@ impl AuthenticationService for AuthenticationServiceImpl { .context("Failed to read license info")?; ldap_login( - Arc::new(Mutex::new(client)), &self.db, &*self.setting, &license, @@ -581,6 +572,60 @@ impl AuthenticationService for AuthenticationServiceImpl { Ok(()) } + async fn read_ldap_credential(&self) -> Result> { + let credential = self.db.read_ldap_credential().await?; + match credential { + Some(c) => Ok(Some(c.try_into()?)), + None => Ok(None), + } + } + + async fn test_ldap_credential(&self, input: UpdateLdapCredentialInput) -> Result<()> { + let mut client = ldap::new_ldap_client( + input.host, + input.port as i64, + input.bind_dn, + input.bind_password, + input.base_dn, + input.user_filter, + input.email_attribute, + input.name_attribute, + ); + + if let Err(e) = client.validate("", "").await { + if e.to_string().contains("User not found") { + return Ok(()); + } else { + bail!("Failed to connect to LDAP server: {e}"); + } + } + + Ok(()) + } + + async fn update_ldap_credential(&self, input: UpdateLdapCredentialInput) -> Result<()> { + self.db + .update_ldap_credential( + &input.host, + input.port, + &input.bind_dn, + &input.bind_password, + &input.base_dn, + &input.user_filter, + &input.encryption.as_enum_str(), + input.skip_tls_verify, + &input.email_attribute, + &input.name_attribute, + ) + .await?; + Ok(()) + } + + async fn delete_ldap_credential(&self) -> Result<()> { + self.db.delete_ldap_credential().await?; + Ok(()) + } + async fn update_user_active(&self, id: &ID, active: bool) -> Result<()> { let id = id.as_rowid()?; let user = self.db.get_user(id).await?.context("User doesn't exits")?; @@ -611,7 +656,6 @@ impl AuthenticationService for AuthenticationServiceImpl { } async fn ldap_login( - client: Arc>, db: &DbConn, setting: &dyn SettingService, license: &LicenseInfo, @@ -619,7 +663,24 @@ async fn ldap_login( user_id: &str, password: &str, ) -> Result { - let user = client.lock().await.validate(user_id, password).await?; + let credential = db.read_ldap_credential().await?; + if credential.is_none() { + bail!("LDAP is not configured"); + } + + let credential = credential.unwrap(); + let mut client = ldap::new_ldap_client( + credential.host, + credential.port, + credential.bind_dn, + credential.bind_password, + credential.base_dn, + credential.user_filter, + credential.email_attribute, + credential.name_attribute, + ); + + let user = client.validate(user_id, password).await?; let user_id = get_or_create_sso_user(license, db, setting, mail, &user.email, &user.name) .await .map_err(|e| CoreError::Other(anyhow!("fail to get or create ldap user: {}", e)))?; From 08d3d6d66c2f3581040dc4f91037c105ae30d3c5 Mon Sep 17 00:00:00 2001 From: Wei Zhang Date: Fri, 27 Dec 2024 16:13:47 +0800 Subject: [PATCH 04/12] chore: need enterprise to test ldap Signed-off-by: Wei Zhang --- ee/tabby-schema/src/schema/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/ee/tabby-schema/src/schema/mod.rs b/ee/tabby-schema/src/schema/mod.rs index 6e2c024e87fb..21c8b767cdf1 100644 --- a/ee/tabby-schema/src/schema/mod.rs +++ b/ee/tabby-schema/src/schema/mod.rs @@ -1092,6 +1092,7 @@ impl Mutation { async fn test_ldap_credential(ctx: &Context, input: UpdateLdapCredentialInput) -> Result { check_admin(ctx).await?; + check_license(ctx, &[LicenseType::Enterprise]).await?; ctx.locator.auth().test_ldap_credential(input).await?; Ok(true) } From 517426837ddbd1513dae7b7507348068f08a9bb6 Mon Sep 17 00:00:00 2001 From: Wei Zhang Date: Fri, 27 Dec 2024 16:20:17 +0800 Subject: [PATCH 05/12] chore: fix review Signed-off-by: Wei Zhang --- ee/tabby-schema/graphql/schema.graphql | 3 +-- ee/tabby-schema/src/dao.rs | 1 - ee/tabby-schema/src/schema/auth.rs | 23 +++++++++++------------ ee/tabby-schema/src/schema/mod.rs | 4 ++-- ee/tabby-webserver/src/service/auth.rs | 2 +- 5 files changed, 15 insertions(+), 18 deletions(-) diff --git a/ee/tabby-schema/graphql/schema.graphql b/ee/tabby-schema/graphql/schema.graphql index 1e367426877a..3b914d3115fd 100644 --- a/ee/tabby-schema/graphql/schema.graphql +++ b/ee/tabby-schema/graphql/schema.graphql @@ -498,7 +498,6 @@ type LdapCredential { host: String! port: Int! bindDn: String! - bindPassword: String! baseDn: String! userFilter: String! encryption: LdapEncryptionKind! @@ -631,7 +630,7 @@ type Mutation { deleteInvitation(id: ID!): ID! updateOauthCredential(input: UpdateOAuthCredentialInput!): Boolean! deleteOauthCredential(provider: OAuthProvider!): Boolean! - testLdapCredential(input: UpdateLdapCredentialInput!): Boolean! + testLdapConnection(input: UpdateLdapCredentialInput!): Boolean! updateLdapCredential(input: UpdateLdapCredentialInput!): Boolean! deleteLdapCredential: Boolean! updateEmailSetting(input: EmailSettingInput!): Boolean! diff --git a/ee/tabby-schema/src/dao.rs b/ee/tabby-schema/src/dao.rs index ebc6d5748fe0..117bd62d5274 100644 --- a/ee/tabby-schema/src/dao.rs +++ b/ee/tabby-schema/src/dao.rs @@ -76,7 +76,6 @@ impl TryFrom for LdapCredential { host: val.host, port: val.port as i32, bind_dn: val.bind_dn, - bind_password: val.bind_password, base_dn: val.base_dn, user_filter: val.user_filter, encryption: LdapEncryptionKind::from_enum_str(&val.encryption)?, diff --git a/ee/tabby-schema/src/schema/auth.rs b/ee/tabby-schema/src/schema/auth.rs index 2ff2e836e595..fb1d04a62cf7 100644 --- a/ee/tabby-schema/src/schema/auth.rs +++ b/ee/tabby-schema/src/schema/auth.rs @@ -387,21 +387,21 @@ pub struct UpdateLdapCredentialInput { pub host: String, pub port: i32, - #[validate(length(min = 1, code = "bind_dn", message = "bind_dn cannot be empty"))] + #[validate(length(min = 1, code = "bindDn", message = "bindDn cannot be empty"))] pub bind_dn: String, #[validate(length( min = 1, - code = "bind_password", - message = "bind_password cannot be empty" + code = "bindPassword", + message = "bindPassword cannot be empty" ))] pub bind_password: String, - #[validate(length(min = 1, code = "base_dn", message = "base_dn cannot be empty"))] + #[validate(length(min = 1, code = "baseDn", message = "baseDn cannot be empty"))] pub base_dn: String, #[validate(length( min = 1, - code = "user_filter", - message = "user_filter cannot be empty, and should be in the format of `(uid=%s)`" + code = "userFilter", + message = "userFilter cannot be empty, and should be in the format of `(uid=%s)`" ))] pub user_filter: String, @@ -410,14 +410,14 @@ pub struct UpdateLdapCredentialInput { #[validate(length( min = 1, - code = "email_attribute", - message = "email_attribute cannot be empty" + code = "emailAttribute", + message = "emailAttribute cannot be empty" ))] pub email_attribute: String, #[validate(length( min = 1, - code = "name_attribute", - message = "name_attribute cannot be empty" + code = "nameAttribute", + message = "nameAttribute cannot be empty" ))] pub name_attribute: String, } @@ -427,7 +427,6 @@ pub struct LdapCredential { pub host: String, pub port: i32, pub bind_dn: String, - pub bind_password: String, pub base_dn: String, pub user_filter: String, pub encryption: LdapEncryptionKind, @@ -510,7 +509,7 @@ pub trait AuthenticationService: Send + Sync { async fn delete_oauth_credential(&self, provider: OAuthProvider) -> Result<()>; async fn read_ldap_credential(&self) -> Result>; - async fn test_ldap_credential(&self, input: UpdateLdapCredentialInput) -> Result<()>; + async fn test_ldap_connection(&self, input: UpdateLdapCredentialInput) -> Result<()>; async fn update_ldap_credential(&self, input: UpdateLdapCredentialInput) -> Result<()>; async fn delete_ldap_credential(&self) -> Result<()>; diff --git a/ee/tabby-schema/src/schema/mod.rs b/ee/tabby-schema/src/schema/mod.rs index 21c8b767cdf1..ebebf208a8c8 100644 --- a/ee/tabby-schema/src/schema/mod.rs +++ b/ee/tabby-schema/src/schema/mod.rs @@ -1090,10 +1090,10 @@ impl Mutation { Ok(true) } - async fn test_ldap_credential(ctx: &Context, input: UpdateLdapCredentialInput) -> Result { + async fn test_ldap_connection(ctx: &Context, input: UpdateLdapCredentialInput) -> Result { check_admin(ctx).await?; check_license(ctx, &[LicenseType::Enterprise]).await?; - ctx.locator.auth().test_ldap_credential(input).await?; + ctx.locator.auth().test_ldap_connection(input).await?; Ok(true) } diff --git a/ee/tabby-webserver/src/service/auth.rs b/ee/tabby-webserver/src/service/auth.rs index 0312cf595b86..b256c0034ab6 100644 --- a/ee/tabby-webserver/src/service/auth.rs +++ b/ee/tabby-webserver/src/service/auth.rs @@ -580,7 +580,7 @@ impl AuthenticationService for AuthenticationServiceImpl { } } - async fn test_ldap_credential(&self, input: UpdateLdapCredentialInput) -> Result<()> { + async fn test_ldap_connection(&self, input: UpdateLdapCredentialInput) -> Result<()> { let mut client = ldap::new_ldap_client( input.host, input.port as i64, From 14ced05c8d7a1e6ef0697e7e9ded7d016454c720 Mon Sep 17 00:00:00 2001 From: Wei Zhang Date: Mon, 30 Dec 2024 18:18:45 +0800 Subject: [PATCH 06/12] feat(graphQL): add auth_providers to list all login providers Signed-off-by: Wei Zhang --- ee/tabby-schema/src/schema/auth.rs | 16 ++++++++++++++++ ee/tabby-schema/src/schema/mod.rs | 27 ++++++++++++++++++++++++--- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/ee/tabby-schema/src/schema/auth.rs b/ee/tabby-schema/src/schema/auth.rs index fb1d04a62cf7..656b83c0bd1a 100644 --- a/ee/tabby-schema/src/schema/auth.rs +++ b/ee/tabby-schema/src/schema/auth.rs @@ -339,6 +339,22 @@ pub enum AuthProviderKind { Ldap, } +impl From for AuthProvider { + fn from(provider: OAuthProvider) -> Self { + match provider { + OAuthProvider::Github => AuthProvider { + kind: AuthProviderKind::OAuthGithub, + }, + OAuthProvider::Google => AuthProvider { + kind: AuthProviderKind::OAuthGoogle, + }, + OAuthProvider::Gitlab => AuthProvider { + kind: AuthProviderKind::OAuthGitlab, + }, + } + } +} + #[derive(GraphQLObject)] pub struct AuthProvider { pub kind: AuthProviderKind, diff --git a/ee/tabby-schema/src/schema/mod.rs b/ee/tabby-schema/src/schema/mod.rs index ebebf208a8c8..550aa571be83 100644 --- a/ee/tabby-schema/src/schema/mod.rs +++ b/ee/tabby-schema/src/schema/mod.rs @@ -28,8 +28,9 @@ use async_openai::{ }, }; use auth::{ - AuthProvider, AuthenticationService, Invitation, LdapCredential, RefreshTokenResponse, - RegisterResponse, TokenAuthResponse, UpdateLdapCredentialInput, UserSecured, + AuthProvider, AuthProviderKind, AuthenticationService, Invitation, LdapCredential, + RefreshTokenResponse, RegisterResponse, TokenAuthResponse, UpdateLdapCredentialInput, + UserSecured, }; use base64::Engine; use chrono::{DateTime, Utc}; @@ -44,6 +45,7 @@ use juniper::{ use ldap3::result::LdapError; use notification::NotificationService; use repository::RepositoryGrepOutput; +use strum::IntoEnumIterator; use tabby_common::{ api::{code::CodeSearch, event::EventLogger}, config::CompletionConfig, @@ -437,7 +439,26 @@ impl Query { } async fn auth_providers(ctx: &Context) -> Result> { - Ok(vec![]) + let mut providers = vec![]; + + let auth = ctx.locator.auth(); + for x in OAuthProvider::iter() { + if auth + .read_oauth_credential(x.clone()) + .await + .is_ok_and(|x| x.is_some()) + { + providers.push(x.into()); + } + } + + if auth.read_ldap_credential().await.is_ok_and(|x| x.is_some()) { + providers.push(AuthProvider { + kind: AuthProviderKind::Ldap, + }); + } + + Ok(providers) } async fn oauth_credential( From 29bd87d0d078af68d0721697ad21545f5c81c94e Mon Sep 17 00:00:00 2001 From: Wei Zhang Date: Mon, 30 Dec 2024 18:41:13 +0800 Subject: [PATCH 07/12] feat(ldap): support starttls and ldaps Signed-off-by: Wei Zhang --- ee/tabby-webserver/src/ldap.rs | 32 ++++++++++++++++++++++---- ee/tabby-webserver/src/service/auth.rs | 8 +++++-- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/ee/tabby-webserver/src/ldap.rs b/ee/tabby-webserver/src/ldap.rs index 6bccde03f3a3..b1ae6e75849c 100644 --- a/ee/tabby-webserver/src/ldap.rs +++ b/ee/tabby-webserver/src/ldap.rs @@ -1,7 +1,7 @@ use anyhow::anyhow; use async_trait::async_trait; -use ldap3::{drive, LdapConnAsync, Scope, SearchEntry}; -use tabby_schema::{CoreError, Result}; +use ldap3::{drive, LdapConnAsync, LdapConnSettings, Scope, SearchEntry}; +use tabby_schema::{email::Encryption, CoreError, Result}; #[async_trait] pub trait LdapClient: Send + Sync { @@ -9,8 +9,10 @@ pub trait LdapClient: Send + Sync { } pub fn new_ldap_client( - host: String, + host: &str, port: i64, + encryption: &str, + skip_verify_tls: bool, bind_dn: String, bind_password: String, base_dn: String, @@ -18,14 +20,31 @@ pub fn new_ldap_client( email_attr: String, name_attr: String, ) -> impl LdapClient { + let mut settings = LdapConnSettings::new(); + if encryption == "starttls" { + settings = settings.set_starttls(true); + }; + if skip_verify_tls { + settings = settings.set_no_tls_verify(true); + }; + + let schema = if encryption == "ldaps" { + "ldaps" + } else { + "ldap" + }; + LdapClientImpl { - address: format!("ldap://{}:{}", host, port), + address: format!("{}://{}:{}", schema, host, port), bind_dn, bind_password, base_dn, user_filter, + email_attr, name_attr, + + settings, } } @@ -38,6 +57,8 @@ pub struct LdapClientImpl { email_attr: String, name_attr: String, + + settings: LdapConnSettings, } pub struct LdapUser { @@ -48,7 +69,8 @@ pub struct LdapUser { #[async_trait] impl LdapClient for LdapClientImpl { async fn validate(&mut self, user: &str, password: &str) -> Result { - let (connection, mut client) = LdapConnAsync::new(&self.address).await?; + let (connection, mut client) = + LdapConnAsync::with_settings(self.settings.clone(), &self.address).await?; drive!(connection); // use bind_dn to search diff --git a/ee/tabby-webserver/src/service/auth.rs b/ee/tabby-webserver/src/service/auth.rs index b256c0034ab6..b49c7ead3be4 100644 --- a/ee/tabby-webserver/src/service/auth.rs +++ b/ee/tabby-webserver/src/service/auth.rs @@ -582,8 +582,10 @@ impl AuthenticationService for AuthenticationServiceImpl { async fn test_ldap_connection(&self, input: UpdateLdapCredentialInput) -> Result<()> { let mut client = ldap::new_ldap_client( - input.host, + input.host.as_ref(), input.port as i64, + input.encryption.as_enum_str(), + input.skip_tls_verify, input.bind_dn, input.bind_password, input.base_dn, @@ -670,8 +672,10 @@ async fn ldap_login( let credential = credential.unwrap(); let mut client = ldap::new_ldap_client( - credential.host, + credential.host.as_ref(), credential.port, + credential.encryption.as_str(), + credential.skip_tls_verify, credential.bind_dn, credential.bind_password, credential.base_dn, From 3fd667a56d7de105381f758200a73c9b9cecfddc Mon Sep 17 00:00:00 2001 From: Wei Zhang Date: Tue, 31 Dec 2024 10:25:46 +0800 Subject: [PATCH 08/12] chore: fix tests Signed-off-by: Wei Zhang --- ee/tabby-schema/src/schema/auth.rs | 2 +- ee/tabby-webserver/src/ldap.rs | 13 +- ee/tabby-webserver/src/service/auth.rs | 139 +++++++++++++++--- .../src/service/auth/testutils.rs | 51 ++++++- 4 files changed, 169 insertions(+), 36 deletions(-) diff --git a/ee/tabby-schema/src/schema/auth.rs b/ee/tabby-schema/src/schema/auth.rs index 656b83c0bd1a..ab024415a296 100644 --- a/ee/tabby-schema/src/schema/auth.rs +++ b/ee/tabby-schema/src/schema/auth.rs @@ -386,7 +386,7 @@ pub struct UpdateOAuthCredentialInput { pub client_secret: Option, } -#[derive(GraphQLEnum)] +#[derive(GraphQLEnum, PartialEq, Debug)] pub enum LdapEncryptionKind { None, StartTLS, diff --git a/ee/tabby-webserver/src/ldap.rs b/ee/tabby-webserver/src/ldap.rs index b1ae6e75849c..4319d9e444ef 100644 --- a/ee/tabby-webserver/src/ldap.rs +++ b/ee/tabby-webserver/src/ldap.rs @@ -1,7 +1,7 @@ use anyhow::anyhow; use async_trait::async_trait; use ldap3::{drive, LdapConnAsync, LdapConnSettings, Scope, SearchEntry}; -use tabby_schema::{email::Encryption, CoreError, Result}; +use tabby_schema::{CoreError, Result}; #[async_trait] pub trait LdapClient: Send + Sync { @@ -121,14 +121,3 @@ impl LdapClient for LdapClientImpl { } } } - -#[cfg(test)] -pub mod test_client { - use super::*; - - #[tokio::test] - async fn test_ldap_client() { - let mut client = new_ldap_client(); - client.validate("kw", "password").await.unwrap(); - } -} diff --git a/ee/tabby-webserver/src/service/auth.rs b/ee/tabby-webserver/src/service/auth.rs index b49c7ead3be4..4faa42b15992 100644 --- a/ee/tabby-webserver/src/service/auth.rs +++ b/ee/tabby-webserver/src/service/auth.rs @@ -1,5 +1,4 @@ use std::sync::Arc; -use tokio::sync::Mutex; use anyhow::{anyhow, Context}; use argon2::{ @@ -330,7 +329,27 @@ impl AuthenticationService for AuthenticationServiceImpl { .await .context("Failed to read license info")?; + let credential = self.db.read_ldap_credential().await?; + if credential.is_none() { + bail!("LDAP is not configured"); + } + + let credential = credential.unwrap(); + let mut client = ldap::new_ldap_client( + credential.host.as_ref(), + credential.port, + credential.encryption.as_str(), + credential.skip_tls_verify, + credential.bind_dn, + credential.bind_password, + credential.base_dn, + credential.user_filter, + credential.email_attribute, + credential.name_attribute, + ); + ldap_login( + &mut client, &self.db, &*self.setting, &license, @@ -658,6 +677,7 @@ impl AuthenticationService for AuthenticationServiceImpl { } async fn ldap_login( + client: &mut dyn LdapClient, db: &DbConn, setting: &dyn SettingService, license: &LicenseInfo, @@ -665,25 +685,6 @@ async fn ldap_login( user_id: &str, password: &str, ) -> Result { - let credential = db.read_ldap_credential().await?; - if credential.is_none() { - bail!("LDAP is not configured"); - } - - let credential = credential.unwrap(); - let mut client = ldap::new_ldap_client( - credential.host.as_ref(), - credential.port, - credential.encryption.as_str(), - credential.skip_tls_verify, - credential.bind_dn, - credential.bind_password, - credential.base_dn, - credential.user_filter, - credential.email_attribute, - credential.name_attribute, - ); - let user = client.validate(user_id, password).await?; let user_id = get_or_create_sso_user(license, db, setting, mail, &user.email, &user.name) .await @@ -821,6 +822,8 @@ fn password_verify(raw: &str, hash: &str) -> bool { #[cfg(test)] mod tests { + use crate::service::auth::testutils::FakeLdapClient; + use tabby_schema::auth::LdapEncryptionKind; struct MockLicenseService { status: LicenseStatus, @@ -1657,6 +1660,37 @@ mod tests { assert!(service.refresh_token(token.refresh_token).await.is_err()); } + #[tokio::test] + async fn test_ldap_credential() { + let service = test_authentication_service().await; + service + .update_ldap_credential(UpdateLdapCredentialInput { + host: "ldap.example.com".into(), + port: 389, + bind_dn: "cn=admin,dc=example,dc=com".into(), + bind_password: "password".into(), + base_dn: "dc=example,dc=com".into(), + user_filter: "(&(objectClass=person)(uid=%s))".into(), + encryption: LdapEncryptionKind::None, + skip_tls_verify: false, + email_attribute: "mail".into(), + name_attribute: "cn".into(), + }) + .await + .unwrap(); + + let cred = service.read_ldap_credential().await.unwrap().unwrap(); + assert_eq!(cred.host, "ldap.example.com"); + assert_eq!(cred.port, 389); + assert_eq!(cred.bind_dn, "cn=admin,dc=example,dc=com"); + assert_eq!(cred.base_dn, "dc=example,dc=com"); + assert_eq!(cred.user_filter, "(&(objectClass=person)(uid=%s))"); + assert_eq!(cred.encryption, LdapEncryptionKind::None); + assert!(!cred.skip_tls_verify); + assert_eq!(cred.email_attribute, "mail"); + assert_eq!(cred.name_attribute, "cn"); + } + #[tokio::test] async fn test_oauth_credential() { let service = test_authentication_service().await; @@ -1679,6 +1713,71 @@ mod tests { assert_eq!(cred.client_secret, "secret"); } + #[tokio::test] + async fn test_ldap_login() { + let service = test_authentication_service().await; + let license = LicenseInfo { + r#type: LicenseType::Enterprise, + status: LicenseStatus::Ok, + seats: 1000, + seats_used: 0, + issued_at: None, + expires_at: None, + }; + + service + .create_invitation("user@example.com".into()) + .await + .unwrap(); + let mut ldap_client = FakeLdapClient { state: "" }; + + let response = ldap_login( + &mut ldap_client, + &service.db, + &*service.setting, + &license, + &*service.mail, + "user", + "password", + ) + .await + .unwrap(); + + assert!(!response.refresh_token.is_empty()); + } + + #[tokio::test] + async fn test_ldap_login_not_found() { + let service = test_authentication_service().await; + let license = LicenseInfo { + r#type: LicenseType::Enterprise, + status: LicenseStatus::Ok, + seats: 1000, + seats_used: 0, + issued_at: None, + expires_at: None, + }; + + service + .create_invitation("user@example.com".into()) + .await + .unwrap(); + let mut ldap_client = FakeLdapClient { state: "not_found" }; + + let response = ldap_login( + &mut ldap_client, + &service.db, + &*service.setting, + &license, + &*service.mail, + "user", + "password", + ) + .await; + + assert!(response.is_err()); + } + #[tokio::test] async fn test_oauth_login() { let service = test_authentication_service().await; diff --git a/ee/tabby-webserver/src/service/auth/testutils.rs b/ee/tabby-webserver/src/service/auth/testutils.rs index c49e4057a3a6..a44c77dc2765 100644 --- a/ee/tabby-webserver/src/service/auth/testutils.rs +++ b/ee/tabby-webserver/src/service/auth/testutils.rs @@ -3,14 +3,43 @@ use chrono::{Duration, Utc}; use juniper::ID; use tabby_schema::{ auth::{ - AuthenticationService, Invitation, JWTPayload, OAuthCredential, OAuthError, OAuthProvider, - OAuthResponse, RefreshTokenResponse, RegisterResponse, RequestInvitationInput, - TokenAuthResponse, UpdateOAuthCredentialInput, UserSecured, + AuthenticationService, Invitation, JWTPayload, LdapCredential, OAuthCredential, OAuthError, + OAuthProvider, OAuthResponse, RefreshTokenResponse, RegisterResponse, + RequestInvitationInput, TokenAuthResponse, UpdateLdapCredentialInput, + UpdateOAuthCredentialInput, UserSecured, }, Result, }; use tokio::task::JoinHandle; +use crate::ldap::{LdapClient, LdapUser}; + +pub struct FakeLdapClient<'a> { + pub state: &'a str, +} + +#[async_trait] +impl<'a> LdapClient for FakeLdapClient<'a> { + async fn validate(&mut self, user_id: &str, _password: &str) -> Result { + match self.state { + "not_found" => Err(ldap3::LdapError::LdapResult { + result: ldap3::LdapResult { + rc: 32, + matched: user_id.to_string(), + text: "User not found".to_string(), + refs: vec![], + ctrls: vec![], + }, + } + .into()), + _ => Ok(LdapUser { + email: "user@example.com".to_string(), + name: "Test User".to_string(), + }), + } + } +} + pub struct FakeAuthService { users: Vec, } @@ -180,6 +209,22 @@ impl AuthenticationService for FakeAuthService { Ok(vec![]) } + async fn read_ldap_credential(&self) -> Result> { + Ok(None) + } + + async fn test_ldap_connection(&self, _credential: UpdateLdapCredentialInput) -> Result<()> { + Ok(()) + } + + async fn update_ldap_credential(&self, _input: UpdateLdapCredentialInput) -> Result<()> { + Ok(()) + } + + async fn delete_ldap_credential(&self) -> Result<()> { + Ok(()) + } + async fn oauth( &self, _code: String, From b3de87870fb0495d7b7e3dfa4bb39bff390babae Mon Sep 17 00:00:00 2001 From: Wei Zhang Date: Mon, 6 Jan 2025 18:10:06 +0800 Subject: [PATCH 09/12] chore: ldap password is not required when update if already set before Signed-off-by: Wei Zhang --- ee/tabby-schema/src/schema/auth.rs | 7 +--- ee/tabby-webserver/src/ldap.rs | 4 +- ee/tabby-webserver/src/service/auth.rs | 58 ++++++++++++++++++++++++-- 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/ee/tabby-schema/src/schema/auth.rs b/ee/tabby-schema/src/schema/auth.rs index ab024415a296..f4aba77cdccb 100644 --- a/ee/tabby-schema/src/schema/auth.rs +++ b/ee/tabby-schema/src/schema/auth.rs @@ -405,12 +405,7 @@ pub struct UpdateLdapCredentialInput { #[validate(length(min = 1, code = "bindDn", message = "bindDn cannot be empty"))] pub bind_dn: String, - #[validate(length( - min = 1, - code = "bindPassword", - message = "bindPassword cannot be empty" - ))] - pub bind_password: String, + pub bind_password: Option, #[validate(length(min = 1, code = "baseDn", message = "baseDn cannot be empty"))] pub base_dn: String, diff --git a/ee/tabby-webserver/src/ldap.rs b/ee/tabby-webserver/src/ldap.rs index 4319d9e444ef..17ceef719081 100644 --- a/ee/tabby-webserver/src/ldap.rs +++ b/ee/tabby-webserver/src/ldap.rs @@ -14,7 +14,7 @@ pub fn new_ldap_client( encryption: &str, skip_verify_tls: bool, bind_dn: String, - bind_password: String, + bind_password: &str, base_dn: String, user_filter: String, email_attr: String, @@ -37,7 +37,7 @@ pub fn new_ldap_client( LdapClientImpl { address: format!("{}://{}:{}", schema, host, port), bind_dn, - bind_password, + bind_password: bind_password.to_string(), base_dn, user_filter, diff --git a/ee/tabby-webserver/src/service/auth.rs b/ee/tabby-webserver/src/service/auth.rs index 4faa42b15992..39efcad85182 100644 --- a/ee/tabby-webserver/src/service/auth.rs +++ b/ee/tabby-webserver/src/service/auth.rs @@ -341,7 +341,7 @@ impl AuthenticationService for AuthenticationServiceImpl { credential.encryption.as_str(), credential.skip_tls_verify, credential.bind_dn, - credential.bind_password, + &credential.bind_password, credential.base_dn, credential.user_filter, credential.email_attribute, @@ -600,13 +600,23 @@ impl AuthenticationService for AuthenticationServiceImpl { } async fn test_ldap_connection(&self, input: UpdateLdapCredentialInput) -> Result<()> { + let password = if let Some(password) = input.bind_password.as_deref() { + password + } else { + &self + .db + .read_ldap_credential() + .await? + .ok_or_else(|| anyhow!("LDAP password is not configured"))? + .bind_password + }; let mut client = ldap::new_ldap_client( input.host.as_ref(), input.port as i64, input.encryption.as_enum_str(), input.skip_tls_verify, input.bind_dn, - input.bind_password, + password, input.base_dn, input.user_filter, input.email_attribute, @@ -625,12 +635,22 @@ impl AuthenticationService for AuthenticationServiceImpl { } async fn update_ldap_credential(&self, input: UpdateLdapCredentialInput) -> Result<()> { + let password = if let Some(password) = input.bind_password.as_deref() { + password + } else { + &self + .db + .read_ldap_credential() + .await? + .ok_or_else(|| anyhow!("LDAP password is not configured"))? + .bind_password + }; self.db .update_ldap_credential( &input.host, input.port, &input.bind_dn, - &input.bind_password, + password, &input.base_dn, &input.user_filter, &input.encryption.as_enum_str(), @@ -1668,7 +1688,7 @@ mod tests { host: "ldap.example.com".into(), port: 389, bind_dn: "cn=admin,dc=example,dc=com".into(), - bind_password: "password".into(), + bind_password: Some("password".into()), base_dn: "dc=example,dc=com".into(), user_filter: "(&(objectClass=person)(uid=%s))".into(), encryption: LdapEncryptionKind::None, @@ -1679,6 +1699,7 @@ mod tests { .await .unwrap(); + // test the read_ldap_credential let cred = service.read_ldap_credential().await.unwrap().unwrap(); assert_eq!(cred.host, "ldap.example.com"); assert_eq!(cred.port, 389); @@ -1689,6 +1710,35 @@ mod tests { assert!(!cred.skip_tls_verify); assert_eq!(cred.email_attribute, "mail"); assert_eq!(cred.name_attribute, "cn"); + + service + .update_ldap_credential(UpdateLdapCredentialInput { + host: "ldap1.example1.com".into(), + port: 3890, + bind_dn: "cn=admin1,dc=example1,dc=com".into(), + bind_password: None, + base_dn: "dc=example1,dc=com".into(), + user_filter: "((uid=%s))".into(), + encryption: LdapEncryptionKind::None, + skip_tls_verify: true, + email_attribute: "email".into(), + name_attribute: "name".into(), + }) + .await + .unwrap(); + + // use db to verify the update and password sine it's not returned in service + let cred = service.db.read_ldap_credential().await.unwrap().unwrap(); + assert_eq!(cred.host, "ldap1.example1.com"); + assert_eq!(cred.port, 3890); + assert_eq!(cred.bind_dn, "cn=admin1,dc=example1,dc=com"); + assert_eq!(cred.bind_password, "password"); + assert_eq!(cred.base_dn, "dc=example1,dc=com"); + assert_eq!(cred.user_filter, "((uid=%s))"); + assert_eq!(cred.encryption, "none"); + assert!(cred.skip_tls_verify); + assert_eq!(cred.email_attribute, "email"); + assert_eq!(cred.name_attribute, "name"); } #[tokio::test] From 4c48509ca95bb623d1267670245e58ddbd2d6dac Mon Sep 17 00:00:00 2001 From: Wei Zhang Date: Mon, 6 Jan 2025 23:19:15 +0800 Subject: [PATCH 10/12] chore: update graphql schema Signed-off-by: Wei Zhang --- ee/tabby-schema/graphql/schema.graphql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ee/tabby-schema/graphql/schema.graphql b/ee/tabby-schema/graphql/schema.graphql index 3b914d3115fd..9c3c2c81c9fd 100644 --- a/ee/tabby-schema/graphql/schema.graphql +++ b/ee/tabby-schema/graphql/schema.graphql @@ -248,7 +248,7 @@ input UpdateLdapCredentialInput { host: String! port: Int! bindDn: String! - bindPassword: String! + bindPassword: String baseDn: String! userFilter: String! encryption: LdapEncryptionKind! From 89ffd2d5a38ac36d99ee83f21306966b81389f47 Mon Sep 17 00:00:00 2001 From: Wei Zhang Date: Tue, 7 Jan 2025 12:38:17 +0800 Subject: [PATCH 11/12] chore: ldap name_attr optional and use user_id by default Signed-off-by: Wei Zhang --- ee/tabby-schema/src/schema/auth.rs | 10 +++------ ee/tabby-webserver/src/ldap.rs | 28 +++++++++++++++++--------- ee/tabby-webserver/src/service/auth.rs | 15 +++++++------- ee/tabby-webserver/src/service/mod.rs | 5 ++--- 4 files changed, 31 insertions(+), 27 deletions(-) diff --git a/ee/tabby-schema/src/schema/auth.rs b/ee/tabby-schema/src/schema/auth.rs index f4aba77cdccb..237e0d4538d4 100644 --- a/ee/tabby-schema/src/schema/auth.rs +++ b/ee/tabby-schema/src/schema/auth.rs @@ -425,12 +425,8 @@ pub struct UpdateLdapCredentialInput { message = "emailAttribute cannot be empty" ))] pub email_attribute: String, - #[validate(length( - min = 1, - code = "nameAttribute", - message = "nameAttribute cannot be empty" - ))] - pub name_attribute: String, + // if name_attribute is None, we will use username as name + pub name_attribute: Option, } #[derive(GraphQLObject)] @@ -443,7 +439,7 @@ pub struct LdapCredential { pub encryption: LdapEncryptionKind, pub skip_tls_verify: bool, pub email_attribute: String, - pub name_attribute: String, + pub name_attribute: Option, pub created_at: DateTime, pub updated_at: DateTime, diff --git a/ee/tabby-webserver/src/ldap.rs b/ee/tabby-webserver/src/ldap.rs index 17ceef719081..9bd8eb1379f6 100644 --- a/ee/tabby-webserver/src/ldap.rs +++ b/ee/tabby-webserver/src/ldap.rs @@ -18,7 +18,7 @@ pub fn new_ldap_client( base_dn: String, user_filter: String, email_attr: String, - name_attr: String, + name_attr: Option, ) -> impl LdapClient { let mut settings = LdapConnSettings::new(); if encryption == "starttls" { @@ -56,7 +56,7 @@ pub struct LdapClientImpl { user_filter: String, email_attr: String, - name_attr: String, + name_attr: Option, settings: LdapConnSettings, } @@ -79,12 +79,16 @@ impl LdapClient for LdapClientImpl { .await? .success()?; + let mut attrs = vec![&self.email_attr]; + if let Some(name_attr) = &self.name_attr { + attrs.push(name_attr); + } let searched = client .search( &self.base_dn, Scope::OneLevel, &self.user_filter.replace("%s", user), - vec![&self.name_attr, &self.email_attr], + attrs, ) .await?; @@ -94,15 +98,19 @@ impl LdapClient for LdapClientImpl { let email = entry .attrs .get(&self.email_attr) - .and_then(|v| v.get(0)) + .and_then(|v| v.first()) .cloned() .ok_or_else(|| CoreError::Other(anyhow!("email not found for user")))?; - let name = entry - .attrs - .get(&self.name_attr) - .and_then(|v| v.get(0)) - .cloned() - .ok_or_else(|| CoreError::Other(anyhow!("name not found for user")))?; + let name = if let Some(name_attr) = &self.name_attr { + entry + .attrs + .get(name_attr) + .and_then(|v| v.first()) + .cloned() + .ok_or_else(|| CoreError::Other(anyhow!("name not found for user")))? + } else { + user.to_string() + }; client.simple_bind(&user_dn, password).await?.success()?; diff --git a/ee/tabby-webserver/src/service/auth.rs b/ee/tabby-webserver/src/service/auth.rs index 39efcad85182..6fbd615640f8 100644 --- a/ee/tabby-webserver/src/service/auth.rs +++ b/ee/tabby-webserver/src/service/auth.rs @@ -653,10 +653,10 @@ impl AuthenticationService for AuthenticationServiceImpl { password, &input.base_dn, &input.user_filter, - &input.encryption.as_enum_str(), + input.encryption.as_enum_str(), input.skip_tls_verify, &input.email_attribute, - &input.name_attribute, + input.name_attribute.as_deref(), ) .await?; Ok(()) @@ -842,9 +842,10 @@ fn password_verify(raw: &str, hash: &str) -> bool { #[cfg(test)] mod tests { - use crate::service::auth::testutils::FakeLdapClient; use tabby_schema::auth::LdapEncryptionKind; + use crate::service::auth::testutils::FakeLdapClient; + struct MockLicenseService { status: LicenseStatus, seats: i32, @@ -1694,7 +1695,7 @@ mod tests { encryption: LdapEncryptionKind::None, skip_tls_verify: false, email_attribute: "mail".into(), - name_attribute: "cn".into(), + name_attribute: Some("cn".into()), }) .await .unwrap(); @@ -1709,7 +1710,7 @@ mod tests { assert_eq!(cred.encryption, LdapEncryptionKind::None); assert!(!cred.skip_tls_verify); assert_eq!(cred.email_attribute, "mail"); - assert_eq!(cred.name_attribute, "cn"); + assert_eq!(cred.name_attribute, Some("cn".into())); service .update_ldap_credential(UpdateLdapCredentialInput { @@ -1722,7 +1723,7 @@ mod tests { encryption: LdapEncryptionKind::None, skip_tls_verify: true, email_attribute: "email".into(), - name_attribute: "name".into(), + name_attribute: Some("name".into()), }) .await .unwrap(); @@ -1738,7 +1739,7 @@ mod tests { assert_eq!(cred.encryption, "none"); assert!(cred.skip_tls_verify); assert_eq!(cred.email_attribute, "email"); - assert_eq!(cred.name_attribute, "name"); + assert_eq!(cred.name_attribute, Some("name".into())); } #[tokio::test] diff --git a/ee/tabby-webserver/src/service/mod.rs b/ee/tabby-webserver/src/service/mod.rs index b2e0bdb0f203..7e7cc2731e03 100644 --- a/ee/tabby-webserver/src/service/mod.rs +++ b/ee/tabby-webserver/src/service/mod.rs @@ -20,13 +20,12 @@ pub mod web_documents; use std::sync::Arc; -#[cfg(test)] -pub use auth::testutils::FakeAuthService; - use answer::AnswerService; use anyhow::Context; use async_trait::async_trait; pub use auth::create as new_auth_service; +#[cfg(test)] +pub use auth::testutils::FakeAuthService; use axum::{ body::Body, http::{HeaderName, HeaderValue, Request, StatusCode}, From e7c91d8f1b1c85ce2d4674dd5fbf6b29885ed1ac Mon Sep 17 00:00:00 2001 From: Wei Zhang Date: Tue, 7 Jan 2025 16:12:49 +0800 Subject: [PATCH 12/12] chore: update graphql for optional name_attr Signed-off-by: Wei Zhang --- ee/tabby-schema/graphql/schema.graphql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ee/tabby-schema/graphql/schema.graphql b/ee/tabby-schema/graphql/schema.graphql index 9c3c2c81c9fd..b25f4d292225 100644 --- a/ee/tabby-schema/graphql/schema.graphql +++ b/ee/tabby-schema/graphql/schema.graphql @@ -254,7 +254,7 @@ input UpdateLdapCredentialInput { encryption: LdapEncryptionKind! skipTlsVerify: Boolean! emailAttribute: String! - nameAttribute: String! + nameAttribute: String } input UpdateMessageInput { @@ -503,7 +503,7 @@ type LdapCredential { encryption: LdapEncryptionKind! skipTlsVerify: Boolean! emailAttribute: String! - nameAttribute: String! + nameAttribute: String createdAt: DateTime! updatedAt: DateTime! }