diff --git a/.env.example b/.env.example index 6c73e4a..153521d 100644 --- a/.env.example +++ b/.env.example @@ -76,3 +76,50 @@ SMTP_PORT=587 SMTP_USERNAME= SMTP_PASSWORD= SMTP_FROM="RustPad " + +# 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 + +# Verify the LDAP server certificate for LDAPS/StartTLS. +# Set false only for trusted internal/test servers with a self-signed certificate. +# This allows encrypted TLS without mounting a custom CA certificate. +LDAP_TLS_VERIFY=true + +# Connection and LDAP operation timeouts. +LDAP_CONNECT_TIMEOUT_SECONDS=5 +LDAP_OPERATION_TIMEOUT_SECONDS=10 +# 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 + +# Stable directory identifier. Defaults: +# ldap: entryUUID +# ad: objectGUID +# LDAP_EXTERNAL_ID_ATTRIBUTE=entryUUID + +# Reject directory users without a valid mail attribute. +LDAP_EMAIL_REQUIRED=true + +# Link an existing account with the same e-mail on first directory login. +# Keep false unless you intentionally migrate existing local/legacy LDAP accounts. +LDAP_LINK_EXISTING_BY_EMAIL=false + +# 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 +# To allow login by either username or e-mail: +# LDAP_USER_FILTER=(|(uid={username})(mail={username})) \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index ed63db1..2b45326 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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.32" 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" diff --git a/Cargo.toml b/Cargo.toml index f4feaea..1cf794b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.0.29" +version = "0.0.32" 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"] } diff --git a/README.md b/README.md index f3fac16..5313158 100644 --- a/README.md +++ b/README.md @@ -157,3 +157,94 @@ 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`. Directory accounts are stored using a stable `entryUUID` (LDAP) or `objectGUID` (AD), while e-mail, nickname, and DN are synchronized after every successful login. + +### 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. By default TLS certificates are verified. For a trusted internal or test server using a self-signed certificate, `LDAP_TLS_VERIFY=false` keeps LDAPS/StartTLS encryption enabled without requiring a custom CA file. Do not disable verification on untrusted networks. + +Additional directory options: + +```env +LDAP_EXTERNAL_ID_ATTRIBUTE=entryUUID +LDAP_EMAIL_REQUIRED=true +LDAP_LINK_EXISTING_BY_EMAIL=false +LDAP_TLS_VERIFY=true +LDAP_CONNECT_TIMEOUT_SECONDS=5 +LDAP_OPERATION_TIMEOUT_SECONDS=10 +``` + +For Active Directory, the default external identifier is `objectGUID`. Set `LDAP_LINK_EXISTING_BY_EMAIL=true` only during an intentional migration of existing local or legacy LDAP accounts; otherwise an e-mail collision is rejected. + +### 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` diff --git a/docker-compose.yml b/docker-compose.yml index 7cb583d..3c94446 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/docker/ldap/bootstrap/01-users.ldif b/docker/ldap/bootstrap/01-users.ldif new file mode 100644 index 0000000..1fd6fc2 --- /dev/null +++ b/docker/ldap/bootstrap/01-users.ldif @@ -0,0 +1,27 @@ +dn: ou=people,dc=organization,dc=local +objectClass: organizationalUnit +ou: people + +dn: uid=admin,ou=people,dc=organization,dc=local +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +objectClass: top +uid: admin +cn: Organization Administrator +sn: Administrator +displayName: Organization Administrator +mail: admin@organization.local +userPassword: test1234! + +dn: uid=user,ou=people,dc=organization,dc=local +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +objectClass: top +uid: user +cn: Organization User +sn: User +displayName: Organization User +mail: user@organization.local +userPassword: test1234! \ No newline at end of file diff --git a/docker/ldap/docker-compose.yml b/docker/ldap/docker-compose.yml new file mode 100644 index 0000000..44b9d91 --- /dev/null +++ b/docker/ldap/docker-compose.yml @@ -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: organization.local + LDAP_ADMIN_PASSWORD: admin + LDAP_CONFIG_PASSWORD: admin + 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 diff --git a/migrations/mysql/0013_directory_identity.sql b/migrations/mysql/0013_directory_identity.sql new file mode 100644 index 0000000..27de5a5 --- /dev/null +++ b/migrations/mysql/0013_directory_identity.sql @@ -0,0 +1,5 @@ +ALTER TABLE users + ADD COLUMN auth_provider VARCHAR(16) NOT NULL DEFAULT 'local', + ADD COLUMN external_id VARCHAR(512) NULL, + ADD COLUMN external_dn TEXT NULL, + ADD UNIQUE INDEX idx_users_external_identity (auth_provider, external_id); diff --git a/migrations/postgres/0013_directory_identity.sql b/migrations/postgres/0013_directory_identity.sql new file mode 100644 index 0000000..fe0d80e --- /dev/null +++ b/migrations/postgres/0013_directory_identity.sql @@ -0,0 +1,4 @@ +ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'local'; +ALTER TABLE users ADD COLUMN external_id TEXT; +ALTER TABLE users ADD COLUMN external_dn TEXT; +CREATE UNIQUE INDEX idx_users_external_identity ON users(auth_provider, external_id) WHERE external_id IS NOT NULL; diff --git a/migrations/sqlite/0013_directory_identity.sql b/migrations/sqlite/0013_directory_identity.sql new file mode 100644 index 0000000..fe0d80e --- /dev/null +++ b/migrations/sqlite/0013_directory_identity.sql @@ -0,0 +1,4 @@ +ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'local'; +ALTER TABLE users ADD COLUMN external_id TEXT; +ALTER TABLE users ADD COLUMN external_dn TEXT; +CREATE UNIQUE INDEX idx_users_external_identity ON users(auth_provider, external_id) WHERE external_id IS NOT NULL; diff --git a/src/app.rs b/src/app.rs index da5ab5d..eed4353 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", @@ -387,7 +392,10 @@ fn error_response( asset_version: &str, ) -> Response { let html = include_str!("../static/error.html") - .replace("__APP_STYLESHEET__", &assets::stylesheet_tag(asset_version, "styles")) + .replace( + "__APP_STYLESHEET__", + &assets::stylesheet_tag(asset_version, "styles"), + ) .replace("__ERROR_CODE__", &escape_html(code)) .replace("__ERROR_TITLE__", &escape_html(title)) .replace("__ERROR_MESSAGE__", &escape_html(message)) diff --git a/src/assets.rs b/src/assets.rs index 066a0e3..843521e 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,16 @@ 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/auth/ldap.rs b/src/auth/ldap.rs new file mode 100644 index 0000000..7783ad6 --- /dev/null +++ b/src/auth/ldap.rs @@ -0,0 +1,352 @@ +use chrono::Utc; +use tracing::{debug, info}; + +use crate::{queries, state::SharedState}; + +use super::{ + AuthError, MAX_NICKNAME, SessionResponse, User, create_session, find_user_by_email, + find_user_by_external_id, find_user_by_nickname, hash_password, normalize, random_token, + validate_email, +}; + +use std::time::Duration; + +use ldap3::{LdapConnAsync, LdapConnSettings, Scope, SearchEntry}; +use tokio::time::timeout; + +#[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 external_id_attribute: String, + pub organization: String, + pub provider: String, + pub email_required: bool, + pub link_existing_by_email: bool, + pub tls_verify: bool, + pub connect_timeout_seconds: u64, + pub operation_timeout_seconds: u64, +} + +#[derive(Debug, Clone)] +pub struct LdapIdentity { + pub username: String, + pub email: String, + pub nickname: String, + pub provider: String, + pub external_id: String, + pub external_dn: String, +} + +pub async fn authenticate( + config: &LdapConfig, + login: &str, + password: &str, +) -> Result, String> { + if login.trim().is_empty() || password.is_empty() { + return Ok(None); + } + + let settings = LdapConnSettings::new() + .set_starttls(config.starttls) + .set_no_tls_verify(!config.tls_verify); + let connect_timeout = Duration::from_secs(config.connect_timeout_seconds); + let operation_timeout = Duration::from_secs(config.operation_timeout_seconds); + let (connection, mut ldap) = timeout( + connect_timeout, + LdapConnAsync::with_settings(settings, &config.url), + ) + .await + .map_err(|_| "LDAP connection timed out".to_owned())? + .map_err(|error| format!("LDAP connection failed: {error}"))?; + ldap3::drive!(connection); + + if !config.bind_dn.trim().is_empty() { + let result = timeout( + operation_timeout, + ldap.simple_bind(&config.bind_dn, &config.bind_password), + ) + .await + .map_err(|_| "LDAP service bind timed out".to_owned())? + .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(), + config.external_id_attribute.as_str(), + ]; + let (mut entries, _) = timeout( + operation_timeout, + ldap.search(&config.base_dn, Scope::Subtree, &filter, attributes.clone()), + ) + .await + .map_err(|_| "LDAP search timed out".to_owned())? + .map_err(|error| format!("LDAP search failed: {error}"))? + .success() + .map_err(|error| format!("LDAP search rejected: {error}"))?; + + if entries.is_empty() && login.trim().contains('@') { + let email_filter = format!("({}={})", config.email_attribute, escaped); + let (email_entries, _) = timeout( + operation_timeout, + ldap.search(&config.base_dn, Scope::Subtree, &email_filter, attributes), + ) + .await + .map_err(|_| "LDAP e-mail search timed out".to_owned())? + .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); + } + + 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('@')) + .or_else(|| { + (!config.email_required && login.trim().contains('@')).then(|| login.trim().to_owned()) + }); + if email.is_none() { + let _ = ldap.unbind().await; + return Err(format!( + "LDAP user is missing required e-mail attribute {}", + config.email_attribute + )); + } + let email = email.unwrap_or_default(); + let external_id = first_attr_or_binary(&entry, &config.external_id_attribute) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + format!( + "LDAP user is missing required external identifier attribute {}", + config.external_id_attribute + ) + })?; + let display_name = first_attr(&entry, &config.display_name_attribute) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| username.clone()); + + let result = timeout(operation_timeout, ldap.simple_bind(&user_dn, password)) + .await + .map_err(|_| "LDAP user bind timed out".to_owned())? + .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, + provider: config.provider.clone(), + external_id, + external_dn: user_dn, + })) +} + +fn first_attr(entry: &SearchEntry, name: &str) -> Option { + entry + .attrs + .get(name) + .and_then(|values| values.first()) + .cloned() +} + +fn first_attr_or_binary(entry: &SearchEntry, name: &str) -> Option { + first_attr(entry, name).or_else(|| { + entry + .bin_attrs + .get(name) + .and_then(|values| values.first()) + .map(|value| { + let mut output = String::with_capacity(value.len() * 2); + for byte in value { + use std::fmt::Write; + let _ = write!(&mut output, "{byte:02x}"); + } + output + }) + }) +} + +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 +} + +pub(super) async fn login( + state: &SharedState, + login: &str, + password: &str, +) -> Result { + let config = state + .ldap + .as_ref() + .ok_or_else(|| AuthError::internal("LDAP authentication is not configured."))?; + debug!(login = %login.trim(), "LDAP login requested"); + let identity = authenticate(config, login, 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"); + Ok(session) +} + +async fn provision_ldap_user( + state: &SharedState, + identity: LdapIdentity, +) -> Result { + let email = validate_email(&identity.email)?; + if let Some(user) = + find_user_by_external_id(state, &identity.provider, &identity.external_id).await? + { + return sync_directory_user(state, user, &identity, &email).await; + } + + if let Some(existing) = find_user_by_email(state, &email).await? { + let link_by_email = state + .ldap + .as_ref() + .map(|config| config.link_existing_by_email) + .unwrap_or(false); + if !link_by_email { + return Err(AuthError::conflict( + "This e-mail address already belongs to another account. Ask an administrator to link it or enable LDAP_LINK_EXISTING_BY_EMAIL.", + )); + } + return sync_directory_user(state, existing, &identity, &email).await; + } + + let nickname = available_directory_nickname(state, &identity.nickname, None).await?; + let password_hash = hash_password(&random_token())?; + let confirmed_at = Utc::now().to_rfc3339(); + sqlx::query(queries::get( + state.db.kind(), + queries::AUTH_INSERT_DIRECTORY_USER, + )) + .bind(&nickname) + .bind(normalize(&nickname)) + .bind(&email) + .bind(normalize(&email)) + .bind(password_hash) + .bind(Some(confirmed_at)) + .bind(&identity.provider) + .bind(&identity.external_id) + .bind(&identity.external_dn) + .execute(state.db.pool()) + .await + .map_err(AuthError::database)?; + info!(ldap_username = %identity.username, nickname = %nickname, provider = %identity.provider, "provisioned directory user"); + find_user_by_external_id(state, &identity.provider, &identity.external_id) + .await? + .ok_or_else(|| AuthError::internal("Failed to provision the organization account.")) +} + +async fn sync_directory_user( + state: &SharedState, + user: User, + identity: &LdapIdentity, + email: &str, +) -> Result { + let nickname = available_directory_nickname(state, &identity.nickname, Some(user.id)).await?; + sqlx::query(queries::get( + state.db.kind(), + queries::AUTH_UPDATE_DIRECTORY_USER, + )) + .bind(&nickname) + .bind(normalize(&nickname)) + .bind(email) + .bind(normalize(email)) + .bind(&identity.provider) + .bind(&identity.external_id) + .bind(&identity.external_dn) + .bind(Utc::now().to_rfc3339()) + .bind(user.id) + .execute(state.db.pool()) + .await + .map_err(AuthError::database)?; + find_user_by_external_id(state, &identity.provider, &identity.external_id) + .await? + .ok_or_else(|| AuthError::internal("Failed to synchronize the organization account.")) +} + +async fn available_directory_nickname( + state: &SharedState, + requested: &str, + current_user_id: Option, +) -> Result { + let base = truncate_nickname(requested); + let mut nickname = base.clone(); + let mut suffix = 1usize; + loop { + match find_user_by_nickname(state, &nickname).await? { + None => return Ok(nickname), + Some(user) if Some(user.id) == current_user_id => return Ok(nickname), + Some(_) => { + suffix += 1; + let marker = format!("-{suffix}"); + nickname = format!( + "{}{}", + truncate_to_chars(&base, MAX_NICKNAME - marker.chars().count()), + marker + ); + } + } + } +} + +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() +} diff --git a/src/auth/local.rs b/src/auth/local.rs new file mode 100644 index 0000000..eeafa5e --- /dev/null +++ b/src/auth/local.rs @@ -0,0 +1,34 @@ +use tracing::{debug, info, warn}; + +use crate::state::SharedState; + +use super::{ + AuthError, SessionResponse, create_session, email_domain, find_user_by_email, validate_email, + verify_password, +}; + +pub(super) async fn login( + state: &SharedState, + login: &str, + password: &str, +) -> Result { + let email = validate_email(login)?; + debug!(email_domain = %email_domain(&email), "login requested"); + let user = find_user_by_email(state, &email) + .await? + .ok_or_else(|| AuthError::unauthorized("Invalid e-mail address or password."))?; + if !verify_password(&user.password_hash, password) { + warn!(user_id = user.id, "login rejected: invalid 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( + "Confirm the account using the link sent by e-mail before logging in.", + )); + } + let session = create_session(state, &user).await?; + info!(user_id = user.id, nickname = %user.nickname, "login successful"); + Ok(session) +} diff --git a/src/auth.rs b/src/auth/mod.rs similarity index 98% rename from src/auth.rs rename to src/auth/mod.rs index af4bd27..fc6c565 100644 --- a/src/auth.rs +++ b/src/auth/mod.rs @@ -1,3 +1,6 @@ +pub(crate) mod ldap; +mod local; + use argon2::{ Argon2, password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, @@ -161,6 +164,11 @@ pub async fn identity( State(state): State, Json(req): Json, ) -> Result, 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 +204,11 @@ pub async fn register( State(state): State, Json(req): Json, ) -> Result<(StatusCode, Json), 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,25 +337,15 @@ pub async fn login( State(state): State, Json(req): Json, ) -> Result, AuthError> { - let email = validate_email(&req.email)?; - debug!(email_domain = %email_domain(&email), "login requested"); - let user = find_user_by_email(&state, &email) - .await? - .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.", - )); + if state.ldap.is_some() { + ldap::login(&state, &req.email, &req.password) + .await + .map(Json) + } else { + local::login(&state, &req.email, &req.password) + .await + .map(Json) } - if state.account_confirmation_required && user.confirmed_at.is_none() { - return Err(AuthError::forbidden( - "Confirm the account using the link sent by e-mail before logging in.", - )); - } - let session = create_session(&state, &user).await?; - info!(user_id = user.id, nickname = %user.nickname, "login successful"); - Ok(Json(session)) } pub async fn confirm_account( @@ -1265,6 +1268,22 @@ async fn find_user_by_nickname( .await .map_err(AuthError::database) } +async fn find_user_by_external_id( + state: &SharedState, + provider: &str, + external_id: &str, +) -> Result, AuthError> { + sqlx::query_as::<_, User>(queries::get( + state.db.kind(), + queries::AUTH_USER_BY_EXTERNAL_ID, + )) + .bind(provider) + .bind(external_id) + .fetch_optional(state.db.pool()) + .await + .map_err(AuthError::database) +} + async fn find_user_by_email(state: &SharedState, email: &str) -> Result, AuthError> { sqlx::query_as::<_, User>(queries::get(state.db.kind(), queries::AUTH_USER_BY_EMAIL)) .bind(normalize(email)) diff --git a/src/config.rs b/src/config.rs index f74ca1b..e450891 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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> { + 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, } impl Config { @@ -56,6 +88,54 @@ 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::auth::ldap::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"), + external_id_attribute: env_var( + "LDAP_EXTERNAL_ID_ATTRIBUTE", + match authorization_type { + AuthorizationType::Ldap => "entryUUID", + AuthorizationType::Ad => "objectGUID", + AuthorizationType::Local => unreachable!(), + }, + ), + organization: env_var("LDAP_ORGANIZATION", "organization"), + provider: authorization_type.as_str().to_owned(), + email_required: env_bool("LDAP_EMAIL_REQUIRED", true)?, + link_existing_by_email: env_bool("LDAP_LINK_EXISTING_BY_EMAIL", false)?, + tls_verify: env_bool("LDAP_TLS_VERIFY", true)?, + connect_timeout_seconds: env_positive_u64("LDAP_CONNECT_TIMEOUT_SECONDS", 5)?, + operation_timeout_seconds: env_positive_u64( + "LDAP_OPERATION_TIMEOUT_SECONDS", + 10, + )?, + }) + } + }; + let smtp_host = std::env::var("SMTP_HOST") .ok() .filter(|v| !v.trim().is_empty()); @@ -95,6 +175,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, }) } } @@ -130,6 +212,14 @@ fn env_positive_i64(name: &str, default: i64) -> Result Result> { + let value: u64 = env_var(name, &default.to_string()).parse()?; + if value == 0 { + return Err(format!("{name} must be greater than 0").into()); + } + Ok(value) +} + fn env_nonnegative_u64(name: &str, default: u64) -> Result> { Ok(env_var(name, &default.to_string()).parse()?) } @@ -141,3 +231,11 @@ fn required_env(name: &str) -> Result> { } Ok(value) } + +fn required_nonempty_env(name: &str, context: &str) -> Result> { + 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) +} diff --git a/src/ldap_auth.rs b/src/ldap_auth.rs new file mode 100644 index 0000000..6632436 --- /dev/null +++ b/src/ldap_auth.rs @@ -0,0 +1,142 @@ +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, 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 (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); + } + + 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 { + 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 +} diff --git a/src/main.rs b/src/main.rs index 4082194..8dcdf9a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,6 +43,7 @@ async fn main() -> Result<(), Box> { 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 +74,13 @@ async fn main() -> Result<(), Box> { 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, diff --git a/src/queries.rs b/src/queries.rs index 0c292c3..1281c96 100644 --- a/src/queries.rs +++ b/src/queries.rs @@ -17,6 +17,9 @@ pub const POSTGRES_PAD_REVISION_LAST_INSERT_ID: &str = // Authentication queries. pub const AUTH_INSERT_USER: &str = "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash, confirmed_at) VALUES (?, ?, ?, ?, ?, ?)"; +pub const AUTH_INSERT_DIRECTORY_USER: &str = "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash, confirmed_at, auth_provider, external_id, external_dn) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"; +pub const AUTH_UPDATE_DIRECTORY_USER: &str = "UPDATE users SET nickname = ?, nickname_key = ?, email = ?, email_key = ?, auth_provider = ?, external_id = ?, external_dn = ?, updated_at = ? WHERE id = ?"; +pub const AUTH_USER_BY_EXTERNAL_ID: &str = "SELECT id, nickname, email, password_hash, confirmed_at FROM users WHERE auth_provider = ? AND external_id = ?"; pub const AUTH_DELETE_USER: &str = "DELETE FROM users WHERE id = ?"; pub const AUTH_SESSION_EXPIRES_AT: &str = "SELECT expires_at FROM user_sessions WHERE token = ?"; pub const AUTH_DELETE_SESSION_BY_TOKEN: &str = "DELETE FROM user_sessions WHERE token = ?"; diff --git a/src/state.rs b/src/state.rs index 84a0c2b..cd7e810 100644 --- a/src/state.rs +++ b/src/state.rs @@ -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, channels: RwLock>>, presence: RwLock>>, 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, ) -> 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), diff --git a/static/css/styles.css b/static/css/styles.css index 3a08dbf..55e0068 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -4139,3 +4139,8 @@ dialog::backdrop { max-width: 150px; transform: none; } + +/* Keep public TOC targets visible below the sticky page header. */ +.public-content :is(h1, h2, h3, h4, h5, h6)[id] { + scroll-margin-top: 88px; +} diff --git a/static/home.html b/static/home.html index db2d393..91e0ba9 100644 --- a/static/home.html +++ b/static/home.html @@ -97,8 +97,8 @@
- +
diff --git a/static/js/auth-ui.js b/static/js/auth-ui.js index 8e2ca04..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) => { @@ -33,10 +34,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; @@ -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,10 +202,12 @@ 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 account.", + message: "Enter the e-mail address assigned to your local account.", label: "E-mail", type: "email", autocomplete: "off", diff --git a/static/js/home.js b/static/js/home.js index a52692c..7533130 100644 --- a/static/js/home.js +++ b/static/js/home.js @@ -110,7 +110,7 @@ async function loadResources() { const sharedLabel = !item.owned ? `Shared by ${escapeHtml(item.shared_by || "another user")}` : ""; const permissionLabel = item.permission === "rw" ? "Read and write" : "Read only"; row.classList.toggle("resource-row--shared", !Boolean(item.owned)); - row.innerHTML = `
${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}
${item.owned ? `` : ""}
`; + row.innerHTML = `
${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}
${item.owned ? `` : ""}
`; const inline = row.querySelector("[data-inline]"); const closeInline = () => { inline.hidden = true; inline.innerHTML = ""; }; diff --git a/static/js/public.js b/static/js/public.js index 0191bff..28c55b9 100644 --- a/static/js/public.js +++ b/static/js/public.js @@ -16,7 +16,26 @@ function lockPublicContent(allowTaskUpdates) { content.querySelectorAll('.preview-editable').forEach(node => node.classList.remove('preview-editable')); content.querySelectorAll('.task-checkbox').forEach(box => { box.disabled = !allowTaskUpdates; box.title = allowTaskUpdates ? 'Update this task' : 'Task updates are disabled by the owner'; }); } -async function initialize() { try { const page = await api(`/api/public/${encodeURIComponent(token)}`); document.querySelector("#public-title").textContent = page.title; document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")}${page.allow_task_updates ? " · tasks can be updated" : ""}`; document.title = `${page.title} · RustPad`; content.innerHTML = renderMarkdown(page.content); alignPreviewLineNumbers(content); lockPublicContent(page.allow_task_updates); await Promise.all([renderMermaid(), renderCodeHighlight()]); } catch (error) { content.innerHTML = `

${String(error.message)}

`; } } +function scrollToPublicAnchor(hash, behavior = "auto") { + const rawId = String(hash || "").replace(/^#/, ""); + if (!rawId) return false; + let id; + try { id = decodeURIComponent(rawId); } catch { id = rawId; } + const target = document.getElementById(id); + if (!target || !content.contains(target)) return false; + target.scrollIntoView({ behavior, block: "start" }); + return true; +} +async function initialize() { try { const page = await api(`/api/public/${encodeURIComponent(token)}`); document.querySelector("#public-title").textContent = page.title; document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")}${page.allow_task_updates ? " · tasks can be updated" : ""}`; document.title = `${page.title} · RustPad`; content.innerHTML = renderMarkdown(page.content); alignPreviewLineNumbers(content); lockPublicContent(page.allow_task_updates); await Promise.all([renderMermaid(), renderCodeHighlight()]); requestAnimationFrame(() => scrollToPublicAnchor(location.hash)); } catch (error) { content.innerHTML = `

${String(error.message)}

`; } } +content.addEventListener("click", event => { + const link = event.target.closest('.markdown-toc a[href^="#"]'); + if (!link) return; + const hash = link.getAttribute("href"); + if (!scrollToPublicAnchor(hash, "smooth")) return; + event.preventDefault(); + history.replaceState(null, "", `${location.pathname}${location.search}${hash}`); +}); +window.addEventListener("hashchange", () => scrollToPublicAnchor(location.hash, "smooth")); content.addEventListener("change", async event => { const box = event.target.closest(".task-checkbox"); if (!box || box.disabled) return; const previous = !box.checked; box.disabled = true; try { const page = await api(`/api/public/${encodeURIComponent(token)}/tasks`, { method: "POST", body: JSON.stringify({ source_line: Number(box.dataset.sourceLine), checked: box.checked }) }); document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")} · tasks can be updated`; toast("Task saved"); } catch (error) { box.checked = previous; toast(error.message); } finally { box.disabled = false; } }); lineNumbersToggle.addEventListener("change", () => { document.body.classList.toggle("hide-preview-line-numbers", !lineNumbersToggle.checked); }); document.querySelector("#copy-public-link").addEventListener("click", async () => { try { await copyText(location.href); toast("Link copied"); } catch (error) { toast(error.message); } }); diff --git a/static/note.html b/static/note.html index 1f5aa24..c8c9d20 100644 --- a/static/note.html +++ b/static/note.html @@ -182,7 +182,7 @@ guest