diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index ecf7c1c..0b350fc 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -456,6 +456,13 @@ netstat -tlnp | grep 389 set `JWT_SECRET`, issued tokens invalidate on container recreation. 4. **Don't expose the UI's HTTP port to the internet** — terminate TLS at a front proxy and keep `3001` on the Docker network / localhost only. -5. The all-in-one image runs slapd as the `ldap` user but the app process as root +5. **Don't port-forward LDAPS (636) to the internet either.** It's mapped to the + host by default for LAN/VPN clients that bind LDAP directly (other hosts + running `ldap-client`, apps with their own LDAP auth settings) — not for + exposure through your router/firewall. LDAP simple-bind is a brute-force + target with no rate limiting in front of it the way the HTTP login endpoints + have. If a remote host needs to bind LDAP, put it behind a VPN (Tailscale, + WireGuard, …) instead of forwarding 636 publicly. +6. The all-in-one image runs slapd as the `ldap` user but the app process as root (matches the bare-metal systemd unit). Harden the app to a non-root user for production if needed. \ No newline at end of file diff --git a/docs/deployment.md b/docs/deployment.md index c0ea8a6..c6fff19 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -203,7 +203,15 @@ automates all four steps with `./setup.sh` — see container recreation. 4. **Don't expose the UI's HTTP port to the internet** — terminate TLS at a front proxy and keep `3001` on the Docker network / localhost only. -5. The all-in-one image runs slapd as the `ldap` user but the app as root +5. **Don't port-forward LDAPS (636) to the internet either.** It's mapped to + the host by default for LAN/VPN clients that bind LDAP directly (other + hosts running `ldap-client`, apps with their own LDAP auth settings) — not + for exposure through your router/firewall. LDAP simple-bind is a + brute-force target and there's no rate limiting in front of it the way + there is for the HTTP login endpoints. If you need a remote host to bind + LDAP, put it behind a VPN (Tailscale, WireGuard, …) instead of forwarding + 636 publicly. +6. The all-in-one image runs slapd as the `ldap` user but the app as root (matches the bare-metal unit). Harden to a non-root user for production. [← Back to Home](index.html) \ No newline at end of file diff --git a/nodejs/app.js b/nodejs/app.js index a5ab98a..518886d 100755 --- a/nodejs/app.js +++ b/nodejs/app.js @@ -74,6 +74,7 @@ app.use('/api/user', middleware.auth, require('./routes/user')); app.use('/api/token', middleware.auth, require('./routes/token')); app.use('/api/group', middleware.auth, require('./routes/group')); +app.use('/api/service-account', middleware.auth, require('./routes/service_account')); app.use('/api/notification', middleware.auth, require('./routes/notification')); // Self-service API tokens (PATs) — owner-scoped, no admin group required. diff --git a/nodejs/models/service_account.js b/nodejs/models/service_account.js new file mode 100644 index 0000000..19b9780 --- /dev/null +++ b/nodejs/models/service_account.js @@ -0,0 +1,114 @@ +'use strict'; + +// Non-person "service" accounts under ou=people -- bind-only LDAP identities +// for things like theta-env's bootstrap-created cn=ldapclient (the proxy's +// direct-LDAP bind account) or any other app/host that needs its own +// dedicated read-only credential, as opposed to a real user who logs into +// the web UI. +// +// Deliberately NOT posixAccount/inetOrgPerson (the User model's shape) -- +// these can't log into the SSO Manager UI or get a home directory/uidNumber. +// objectClass matches exactly what theta-env's bootstrap.js already creates +// for cn=ldapclient, so this model recognizes and manages that account too, +// not just ones created through this UI. + +const { Client, Attribute, Change } = require('ldapts'); +const crypto = require('crypto'); +const conf = require('@simpleworkjs/conf').ldap; + +function hashPasswordSSHA512(password) { + const salt = crypto.randomBytes(8); + const hash = crypto.createHash('sha512').update(password).update(salt).digest(); + return '{SSHA512}' + Buffer.concat([hash, salt]).toString('base64'); +} + +function makeClient() { + return new Client({ url: conf.url }); +} + +async function withClient(fn) { + const client = makeClient(); + try { + await client.bind(conf.bindDN, conf.bindPassword); + return await fn(client); + } finally { + await client.unbind().catch(() => {}); + } +} + +const FILTER = '(&(objectClass=organizationalRole)(objectClass=simpleSecurityObject))'; +const CN_RE = /^[A-Za-z][A-Za-z0-9._-]{1,63}$/; + +var ServiceAccount = {}; + +ServiceAccount.list = async function(){ + return withClient(async (client) => { + const res = await client.search(conf.userBase, { + scope: 'sub', + filter: FILTER, + attributes: ['cn', 'description', 'createTimestamp', 'modifyTimestamp'], + }); + return res.searchEntries.map((entry) => ({ + cn: entry.cn, + dn: `cn=${entry.cn},${conf.userBase}`, + description: entry.description || '', + created_on: entry.createTimestamp || null, + modified_on: entry.modifyTimestamp || null, + })).sort((a, b) => a.cn.localeCompare(b.cn)); + }); +}; + +ServiceAccount.create = async function({cn, description}){ + if(!cn || !CN_RE.test(cn)){ + throw Object.assign(new Error('InvalidName'), {status: 400, message: 'Name must start with a letter and contain only letters, numbers, dot, dash, underscore.'}); + } + + const dn = `cn=${cn},${conf.userBase}`; + const password = crypto.randomBytes(24).toString('base64url'); + + await withClient(async (client) => { + let existing = true; + try{ + const res = await client.search(dn, {scope: 'base', filter: '(objectClass=*)', attributes: ['dn']}); + existing = res.searchEntries.length > 0; + }catch(error){ existing = false; } + if(existing){ + throw Object.assign(new Error('NameInUse'), {status: 409, message: `"${cn}" already exists under ${conf.userBase}.`}); + } + + await client.add(dn, { + objectClass: ['organizationalRole', 'simpleSecurityObject', 'top'], + cn, + description: description || '', + userPassword: hashPasswordSSHA512(password), + }); + }); + + return {cn, dn, description: description || '', password}; +}; + +ServiceAccount.setPassword = async function(cn, password){ + const dn = `cn=${cn},${conf.userBase}`; + const newPassword = password || crypto.randomBytes(24).toString('base64url'); + + await withClient(async (client) => { + await client.modify(dn, [ + new Change({ + operation: 'replace', + modification: new Attribute({type: 'userPassword', values: [hashPasswordSSHA512(newPassword)]}), + }), + ]); + }); + + return {cn, dn, password: newPassword}; +}; + +ServiceAccount.remove = async function(cn){ + const dn = `cn=${cn},${conf.userBase}`; + await withClient(async (client) => { + await client.del(dn); + }); + return true; +}; + +module.exports = {ServiceAccount}; diff --git a/nodejs/routes/index.js b/nodejs/routes/index.js index 8149e38..34822d1 100755 --- a/nodejs/routes/index.js +++ b/nodejs/routes/index.js @@ -77,15 +77,11 @@ router.get('/login', async function(req, res, next) { res.render('login', {...values, redirect: req.query.redirect}); }); -router.get('/oauth-clients', function(req, res, next) { - const issuer = ((conf.oauth && conf.oauth.issuer) || `${req.protocol}://${req.get('host')}`).replace(/\/$/, ''); - res.render('oauth_clients', {...values, issuer, discoveryUrl: `${issuer}/.well-known/openid-configuration`}); -}); - -// Everything a 3rd-party app or the ldap-client host script needs to bind -// this directory, derived from the running config + request host rather than -// hardcoded in a doc -- so it's always right for *this* deployment. -router.get('/ldap-info', function(req, res, next) { +// OAuth client management and LDAP connection info, merged into one page +// (tabs) -- both are "how do other apps/hosts plug into this SSO" concerns. +// LDAP values are derived from the running config + request host rather than +// hardcoded in a doc, so they're always right for *this* deployment. +router.get('/integrations', function(req, res, next) { const issuer = ((conf.oauth && conf.oauth.issuer) || `${req.protocol}://${req.get('host')}`).replace(/\/$/, ''); const ldapHost = issuer.replace(/^https?:\/\//, '').replace(/:\d+$/, ''); @@ -96,8 +92,10 @@ router.get('/ldap-info', function(req, res, next) { // -> dc=example,dc=com). const baseDn = userBase.replace(/^ou=[^,]+,/i, ''); - res.render('ldap_info', { + res.render('integrations', { ...values, + issuer, + discoveryUrl: `${issuer}/.well-known/openid-configuration`, ldapHost, ldapsUrl: `ldaps://${ldapHost}:636`, baseDn, @@ -109,6 +107,8 @@ router.get('/ldap-info', function(req, res, next) { ssoUrl: issuer, }); }); +router.get('/oauth-clients', (req, res) => res.redirect(301, '/integrations')); +router.get('/ldap-info', (req, res) => res.redirect(301, '/integrations')); // API Tokens is now a section on the Profile page (own profile only). router.get('/api-tokens', (req, res) => res.redirect(301, '/')); diff --git a/nodejs/routes/service_account.js b/nodejs/routes/service_account.js new file mode 100644 index 0000000..b916106 --- /dev/null +++ b/nodejs/routes/service_account.js @@ -0,0 +1,54 @@ +'use strict'; + +const router = require('express').Router(); +const {ServiceAccount} = require('../models/service_account'); +const permission = require('../utils/permission'); + +const ADMIN_GROUP = 'app_sso_admin'; + +router.get('/', async function(req, res, next) { + try { + await permission.byGroup(req.user, [ADMIN_GROUP]); + return res.json({results: await ServiceAccount.list()}); + } catch(error) { + next(error); + } +}); + +router.post('/', async function(req, res, next) { + try { + await permission.byGroup(req.user, [ADMIN_GROUP]); + const result = await ServiceAccount.create({cn: req.body.cn, description: req.body.description}); + return res.json({ + results: result, + message: `Service account "${result.cn}" created. Save the password now — it will not be shown again.`, + }); + } catch(error) { + next(error); + } +}); + +router.put('/:cn/password', async function(req, res, next) { + try { + await permission.byGroup(req.user, [ADMIN_GROUP]); + const result = await ServiceAccount.setPassword(req.params.cn, req.body.password); + return res.json({ + results: result, + message: `Password rotated for "${req.params.cn}". Save it now — it will not be shown again.`, + }); + } catch(error) { + next(error); + } +}); + +router.delete('/:cn', async function(req, res, next) { + try { + await permission.byGroup(req.user, [ADMIN_GROUP]); + await ServiceAccount.remove(req.params.cn); + return res.json({message: `Service account "${req.params.cn}" deleted.`}); + } catch(error) { + next(error); + } +}); + +module.exports = router; diff --git a/nodejs/views/integrations.ejs b/nodejs/views/integrations.ejs new file mode 100644 index 0000000..3c47a6f --- /dev/null +++ b/nodejs/views/integrations.ejs @@ -0,0 +1,633 @@ +<%- include('top') %> + + + + + + + + + +

