From 74973a0a6d26b89857c5585718aac2466b5f406c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 20:47:37 +0000 Subject: [PATCH 001/122] Add LDAP login provider and shared, provider-customizable login form Co-Authored-By: tariq.k.soliman --- .github/workflows/playwright-tests.yml | 84 +++++ API/permissions/providers.js | 72 +++- .../ResetPasswordModal/ResetPasswordModal.js | 4 +- .../AuthProviders/AuthProviders.md | 66 ++-- docs/pages/Setup/ENVs/ENVs.md | 87 +++++ docs/pages/Setup/LDAP/LDAP.md | 100 ++++++ package-lock.json | 36 +- package.json | 1 + plugins/core/auth/Ldap/plugin.js | 329 ++++++++++++++++++ plugins/core/auth/Ldap/plugin.json | 19 + plugins/core/auth/Local/plugin.js | 6 + plugins/core/auth/Local/plugin.json | 3 + plugins/core/auth/Local/routes.js | 8 +- .../core/backend/Accounts/routes/accounts.js | 19 +- public/adminlogin.css | 126 +++++++ public/adminlogin.js | 15 + public/login.css | 126 +++++++ public/login.js | 20 +- public/loginForms.js | 93 +++++ sample.env | 36 +- scripts/server.js | 6 +- .../UserInterface_/components/Login/Login.css | 112 ++++++ .../UserInterface_/components/Login/Login.js | 202 +++++++++-- tests/e2e/ldap/ldap-provider.spec.js | 142 ++++++++ tests/e2e/ldap/openldap/.gitignore | 1 + tests/e2e/ldap/openldap/acl.ldif | 8 + tests/e2e/ldap/openldap/seed.ldif | 45 +++ tests/e2e/ldap/openldap/start.sh | 34 ++ tests/unit/ldap-provider.spec.js | 158 +++++++++ tests/unit/permissions-providers.spec.js | 63 +++- tests/unit/permissions-siteAccess.spec.js | 2 +- views/adminlogin.pug | 7 +- views/login.pug | 18 +- views/mixins/loginForms.pug | 23 ++ 34 files changed, 1979 insertions(+), 92 deletions(-) create mode 100644 docs/pages/Setup/LDAP/LDAP.md create mode 100644 plugins/core/auth/Ldap/plugin.js create mode 100644 plugins/core/auth/Ldap/plugin.json create mode 100644 public/loginForms.js create mode 100644 tests/e2e/ldap/ldap-provider.spec.js create mode 100644 tests/e2e/ldap/openldap/.gitignore create mode 100644 tests/e2e/ldap/openldap/acl.ldif create mode 100644 tests/e2e/ldap/openldap/seed.ldif create mode 100755 tests/e2e/ldap/openldap/start.sh create mode 100644 tests/unit/ldap-provider.spec.js create mode 100644 views/mixins/loginForms.pug diff --git a/.github/workflows/playwright-tests.yml b/.github/workflows/playwright-tests.yml index 72b9d1ed0c..e28e68f10d 100644 --- a/.github/workflows/playwright-tests.yml +++ b/.github/workflows/playwright-tests.yml @@ -212,3 +212,87 @@ jobs: name: playwright-report-proxy-${{ matrix.proxy_trust }} path: playwright-report/ retention-days: 30 + + ldap: + # The LDAP login provider against a real OpenLDAP directory, over LDAPS and StartTLS + timeout-minutes: 20 + runs-on: ubuntu-latest + strategy: + matrix: + transport: [ldaps, starttls] + + services: + postgres: + image: postgis/postgis:13-3.1 + env: + POSTGRES_DB: mmgis-test + POSTGRES_USER: test_user + POSTGRES_PASSWORD: test_password # pragma: allowlist secret + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + cache: "npm" + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Install plugin dependencies + run: node scripts/resolve-plugin-deps.js && node scripts/install-plugin-deps.js + + - name: Install Playwright Browsers + run: npx --no-install playwright install --with-deps chromium + + - name: Start OpenLDAP + run: tests/e2e/ldap/openldap/start.sh "$RUNNER_TEMP/ldap" + + - name: Setup test environment + run: | # pragma: allowlist secret + cp sample.env .env || touch .env + sed -i "/^SITE_ACCESS=/d;/^AUTH_PROVIDERS=/d;/^LDAP_/d" .env + echo "NODE_ENV=test" >> .env + echo "PORT=18888" >> .env + echo "DB_HOST=localhost" >> .env + echo "DB_PORT=5432" >> .env + echo "DB_NAME=mmgis-test" >> .env + echo "DB_USER=test_user" >> .env + echo "DB_PASS=test_password" >> .env + echo "DB_USER_TEST=test_user" >> .env + echo "DB_PASS_TEST=test_password" >> .env + echo "ENABLE_MMGIS_WEBSOCKETS=false" >> .env + echo "ENABLE_CONFIG_WEBSOCKETS=false" >> .env + echo "HIDE_CONFIG=false" >> .env + echo "SECRET=ci-test-session-secret-not-for-production" >> .env + + - name: Run E2E LDAP Provider Tests + run: npx --no-install playwright test tests/e2e/ldap --project=chromium + env: + SITE_ACCESS: optional + AUTH_PROVIDERS: '["ldap","local"]' + LDAP_URL: ${{ matrix.transport == 'ldaps' && 'ldaps://localhost:3636' || 'ldap://localhost:3389' }} + LDAP_STARTTLS: ${{ matrix.transport == 'starttls' && 'true' || 'false' }} + LDAP_TLS_CA: ${{ runner.temp }}/ldap/ca.pem + LDAP_BIND_DN: cn=mmgis-svc,ou=services,dc=example,dc=org + LDAP_BIND_PASSWORD: svc-test-password # pragma: allowlist secret + LDAP_USER_SEARCH_BASE: ou=people,dc=example,dc=org + LDAP_GROUP_SEARCH_BASE: ou=groups,dc=example,dc=org + + - name: Upload test results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: playwright-report-ldap-${{ matrix.transport }} + path: playwright-report/ + retention-days: 30 diff --git a/API/permissions/providers.js b/API/permissions/providers.js index f9a96973bf..79d0dd4d6f 100644 --- a/API/permissions/providers.js +++ b/API/permissions/providers.js @@ -9,6 +9,9 @@ // ctx.completeLogin(req, identity) starts a session // validate(env) -> string[] problems that refuse startup // warnings(env) -> string[] problems logged at startup +// loginForm(env) -> { title?, usernameLabel?, hint?, helpUrl? } wording of the +// shared username/password form, for providers whose +// plugin.json sets "login_form": true // An identity is { subject, username?, email?, groups?: string[], strictGroups?: string[], session? }. const path = require("path"); const crypto = require("crypto"); @@ -128,11 +131,13 @@ const providerWarnings = (ids = siteAccess.getProviders(), env = process.env) => enabledProviders(ids).forEach((p) => { const lp = p.manifest.login_path; if (lp != null && (typeof lp !== "string" || !lp.startsWith("/"))) - out.push(`Auth provider '${p.id}': plugin.json "login_path" must start with "/", so no login button is shown.`); + out.push( + `Auth provider '${p.id}': plugin.json "login_path" must start with "/", so no login ${p.manifest.login_form === true ? "form" : "button"} is shown.` + ); }); - if (ids.includes("proxy") && !ids.includes("local")) + if (!ids.includes("local")) out.push( - 'AUTH_PROVIDERS does not include local, so local admin accounts cannot sign in to /configure. Use ["proxy","local"] to keep them.' + `AUTH_PROVIDERS does not include local, so local admin accounts cannot sign in to /configure. Use ${JSON.stringify([...ids, "local"])} to keep them.` ); return out; }; @@ -294,13 +299,63 @@ function returnTo(value, fallback = `${rootPath()}/`) { return value; } -// Login entry points that the login page, Configure sign-in and the in-app modal show. -// "login_path" (a route of the provider) gets returnTo appended; "login_url" (e.g. a -// proxy's own login page, for per-request providers) is linked as is. +const isFormProvider = (p) => + p.manifest.login_form === true && typeof p.manifest.login_path === "string" && p.manifest.login_path.startsWith("/"); + +const ICON_NAME = /^[a-z0-9-]+$/; +const HTTP_URL = /^https?:\/\/\S+$/i; +const text = (v, fallback = "") => (typeof v === "string" && v.trim() !== "" ? v.trim() : fallback); + +function formWording(p) { + if (typeof p.impl.loginForm !== "function") return {}; + try { + const w = p.impl.loginForm(process.env); + return w && typeof w === "object" ? w : {}; + } catch (err) { + logger("error", `Auth provider '${p.id}': loginForm(env) threw; using its default wording.`, "AuthProviders", null, err); + return {}; + } +} + +// The providers that take a username and password in the shared form on the login page, +// Configure sign-in and the in-app modal, in AUTH_PROVIDERS order. With AUTH_LOCAL_LOGIN=admins, +// local accounts are only offered at Configure sign-in (configure: true). +function loginForms(ids = siteAccess.getProviders(), { configure = false } = {}) { + const out = []; + enabledProviders(ids).forEach((p) => { + if (!isFormProvider(p)) return; + if (p.id === "local" && siteAccess.localAdminsOnly() && !configure) return; + const w = formWording(p); + const helpUrl = text(w.helpUrl); + out.push({ + id: p.id, + title: text(w.title, p.label), + usernameLabel: text(w.usernameLabel, "Username"), + hint: text(w.hint), + helpUrl: HTTP_URL.test(helpUrl) ? helpUrl : "", + icon: ICON_NAME.test(p.manifest.login_icon || "") ? p.manifest.login_icon : "account-outline", + path: `api/auth/${p.id}${p.manifest.login_path}`, + }); + }); + return out; +} + +// The name people know a provider by: its login form title, else its display_name +function providerLabel(id) { + const p = getProvider(id); + if (!p) return null; + return isFormProvider(p) ? text(formWording(p).title, p.label) : p.label; +} + +// Login buttons that the login page, Configure sign-in and the in-app modal show for +// providers that don't use the shared form. "login_path" (a route of the provider) gets +// returnTo appended; "login_url" (e.g. a proxy's own login page, for per-request +// providers) is linked as is. function loginLinks(ids = siteAccess.getProviders()) { const out = []; enabledProviders(ids).forEach((p) => { const m = p.manifest; + if (m.login_form === true) return; const label = m.login_label || `Log in with ${p.label}`; if (typeof m.login_path === "string" && m.login_path.startsWith("/")) out.push({ id: p.id, label, path: `api/auth/${p.id}${m.login_path}` }); @@ -334,7 +389,8 @@ function mountProviderRoutes(app, { registerRouter, authorize, rootPath: root }) list.get("/", apilimiter, authorize.public("lists the enabled login providers"), (req, res) => { res.send({ status: "success", - providers: enabledProviders().map((p) => ({ id: p.id, label: p.label })), + providers: enabledProviders().map((p) => ({ id: p.id, label: providerLabel(p.id) })), + loginForms: loginForms(), loginLinks: loginLinks(), }); }); @@ -373,6 +429,8 @@ module.exports = { syncGroups, completeLogin, returnTo, + loginForms, + providerLabel, loginLinks, logoutUrl, mountProviderRoutes, diff --git a/configure/src/pages/UsersAccess/Modals/ResetPasswordModal/ResetPasswordModal.js b/configure/src/pages/UsersAccess/Modals/ResetPasswordModal/ResetPasswordModal.js index ae57095999..c1881768c0 100644 --- a/configure/src/pages/UsersAccess/Modals/ResetPasswordModal/ResetPasswordModal.js +++ b/configure/src/pages/UsersAccess/Modals/ResetPasswordModal/ResetPasswordModal.js @@ -275,7 +275,7 @@ const ResetPasswordModal = (props) => { } else { dispatch( setSnackBarText({ - text: `Failed to generate a password reset link for '${modal.row.username}'.`, + text: res?.message || `Failed to generate a password reset link for '${modal.row.username}'.`, severity: "error", }) ); @@ -284,7 +284,7 @@ const ResetPasswordModal = (props) => { (res) => { dispatch( setSnackBarText({ - text: `Failed to generate a password reset link for '${modal.row.username}'.`, + text: res?.message || `Failed to generate a password reset link for '${modal.row.username}'.`, severity: "error", }) ); diff --git a/docs/pages/Contributing/AuthProviders/AuthProviders.md b/docs/pages/Contributing/AuthProviders/AuthProviders.md index 3cca95562d..63b59caebf 100644 --- a/docs/pages/Contributing/AuthProviders/AuthProviders.md +++ b/docs/pages/Contributing/AuthProviders/AuthProviders.md @@ -16,6 +16,7 @@ nav_order: 3 - [plugin.js hooks](#pluginjs-hooks) - [Identities, accounts and groups](#identities-accounts-and-groups) - [The ctx object](#the-ctx-object) +- [Login form providers](#login-form-providers) - [Login buttons, returnTo and sign out](#login-buttons-returnto-and-sign-out) - [Example: proxy header provider](#example-proxy-header-provider) - [Example: login form provider](#example-login-form-provider) @@ -28,7 +29,7 @@ nav_order: 3 A login provider answers "who is this?"; MMGIS roles, groups and grants decide what that person may do, whichever provider answered. -`local` (MMGIS username/password accounts) and `proxy` (an authenticating reverse proxy such as Cloud Single Sign-On) ship with MMGIS. A new provider is a folder under `plugins/core/auth/` and is turned on by listing its id in [`AUTH_PROVIDERS`](/MMGIS/setup/envs). Nothing else in MMGIS needs to change: Configure > Users & Access, roles, mission and layer grants, groups and API tokens all work for its users. +`local` (MMGIS username/password accounts), `ldap` (a directory, see [LDAP Login](/MMGIS/setup/ldap)) and `proxy` (an authenticating reverse proxy such as Cloud Single Sign-On) ship with MMGIS. A new provider is a folder under `plugins/core/auth/` and is turned on by listing its id in [`AUTH_PROVIDERS`](/MMGIS/setup/envs). Nothing else in MMGIS needs to change: Configure > Users & Access, roles, mission and layer grants, groups and API tokens all work for its users. ## Quick start @@ -67,7 +68,9 @@ There are two kinds of provider. Pick one: | `provider` | yes | The id used in `AUTH_PROVIDERS`, in group sources and in `/api/auth/`. Lowercase letters, digits, `-` and `_`, starting with a letter. `local` and `proxy` belong to the shipped providers. | | `tier`, `overridable` | yes | Must be `"core"` and `false`. Providers can't be overridden by external plugin containers. | | `display_name` | no | Shown on login buttons (`Log in with `). Defaults to the folder name. | -| `login_path` | no | Session providers: the route that starts a login, relative to `/api/auth/`. When set, the login page, the Configure sign-in page and the in-app login modal show a button for it. | +| `login_path` | no | Session providers: the route that starts a login, relative to `/api/auth/`. When set, the login page, the Configure sign-in page and the in-app login modal show a button for it (or, with `login_form`, post their form to it). | +| `login_form` | no | `true` for providers that take a username and password: instead of a button, the shared login form posts to `login_path` (see [Login form providers](#login-form-providers)). | +| `login_icon` | no | A [Material Design Icons](https://pictogrammers.com/library/mdi/) name (e.g. `card-account-details-outline`) shown on the form's provider switch and banner. | | `login_url` | no | Per-request providers: a login page outside MMGIS (for example the proxy's), linked as is. Use it instead of `login_path`. | | `login_label` | no | Text of that button. | | `logout_url` | no | Where the main site's **Sign out** sends the browser after ending the MMGIS session, e.g. your identity provider's logout page. Per-request providers need it, because MMGIS can't end a proxy's login. | @@ -87,6 +90,8 @@ module.exports = { validate(env) { return []; }, // Problems logged at startup, as strings. warnings(env) { return []; }, + // Login form providers: the form's wording, usually from your ENVs. Every field is optional. + loginForm(env) { return { title, usernameLabel, hint, helpUrl }; }, }; ``` @@ -135,6 +140,17 @@ There's no database handle or config object; read settings from `process.env`. **Rate limiting is automatic.** Each provider's routes share a limit of 10 failed responses (status 400 or higher) per IP per 15 minutes, like local login. So answer bad credentials with `401`, not `200`; successful logins and redirects don't count. +## Login form providers + +A provider whose `plugin.json` sets `"login_form": true` and a `login_path` shares MMGIS's username/password form on the login page, Configure sign-in and the in-app modal; it doesn't serve a login page of its own. + +- **Layout.** With one form provider that isn't `local`, the form shows a "Sign in with " banner. With several (for example `ldap` and `local`), it shows a switch; the first in `AUTH_PROVIDERS` is the default and the browser remembers the last choice. The password is only sent to the selected provider. With `AUTH_LOCAL_LOGIN=admins`, `local` is only offered at Configure sign-in. +- **Wording.** `loginForm(env)` returns `title` (the tab and banner name, default `display_name`), `usernameLabel` (default `Username`), `hint` (shown with the form) and `helpUrl` (an http(s) page, linked as "Reset your <title> password"). Read them from `<PROVIDERID>_*` ENVs so each site can name the provider its own way. `GET /api/auth` returns them as `loginForms`. +- **Request.** The form sends `POST /api/auth/<id><login_path>` with a JSON body `{ "username", "password" }`. +- **Response.** On success call `ctx.completeLogin` and answer `{ "status": "success", "username", "provider": "<id>" }`; the page then reloads (login page, Configure) or the modal closes. On failure answer `{ "status": "failure", "message" }` with `400` (missing input), `401` (wrong credentials; counts towards the rate limit), `409` (`ctx.LinkError`) or `503` (the backend is unreachable). The message is shown as is, so name the provider in it. + +`plugins/core/auth/Ldap/plugin.js` is a complete example. + ## Login buttons, returnTo and sign out - **Buttons.** With `login_path` set, the login page, Configure sign-in and the main site's login modal link to `/api/auth/<id><login_path>?returnTo=<the page the user was on>`. `GET /api/auth` returns the enabled providers and these links. @@ -201,7 +217,7 @@ Enable it with `AUTH_PROVIDERS=["local","proxyheader"]` and `PROXYHEADER_SECRET= ## Example: login form provider -A username/password form checked against a directory. Here the directory is a JSON file so the example runs as is; replace `checkPassword` with your LDAP or HTTP call. +A username/password check against a directory, shown in the shared login form. Here the directory is a JSON file so the example runs as is; replace `checkPassword` with your own call. For LDAP, use the shipped `ldap` provider. `plugins/core/auth/Directory/plugin.json` @@ -213,7 +229,9 @@ A username/password form checked against a directory. Here the directory is a JS "provider": "directory", "tier": "core", "overridable": false, - "login_path": "/login" + "login_path": "/login", + "login_form": true, + "login_icon": "card-account-details-outline" } ``` @@ -233,42 +251,34 @@ function checkPassword(username, password) { return a.length === b.length && crypto.timingSafeEqual(a, b) ? person : null; } -const escape = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); -const page = (returnTo, message = "") => `<!doctype html><title>Directory login -
-

