Skip to content

Commit

Permalink
feat(graphQL): add login with ldap support
Browse files Browse the repository at this point in the history
Signed-off-by: Wei Zhang <[email protected]>
  • Loading branch information
zwpaper committed Dec 26, 2024
1 parent 9541f7f commit 5e685cf
Show file tree
Hide file tree
Showing 9 changed files with 153 additions and 44 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions ee/tabby-schema/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]}
Expand Down
1 change: 1 addition & 0 deletions ee/tabby-schema/graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
11 changes: 11 additions & 0 deletions ee/tabby-schema/src/schema/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -412,6 +421,8 @@ pub trait AuthenticationService: Send + Sync {

async fn token_auth(&self, email: String, password: String) -> Result<TokenAuthResponse>;

async fn token_auth_ldap(&self, email: &str, password: &str) -> Result<TokenAuthResponse>;

async fn refresh_token(&self, refresh_token: String) -> Result<RefreshTokenResponse>;
async fn verify_access_token(&self, access_token: &str) -> Result<JWTPayload>;
async fn verify_auth_token(&self, token: &str) -> Result<ID>;
Expand Down
23 changes: 23 additions & 0 deletions ee/tabby-schema/src/schema/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -145,6 +146,12 @@ pub enum CoreError {
Other(#[from] anyhow::Error),
}

impl From<LdapError> for CoreError {
fn from(err: LdapError) -> Self {
Self::Other(err.into())
}
}

impl<S: ScalarValue> IntoFieldError<S> for CoreError {
fn into_field_error(self) -> FieldError<S> {
match self {
Expand Down Expand Up @@ -989,6 +996,22 @@ impl Mutation {
.await
}

async fn token_auth_ldap(
ctx: &Context,
user_id: String,
password: String,
) -> Result<TokenAuthResponse> {
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<bool> {
ctx.locator.auth().verify_access_token(&token).await?;
Ok(true)
Expand Down
86 changes: 49 additions & 37 deletions ee/tabby-webserver/src/ldap.rs
Original file line number Diff line number Diff line change
@@ -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<LdapUser>;
}

pub async fn new_ldap_client(auth: Arc<dyn AuthenticationService>) -> Arc<Mutex<dyn LdapClient>> {
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<dyn AuthenticationService>,
address: String,
bind_dn: String,
bind_password: String,
base_dn: String,
user_filter: String,

email_attr: String,
name_attr: String,
}

pub struct LdapUser {
Expand All @@ -25,55 +47,52 @@ pub struct LdapUser {
#[async_trait]
impl LdapClient for LdapClientImpl {
async fn validate(&mut self, user: &str, password: &str) -> Result<LdapUser> {
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())
}
Expand All @@ -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();
}
}
66 changes: 59 additions & 7 deletions ee/tabby-webserver/src/service/auth.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::sync::Arc;
use tokio::sync::Mutex;

use anyhow::{anyhow, Context};
use argon2::{
Expand Down Expand Up @@ -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},
};

Expand Down Expand Up @@ -320,6 +322,34 @@ impl AuthenticationService for AuthenticationServiceImpl {
Ok(resp)
}

async fn token_auth_ldap(&self, user_id: &str, password: &str) -> Result<TokenAuthResponse> {
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<RefreshTokenResponse> {
let Some(refresh_token) = self.db.get_refresh_token(&token).await? else {
bail!("Invalid refresh token");
Expand Down Expand Up @@ -580,6 +610,28 @@ impl AuthenticationService for AuthenticationServiceImpl {
}
}

async fn ldap_login(
client: Arc<Mutex<dyn LdapClient>>,
db: &DbConn,
setting: &dyn SettingService,
license: &LicenseInfo,
mail: &dyn EmailService,
user_id: &str,
password: &str,
) -> Result<TokenAuthResponse> {
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<dyn OAuthClient>,
code: String,
Expand All @@ -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?;

Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions ee/tabby-webserver/src/service/auth/testutils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ impl AuthenticationService for FakeAuthService {
))
}

async fn token_auth_ldap(&self, _user_id: &str, _password: &str) -> Result<TokenAuthResponse> {
Ok(TokenAuthResponse::new(
"access_token".to_string(),
"refresh_token".to_string(),
))
}

async fn refresh_token(&self, _token: String) -> Result<RefreshTokenResponse> {
Ok(RefreshTokenResponse::new(
"access_token".to_string(),
Expand Down

0 comments on commit 5e685cf

Please sign in to comment.