Integrations

+ + + +
+
+ +
+ +
+

+ Everything a 3rd-party app or host needs to bind this directory, filled in + for <%= ssoUrl %>. +

+ +
+
+
+
+ Connection details +
+
+

+ For a single app's own "LDAP authentication" settings — see + Connecting a 3rd-party app or container + for a field-by-field walkthrough (Gitea, generic Docker LDAP_* env vars, …). +

+
+
LDAPS URL
+
+
+ + +
+
+ +
Base DN
+
+
+ + +
+
+ +
User search base
+
+
+ + +
+
+ +
Group search base
+
+
+ + +
+
+ +
User filter
+
+
+ + +
+
+ +
Username attribute
+
+
+ + +
+
+ +
Example bind DN
+
+
+ + +
+ + A read-only bind account — create one below under + Service Accounts (don't reuse a real person's login or the admin DN). + +
+
+
+
+
+ +
+
+
+ Set up a Linux host (ldap-client) +
+
+

+ For full host login, SSH keys, and sudo via LDAP (not just one app) — + clone theta42/ldap-client + and run this on the host. Fill in a service account's password (create + one below) and, if you want this host's access/sudo groups + auto-registered, an API token from your Profile. +

+
+ +
+ +
+
+
+ +
+
+
+ Service Accounts +
+
+