${escape(message)}

- - - - -
`; - module.exports = { routes(router, ctx) { - router.get("/login", ctx.authorize.public("directory login page"), (req, res) => { - res.send(page(ctx.returnTo(req.query.returnTo))); - }); - router.post("/login", ctx.authorize.public("directory login"), async (req, res) => { - const returnTo = ctx.returnTo(req.body.returnTo); - const person = checkPassword(req.body.username, req.body.password); + const { username, password } = req.body || {}; + if (!username || !password) + return res.status(400).send({ status: "failure", message: "Enter your username and password." }); + const person = checkPassword(username, password); // 401 so failed attempts count towards the rate limit - if (!person) return res.status(401).send(page(returnTo, "Invalid username or password.")); + if (!person) + return res.status(401).send({ status: "failure", message: "Invalid Directory username or password." }); try { - await ctx.completeLogin(req, { + const user = await ctx.completeLogin(req, { subject: person.id, username: person.username, email: person.email, groups: person.groups, }); - res.redirect(returnTo); + res.send({ status: "success", username: user.username, provider: "directory" }); } catch (err) { - if (err instanceof ctx.LinkError) return res.status(409).send(page(returnTo, err.message)); + if (err instanceof ctx.LinkError) return res.status(409).send({ status: "failure", message: err.message }); ctx.logger("error", "Directory login failed.", "Directory", req, err); - res.status(500).send(page(returnTo, "Login failed.")); + res.status(500).send({ status: "failure", message: "Login failed." }); } }); }, + loginForm(env) { + return { title: env.DIRECTORY_DISPLAY_NAME || "Directory", hint: env.DIRECTORY_LOGIN_HINT }; + }, validate(env) { return env.DIRECTORY_FILE && fs.existsSync(env.DIRECTORY_FILE) ? [] @@ -277,7 +287,7 @@ module.exports = { }; ``` -Enable it with `AUTH_PROVIDERS=["local","directory"]` and `DIRECTORY_FILE=/path/to/people.json`. The login page and the in-app modal now show **Log in with Directory**. +Enable it with `AUTH_PROVIDERS=["directory","local"]` and `DIRECTORY_FILE=/path/to/people.json`. The login page, Configure sign-in and the in-app modal now show a **Directory | MMGIS account** switch above the form. Without `"login_form": true`, the provider would instead get a **Log in with Directory** button and would have to serve its own login page at `GET /login`. ## Redirect (OIDC / OAuth2) providers @@ -297,7 +307,7 @@ Declare both with `ctx.authorize.public(...)`, set `"login_path": "/login"`, and - **Coexistence:** local accounts still log in with your provider enabled. - **Removal:** take the id out of `AUTH_PROVIDERS` and restart: its routes return 404 and its sessions and tokens stop working. Its accounts, groups and grants are kept. - **Websockets:** scripted websocket clients must send an `Origin` header, or MMGIS closes the connection (the reason is only in the server log). -- **Unit tests:** see `tests/unit/permissions-providers.spec.js` for loading a provider from a temporary plugins folder. +- **Unit tests:** see `tests/unit/permissions-providers.spec.js` for loading a provider from a temporary plugins folder, and `tests/unit/ldap-provider.spec.js` for testing against a fake backend. Redirect flows are easiest to test with a production build (`npm run build`, then `NODE_ENV=production npm start`). In development the map is served on a different port (PORT+1) from the API, so redirects back to `/` land on the API port. @@ -321,4 +331,4 @@ Redirect flows are easiest to test with a production build (`npm run build`, the - `PROXY_LEAD_GROUP` and `PROXY_GROUPS` only apply to `proxy`. - Draw files belong to a username, so keep usernames unique across providers; `completeLogin` already refuses a username that another account has. -Code: `API/permissions/providers.js` (loading, `completeLogin`, routes), `API/permissions/siteAccess.js` (`AUTH_PROVIDERS`), `plugins/core/auth/Proxy/plugin.js` (a per-request provider). +Code: `API/permissions/providers.js` (loading, `completeLogin`, `loginForms`, routes), `API/permissions/siteAccess.js` (`AUTH_PROVIDERS`), `plugins/core/auth/Proxy/plugin.js` (a per-request provider), `plugins/core/auth/Ldap/plugin.js` (a login form provider), `views/mixins/loginForms.pug` and `public/loginForms.js` (the shared form). diff --git a/docs/pages/Setup/ENVs/ENVs.md b/docs/pages/Setup/ENVs/ENVs.md index 5d9eddf893..4c7ea92e96 100644 --- a/docs/pages/Setup/ENVs/ENVs.md +++ b/docs/pages/Setup/ENVs/ENVs.md @@ -44,6 +44,9 @@ Login methods, as a JSON array or comma-separated list | string[] | default `["l - _local:_ MMGIS username/password accounts. Keep it listed so `/configure` admins can always log in. - _proxy:_ An authenticating reverse proxy in front of MMGIS (such as Cloud Single Sign On) that sends the user's identity in request headers. See `PROXY_TRUST`. +- _ldap:_ Username and password checked against an LDAP directory (OpenLDAP, Active Directory, ...). See `LDAP_URL` and [LDAP Login](/MMGIS/setup/ldap). + +When more than one listed provider takes a username and password (`local`, `ldap`), the login page, Configure sign-in and the in-app login modal show a switch above the form; the first one listed is selected by default. With `AUTH_LOCAL_LOGIN=admins`, local accounts are only offered at Configure sign-in. Each provider is a core auth provider plugin under `plugins/core/auth/`. To add one, see [Writing an Auth Provider](/MMGIS/contributing/auth-providers). @@ -207,6 +210,90 @@ The proxy's sign-in redirect the map can send users to (`?redirect= password". MMGIS can't reset directory passwords. Hidden when empty | string | default `''` + #### `VERBOSE_LOGGING=` Potentially logs a bunch of extra stuff for development purposes | bool | default `false` diff --git a/docs/pages/Setup/LDAP/LDAP.md b/docs/pages/Setup/LDAP/LDAP.md new file mode 100644 index 0000000000..19dded77fa --- /dev/null +++ b/docs/pages/Setup/LDAP/LDAP.md @@ -0,0 +1,100 @@ +--- +layout: page +title: LDAP Login +permalink: /setup/ldap +parent: Setup +nav_order: 4 +--- + +# LDAP Login + +The `ldap` login provider checks usernames and passwords against your organization's directory (OpenLDAP, Active Directory, or anything else that speaks LDAP). People sign in on the normal MMGIS login form, MMGIS links them to an MMGIS account by a permanent directory id, and their directory groups can be given roles in Configure > Users & Access. + +MMGIS never stores directory passwords, but it does receive them when people sign in. If your organization offers single sign-on (an authenticating proxy, OIDC or SAML), prefer that. + +##### Contents + +- [What to ask your directory admins for](#what-to-ask-your-directory-admins-for) +- [Configuration](#configuration) +- [What people see](#what-people-see) +- [Groups and roles](#groups-and-roles) +- [Accounts](#accounts) +- [Troubleshooting](#troubleshooting) + +## What to ask your directory admins for + +- The server address, and whether it uses LDAPS (port 636) or StartTLS (port 389). +- The CA certificate that signed the server's certificate, if it isn't from a public CA. +- Either a read-only service account for MMGIS, or the format of people's DNs (or Active Directory `user@domain` names) so MMGIS can sign in as them directly. +- Where people and groups live (search bases), which attribute is the login name and which is a permanent id. +- Firewall access from the MMGIS server to the directory's port. + +## Configuration + +All settings are ENVs; see [ENVs](/MMGIS/setup/envs) for each `LDAP_*` variable. Keep `local` in `AUTH_PROVIDERS` so local admin accounts can still sign in to `/configure`. + +OpenLDAP with a service account: + +``` +AUTH_PROVIDERS=["ldap","local"] +LDAP_URL=ldaps://ldap.example.org:636 +LDAP_TLS_CA=/etc/mmgis/internal-ca.pem +LDAP_BIND_DN=cn=mmgis-svc,ou=services,dc=example,dc=org +LDAP_BIND_PASSWORD=... +LDAP_USER_SEARCH_BASE=ou=people,dc=example,dc=org +LDAP_USER_SEARCH_FILTER=(uid={{username}}) +LDAP_GROUP_SEARCH_BASE=ou=groups,dc=example,dc=org +LDAP_DISPLAY_NAME=Example Directory +``` + +Active Directory, signing in as the person (no service account): + +``` +AUTH_PROVIDERS=["ldap","local"] +LDAP_URL=ldaps://dc1.corp.example.org:636 +LDAP_USER_DN_TEMPLATE={{username}}@corp.example.org +LDAP_USER_SEARCH_BASE=OU=Users,DC=corp,DC=example,DC=org +LDAP_USER_SEARCH_FILTER=(sAMAccountName={{username}}) +LDAP_SUBJECT_ATTRIBUTE=objectGUID +LDAP_USERNAME_ATTRIBUTE=sAMAccountName +``` + +- **Encryption is required.** `ldaps://`, or `ldap://` with `LDAP_STARTTLS=true`. The certificate must chain to a trusted CA (`LDAP_TLS_CA` for internal CAs) and match the host name in `LDAP_URL`; MMGIS never turns these checks off. `LDAP_ALLOW_INSECURE=true` allows plain `ldap://` for local testing only. +- **`LDAP_BIND_PASSWORD`** can come from the environment like `DB_PASS`. Inject it at runtime (Docker or Kubernetes secrets), keep `.env` readable only by MMGIS, and ask for a read-only account. +- Usernames are escaped before they go into filters and DNs, empty passwords are refused before contacting the directory, and a search must match exactly one person. + +## What people see + +The login page, Configure sign-in and the in-app login modal share one username/password form: + +- **Only LDAP** (or LDAP plus a proxy): a "Sign in with " banner above the form, with `LDAP_LOGIN_HINT` under it. +- **LDAP and local accounts:** a switch above the form (" | MMGIS account"). The first provider in `AUTH_PROVIDERS` is the default, and the browser remembers the last choice. The password only goes to the selected provider. Sign-up only appears on the MMGIS account tab. +- **`AUTH_LOCAL_LOGIN=admins`:** local accounts are only offered at Configure sign-in. +- `LDAP_USERNAME_LABEL` names the username field, and `LDAP_PASSWORD_HELP_URL` adds a "Reset your password" link to your organization's password page (MMGIS can't reset directory passwords). +- Errors say which provider failed: wrong username or password, directory unavailable, an expired Active Directory password, or a username that already belongs to an MMGIS account. +- Once signed in, the account bubble's tooltip says which provider was used. + +## Groups and roles + +By default MMGIS reads the person's `memberOf` attribute (Active Directory, or OpenLDAP with the memberof overlay). Without it, set `LDAP_GROUP_SEARCH_BASE` to search for groups whose `member` is the person, or `LDAP_GROUP_SEARCH_FILTER=(memberUid={{username}})` for posixGroup groups. + +Groups are reported by name (`cn=science-team,ou=groups,...` is `science-team`). In Configure > Users & Access > Groups, create a group with **Members from LDAP** and that name, then give the group a role. Membership is updated at every login. + +## Accounts + +- The first LDAP login creates an MMGIS account, linked by `LDAP_SUBJECT_ATTRIBUTE` (`entryUUID`, or `objectGUID` on Active Directory), so renames and moves in the directory keep the same account. +- A directory username that already belongs to an MMGIS account (for example a local account) is refused with a message to ask an administrator; MMGIS never takes over an existing account. +- LDAP accounts have no MMGIS password, so Users & Access can't generate a password reset link for them. +- Removing `ldap` from `AUTH_PROVIDERS` ends LDAP sessions and API tokens; the accounts, groups and grants are kept. +- Someone disabled in the directory can't sign in again, but an existing MMGIS session (up to its normal lifetime) and their API tokens keep working until an admin removes the account or its tokens. + +## Troubleshooting + +| Message | Cause | +|---|---| +| MMGIS refuses to start with an `LDAP_*` error | A required setting is missing or malformed; the message names it. | +| " is unavailable" | MMGIS couldn't reach the directory, the TLS certificate didn't verify, the service account was rejected, or the entry had no `LDAP_SUBJECT_ATTRIBUTE`. The server log has the reason. | +| "Invalid username or password" for everyone | Check `LDAP_USER_SEARCH_BASE`/`LDAP_USER_SEARCH_FILTER` (or `LDAP_USER_DN_TEMPLATE`) and that the service account may read people. | +| Groups don't apply | The group's external name must equal the directory group's name; check whether your directory has `memberOf`, or set `LDAP_GROUP_SEARCH_BASE`. | + +To try it locally, `tests/e2e/ldap/openldap/start.sh` starts an OpenLDAP container with test users; `tests/e2e/ldap/ldap-provider.spec.js` shows the matching settings. diff --git a/package-lock.json b/package-lock.json index d4b1108315..7565560cd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mmgis", - "version": "5.3.12-20260824", + "version": "5.5.0-20261027", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "mmgis", - "version": "5.3.12-20260824", + "version": "5.5.0-20261027", "hasInstallScript": true, "dependencies": { "@babel/runtime": "^7.23.1", @@ -70,6 +70,7 @@ "http-proxy-middleware": "3.0.7", "isomorphic-ws": "5.0.0", "jquery": "^3.5.1", + "ldapts": "^8.2.0", "leaflet": "^1.9.4", "leaflet-geometryutil": "^0.9.1", "leaflet-polylinedecorator": "^1.6.0", @@ -17516,6 +17517,18 @@ "shell-quote": "^1.8.4" } }, + "node_modules/ldapts": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ldapts/-/ldapts-8.2.0.tgz", + "integrity": "sha512-XfKocadpoHc1kNgtbTBBhD+pSh1U9KVTzxcKwDS4ulFFyC7rJkLC3TwIfbQ4rt3my9KHz8qsw9qFXVjcPOGebw==", + "license": "MIT", + "dependencies": { + "strict-event-emitter-types": "2.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/leaflet": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", @@ -23439,6 +23452,12 @@ "node": ">=10.0.0" } }, + "node_modules/strict-event-emitter-types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz", + "integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==", + "license": "ISC" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -38091,6 +38110,14 @@ "shell-quote": "^1.8.4" } }, + "ldapts": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ldapts/-/ldapts-8.2.0.tgz", + "integrity": "sha512-XfKocadpoHc1kNgtbTBBhD+pSh1U9KVTzxcKwDS4ulFFyC7rJkLC3TwIfbQ4rt3my9KHz8qsw9qFXVjcPOGebw==", + "requires": { + "strict-event-emitter-types": "2.0.0" + } + }, "leaflet": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", @@ -42184,6 +42211,11 @@ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==" }, + "strict-event-emitter-types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz", + "integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==" + }, "string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", diff --git a/package.json b/package.json index ce4149ac28..4ba3c5c487 100644 --- a/package.json +++ b/package.json @@ -137,6 +137,7 @@ "http-proxy-middleware": "3.0.7", "isomorphic-ws": "5.0.0", "jquery": "^3.5.1", + "ldapts": "^8.2.0", "leaflet": "^1.9.4", "leaflet-geometryutil": "^0.9.1", "leaflet-polylinedecorator": "^1.6.0", diff --git a/plugins/core/auth/Ldap/plugin.js b/plugins/core/auth/Ldap/plugin.js new file mode 100644 index 0000000000..577c605fcc --- /dev/null +++ b/plugins/core/auth/Ldap/plugin.js @@ -0,0 +1,329 @@ +// LDAP directory login. The shared username/password form (login page, Configure +// sign-in, in-app modal) posts to /api/auth/ldap/login. MMGIS finds the person's +// entry (searching as a service account or anonymously, or from a DN template), +// checks the password by binding as that entry, then links the MMGIS account by a +// stable id and syncs the person's directory groups. +const fs = require("fs"); +const net = require("net"); +const { Client, InvalidCredentialsError, SizeLimitExceededError } = require("ldapts"); + +const setting = (v, fallback = "") => (v != null && String(v).trim() !== "" ? String(v).trim() : fallback); +const isTrue = (v) => String(v).trim().toLowerCase() === "true"; +const HTTP_URL = /^https?:\/\/\S+$/i; +const MAX_USERNAME = 256; + +function config(env = process.env) { + const timeout = Number(setting(env.LDAP_TIMEOUT_MS, "5000")); + return { + url: setting(env.LDAP_URL), + startTls: isTrue(env.LDAP_STARTTLS), + allowInsecure: isTrue(env.LDAP_ALLOW_INSECURE), + ca: setting(env.LDAP_TLS_CA), + bindDn: setting(env.LDAP_BIND_DN), + bindPassword: env.LDAP_BIND_PASSWORD || "", + userDnTemplate: setting(env.LDAP_USER_DN_TEMPLATE), + searchBase: setting(env.LDAP_USER_SEARCH_BASE), + searchFilter: setting(env.LDAP_USER_SEARCH_FILTER, "(uid={{username}})"), + subjectAttribute: setting(env.LDAP_SUBJECT_ATTRIBUTE, "entryUUID"), + usernameAttribute: setting(env.LDAP_USERNAME_ATTRIBUTE, "uid"), + emailAttribute: setting(env.LDAP_EMAIL_ATTRIBUTE, "mail"), + groupsAttribute: setting(env.LDAP_GROUPS_ATTRIBUTE, "memberOf"), + groupSearchBase: setting(env.LDAP_GROUP_SEARCH_BASE), + groupSearchFilter: setting(env.LDAP_GROUP_SEARCH_FILTER, "(member={{dn}})"), + groupNameAttribute: setting(env.LDAP_GROUP_NAME_ATTRIBUTE, "cn"), + timeoutMs: Number.isInteger(timeout) && timeout > 0 ? timeout : 5000, + displayName: setting(env.LDAP_DISPLAY_NAME, "LDAP"), + usernameLabel: setting(env.LDAP_USERNAME_LABEL, "Username"), + hint: setting(env.LDAP_LOGIN_HINT), + helpUrl: setting(env.LDAP_PASSWORD_HELP_URL), + }; +} + +////// Escaping user input into filters (RFC 4515) and DNs (RFC 4514) + +function escapeFilter(value) { + return String(value).replace(/[\\*()\0]/g, (c) => `\\${c.charCodeAt(0).toString(16).padStart(2, "0")}`); +} + +function escapeDn(value) { + return String(value) + .replace(/[\\,+"<>;=]/g, (c) => `\\${c}`) + .replace(/\0/g, "\\00") + .replace(/^([ #])/, "\\$1") + .replace(/ $/, "\\ "); +} + +const fill = (template, values) => + template.replace(/\{\{(username|dn)\}\}/g, (m, key) => (values[key] != null ? values[key] : m)); + +// "cn=science-team,ou=groups,dc=example,dc=org" -> "science-team"; anything that isn't a DN is returned as is +function firstRdnValue(dn) { + const s = String(dn); + let i = 0; + let eq = -1; + for (; i < s.length; i++) { + const c = s[i]; + if (c === "\\") i++; + else if (c === "=" && eq < 0) eq = i; + else if (c === "," || c === "+") break; + } + if (eq < 0) return s; + return s + .slice(eq + 1, i) + .trim() + .replace(/\\([0-9a-fA-F]{2})|\\(.)/g, (m, hex, ch) => (hex ? String.fromCharCode(parseInt(hex, 16)) : ch)); +} + +////// Directory entries + +function values(entry, name) { + const key = Object.keys(entry).find((k) => k.toLowerCase() === name.toLowerCase()); + if (!key) return []; + const v = entry[key]; + return (Array.isArray(v) ? v : [v]).filter((x) => x != null && x !== ""); +} + +// Active Directory's objectGUID is binary, written in its usual mixed-endian form +function guidString(buf) { + const h = buf.toString("hex"); + const swap = (s) => s.match(/../g).reverse().join(""); + return `${swap(h.slice(0, 8))}-${swap(h.slice(8, 12))}-${swap(h.slice(12, 16))}-${h.slice(16, 20)}-${h.slice(20, 32)}`; +} + +function subjectOf(entry, c) { + if (c.subjectAttribute.toLowerCase() === "dn") return entry.dn; + const v = values(entry, c.subjectAttribute)[0]; + if (v == null) return null; + if (Buffer.isBuffer(v)) return v.length === 16 && /^objectguid$/i.test(c.subjectAttribute) ? guidString(v) : v.toString("hex"); + return String(v); +} + +const isBinaryAttribute = (name) => /^(objectguid|objectsid)$/i.test(name); + +function userAttributes(c) { + const attrs = [c.subjectAttribute, c.usernameAttribute, c.emailAttribute]; + if (!c.groupSearchBase) attrs.push(c.groupsAttribute); + return attrs.filter((a) => a.toLowerCase() !== "dn"); +} + +////// Errors the login route tells apart + +// The password was right but the directory says it must be changed (Active Directory) +class PasswordExpiredError extends Error {} + +function isPasswordExpired(err) { + return /\bdata (532|773)\b/.test(String((err && err.message) || "")); +} + +////// Login + +function tlsOptions(c) { + const opts = {}; + if (c.ca) opts.ca = [fs.readFileSync(c.ca)]; + return opts; +} + +// StartTLS upgrades the existing socket, so the host to check the certificate against is given here +function startTlsOptions(c) { + const host = new URL(c.url).hostname.replace(/^\[|\]$/g, ""); + const opts = { ...tlsOptions(c), host }; + if (!net.isIP(host)) opts.servername = host; + return opts; +} + +function newClient(c) { + const options = { url: c.url, timeout: c.timeoutMs, connectTimeout: c.timeoutMs }; + // ldapts opens a TLS connection whenever tlsOptions is given, so only ldaps:// gets them here + if (/^ldaps:/i.test(c.url)) options.tlsOptions = tlsOptions(c); + return new Client(options); +} + +async function bindAs(client, dn, password) { + try { + await client.bind(dn, password); + return true; + } catch (err) { + if (!(err instanceof InvalidCredentialsError)) throw err; + if (isPasswordExpired(err)) throw new PasswordExpiredError("password expired"); + return false; + } +} + +async function findUser(client, c, username) { + const filter = fill(c.searchFilter, { username: escapeFilter(username) }); + let result; + try { + result = await client.search(c.searchBase, { + scope: "sub", + filter, + attributes: userAttributes(c), + sizeLimit: 2, + explicitBufferAttributes: userAttributes(c).filter(isBinaryAttribute), + }); + } catch (err) { + // More than one match: treat as no match rather than guess + if (err instanceof SizeLimitExceededError) return null; + throw err; + } + return result.searchEntries.length === 1 ? result.searchEntries[0] : null; +} + +async function readEntry(client, c, dn) { + const { searchEntries } = await client.search(dn, { + scope: "base", + filter: "(objectClass=*)", + attributes: userAttributes(c), + explicitBufferAttributes: userAttributes(c).filter(isBinaryAttribute), + }); + return searchEntries[0] || null; +} + +async function groupsOf(client, c, entry, username) { + if (!c.groupSearchBase) return values(entry, c.groupsAttribute).map((v) => firstRdnValue(v)); + const { searchEntries } = await client.search(c.groupSearchBase, { + scope: "sub", + filter: fill(c.groupSearchFilter, { dn: escapeFilter(entry.dn), username: escapeFilter(username) }), + attributes: [c.groupNameAttribute], + }); + return searchEntries.map((g) => { + const name = values(g, c.groupNameAttribute)[0]; + return name != null ? String(name) : firstRdnValue(g.dn); + }); +} + +// The person's identity if the password is right, null if it isn't; throws when the +// directory can't be reached or doesn't answer as configured +async function authenticate(username, password, c = config(), createClient = newClient) { + const client = createClient(c); + try { + if (c.startTls) await client.startTLS(startTlsOptions(c)); + let entry; + if (c.userDnTemplate) { + const dn = fill(c.userDnTemplate, { username: escapeDn(username) }); + if (!(await bindAs(client, dn, password))) return null; + entry = c.searchBase ? await findUser(client, c, username) : await readEntry(client, c, dn); + if (!entry) throw new Error("Signed in to the directory but could not read the person's entry."); + } else { + if (c.bindDn && !(await bindAs(client, c.bindDn, c.bindPassword))) + throw new Error("The directory rejected LDAP_BIND_DN / LDAP_BIND_PASSWORD."); + entry = await findUser(client, c, username); + if (!entry) return null; + if (!(await bindAs(client, entry.dn, password))) return null; + // Group searches run as the service account, as the user search did + if (c.groupSearchBase && c.bindDn) await client.bind(c.bindDn, c.bindPassword); + } + const subject = subjectOf(entry, c); + if (!subject) throw new Error(`The directory entry has no ${c.subjectAttribute} (LDAP_SUBJECT_ATTRIBUTE).`); + const name = values(entry, c.usernameAttribute)[0]; + const email = values(entry, c.emailAttribute)[0]; + return { + subject, + username: name != null ? String(name) : username, + email: email != null ? String(email) : undefined, + groups: await groupsOf(client, c, entry, username), + }; + } finally { + await client.unbind().catch(() => {}); + } +} + +const validUsername = (u) => + typeof u === "string" && u.trim() !== "" && u.length <= MAX_USERNAME && !/[\u0000-\u001f\u007f]/.test(u); + +////// Startup checks + +function validate(env) { + const c = config(env); + const errors = []; + let protocol = null; + try { + protocol = new URL(c.url).protocol; + } catch (err) { + // reported below + } + if (protocol !== "ldap:" && protocol !== "ldaps:") + errors.push("LDAP_URL must be set to the directory's address, e.g. ldaps://ldap.example.org:636."); + if (protocol === "ldaps:" && c.startTls) + errors.push("LDAP_STARTTLS=true is for ldap:// URLs; an ldaps:// URL is already encrypted."); + if (protocol === "ldap:" && !c.startTls && !c.allowInsecure) + errors.push( + "LDAP_URL uses ldap://, which sends passwords unencrypted. Use ldaps://, set LDAP_STARTTLS=true, or, for local testing only, LDAP_ALLOW_INSECURE=true." + ); + if (!c.userDnTemplate && !c.searchBase) + errors.push("Set LDAP_USER_SEARCH_BASE (to search for people) or LDAP_USER_DN_TEMPLATE (to sign in as them directly)."); + if (c.userDnTemplate && !c.userDnTemplate.includes("{{username}}")) + errors.push("LDAP_USER_DN_TEMPLATE must contain {{username}}."); + if (c.userDnTemplate && !c.searchBase && !c.userDnTemplate.includes("=")) + errors.push( + "LDAP_USER_DN_TEMPLATE isn't a DN (e.g. {{username}}@corp.example.org), so MMGIS can't read the person's entry from it. Also set LDAP_USER_SEARCH_BASE." + ); + if (c.searchBase && !/^\(.*\{\{username\}\}.*\)$/.test(c.searchFilter)) + errors.push("LDAP_USER_SEARCH_FILTER must be a parenthesized filter containing {{username}}, e.g. (uid={{username}})."); + if (c.bindDn && !c.bindPassword) errors.push("LDAP_BIND_DN is set but LDAP_BIND_PASSWORD is empty."); + if (!c.bindDn && c.bindPassword) errors.push("LDAP_BIND_PASSWORD is set but LDAP_BIND_DN is empty."); + if (c.groupSearchBase && !/\{\{(dn|username)\}\}/.test(c.groupSearchFilter)) + errors.push("LDAP_GROUP_SEARCH_FILTER must contain {{dn}} or {{username}}, e.g. (member={{dn}})."); + if (c.ca) { + try { + fs.accessSync(c.ca, fs.constants.R_OK); + } catch (err) { + errors.push("LDAP_TLS_CA must be the path of a readable PEM file."); + } + } + if (c.helpUrl && !HTTP_URL.test(c.helpUrl)) errors.push("LDAP_PASSWORD_HELP_URL must be an http(s) URL."); + if (setting(env.LDAP_TIMEOUT_MS) && String(c.timeoutMs) !== setting(env.LDAP_TIMEOUT_MS)) + errors.push("LDAP_TIMEOUT_MS must be a whole number of milliseconds."); + return errors; +} + +function warnings(env) { + const c = config(env); + const out = []; + if (c.allowInsecure && !c.startTls && /^ldap:/i.test(c.url)) + out.push("LDAP_ALLOW_INSECURE=true: LDAP passwords cross the network unencrypted. Use it only for local testing."); + if (c.subjectAttribute.toLowerCase() === "dn") + out.push("LDAP_SUBJECT_ATTRIBUTE=dn: a person's DN changes when they are renamed or moved, which then creates a new MMGIS account."); + return out; +} + +module.exports = { + routes(router, ctx) { + router.post("/login", ctx.authorize.public("LDAP directory login"), async (req, res) => { + const c = config(); + const name = c.displayName; + const body = req.body || {}; + if (!validUsername(body.username) || typeof body.password !== "string" || body.password === "") + return res.status(400).send({ status: "failure", message: "Enter your username and password." }); + let identity; + try { + identity = await authenticate(body.username.trim(), body.password, c); + } catch (err) { + if (err instanceof PasswordExpiredError) + return res.status(401).send({ status: "failure", message: `Your ${name} password has expired.` }); + ctx.logger("error", `LDAP login could not reach or read the directory: ${err && err.message}`, "Ldap", req, err); + return res.status(503).send({ status: "failure", message: `${name} is unavailable. Try again later.` }); + } + // 401 so failed attempts count towards the rate limit + if (!identity) + return res.status(401).send({ status: "failure", message: `Invalid ${name} username or password.` }); + try { + const user = await ctx.completeLogin(req, identity); + ctx.logger("info", `${user.username} logged in with LDAP.`, "Ldap", req); + res.send({ status: "success", username: user.username, provider: "ldap", groups: [] }); + } catch (err) { + if (err instanceof ctx.LinkError) + return res.status(409).send({ status: "failure", message: `${err.message} Ask an administrator.` }); + ctx.logger("error", "LDAP login failed.", "Ldap", req, err); + res.status(500).send({ status: "failure", message: "Login failed." }); + } + }); + }, + loginForm(env) { + const c = config(env); + return { title: c.displayName, usernameLabel: c.usernameLabel, hint: c.hint, helpUrl: c.helpUrl }; + }, + validate, + warnings, + // For tests + _internal: { config, escapeFilter, escapeDn, firstRdnValue, guidString, authenticate, PasswordExpiredError }, +}; diff --git a/plugins/core/auth/Ldap/plugin.json b/plugins/core/auth/Ldap/plugin.json new file mode 100644 index 0000000000..d52b5fd5a6 --- /dev/null +++ b/plugins/core/auth/Ldap/plugin.json @@ -0,0 +1,19 @@ +{ + "uuid": "940736d8-2372-445a-b522-0cde5a340886", + "id": "core-auth-ldap", + "name": "Ldap", + "display_name": "LDAP", + "description": "Username/password login checked against an LDAP directory (OpenLDAP, Active Directory, ...) at /api/auth/ldap/login.", + "version": "core", + "type": "auth", + "provider": "ldap", + "tier": "core", + "overridable": false, + "required": true, + "login_path": "/login", + "login_form": true, + "login_icon": "card-account-details-outline", + "author": "NASA-AMMOS/MMGIS", + "license": "Apache-2.0", + "repository": "https://github.com/NASA-AMMOS/MMGIS" +} diff --git a/plugins/core/auth/Local/plugin.js b/plugins/core/auth/Local/plugin.js index 3b3d1caf4b..d5d7a29df1 100644 --- a/plugins/core/auth/Local/plugin.js +++ b/plugins/core/auth/Local/plugin.js @@ -5,4 +5,10 @@ module.exports = { routes(router) { mountLocalRoutes(router); }, + loginForm(env) { + return { + title: "MMGIS account", + hint: env.AUTH_LOCAL_LOGIN === "admins" ? "Only accounts with an admin role can sign in here." : "", + }; + }, }; diff --git a/plugins/core/auth/Local/plugin.json b/plugins/core/auth/Local/plugin.json index 058329a415..6ce79eb099 100644 --- a/plugins/core/auth/Local/plugin.json +++ b/plugins/core/auth/Local/plugin.json @@ -13,6 +13,9 @@ "tier": "core", "overridable": false, "required": true, + "login_path": "/login", + "login_form": true, + "login_icon": "account-key-outline", "author": "NASA-AMMOS/MMGIS", "license": "Apache-2.0", "repository": "https://github.com/NASA-AMMOS/MMGIS" diff --git a/plugins/core/auth/Local/routes.js b/plugins/core/auth/Local/routes.js index 32080a0868..1a570475c7 100644 --- a/plugins/core/auth/Local/routes.js +++ b/plugins/core/auth/Local/routes.js @@ -32,6 +32,7 @@ const policy = require("../../../../API/permissions/policy"); const bindings = require("../../../../API/permissions/bindings"); const setupCode = require("../../../../API/permissions/setupCode"); const { LongTermTokens } = require("../../backend/LongTermToken/models/longtermtokens"); +const { AuthIdentity } = require("../../../../API/permissions/models"); // Same message whether the username or the email is taken const SIGNUP_UNAVAILABLE = @@ -542,7 +543,12 @@ router.post("/resetPassword", resetLimiter, authorize.public("login and account "token", ], }) - .then((user) => { + .then(async (user) => { + // Accounts created by a login provider sign in through it, never with a local password + if (user && (await AuthIdentity.count({ where: { user_id: user.id } })) > 0) { + res.send({ status: "failure", message: "This account signs in through its login provider." }); + return null; + } if (user) { if ( user.reset_token_expiration == null || diff --git a/plugins/core/backend/Accounts/routes/accounts.js b/plugins/core/backend/Accounts/routes/accounts.js index 3453eac69e..d1de673b59 100644 --- a/plugins/core/backend/Accounts/routes/accounts.js +++ b/plugins/core/backend/Accounts/routes/accounts.js @@ -19,7 +19,8 @@ const bindings = require("../../../../../API/permissions/bindings"); const policy = require("../../../../../API/permissions/policy"); const missions = require("../../../../../API/permissions/missions"); const { ensurePrincipal } = require("../../../../../API/permissions/authorize"); -const { RoleBinding } = require("../../../../../API/permissions/models"); +const { RoleBinding, AuthIdentity } = require("../../../../../API/permissions/models"); +const providers = require("../../../../../API/permissions/providers"); const { LongTermTokens } = require("../../LongTermToken/models/longtermtokens"); const connections = require("../../../../../API/connections"); const User = userModel.User; @@ -337,8 +338,20 @@ router.post("/generateResetPasswordLink", authorize("accounts.manage"), function }, }; - User.update(toUpdateTo, updateObj) - .then(() => { + // Accounts created by a login provider sign in through it; a local password would get around it + AuthIdentity.findOne({ where: { user_id: id }, attributes: ["provider"], raw: true }) + .then((link) => { + if (link) { + res.send({ + status: "failure", + message: `This account signs in with ${providers.providerLabel(link.provider) || link.provider}, so it has no MMGIS password to reset.`, + }); + return null; + } + return User.update(toUpdateTo, updateObj).then(() => true); + }) + .then((updated) => { + if (!updated) return null; res.send({ status: "success", message: `Successfully generated a password reset token for user with id: '${id}'.`, diff --git a/public/adminlogin.css b/public/adminlogin.css index f8397866ad..0e7c2c2853 100644 --- a/public/adminlogin.css +++ b/public/adminlogin.css @@ -335,3 +335,129 @@ img { color: #1a1a1a; outline: none; } + +/* Provider switch, single-provider banner, hint and help link above/below the shared form */ +.loginProviders.multiple { + display: flex; + gap: 4px; + width: 342px; + max-width: 100%; + box-sizing: border-box; + margin: 0 auto 18px; + padding: 4px; + background: #121d23; + border-radius: 7px; + box-shadow: 0 0 0 1px rgba(8, 174, 234, 0.5) inset; +} +.loginProviders.single { + display: none; +} +.loginProvider { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + min-width: 0; + padding: 8px 6px; + border: 0; + border-radius: 5px; + background: transparent; + color: #a1a4ad; + font-family: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} +.loginProvider span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.loginProvider:hover, +.loginProvider:focus-visible { + color: #ececec; + outline: none; +} +.loginProvider.active { + background: #0c3546; + color: #ffffff; + box-shadow: 0 0 0 1.5px #08aeea inset; +} +.loginProvider.active .mdi { + color: #08aeea; +} +.loginBanner { + display: flex; + align-items: center; + gap: 12px; + width: 342px; + max-width: 100%; + box-sizing: border-box; + margin: 0 auto 18px; + padding: 12px 14px; + text-align: left; + border-radius: 8px; + background: #13252e; + border: 1px solid rgba(8, 174, 234, 0.45); + border-left: 4px solid #08aeea; +} +.loginBanner > .mdi { + flex: none; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + background: #0c3546; + color: #08aeea; + font-size: 22px; +} +.loginBanner small { + display: block; + color: #b4b7bf; + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.loginBanner b { + display: block; + color: #ffffff; + font-size: 17px; +} +.loginBanner span { + display: block; + margin-top: 2px; + color: #b4b7bf; + font-size: 12.5px; + line-height: 1.35; +} +.loginHint { + display: none; + width: 342px; + max-width: 100%; + margin: -8px auto 14px; + text-align: left; + color: #a1a4ad; + font-style: italic; + font-size: 12.5px; + line-height: 1.4; +} +.loginHelp { + display: none; + align-items: center; + justify-content: flex-end; + gap: 4px; + width: 342px; + max-width: 100%; + margin: 0 auto 10px; + color: #7fd3f5; + font-size: 13px; + text-decoration: none; +} +.loginHelp:hover, +.loginHelp:focus-visible { + text-decoration: underline; + outline: none; +} diff --git a/public/adminlogin.js b/public/adminlogin.js index 272284ba64..36cd0594ed 100644 --- a/public/adminlogin.js +++ b/public/adminlogin.js @@ -15,6 +15,18 @@ function login() { return; } + if (!LoginForms.isLocal()) { + LoginForms.submit( + document.getElementById("username").value, + document.getElementById("pwd").value, + function (message) { + document.getElementById("msg").innerText = message; + document.getElementById("msg").style.opacity = 1; + } + ); + return; + } + $.ajax({ type: "POST", url: "api/users/login", @@ -120,6 +132,7 @@ function setupLogin() { } $(document).ready(function () { + LoginForms.init(); $(document).on("keypress", function (e) { if (e.which == 13) { login(); @@ -134,6 +147,8 @@ $(document).ready(function () { if (data.status == "success") { if (data.has == false) { setup = true; + // The first administrator is always a local account + LoginForms.onlyLocal(); $("#setup").css("opacity", "1"); $("#pwd_retype, #pwd_retype_label").css("display", "block"); $("#setup_code, #setup_code_label").css("display", "block"); diff --git a/public/login.css b/public/login.css index 0e710ed724..e0d2a13f9f 100644 --- a/public/login.css +++ b/public/login.css @@ -286,3 +286,129 @@ img { color: #1a1a1a; outline: none; } + +/* Provider switch, single-provider banner, hint and help link above/below the shared form */ +.loginProviders.multiple { + display: flex; + gap: 4px; + width: 342px; + max-width: 100%; + box-sizing: border-box; + margin: 0 auto 18px; + padding: 4px; + background: #121d23; + border-radius: 7px; + box-shadow: 0 0 0 1px rgba(8, 174, 234, 0.5) inset; +} +.loginProviders.single { + display: none; +} +.loginProvider { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + min-width: 0; + padding: 8px 6px; + border: 0; + border-radius: 5px; + background: transparent; + color: #a1a4ad; + font-family: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} +.loginProvider span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.loginProvider:hover, +.loginProvider:focus-visible { + color: #ececec; + outline: none; +} +.loginProvider.active { + background: #0c3546; + color: #ffffff; + box-shadow: 0 0 0 1.5px #08aeea inset; +} +.loginProvider.active .mdi { + color: #08aeea; +} +.loginBanner { + display: flex; + align-items: center; + gap: 12px; + width: 342px; + max-width: 100%; + box-sizing: border-box; + margin: 0 auto 18px; + padding: 12px 14px; + text-align: left; + border-radius: 8px; + background: #13252e; + border: 1px solid rgba(8, 174, 234, 0.45); + border-left: 4px solid #08aeea; +} +.loginBanner > .mdi { + flex: none; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + background: #0c3546; + color: #08aeea; + font-size: 22px; +} +.loginBanner small { + display: block; + color: #b4b7bf; + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.loginBanner b { + display: block; + color: #ffffff; + font-size: 17px; +} +.loginBanner span { + display: block; + margin-top: 2px; + color: #b4b7bf; + font-size: 12.5px; + line-height: 1.35; +} +.loginHint { + display: none; + width: 342px; + max-width: 100%; + margin: -8px auto 14px; + text-align: left; + color: #a1a4ad; + font-style: italic; + font-size: 12.5px; + line-height: 1.4; +} +.loginHelp { + display: none; + align-items: center; + justify-content: flex-end; + gap: 4px; + width: 342px; + max-width: 100%; + margin: 0 auto 10px; + color: #7fd3f5; + font-size: 13px; + text-decoration: none; +} +.loginHelp:hover, +.loginHelp:focus-visible { + text-decoration: underline; + outline: none; +} diff --git a/public/login.js b/public/login.js index 291375fc84..eba2d487e4 100644 --- a/public/login.js +++ b/public/login.js @@ -12,7 +12,16 @@ function login() { return; } - if (toggleState === "login") { + if (toggleState === "login" && !LoginForms.isLocal()) { + LoginForms.submit( + document.getElementById("username").value, + document.getElementById("pwd").value, + function (message) { + document.getElementById("msg").innerText = message; + document.getElementById("msg").style.opacity = 1; + } + ); + } else if (toggleState === "login") { $.ajax({ type: "POST", url: "api/users/login", @@ -126,7 +135,14 @@ function toggle() { } $(document).ready(function () { - if (allowSignup === true && document.getElementById("toggleWrapper")) + // Sign-up is only for local accounts + LoginForms.init(function (form) { + const wrapper = document.getElementById("toggleWrapper"); + if (!wrapper) return; + if (form.id !== "local" && toggleState === "signup") toggle(); + wrapper.style.display = allowSignup === true && form.id === "local" ? "flex" : "none"; + }); + if (allowSignup === true && LoginForms.isLocal() && document.getElementById("toggleWrapper")) document.getElementById("toggleWrapper").style.display = "flex"; $(document).on("keypress", function (e) { if (e.which === 13) { diff --git a/public/loginForms.js b/public/loginForms.js new file mode 100644 index 0000000000..41c118d5db --- /dev/null +++ b/public/loginForms.js @@ -0,0 +1,93 @@ +/* global $ */ +// The provider switch above the username/password form on the login page and Configure +// sign-in (views/mixins/loginForms.pug). Each .loginProvider button carries its provider's +// wording; local accounts keep their own login code, other providers get a JSON POST. +const LoginForms = (function () { + const KEY = "mmgisLoginProvider"; + let active = null; + let onChange = function () {}; + + const buttons = () => Array.from(document.querySelectorAll(".loginProvider")); + const read = (b) => ({ + id: b.dataset.id, + title: b.dataset.title, + label: b.dataset.label, + hint: b.dataset.hint, + help: b.dataset.help, + path: b.dataset.path, + }); + + function select(id, remember) { + const all = buttons(); + const button = all.find((b) => b.dataset.id === id) || all[0]; + if (!button) return; + all.forEach((b) => { + b.classList.toggle("active", b === button); + b.setAttribute("aria-selected", b === button ? "true" : "false"); + }); + active = read(button); + const label = document.getElementById("username_label"); + if (label) label.textContent = active.label || "Username"; + // A single provider shows its hint in the banner instead + const hint = document.getElementById("loginHint"); + if (hint) { + hint.textContent = all.length > 1 ? active.hint || "" : ""; + hint.style.display = hint.textContent ? "block" : "none"; + } + const help = document.getElementById("loginHelp"); + if (help) { + if (active.help) { + help.href = active.help; + document.getElementById("loginHelpText").textContent = `Reset your ${active.title} password`; + help.style.display = "flex"; + } else help.style.display = "none"; + } + if (remember) { + try { + localStorage.setItem(KEY, active.id); + } catch (err) {} + } + onChange(active); + } + + function init(changed) { + if (typeof changed === "function") onChange = changed; + buttons().forEach((b) => b.addEventListener("click", () => select(b.dataset.id, true))); + let remembered = null; + try { + remembered = localStorage.getItem(KEY); + } catch (err) {} + select(remembered, false); + } + + // Shows only the local account form, e.g. while creating the first administrator + function onlyLocal() { + document.querySelectorAll(".loginProviders, .loginBanner").forEach((el) => (el.style.display = "none")); + select("local", false); + const label = document.getElementById("username_label"); + if (label) label.textContent = "Username"; + } + + // Local accounts, or no provider switch at all + const isLocal = () => !active || active.id === "local"; + + function submit(username, password, done) { + $.ajax({ + type: "POST", + url: active.path, + contentType: "application/json", + data: JSON.stringify({ username: username, password: password }), + success: function (data) { + if (data && data.status === "success") { + document.cookie = "MMGISUser=;expires=Thu, 01 Jan 1970 00:00:01 GMT;"; + window.location.reload(); + } else done((data && data.message) || "Login failed."); + }, + error: function (xhr) { + done((xhr && xhr.responseJSON && xhr.responseJSON.message) || "Server error."); + }, + }); + } + + return { init: init, onlyLocal: onlyLocal, isLocal: isLocal, submit: submit, current: () => active }; +})(); diff --git a/sample.env b/sample.env index f3d6a4e68e..94bafd185e 100644 --- a/sample.env +++ b/sample.env @@ -18,9 +18,11 @@ PORT=8888 # AUTH=csso -> SITE_ACCESS=required + AUTH_PROVIDERS=["proxy","local"] + PROXY_TRUST=network (local keeps admin logins) SITE_ACCESS=optional -# AUTH_PROVIDERS - JSON array (or comma list) of login methods: "local", "proxy" +# AUTH_PROVIDERS - JSON array (or comma list) of login methods: "local", "proxy", "ldap" # local: MMGIS username/password accounts. Keep it listed so /configure admins can always log in. # proxy: an authenticating reverse proxy (e.g. Cloud Single Sign On) in front of MMGIS sends identity headers (see PROXY_TRUST) +# ldap: username/password checked against an LDAP directory (see LDAP_*) +# With several username/password providers (local, ldap), the login form shows a switch; the first listed is the default. AUTH_PROVIDERS=["local"] # If SITE_ACCESS=required and set to true, this allows all guests to the site to create user accounts @@ -197,6 +199,38 @@ PROXY_STATUS_URL=/ssostatus PROXY_LOGOUT_URL=/ssologoutredirect PROXY_LOGIN_URL=/ssologinredirect PROXY_REFRESH_URL=/ssorefreshtimeout + +# LDAP login provider (AUTH_PROVIDERS includes "ldap"). See docs/pages/Setup/ENVs/ENVs.md +# LDAP_URL - ldaps://host:636, or ldap://host:389 with LDAP_STARTTLS=true +LDAP_URL= +LDAP_STARTTLS=false +# PEM file of the CA that signed the directory's certificate (internal CAs) +LDAP_TLS_CA= +# Local testing only: allow ldap:// without StartTLS (passwords unencrypted) +LDAP_ALLOW_INSECURE=false +# Finding people: search LDAP_USER_SEARCH_BASE with LDAP_USER_SEARCH_FILTER, as LDAP_BIND_DN (a read-only +# service account) or anonymously when it is empty. Or sign in as LDAP_USER_DN_TEMPLATE directly. +LDAP_BIND_DN= +LDAP_BIND_PASSWORD= +LDAP_USER_SEARCH_BASE= +LDAP_USER_SEARCH_FILTER=(uid={{username}}) +LDAP_USER_DN_TEMPLATE= +# Attributes: a stable id (entryUUID; objectGUID on Active Directory), the username, email and groups +LDAP_SUBJECT_ATTRIBUTE=entryUUID +LDAP_USERNAME_ATTRIBUTE=uid +LDAP_EMAIL_ATTRIBUTE=mail +LDAP_GROUPS_ATTRIBUTE=memberOf +# Or find groups by searching LDAP_GROUP_SEARCH_BASE ({{dn}} / {{username}} are the person's) +LDAP_GROUP_SEARCH_BASE= +LDAP_GROUP_SEARCH_FILTER=(member={{dn}}) +LDAP_GROUP_NAME_ATTRIBUTE=cn +LDAP_TIMEOUT_MS=5000 +# Login form wording +LDAP_DISPLAY_NAME=LDAP +LDAP_USERNAME_LABEL=Username +LDAP_LOGIN_HINT= +LDAP_PASSWORD_HELP_URL= + # When not using the proxy, a string array of internal usernames with elevated "Lead" permissions LEADS=["user1"] diff --git a/scripts/server.js b/scripts/server.js index 76e2a1ec17..9d05789ce6 100644 --- a/scripts/server.js +++ b/scripts/server.js @@ -437,7 +437,7 @@ function renderAdminLogin(req, res, principal) { res.render("adminlogin", { user: req.user, SIGNED_IN_AS: signedIn ? principal.username || req.user || "" : null, - LOCAL_LOGIN: siteAccess.hasProvider("local"), + LOGIN_FORMS: providers.loginForms(undefined, { configure: true }), LOGIN_LINKS: providers.loginLinks(), RETURN_TO: providers.returnTo(req.originalUrl), VERSION: configurePackageJson.version, @@ -541,12 +541,12 @@ function ensureUser(options = {}) { } if (options.forbid) sendForbidden(req, res); // Sites with no login form or login links sign in through their authenticating proxy - else if (!siteAccess.hasProvider("local") && providers.loginLinks().length === 0) + else if (providers.loginForms().length === 0 && providers.loginLinks().length === 0) res.render("unauthorized", { user: null, PROXY_LOGOUT_URL: proxyUrls().logout }); else res.render("login", { user: req.user, - LOCAL_LOGIN: siteAccess.hasProvider("local"), + LOGIN_FORMS: providers.loginForms(), LOGIN_LINKS: providers.loginLinks(), RETURN_TO: providers.returnTo(req.originalUrl), CLEARANCE_NUMBER: process.env.CLEARANCE_NUMBER || "CL##-####", diff --git a/src/essence/Basics/UserInterface_/components/Login/Login.css b/src/essence/Basics/UserInterface_/components/Login/Login.css index ab88be8ac2..2f69da37f9 100644 --- a/src/essence/Basics/UserInterface_/components/Login/Login.css +++ b/src/essence/Basics/UserInterface_/components/Login/Login.css @@ -272,3 +272,115 @@ color: #1a1a1a; outline: none; } + +/* Provider switch, single-provider banner, hint and help link (as in public/login.css) */ +#loginModalProviders { + width: 342px; + max-width: calc(100% - 40px); + margin: 20px auto 0; +} +#loginModalProviders:empty { + display: none; +} +#loginModalProviders + #loginForm { + margin-top: 18px; +} +#loginModalProviders .loginProviders { + display: flex; + gap: 4px; + padding: 4px; + background: #121d23; + border-radius: 7px; + box-shadow: 0 0 0 1px rgba(8, 174, 234, 0.5) inset; +} +#loginModalProviders .loginProvider { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + min-width: 0; + padding: 8px 6px; + border: 0; + border-radius: 5px; + background: transparent; + color: #a1a4ad; + font-family: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} +#loginModalProviders .loginProvider span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +#loginModalProviders .loginProvider:hover, +#loginModalProviders .loginProvider:focus-visible { + color: #ececec; + outline: none; +} +#loginModalProviders .loginProvider.active { + background: #0c3546; + color: #ffffff; + box-shadow: 0 0 0 1.5px #08aeea inset; +} +#loginModalProviders .loginProvider.active .mdi { + color: #08aeea; +} +#loginModalProviders .loginBanner { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + text-align: left; + border-radius: 8px; + background: #13252e; + border: 1px solid rgba(8, 174, 234, 0.45); + border-left: 4px solid #08aeea; +} +#loginModalProviders .loginBanner > .mdi { + flex: none; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + background: #0c3546; + color: #08aeea; + font-size: 22px; +} +#loginModalProviders .loginBanner small { + display: block; + color: #b4b7bf; + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} +#loginModalProviders .loginBanner b { + display: block; + color: #ffffff; + font-size: 17px; +} +#loginModalProviders .loginBanner span { + display: block; + margin-top: 2px; + color: #b4b7bf; + font-size: 12.5px; + line-height: 1.35; +} +#loginModalHelp { + align-items: center; + justify-content: flex-end; + gap: 4px; + margin: 0 0 10px; + color: #7fd3f5; + font-size: 13px; + text-decoration: none; +} +#loginModalHelp:hover, +#loginModalHelp:focus-visible { + text-decoration: underline; + outline: none; +} diff --git a/src/essence/Basics/UserInterface_/components/Login/Login.js b/src/essence/Basics/UserInterface_/components/Login/Login.js index 41b752a99d..61ac02f925 100644 --- a/src/essence/Basics/UserInterface_/components/Login/Login.js +++ b/src/essence/Basics/UserInterface_/components/Login/Login.js @@ -24,6 +24,7 @@ var modalFormSignIn = "" + "
Log in to your account.
" + "
" + + "
" + "
" + "
" + "" + @@ -41,12 +42,14 @@ var modalFormSignIn = "" + "" + "" + + "" + "
" + "" + + "" + "" + "
or sign up
" + "
" + @@ -71,6 +74,11 @@ var Login = { providerLogoutUrl: null, providerPerRequest: false, providerLinks: null, + // Providers that take a username and password in the modal's form (GET /api/auth loginForms) + loginForms: null, + activeForm: null, + // What the signed-in provider is called, e.g. "JPL Directory" + providerTitle: null, init: function () { if (window.mmgisglobal.SITE_ACCESS === 'public') return if ( @@ -95,6 +103,7 @@ var Login = { this.provider = loginProvider this.providerPerRequest = window.mmgisglobal.loginPerRequest === 'true' this.providerLogoutUrl = window.mmgisglobal.logoutUrl || null + Login.fetchAuth(() => Login.updateTooltip()) } Login.loginBar = $('
') @@ -128,17 +137,7 @@ var Login = { }) .text(Login.loggedIn ? Login.username[0] : '')) - if (Login.loggedIn) { - if (window._tippyLoginUser && window._tippyLoginUser[0]) - window._tippyLoginUser[0].setContent(Login.username) - else - window._tippyLoginUser = tippy('#loginUser', { - content: Login.username, - placement: 'bottom-end', - theme: 'blue', - allowHTML: false, - }) - } + if (Login.loggedIn) Login.updateTooltip() //Show signup for admins if ( @@ -251,6 +250,7 @@ var Login = { // Then end the login at the provider too const providerLogoutUrl = Login.providerLogoutUrl Login.provider = null + Login.providerTitle = null Login.providerLogoutUrl = null if (providerLogoutUrl) { window.location.href = providerLogoutUrl @@ -294,6 +294,7 @@ var Login = { Login.provider = d.provider || null Login.providerLogoutUrl = d.logoutUrl || null Login.providerPerRequest = d.perRequest === true + if (Login.provider) Login.fetchAuth(() => Login.updateTooltip()) loginSuccess(d) }, @@ -319,14 +320,160 @@ var Login = { $('#loginSubmit').val(signUp ? 'Sign Up' : 'Log In') $('#loginInUpToggle').html(signUp ? 'or log in' : 'or sign up') }, - // Buttons for providers whose plugin.json sets "login_path" + // Who the user is, and the provider they signed in with + updateTooltip: function () { + if (!Login.loggedIn || !Login.username) return + const content = + Login.provider && Login.providerTitle + ? `${Login.username} · signed in with ${Login.providerTitle}` + : Login.username + if (window._tippyLoginUser && window._tippyLoginUser[0]) + window._tippyLoginUser[0].setContent(content) + else + window._tippyLoginUser = tippy('#loginUser', { + content: content, + placement: 'bottom-end', + theme: 'blue', + allowHTML: false, + }) + }, + // GET /api/auth once: the login forms, login buttons and provider names + fetchAuth: function (done) { + if (Login.providerLinks) { + if (done) done() + return + } + const root = window.mmgisglobal.ROOT_PATH ? window.mmgisglobal.ROOT_PATH + '/' : '' + $.ajax({ + type: 'GET', + url: `${root}api/auth`, + xhrFields: { withCredentials: true }, + success: (d) => { + Login.providerLinks = (d && d.loginLinks) || [] + Login.loginForms = (d && d.loginForms) || [] + const p = ((d && d.providers) || []).find((x) => x.id === Login.provider) + if (p) Login.providerTitle = p.label + if (done) done() + }, + }) + }, + // The provider switch (several form providers) or banner (one that isn't local accounts) + renderForms: function () { + if (!Login.loginForms) return + const forms = Login.loginForms + const el = $('#loginModalProviders').empty() + if (Login.signUp) { + // Admins create local accounts + Login.selectForm(null) + return + } + if (forms.length === 0) { + $('#loginModal #loginForm').hide() + $('#loginModalLead').text('Choose how to log in.') + return + } + $('#loginModal #loginForm').show() + if (forms.length > 1) { + $('#loginModalLead').text('Choose your account type.') + const tabs = $('
') + .addClass('loginProviders') + .attr({ role: 'tablist', 'aria-label': 'Account type' }) + forms.forEach((f) => { + tabs.append( + $('