diff --git a/.env.example b/.env.example index 350baf8..6a38ad2 100644 --- a/.env.example +++ b/.env.example @@ -100,4 +100,5 @@ AUTHORIZATION_TYPE=local # LDAP_USERNAME_ATTRIBUTE=sAMAccountName # LDAP_USER_FILTER=(uid={username}) # LDAP_USERNAME_ATTRIBUTE=uid +# email auth: LDAP_EMAIL_ATTRIBUTE=mail diff --git a/docker/ldap/bootstrap/01-users.ldif b/docker/ldap/bootstrap/01-users.ldif index 1b9b856..18cd2f7 100644 --- a/docker/ldap/bootstrap/01-users.ldif +++ b/docker/ldap/bootstrap/01-users.ldif @@ -1,27 +1,15 @@ -dn: ou=people,dc=example,dc=org +dn: ou=people,dc=organization,dc=local objectClass: organizationalUnit ou: people -dn: uid=mateusz,ou=people,dc=example,dc=org +dn: uid=admin,ou=people,dc=organization,dc=local objectClass: inetOrgPerson objectClass: organizationalPerson objectClass: person objectClass: top -uid: mateusz -cn: Mateusz Testowy -sn: Testowy -displayName: Mateusz Testowy -mail: mateusz@example.org -userPassword: test1234 - -dn: uid=anna,ou=people,dc=example,dc=org -objectClass: inetOrgPerson -objectClass: organizationalPerson -objectClass: person -objectClass: top -uid: anna -cn: Anna Testowa -sn: Testowa -displayName: Anna Testowa -mail: anna@example.org -userPassword: test1234 +uid: admin +cn: Organization Administrator +sn: Administrator +displayName: Organization Administrator +mail: admin@organization.local +userPassword: test1234! \ No newline at end of file diff --git a/docker/ldap/docker-compose.yml b/docker/ldap/docker-compose.yml index 843f59c..cef6967 100644 --- a/docker/ldap/docker-compose.yml +++ b/docker/ldap/docker-compose.yml @@ -10,7 +10,7 @@ services: LDAP_CONFIG_PASSWORD: config LDAP_TLS: "false" ports: - - "12389:389" + - "10.87.2.6:389:389" volumes: - ./bootstrap:/container/service/slapd/assets/config/bootstrap/ldif/custom:ro command: --copy-service @@ -23,6 +23,6 @@ services: PHPLDAPADMIN_LDAP_HOSTS: ldap PHPLDAPADMIN_HTTPS: "false" ports: - - "12390:80" + - "10.87.2.6:8088:80" depends_on: - ldap diff --git a/src/app.rs b/src/app.rs index da5ab5d..7c5e4f3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -180,6 +180,7 @@ async fn home(State(state): State) -> Response { include_str!("../static/home.html"), &state.asset_version, state.registration_enabled, + state.ldap.is_some(), &state.frontend_log_level, state.upload_max_size_bytes, "home", @@ -195,6 +196,7 @@ async fn pad(State(state): State, Path(slug): Path) -> Resp &html, &state.asset_version, state.registration_enabled, + state.ldap.is_some(), &state.frontend_log_level, state.upload_max_size_bytes, "pad", @@ -222,6 +224,7 @@ async fn public_page(State(state): State, Path(token): Path include_str!("../static/public.html"), &state.asset_version, state.registration_enabled, + state.ldap.is_some(), &state.frontend_log_level, state.upload_max_size_bytes, "public", @@ -254,6 +257,7 @@ async fn workspace( &html, &state.asset_version, state.registration_enabled, + state.ldap.is_some(), &state.frontend_log_level, state.upload_max_size_bytes, "workspace", @@ -308,6 +312,7 @@ async fn note( &html, &state.asset_version, state.registration_enabled, + state.ldap.is_some(), &state.frontend_log_level, state.upload_max_size_bytes, "note", diff --git a/src/assets.rs b/src/assets.rs index 066a0e3..3188801 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -27,12 +27,13 @@ pub fn render_html( template: &str, asset_version: &str, registration_enabled: bool, + external_auth: bool, frontend_log_level: &str, upload_max_size_bytes: usize, entrypoint: &str, ) -> Response { let urls = AssetUrls::new(asset_version); - let frontend_config = frontend_config(frontend_log_level, upload_max_size_bytes); + let frontend_config = frontend_config(frontend_log_level, upload_max_size_bytes, external_auth); let html = template .replace("__APP_STYLESHEET__", &urls.stylesheet("styles")) .replace("__APP_IMPORT_MAP__", &urls.import_map()) @@ -59,11 +60,12 @@ pub fn stylesheet_tag(asset_version: &str, name: &str) -> String { AssetUrls::new(asset_version).stylesheet(name) } -fn frontend_config(frontend_log_level: &str, upload_max_size_bytes: usize) -> String { +fn frontend_config(frontend_log_level: &str, upload_max_size_bytes: usize, external_auth: bool) -> String { format!( - r#""#, + r#""#, escape_js_string(frontend_log_level), upload_max_size_bytes, + external_auth, ) } diff --git a/src/ldap_auth.rs b/src/ldap_auth.rs index 67cef69..6632436 100644 --- a/src/ldap_auth.rs +++ b/src/ldap_auth.rs @@ -53,13 +53,26 @@ pub async fn authenticate( config.email_attribute.as_str(), config.display_name_attribute.as_str(), ]; - let (entries, _) = ldap - .search(&config.base_dn, Scope::Subtree, &filter, attributes) + let (mut entries, _) = ldap + .search(&config.base_dn, Scope::Subtree, &filter, attributes.clone()) .await .map_err(|error| format!("LDAP search failed: {error}"))? .success() .map_err(|error| format!("LDAP search rejected: {error}"))?; + // Allow users to sign in with their directory e-mail even when the configured + // primary filter searches by uid/sAMAccountName only. + if entries.is_empty() && login.trim().contains('@') { + let email_filter = format!("({}={})", config.email_attribute, escaped); + let (email_entries, _) = ldap + .search(&config.base_dn, Scope::Subtree, &email_filter, attributes) + .await + .map_err(|error| format!("LDAP e-mail search failed: {error}"))? + .success() + .map_err(|error| format!("LDAP e-mail search rejected: {error}"))?; + entries = email_entries; + } + if entries.len() != 1 { let _ = ldap.unbind().await; return Ok(None); diff --git a/static/js/auth-ui.js b/static/js/auth-ui.js index fad43e2..9c4efdc 100644 --- a/static/js/auth-ui.js +++ b/static/js/auth-ui.js @@ -24,6 +24,7 @@ export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" } const resetButton = dialog.querySelector("#show-reset"); const backButton = dialog.querySelector("#reset-back"); const registrationEnabled = document.body.dataset.registrationEnabled === "true"; + const externalAuth = window.__RUSTPAD_CONFIG__?.externalAuth === true; let mode = initialMode; const setMode = (nextMode) => { @@ -47,9 +48,12 @@ export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" } submit.textContent = resetting ? "Send reset link" : registering ? "Create account" : "Log in"; switchMode.hidden = resetting || !registrationEnabled; switchMode.textContent = registering ? "Already registered? Log in" : "Create an account"; - resetButton.hidden = resetting || registering; + resetButton.hidden = resetting || registering || externalAuth; backButton.hidden = !resetting; const loginMode = mode === "login"; + email.type = externalAuth && loginMode ? "text" : "email"; + if (externalAuth && loginMode) password.removeAttribute("minlength"); + else password.minLength = 8; form.autocomplete = loginMode ? "on" : "off"; nickname.autocomplete = "off"; nickname.dataset.bwignore = "true"; @@ -143,6 +147,7 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) { const backButton = dialog.querySelector("#auth-back"); const logoutButton = dialog.querySelector("#logout-account"); const registrationEnabled = document.body.dataset.registrationEnabled === "true"; + const externalAuth = window.__RUSTPAD_CONFIG__?.externalAuth === true; let mode = "login"; const updateActions = () => { @@ -170,6 +175,9 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) { modeTitle.textContent = mode === "register" ? "Register nickname" : "Log in"; authSubmit.textContent = mode === "register" ? "Register and continue" : "Log in and continue"; const loginMode = mode === "login"; + email.type = externalAuth && loginMode ? "text" : "email"; + if (externalAuth && loginMode) password.removeAttribute("minlength"); + else password.minLength = 8; form.autocomplete = loginMode ? "on" : "off"; nickname.autocomplete = "off"; nickname.dataset.bwignore = "true"; @@ -194,7 +202,9 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) { dialog.addEventListener("close", collapse); dialog.addEventListener("cancel", collapse); - dialog.querySelector("#show-reset")?.addEventListener("click", async () => { + const legacyResetButton = dialog.querySelector("#show-reset"); + if (legacyResetButton) legacyResetButton.hidden = externalAuth; + legacyResetButton?.addEventListener("click", async () => { const value = email.value.trim() || await askInput({ title: "Reset password", message: "Enter the e-mail address assigned to your local account.",