+ Bind-only LDAP identities for apps and hosts — not real people, can't log + into this UI, no home directory. theta-env's cn=ldapclient + bootstrap account (used by theta42/proxy) shows up here too, since it's + the same kind of account. +

+
+
+
+
+ + +
+
+ + +
+ +
+
+
+
+ + + + + + + + + +
NameDescription
cn={{cn}},<%= userBase %>{{description}} + + +
+
+
+
+
+
+
+
+
+
+ + + +<%- include('bottom') %> diff --git a/nodejs/views/ldap_info.ejs b/nodejs/views/ldap_info.ejs deleted file mode 100644 index e3536fd..0000000 --- a/nodejs/views/ldap_info.ejs +++ /dev/null @@ -1,155 +0,0 @@ -<%- include('top') %> - - - -

LDAP Info

-

- Everything a 3rd-party app or host needs to bind this directory, filled in - for <%= ssoUrl %>. -

- -
-
-
-
- Connection details -
-
-

- For a single app's own "LDAP authentication" settings — see - Connecting a 3rd-party app or container - for a field-by-field walkthrough (Gitea, generic Docker LDAP_* env vars, …). -

-
-
LDAPS URL
-
-
- - -
-
- -
Base DN
-
-
- - -
-
- -
User search base
-
-
- - -
-
- -
Group search base
-
-
- - -
-
- -
User filter
-
-
- - -
-
- -
Username attribute
-
-
- - -
-
- -
Example bind DN
-
-
- - -
- - A read-only bind account — create it as a plain user via - Users (don't put it in app_sso_admin - or any other privileged group). - -
-
-
-
-
- -
-
-
- Set up a Linux host (ldap-client) -
-
-

- For full host login, SSH keys, and sudo via LDAP (not just one app) — - clone theta42/ldap-client - and run this on the host. Fill in the bind account's password and, - if you want this host's access/sudo groups auto-registered, an - API token from your Profile. -

-
- -
- -
-
-
-
- - - -<%- include('bottom') %> diff --git a/nodejs/views/oauth_clients.ejs b/nodejs/views/oauth_clients.ejs deleted file mode 100644 index eeaefe6..0000000 --- a/nodejs/views/oauth_clients.ejs +++ /dev/null @@ -1,383 +0,0 @@ -<%- include('top') %> - - - - - - - - - -<%- include('bottom') %> diff --git a/nodejs/views/top.ejs b/nodejs/views/top.ejs index b912922..c5e62f1 100755 --- a/nodejs/views/top.ejs +++ b/nodejs/views/top.ejs @@ -50,16 +50,10 @@ Groups - -