Ldap support #2

Merged
gru merged 6 commits from ldap_support into master 2026-07-25 22:14:18 +02:00
16 changed files with 608 additions and 13 deletions
Showing only changes of commit fcf7910f28 - Show all commits
+25
View File
@@ -76,3 +76,28 @@ SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM="RustPad <no-reply@example.com>"
# Authentication mode: local, ldap, or ad
# local: built-in registration/login
# ldap/ad: organization directory login; local registration and guest access are disabled
AUTHORIZATION_TYPE=local
# Shared LDAP / Active Directory connection settings
# LDAP_URL=ldap://10.0.0.22:389
# LDAP_STARTTLS=false
# For LDAPS: LDAP_URL=ldaps://ldap.example.org:636 and LDAP_STARTTLS=false
# For StartTLS: LDAP_URL=ldap://ldap.example.org:389 and LDAP_STARTTLS=true
# LDAP_BIND_DN=cn=admin,dc=example,dc=org
# LDAP_BIND_PASSWORD=admin
# LDAP_BASE_DN=ou=people,dc=example,dc=org
# LDAP_ORGANIZATION=example
# LDAP_EMAIL_ATTRIBUTE=mail
# LDAP_DISPLAY_NAME_ATTRIBUTE=displayName
# Optional overrides. Defaults depend on AUTHORIZATION_TYPE:
# ldap: LDAP_USER_FILTER=(uid={username}), LDAP_USERNAME_ATTRIBUTE=uid
# ad: LDAP_USER_FILTER=(|(sAMAccountName={username})(userPrincipalName={username}))
# LDAP_USERNAME_ATTRIBUTE=sAMAccountName
# LDAP_USER_FILTER=(uid={username})
# LDAP_USERNAME_ATTRIBUTE=uid
Generated
+168 -2
View File
@@ -47,6 +47,45 @@ dependencies = [
"password-hash",
]
[[package]]
name = "asn1-rs"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
"displaydoc",
"nom 7.1.3",
"num-traits",
"rusticata-macros",
"thiserror",
"time",
]
[[package]]
name = "asn1-rs-derive"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
]
[[package]]
name = "asn1-rs-impl"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "async-trait"
version = "0.1.91"
@@ -909,6 +948,20 @@ dependencies = [
"zeroize",
]
[[package]]
name = "der-parser"
version = "10.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
dependencies = [
"asn1-rs",
"displaydoc",
"nom 7.1.3",
"num-bigint",
"num-traits",
"rusticata-macros",
]
[[package]]
name = "deranged"
version = "0.5.8"
@@ -1140,6 +1193,21 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
@@ -1213,6 +1281,7 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
@@ -1762,6 +1831,41 @@ dependencies = [
"spin 0.9.9",
]
[[package]]
name = "lber"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbcf559624bfd9fe8d488329a8959766335a43a9b8b2cdd6a2c379fca02909a5"
dependencies = [
"bytes",
"nom 7.1.3",
]
[[package]]
name = "ldap3"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01fe89f5e7cfb7e4701e3a38ff9f00358e026a9aee940355d88ee9d81e5c7503"
dependencies = [
"async-trait",
"bytes",
"futures",
"futures-util",
"lber",
"log",
"nom 7.1.3",
"percent-encoding",
"rustls 0.23.42",
"rustls-native-certs",
"thiserror",
"tokio",
"tokio-rustls 0.26.4",
"tokio-stream",
"tokio-util",
"url",
"x509-parser",
]
[[package]]
name = "lettre"
version = "0.11.22"
@@ -1778,7 +1882,7 @@ dependencies = [
"httpdate",
"idna",
"mime",
"nom",
"nom 8.0.0",
"percent-encoding",
"quoted_printable",
"rustls 0.23.42",
@@ -1911,6 +2015,12 @@ dependencies = [
"unicase",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "mio"
version = "1.2.2"
@@ -1939,6 +2049,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "nom"
version = "8.0.0"
@@ -1957,6 +2077,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
@@ -2008,6 +2138,15 @@ dependencies = [
"libm",
]
[[package]]
name = "oid-registry"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
dependencies = [
"asn1-rs",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -2360,6 +2499,15 @@ dependencies = [
"semver",
]
[[package]]
name = "rusticata-macros"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom 7.1.3",
]
[[package]]
name = "rustls"
version = "0.21.12"
@@ -2433,7 +2581,7 @@ dependencies = [
[[package]]
name = "rustpad"
version = "0.0.29"
version = "0.0.30"
dependencies = [
"argon2",
"aws-config",
@@ -2445,6 +2593,7 @@ dependencies = [
"dotenvy",
"futures-util",
"hex",
"ldap3",
"lettre",
"mime_guess",
"rand_core 0.6.4",
@@ -3750,6 +3899,23 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "x509-parser"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
dependencies = [
"asn1-rs",
"data-encoding",
"der-parser",
"lazy_static",
"nom 7.1.3",
"oid-registry",
"rusticata-macros",
"thiserror",
"time",
]
[[package]]
name = "xmlparser"
version = "0.13.6"
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rustpad"
version = "0.0.29"
version = "0.0.30"
edition = "2024"
rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
@@ -18,6 +18,7 @@ dotenvy = "0.15"
futures-util = "0.3"
mime_guess = "2"
lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1-rustls-tls"] }
ldap3 = { version = "0.12", default-features = false, features = ["tls-rustls-ring"] }
sha2 = "0.10"
rand_core = { version = "0.6", features = ["getrandom"] }
serde = { version = "1", features = ["derive"] }
+78
View File
@@ -157,3 +157,81 @@ docker compose --profile s3 up -d --build
```
Garage runs as a separate Compose service. Existing PostgreSQL and MySQL profiles remain unchanged. The included single-node setup is intended for local or self-hosted development without redundancy. Production Garage deployments should use a properly designed multi-node configuration.
## Authentication: local, LDAP, LDAPS, or Active Directory
Choose exactly one authentication backend:
```env
AUTHORIZATION_TYPE=local
```
Supported values:
- `local` - built-in registration and password login
- `ldap` - OpenLDAP-compatible directory
- `ad` - Microsoft Active Directory defaults
For `ldap` and `ad`, RustPad searches the directory with the service account, validates the password by binding as the user, and automatically provisions a local account. Existing sessions, ownership, sharing, and other account functions continue to use the existing `users` table. Local registration and guest nickname access are disabled.
The nickname is generated as `LDAP_ORGANIZATION/displayName`, for example `example/Mateusz Testowy`. The directory e-mail is the stable local account identifier.
### OpenLDAP
```env
AUTHORIZATION_TYPE=ldap
LDAP_URL=ldap://10.87.2.6:389
LDAP_STARTTLS=false
LDAP_BIND_DN=cn=admin,dc=example,dc=org
LDAP_BIND_PASSWORD=admin
LDAP_BASE_DN=ou=people,dc=example,dc=org
LDAP_ORGANIZATION=example
```
In `ldap` mode the defaults are:
```env
LDAP_USER_FILTER=(uid={username})
LDAP_USERNAME_ATTRIBUTE=uid
LDAP_EMAIL_ATTRIBUTE=mail
LDAP_DISPLAY_NAME_ATTRIBUTE=displayName
```
### Active Directory
```env
AUTHORIZATION_TYPE=ad
LDAP_URL=ldaps://ad.example.org:636
LDAP_STARTTLS=false
LDAP_BASE_DN=DC=example,DC=org
LDAP_BIND_DN=CN=rustpad-bind,OU=Service Accounts,DC=example,DC=org
LDAP_BIND_PASSWORD=secret
LDAP_ORGANIZATION=example
```
In `ad` mode the defaults are:
```env
LDAP_USER_FILTER=(|(sAMAccountName={username})(userPrincipalName={username}))
LDAP_USERNAME_ATTRIBUTE=sAMAccountName
LDAP_EMAIL_ATTRIBUTE=mail
LDAP_DISPLAY_NAME_ATTRIBUTE=displayName
```
All LDAP attributes and filters can still be overridden explicitly. The LDAP/LDAPS server certificate must be trusted by the RustPad container.
### Test LDAP on 10.87.2.6
```bash
cd docker/ldap
docker compose up -d
```
Test users:
- `mateusz` / `test1234`
- `anna` / `test1234`
phpLDAPadmin: `http://10.87.2.6:8088`
Administrator: `cn=admin,dc=example,dc=org` / `admin`
+1
View File
@@ -21,6 +21,7 @@ services:
S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-false}
UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB:-20}
REGISTRATION_ENABLED: ${REGISTRATION_ENABLED:-false}
AUTHORIZATION_TYPE: ${AUTHORIZATION_TYPE:-local}
ACCOUNT_CONFIRMATION_REQUIRED: ${ACCOUNT_CONFIRMATION_REQUIRED:-false}
ANONYMOUS_ACCESS_TOKEN_TTL_DAYS: ${ANONYMOUS_ACCESS_TOKEN_TTL_DAYS:-7}
USER_SESSION_TTL_DAYS: ${USER_SESSION_TTL_DAYS:-30}
+27
View File
@@ -0,0 +1,27 @@
dn: ou=people,dc=example,dc=org
objectClass: organizationalUnit
ou: people
dn: uid=mateusz,ou=people,dc=example,dc=org
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
+28
View File
@@ -0,0 +1,28 @@
services:
ldap:
image: osixia/openldap:1.5.0
container_name: rustpad-test-ldap
restart: unless-stopped
environment:
LDAP_ORGANISATION: RustPad Test
LDAP_DOMAIN: example.org
LDAP_ADMIN_PASSWORD: admin
LDAP_CONFIG_PASSWORD: config
LDAP_TLS: "false"
ports:
- "12389:389"
volumes:
- ./bootstrap:/container/service/slapd/assets/config/bootstrap/ldif/custom:ro
command: --copy-service
ldap-admin:
image: osixia/phpldapadmin:0.9.0
container_name: rustpad-test-ldap-admin
restart: unless-stopped
environment:
PHPLDAPADMIN_LDAP_HOSTS: ldap
PHPLDAPADMIN_HTTPS: "false"
ports:
- "12390:80"
depends_on:
- ldap
+67 -3
View File
@@ -161,6 +161,9 @@ pub async fn identity(
State(state): State<SharedState>,
Json(req): Json<IdentityRequest>,
) -> Result<Json<IdentityResponse>, AuthError> {
if state.ldap.is_some() && req.session_token.is_none() {
return Err(AuthError::unauthorized("Log in with your organization account."));
}
let nickname = validate_nickname(&req.nickname)?;
debug!(nickname = %nickname, has_session = req.session_token.is_some(), "identity check requested");
match find_user_by_nickname(&state, &nickname).await? {
@@ -196,6 +199,9 @@ pub async fn register(
State(state): State<SharedState>,
Json(req): Json<RegisterRequest>,
) -> Result<(StatusCode, Json<RegisterResponse>), AuthError> {
if state.ldap.is_some() {
return Err(AuthError::forbidden("Local registration is disabled while LDAP authentication is enabled."));
}
if !state.registration_enabled {
warn!("registration attempt rejected because registration is disabled");
return Err(AuthError::forbidden("Registration is disabled."));
@@ -324,6 +330,21 @@ pub async fn login(
State(state): State<SharedState>,
Json(req): Json<LoginRequest>,
) -> Result<Json<SessionResponse>, AuthError> {
if let Some(config) = state.ldap.as_ref() {
debug!(login = %req.email.trim(), "LDAP login requested");
let identity = crate::ldap_auth::authenticate(config, &req.email, &req.password)
.await
.map_err(|error| {
tracing::error!(error = %error, "LDAP authentication error");
AuthError::service_unavailable("The organization directory is currently unavailable.")
})?
.ok_or_else(|| AuthError::unauthorized("Invalid organization login or password."))?;
let user = provision_ldap_user(&state, identity).await?;
let session = create_session(&state, &user).await?;
info!(user_id = user.id, nickname = %user.nickname, "LDAP login successful");
return Ok(Json(session));
}
let email = validate_email(&req.email)?;
debug!(email_domain = %email_domain(&email), "login requested");
let user = find_user_by_email(&state, &email)
@@ -331,9 +352,7 @@ pub async fn login(
.ok_or_else(|| AuthError::unauthorized("Invalid e-mail address or password."))?;
if !verify_password(&user.password_hash, &req.password) {
warn!(user_id = user.id, "login rejected: invalid password");
return Err(AuthError::unauthorized(
"Invalid e-mail address or password.",
));
return Err(AuthError::unauthorized("Invalid e-mail address or password."));
}
if state.account_confirmation_required && user.confirmed_at.is_none() {
return Err(AuthError::forbidden(
@@ -345,6 +364,51 @@ pub async fn login(
Ok(Json(session))
}
async fn provision_ldap_user(
state: &SharedState,
identity: crate::ldap_auth::LdapIdentity,
) -> Result<User, AuthError> {
let email = validate_email(&identity.email)?;
if let Some(user) = find_user_by_email(state, &email).await? {
return Ok(user);
}
let base = truncate_nickname(&identity.nickname);
let mut nickname = base.clone();
let mut suffix = 1usize;
while find_user_by_nickname(state, &nickname).await?.is_some() {
suffix += 1;
let marker = format!("-{suffix}");
nickname = format!("{}{}", truncate_to_chars(&base, MAX_NICKNAME - marker.chars().count()), marker);
}
let password_hash = hash_password(&random_token())?;
let confirmed_at = Utc::now().to_rfc3339();
sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_USER))
.bind(&nickname)
.bind(normalize(&nickname))
.bind(&email)
.bind(normalize(&email))
.bind(password_hash)
.bind(Some(confirmed_at))
.execute(state.db.pool())
.await
.map_err(AuthError::database)?;
info!(ldap_username = %identity.username, nickname = %nickname, "provisioned LDAP user");
find_user_by_email(state, &email)
.await?
.ok_or_else(|| AuthError::internal("Failed to provision the organization account."))
}
fn truncate_nickname(value: &str) -> String {
let value = value.trim();
let value = if value.is_empty() { "ldap-user" } else { value };
truncate_to_chars(value, MAX_NICKNAME)
}
fn truncate_to_chars(value: &str, max: usize) -> String {
value.chars().take(max).collect()
}
pub async fn confirm_account(
State(state): State<SharedState>,
Json(req): Json<ConfirmAccountRequest>,
+70
View File
@@ -1,5 +1,35 @@
use std::{env, net::IpAddr};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthorizationType {
Local,
Ldap,
Ad,
}
impl AuthorizationType {
pub fn from_env() -> Result<Self, Box<dyn std::error::Error>> {
match env_var("AUTHORIZATION_TYPE", "local")
.trim()
.to_ascii_lowercase()
.as_str()
{
"local" => Ok(Self::Local),
"ldap" => Ok(Self::Ldap),
"ad" => Ok(Self::Ad),
_ => Err("AUTHORIZATION_TYPE must be one of: local, ldap, ad".into()),
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Local => "local",
Self::Ldap => "ldap",
Self::Ad => "ad",
}
}
}
#[derive(Debug, Clone)]
pub struct Config {
pub host: IpAddr,
@@ -20,6 +50,8 @@ pub struct Config {
pub frontend_log_level: String,
pub anonymous_access_token_ttl_days: i64,
pub user_session_ttl_days: i64,
pub authorization_type: AuthorizationType,
pub ldap: Option<crate::ldap_auth::LdapConfig>,
}
impl Config {
@@ -56,6 +88,34 @@ impl Config {
return Err("UPLOAD_MAX_SIZE_MB must be greater than 0".into());
}
let authorization_type = AuthorizationType::from_env()?;
let ldap = match authorization_type {
AuthorizationType::Local => None,
AuthorizationType::Ldap | AuthorizationType::Ad => {
let context = format!("AUTHORIZATION_TYPE={}", authorization_type.as_str());
let (default_filter, default_username_attribute) = match authorization_type {
AuthorizationType::Ldap => ("(uid={username})", "uid"),
AuthorizationType::Ad => (
"(|(sAMAccountName={username})(userPrincipalName={username}))",
"sAMAccountName",
),
AuthorizationType::Local => unreachable!(),
};
Some(crate::ldap_auth::LdapConfig {
url: required_nonempty_env("LDAP_URL", &context)?,
starttls: env_bool("LDAP_STARTTLS", false)?,
bind_dn: env::var("LDAP_BIND_DN").unwrap_or_default(),
bind_password: env::var("LDAP_BIND_PASSWORD").unwrap_or_default(),
base_dn: required_nonempty_env("LDAP_BASE_DN", &context)?,
user_filter: env_var("LDAP_USER_FILTER", default_filter),
username_attribute: env_var("LDAP_USERNAME_ATTRIBUTE", default_username_attribute),
email_attribute: env_var("LDAP_EMAIL_ATTRIBUTE", "mail"),
display_name_attribute: env_var("LDAP_DISPLAY_NAME_ATTRIBUTE", "displayName"),
organization: env_var("LDAP_ORGANIZATION", "organization"),
})
}
};
let smtp_host = std::env::var("SMTP_HOST")
.ok()
.filter(|v| !v.trim().is_empty());
@@ -95,6 +155,8 @@ impl Config {
frontend_log_level: env_log_level("FRONTEND_LOG_LEVEL", "warn")?,
anonymous_access_token_ttl_days,
user_session_ttl_days,
authorization_type,
ldap,
})
}
}
@@ -141,3 +203,11 @@ fn required_env(name: &str) -> Result<String, Box<dyn std::error::Error>> {
}
Ok(value)
}
fn required_nonempty_env(name: &str, context: &str) -> Result<String, Box<dyn std::error::Error>> {
let value = env::var(name).map_err(|_| format!("{name} is required when {context}"))?;
if value.trim().is_empty() {
return Err(format!("{name} cannot be empty when {context}").into());
}
Ok(value)
}
+129
View File
@@ -0,0 +1,129 @@
use ldap3::{LdapConnAsync, LdapConnSettings, Scope, SearchEntry};
#[derive(Debug, Clone)]
pub struct LdapConfig {
pub url: String,
pub starttls: bool,
pub bind_dn: String,
pub bind_password: String,
pub base_dn: String,
pub user_filter: String,
pub username_attribute: String,
pub email_attribute: String,
pub display_name_attribute: String,
pub organization: String,
}
#[derive(Debug, Clone)]
pub struct LdapIdentity {
pub username: String,
pub email: String,
pub nickname: String,
}
pub async fn authenticate(
config: &LdapConfig,
login: &str,
password: &str,
) -> Result<Option<LdapIdentity>, String> {
if login.trim().is_empty() || password.is_empty() {
return Ok(None);
}
let settings = LdapConnSettings::new().set_starttls(config.starttls);
let (connection, mut ldap) = LdapConnAsync::with_settings(settings, &config.url)
.await
.map_err(|error| format!("LDAP connection failed: {error}"))?;
ldap3::drive!(connection);
if !config.bind_dn.trim().is_empty() {
let result = ldap
.simple_bind(&config.bind_dn, &config.bind_password)
.await
.map_err(|error| format!("LDAP service bind failed: {error}"))?;
result
.success()
.map_err(|error| format!("LDAP service bind rejected: {error}"))?;
}
let escaped = escape_filter(login.trim());
let filter = config.user_filter.replace("{username}", &escaped);
let attributes = vec![
config.username_attribute.as_str(),
config.email_attribute.as_str(),
config.display_name_attribute.as_str(),
];
let (entries, _) = ldap
.search(&config.base_dn, Scope::Subtree, &filter, attributes)
.await
.map_err(|error| format!("LDAP search failed: {error}"))?
.success()
.map_err(|error| format!("LDAP search rejected: {error}"))?;
if entries.len() != 1 {
let _ = ldap.unbind().await;
return Ok(None);
}
let entry = SearchEntry::construct(entries.into_iter().next().unwrap());
let user_dn = entry.dn.clone();
let username = first_attr(&entry, &config.username_attribute)
.unwrap_or_else(|| login.trim().to_owned());
let email = first_attr(&entry, &config.email_attribute)
.filter(|value| value.contains('@'))
.unwrap_or_else(|| format!("{}@ldap.local", safe_identifier(&username)));
let display_name = first_attr(&entry, &config.display_name_attribute)
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| username.clone());
let result = ldap
.simple_bind(&user_dn, password)
.await
.map_err(|error| format!("LDAP user bind failed: {error}"))?;
if result.success().is_err() {
let _ = ldap.unbind().await;
return Ok(None);
}
let _ = ldap.unbind().await;
let organization = config.organization.trim();
let nickname = if organization.is_empty() {
display_name
} else {
format!("{organization}/{display_name}")
};
Ok(Some(LdapIdentity {
username,
email,
nickname,
}))
}
fn first_attr(entry: &SearchEntry, name: &str) -> Option<String> {
entry.attrs.get(name).and_then(|values| values.first()).cloned()
}
fn safe_identifier(value: &str) -> String {
let result: String = value
.chars()
.map(|ch| if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') { ch } else { '_' })
.collect();
if result.is_empty() { "user".into() } else { result }
}
fn escape_filter(value: &str) -> String {
let mut result = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'*' => result.push_str("\\2a"),
b'(' => result.push_str("\\28"),
b')' => result.push_str("\\29"),
b'\\' => result.push_str("\\5c"),
0 => result.push_str("\\00"),
value if value < 0x20 || value >= 0x7f => result.push_str(&format!("\\{value:02x}")),
value => result.push(value as char),
}
}
result
}
+4 -1
View File
@@ -3,6 +3,7 @@ mod app;
mod assets;
mod auth;
mod config;
mod ldap_auth;
mod database;
mod db;
mod queries;
@@ -43,6 +44,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
anonymous_access_token_ttl_days = config.anonymous_access_token_ttl_days,
user_session_ttl_days = config.user_session_ttl_days,
smtp_configured = config.smtp.is_some(),
authorization_type = config.authorization_type.as_str(),
asset_version = %config.asset_version,
"configuration loaded"
);
@@ -73,12 +75,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
config.upload_max_size_bytes,
config.file_cache_max_age_seconds,
config.smtp.clone(),
config.registration_enabled,
config.registration_enabled && config.ldap.is_none(),
config.account_confirmation_required,
config.share_confirmation_required,
config.frontend_log_level.clone(),
config.anonymous_access_token_ttl_days,
config.user_session_ttl_days,
config.ldap.clone(),
));
let app = app::router(
state,
+3
View File
@@ -63,6 +63,7 @@ pub struct AppState {
pub frontend_log_level: String,
pub anonymous_access_token_ttl_days: i64,
pub user_session_ttl_days: i64,
pub ldap: Option<crate::ldap_auth::LdapConfig>,
channels: RwLock<HashMap<String, broadcast::Sender<RoomEvent>>>,
presence: RwLock<HashMap<String, HashMap<u64, PresenceConnection>>>,
next_connection_id: AtomicU64,
@@ -82,6 +83,7 @@ impl AppState {
frontend_log_level: String,
anonymous_access_token_ttl_days: i64,
user_session_ttl_days: i64,
ldap: Option<crate::ldap_auth::LdapConfig>,
) -> Self {
Self {
db,
@@ -96,6 +98,7 @@ impl AppState {
frontend_log_level,
anonymous_access_token_ttl_days,
user_session_ttl_days,
ldap,
channels: RwLock::new(HashMap::new()),
presence: RwLock::new(HashMap::new()),
next_connection_id: AtomicU64::new(1),
+1 -1
View File
@@ -97,7 +97,7 @@
<div class="identity-fields">
<label>Nickname<input id="nickname" name="nickname" maxlength="40" autocomplete="off" data-bwignore="true"
placeholder="Your nickname"></label>
<label id="auth-email-field">E-mail<input id="auth-email" name="username" type="email" maxlength="320"
<label id="auth-email-field">E-mail / organization login<input id="auth-email" name="username" type="email" maxlength="320"
autocomplete="username" required placeholder="you@example.com"></label>
<label>Password<input id="auth-password" name="password" type="password" minlength="8" maxlength="128"
autocomplete="current-password" required placeholder="At least 8 characters"></label>
+3 -3
View File
@@ -33,10 +33,10 @@ export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }
title.textContent = resetting ? "Reset password" : registering ? "Register nickname" : "Log in";
copy.textContent = resetting
? "Enter the e-mail address assigned to your account."
? "Enter the e-mail address assigned to your local account."
: registering
? "Reserve your nickname with an e-mail address and password."
: "Use the e-mail address and password assigned to your account.";
: "Use your e-mail address or organization login and password.";
nickname.closest("label").hidden = !registering;
nickname.disabled = !registering;
@@ -197,7 +197,7 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) {
dialog.querySelector("#show-reset")?.addEventListener("click", async () => {
const value = email.value.trim() || await askInput({
title: "Reset password",
message: "Enter the e-mail address assigned to your account.",
message: "Enter the e-mail address assigned to your local account.",
label: "E-mail",
type: "email",
autocomplete: "off",
+1 -1
View File
@@ -182,7 +182,7 @@
guest</button><button id="show-register" class="text-button" type="button">Register</button><button
id="show-login" class="text-button" type="button">Log in</button></div>
<section id="auth-panel" class="auth-panel" hidden>
<h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail<input id="auth-email"
<h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail / organization login<input id="auth-email"
name="username" type="email" maxlength="320" autocomplete="username"
placeholder="you@example.com"></label><label>Password<input id="auth-password" name="password"
type="password" minlength="8" maxlength="128" autocomplete="current-password"></label><button
+1 -1
View File
@@ -180,7 +180,7 @@
guest</button><button id="show-register" class="text-button" type="button">Register</button><button
id="show-login" class="text-button" type="button">Log in</button></div>
<section id="auth-panel" class="auth-panel" hidden>
<h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail<input id="auth-email"
<h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail / organization login<input id="auth-email"
name="username" type="email" maxlength="320" autocomplete="username"
placeholder="you@example.com"></label><label>Password<input id="auth-password" name="password"
type="password" minlength="8" maxlength="128" autocomplete="current-password"></label><button