Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3babf18fe4 | |||
| d78f1dfabf | |||
| c3c206b830 | |||
| 0a21dce0d7 | |||
| 5940880d9b | |||
| 17fcf2fed0 | |||
| ec76054e41 | |||
| 5c0fc4f016 | |||
| 43dae2a3eb | |||
| 20c0a48199 | |||
| 12da7140c2 | |||
| dfd5f46095 | |||
| 5dcc75195c | |||
| 12a99b550c | |||
| 4ce5a5f492 | |||
| c84141b2f3 | |||
| f76d93d840 | |||
| e910a492ba | |||
| 1ef868e23c | |||
| e11e39c23a | |||
| b91089ad4c | |||
| 95ae50a924 | |||
| 0076784fae |
+3
-3
@@ -20,9 +20,9 @@
|
||||
!directory_spec.md
|
||||
!docs/**/*.md
|
||||
|
||||
# Tests
|
||||
nodejs/tests/
|
||||
nodejs/*.test.js
|
||||
# Tests (excluded from production builds; test-runner Dockerfile copies them explicitly)
|
||||
# nodejs/tests/
|
||||
# nodejs/*.test.js
|
||||
|
||||
# Host dependency tree — let the image run a clean `npm ci`. Also avoids
|
||||
# copying platform-wrong native modules (e.g. bcrypt built for the host OS).
|
||||
|
||||
@@ -4,6 +4,33 @@ All notable changes to this project are documented here. Format loosely
|
||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
||||
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
||||
|
||||
## [1.3.1] - 2026-07-23
|
||||
|
||||
### Added
|
||||
- The Directory documentation (`docs/directory.md`) is now surfaced: registered in-app at `/docs/directory` ("Directory & Inventory"), help-linked from the Directory page header, and linked from the docs-site index. Extended with the shared slug conventions (`site_<name>`, `host_<hostname>` — as used by ldap-client and the theta-env seed), the automatic-registration story (theta-env stack seeding, ldap-client Linux host enrollment), and the API surface (admin at `/api/directory-admin`, read-only graph at `/api/discovery`).
|
||||
|
||||
### Changed
|
||||
- Direct LDAP binds are described as first-class, not "legacy", across README, DEPLOYMENT.md, docs, and the Dockerfile: Linux hosts are a primary consumer of the directory (PAM/SSSD login, LDAP-backed `sudo` via `sudoRole`, SSH public keys via openssh-lpk) — exactly what the custom schemas exist for.
|
||||
|
||||
## [1.3.0] - 2026-07-23
|
||||
|
||||
### Added
|
||||
- **OAuth client management API** at `/api/oauth/client` (group `app_sso_oauth_admin`): list, create, update, delete, and rotate-secret for OAuth clients, backed by the Resource model. Accepts form-style string inputs (newline-separated `redirect_uris`/`allowed_groups`, space-separated `scopes`).
|
||||
- **Dockerized test suite**: `docker-compose -f docker-compose.test.yml up --build` spins up OpenLDAP + Redis + a test-runner that seeds the test user and runs the full jest suite (174 tests) against them. `tests/globalSetup.js` honors `REDIS_URL`.
|
||||
|
||||
### Fixed
|
||||
- Completed the model-redis → `@simpleworkjs/orm` port that shipped half-finished in 1.2.1:
|
||||
- `OtpToken.issue`/`verify` called nonexistent `find()`/`listDetail()` — every OTP login 500'd.
|
||||
- Impersonation create/revoke called nonexistent `ImpersonationToken.listDetail()` — both endpoints 500'd.
|
||||
- `OAuthClient` read `is_valid` from the Resource model, which has no such column — every client evaluated as disabled and **all `/oauth/authorize` requests were rejected with 400**. Client validity now lives in `metadata` (absent = valid).
|
||||
- `OAuthClient.add` didn't set the required-unique `Resource.slug`; clients now get a slug derived from the client name.
|
||||
- `GET /api/token/:name/:token` returned `{results: null}` with 200 for unknown tokens (orm `get()` returns null instead of throwing); now 404s.
|
||||
- `User.login` returns a clean 401 instead of crashing when neither `uid` nor `username` is supplied.
|
||||
- Depend on published `@simpleworkjs/orm` ^0.2.8 and `model-redis` ^1.6.0 instead of a local `file:` link that broke `npm ci` in docker builds.
|
||||
|
||||
### Changed
|
||||
- Removed the Mobile Phone field from the user create/edit form.
|
||||
|
||||
## [1.2.1] - 2026-07-22
|
||||
|
||||
### Added
|
||||
|
||||
+3
-3
@@ -120,7 +120,7 @@ bare-metal / advanced standalone use; most deployments should use the file.
|
||||
- Health check: `http://localhost:3001/health` → `{"status":"ok"}`
|
||||
- OIDC discovery: `http://localhost:3001/.well-known/openid-configuration`
|
||||
- LDAP (internal, app↔slapd): `ldap://localhost:389` (not mapped to the host)
|
||||
- LDAPS (for legacy apps / direct binds): `ldaps://<host>:636` (TLS)
|
||||
- LDAPS (direct binds: Linux hosts, LDAP-native apps): `ldaps://<host>:636` (TLS)
|
||||
|
||||
### API tokens (personal access tokens)
|
||||
|
||||
@@ -483,8 +483,8 @@ netstat -tlnp | grep 389
|
||||
2. **Use LDAPS / StartTLS** for any LDAP connection that crosses the network. The
|
||||
bundled slapd listens on `ldaps:///` (636, TLS) and `ldap:///` (389, plain +
|
||||
StartTLS); port 389 is not mapped to the host by default so LAN clients can't
|
||||
bind in cleartext. Direct-LDAP apps (legacy services, `theta42/proxy`) should
|
||||
use `ldaps://…:636` or StartTLS.
|
||||
bind in cleartext. Direct-LDAP consumers (Linux hosts, LDAP-native apps,
|
||||
`theta42/proxy`) should use `ldaps://…:636` or StartTLS.
|
||||
3. **Persist `JWT_SECRET`** — if the Docker image auto-generates one and you don't
|
||||
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
|
||||
|
||||
+2
-1
@@ -131,7 +131,8 @@ COPY ops/schema/openssh-lpk.schema /etc/openldap/schema/openssh-lpk.schema
|
||||
# 3001: SSO Manager web interface (HTTP — terminate TLS at the front proxy)
|
||||
# 389: LDAP (plain + StartTLS) — used internally by the app; map to host only
|
||||
# if you want LAN clients to bind without TLS (not recommended).
|
||||
# 636: LDAPS — for legacy apps / direct LDAP binds over the network (TLS)
|
||||
# 636: LDAPS — direct LDAP binds over the network (TLS): Linux host auth
|
||||
# (PAM/SSSD, sudo, SSH keys) and LDAP-native apps
|
||||
EXPOSE 3001 389 636
|
||||
|
||||
# Health check
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Test-runner image for SSO Manager.
|
||||
#
|
||||
# Installs all dependencies (including dev) and bundles the app code plus
|
||||
# the seed script. The entrypoint waits for LDAP + Redis, seeds the test
|
||||
# user, then runs whatever command is given (default: npm test).
|
||||
|
||||
FROM node:20-alpine
|
||||
|
||||
# Install OpenLDAP clients (ldapadd, ldapsearch) and bash for the seed script
|
||||
RUN apk add --no-cache openldap-clients bash
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy and install dependencies (including devDependencies for jest/supertest)
|
||||
COPY nodejs/package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy the application source
|
||||
COPY nodejs/app.js ./
|
||||
COPY nodejs/bin ./bin
|
||||
COPY nodejs/conf ./conf
|
||||
COPY nodejs/controller ./controller
|
||||
COPY nodejs/middleware ./middleware
|
||||
COPY nodejs/models ./models
|
||||
COPY nodejs/routes ./routes
|
||||
COPY nodejs/services ./services
|
||||
COPY nodejs/utils ./utils
|
||||
COPY nodejs/views ./views
|
||||
COPY nodejs/public ./public
|
||||
COPY nodejs/tests ./tests
|
||||
|
||||
# SQLite database directory (config/inventory.sqlite for Resource model's ORM)
|
||||
RUN mkdir -p /app/config
|
||||
|
||||
# Files expected at the flattened /app path (see Dockerfile.openldap notes)
|
||||
COPY tos.md /tos.md
|
||||
COPY README.md /README.md
|
||||
COPY CHANGELOG.md /CHANGELOG.md
|
||||
COPY DEPLOYMENT.md /DEPLOYMENT.md
|
||||
COPY API.md /API.md
|
||||
COPY directory_spec.md /directory_spec.md
|
||||
COPY docs /docs
|
||||
|
||||
# Seed script and utility
|
||||
COPY test_seed.js ./test_seed.js
|
||||
COPY test/seed-test-user.sh /usr/local/bin/seed-test-user
|
||||
RUN chmod +x /usr/local/bin/seed-test-user
|
||||
|
||||
# Default command: seed the test user, then run the test suite
|
||||
CMD ["sh", "-c", "seed-test-user && npm test"]
|
||||
@@ -41,9 +41,10 @@ phone-home, no hosted control plane, and no per-user pricing.
|
||||
- **Web management UI** — manage users, groups, and OAuth clients from a
|
||||
browser; invite and password-reset flows over email; user self-service for
|
||||
profile and API tokens.
|
||||
- **LDAPS for legacy apps** — apps that bind LDAP directly (Gitea, Emby, and
|
||||
anything else that speaks LDAP) use LDAPS (636) or StartTLS against the same
|
||||
directory, so you don't maintain a second user database for them.
|
||||
- **Direct LDAP binds** — Linux hosts (PAM/SSSD login, LDAP-backed `sudo`
|
||||
rules, SSH public keys via openssh-lpk) and LDAP-native apps (Gitea, Emby,
|
||||
and anything else that speaks LDAP) use LDAPS (636) or StartTLS against the
|
||||
same directory, so you don't maintain a second user database for them.
|
||||
- **Personal access tokens** — any user can mint a long-lived bearer token to
|
||||
drive the management API from scripts or CI, scoped to their own permissions.
|
||||
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or run
|
||||
@@ -151,7 +152,7 @@ details, including env var overrides (`LDAP_BASE_DN`, `SKIP_LDAP`, ...), in
|
||||
┌────────────────────────┐
|
||||
│ OpenLDAP (slapd) │
|
||||
│ - users / groups │
|
||||
│ - LDAPS :636 │─── legacy apps bind directly
|
||||
│ - LDAPS :636 │─── Linux hosts + LDAP apps bind directly
|
||||
│ - StartTLS :389 │
|
||||
└────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Docker Compose for running the SSO Manager test suite.
|
||||
#
|
||||
# Spins up:
|
||||
# ldap — OpenLDAP + Redis (all-in-one image, slapd + redis only, no app)
|
||||
# redis — Standalone Redis for the app's model/token storage
|
||||
# test-runner — Seeds the test user, then runs `npm test`
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.test.yml up --build
|
||||
# # Or to run a specific test file:
|
||||
# docker compose -f docker-compose.test.yml run --rm test-runner npx jest tests/auth.test.js
|
||||
#
|
||||
# The LDAP service uses the same Dockerfile.openldap image as production but
|
||||
# overrides the command to only start slapd + redis (the entrypoint handles
|
||||
# slapd.conf generation, directory initialization, and Redis startup before
|
||||
# running the given command — "sleep infinity" keeps it alive).
|
||||
#
|
||||
# The test-runner connects to ldap:389 and redis:6379 via Docker networking.
|
||||
# app_* env vars override conf/secrets.js (highest precedence in
|
||||
# @simpleworkjs/conf), so the production secrets.js is never read.
|
||||
|
||||
services:
|
||||
ldap:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.openldap
|
||||
environment:
|
||||
- LDAP_BASE_DN=dc=test,dc=local
|
||||
- LDAP_ADMIN_PASS=secret
|
||||
- ORG_NAME=Test SSO
|
||||
# The entrypoint starts slapd + redis, then runs whatever command is given.
|
||||
# "sleep infinity" keeps the container alive so the test-runner can connect.
|
||||
command: ["sleep", "infinity"]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "ldapsearch -x -H ldap://localhost:389 -b '' -s base '(objectClass=*)' >/dev/null 2>&1"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
start_period: 5s
|
||||
volumes:
|
||||
- ldap-data:/var/lib/ldap
|
||||
- ldap-certs:/etc/openldap/certs
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 15
|
||||
|
||||
test-runner:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.test-runner
|
||||
environment:
|
||||
# Tell the app which environment it's in (loads conf/test.js for Redis prefix)
|
||||
- NODE_ENV=test
|
||||
# LDAP — point at the ldap service container
|
||||
- app_ldap__url=ldap://ldap:389
|
||||
- app_ldap__bindDN=cn=admin,dc=test,dc=local
|
||||
- app_ldap__bindPassword=secret
|
||||
- app_ldap__userBase=ou=people,dc=test,dc=local
|
||||
- app_ldap__groupBase=ou=groups,dc=test,dc=local
|
||||
# Redis — point at the redis service container
|
||||
- app_redis__redisConf__url=redis://redis:6379
|
||||
# Also used by tests/globalSetup.js (direct Redis client, not @simpleworkjs/conf)
|
||||
- REDIS_URL=redis://redis:6379
|
||||
# JWT secret (required by the app, not sensitive in test)
|
||||
- app_oauth__jwtSecret=test-jwt-secret-for-testing-only
|
||||
# App name
|
||||
- app_name=Test SSO
|
||||
depends_on:
|
||||
ldap:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
ldap-data:
|
||||
ldap-certs:
|
||||
@@ -57,3 +57,41 @@ Resources carry a flexible `metadata` JSON object that can store essential conte
|
||||
## Navigating the UI
|
||||
|
||||
The Directory Management interface provides a **Tree View** toggle that visually nests your resources, making it easy to comprehend your network topography at a glance. You can also filter, search, and sort your entire infrastructure inventory. From the tree view, you can click the green `+` icon next to any resource to instantly add a child resource beneath it.
|
||||
|
||||
## Slug conventions
|
||||
|
||||
Slugs are the stable identifiers automation keys off, so the tooling around the SSO Manager follows a shared convention:
|
||||
|
||||
- **Sites**: `site_<name>` — e.g. `site_local`, `site_us-east`
|
||||
- **Hosts**: `host_<hostname>` — e.g. `host_pve1`, `host_web01`
|
||||
- **Services/apps**: a plain slug or `app_<name>` — e.g. `sso-manager`, `app_emby`
|
||||
|
||||
The auto-created LDAP groups derive from the slug (`<slug>_access` / `<slug>_admin`), so keep slugs stable once access groups are in use.
|
||||
|
||||
## Automatic registration
|
||||
|
||||
You don't have to build the graph by hand — the theta42 tooling registers itself:
|
||||
|
||||
### The stack itself (theta-env)
|
||||
|
||||
[theta-env](https://github.com/theta42/theta-env)'s `./setup.sh` seeds the directory on every run with the stack it deploys:
|
||||
|
||||
- a **site** (name from `CFG_SITE_NAME` in `setup.env`, default `local` → slug `site_local`) marked as the current site
|
||||
- the **host** the stack runs on (`host_<hostname>`), with IP, MAC address, OS, and kernel collected from the machine
|
||||
- the **services** it composes — SSO Manager, Proxy (management UI), OpenLDAP Directory (the LDAPS endpoint Linux hosts and LDAP-native apps bind to), and OpenResty Edge (the 80/443 data plane) — each with its address, internal port, and git repo
|
||||
- the proxy's auto-registered **OAuth client**, linked under its service
|
||||
|
||||
The seed is idempotent and non-destructive: a resource whose slug already exists is considered operator-owned — the seed only fills in metadata fields you haven't set, and never overwrites your values.
|
||||
|
||||
### Linux hosts (ldap-client)
|
||||
|
||||
The `ldap-client` join script enrolls a Debian/Ubuntu machine for LDAP login (SSSD/PAM), LDAP-backed `sudo`, and SSH keys from the directory — and, when given an SSO API token, registers the machine as a `host_<hostname>` resource with its IP, MAC, OS, and kernel, parented to the site named by its configured location.
|
||||
|
||||
## API
|
||||
|
||||
All of the above uses the same admin API the UI does (group `app_sso_directory_admin` or `app_sso_admin`):
|
||||
|
||||
- `GET/POST /api/directory-admin/resources`, `PUT/DELETE /api/directory-admin/resources/:id`
|
||||
- `GET/POST/DELETE /api/directory-admin/edges` — parent/child links (`hosts`, `oauth` relations)
|
||||
- `GET/POST/DELETE /api/directory-admin/groups` — resource ↔ LDAP group links
|
||||
- Read-only graph views (any authenticated user): `GET /api/discovery/resources`, `/api/discovery/resources/:slug`, `/api/discovery/graph`, `/api/discovery/me`
|
||||
|
||||
+4
-2
@@ -52,11 +52,13 @@ backend, that's the niche.
|
||||
- **Web management UI** — users, groups, and OAuth clients from a browser;
|
||||
invite and password-reset flows over email; self-service profile + API
|
||||
tokens.
|
||||
- **LDAPS for legacy apps** — anything that binds LDAP directly (Gitea,
|
||||
Emby, …) uses LDAPS/StartTLS against the same directory.
|
||||
- **Direct LDAP binds** — anything that binds LDAP directly (Linux hosts
|
||||
via PAM/SSSD, Gitea, Emby, …) uses LDAPS/StartTLS against the same
|
||||
directory.
|
||||
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
|
||||
run the pieces separately via `app_*` env config.
|
||||
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
|
||||
- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client.
|
||||
|
||||
## Get it
|
||||
|
||||
|
||||
+3
-2
@@ -14,8 +14,9 @@ description: SSO Manager's bundled OpenLDAP directory — schema, service accoun
|
||||
|
||||
SSO Manager runs an OpenLDAP directory holding your users and groups. The app
|
||||
authenticates against it over `localhost:389` (inside the all-in-one container)
|
||||
and exposes **LDAPS** (`ldaps://…:636`, TLS) for legacy apps that bind LDAP
|
||||
directly — Gitea, Emby, the theta42/proxy, etc.
|
||||
and exposes **LDAPS** (`ldaps://…:636`, TLS) for anything that binds LDAP
|
||||
directly — Linux hosts (PAM/SSSD, sudo rules, SSH keys), Gitea, Emby, the
|
||||
theta42/proxy, etc.
|
||||
|
||||
## Directory layout
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
|
||||
// OAuth 2.0 / OpenID Connect
|
||||
app.use('/oauth', oauthRouter);
|
||||
app.use('/api/oauth', middleware.auth, oauthApiRouter);
|
||||
app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client'));
|
||||
app.get('/.well-known/openid-configuration', discovery);
|
||||
|
||||
|
||||
|
||||
+15
-7
@@ -23,13 +23,21 @@ async function initORM() {
|
||||
};
|
||||
ormConf.redis = conf.redis;
|
||||
|
||||
await init({
|
||||
conf: { orm: ormConf },
|
||||
models: [
|
||||
Resource, ResourceEdge, ResourceGroup,
|
||||
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
||||
]
|
||||
});
|
||||
console.log('[initORM] Starting ORM initialization...');
|
||||
try {
|
||||
await init({
|
||||
conf: { orm: ormConf },
|
||||
models: [
|
||||
Resource, ResourceEdge, ResourceGroup,
|
||||
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
||||
]
|
||||
});
|
||||
console.log('[initORM] ORM initialized successfully');
|
||||
console.log('[initORM] Resource.orm =', !!Resource.orm, 'Token.orm =', !!Token.orm);
|
||||
} catch (err) {
|
||||
console.error('[initORM] ORM initialization failed:', err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.initORM = initORM;
|
||||
|
||||
@@ -16,11 +16,18 @@ class OAuthClient {
|
||||
const raw_secret = crypto.randomUUID();
|
||||
const client_id = crypto.randomUUID();
|
||||
const client_secret_hash = await bcrypt.hash(raw_secret, 10);
|
||||
|
||||
|
||||
// Generate a unique slug from the client name
|
||||
let slug = data.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'oauth-client';
|
||||
// Ensure uniqueness by appending a suffix if needed
|
||||
const existing = await Resource.list({ where: { slug } });
|
||||
if (existing.length) slug = `${slug}-${client_id.slice(0, 8)}`;
|
||||
|
||||
const r = await Resource.create({
|
||||
id: client_id,
|
||||
kind: 'oauth',
|
||||
name: data.name,
|
||||
slug: slug,
|
||||
description: data.description || '',
|
||||
owner: data.created_by,
|
||||
metadata: {
|
||||
@@ -37,10 +44,13 @@ class OAuthClient {
|
||||
return r;
|
||||
}
|
||||
static async get(client_id) {
|
||||
const resources = await Resource.list({ where: { id: client_id, kind: 'oauth' } });
|
||||
if (!resources.length) throw new Error('OAuthClient not found');
|
||||
|
||||
const r = resources[0];
|
||||
let r;
|
||||
try {
|
||||
r = await Resource.get(client_id);
|
||||
} catch (_) {
|
||||
throw new Error('OAuthClient not found');
|
||||
}
|
||||
if (r.kind !== 'oauth') throw new Error('OAuthClient not found');
|
||||
// Map metadata to top-level properties to satisfy routes/oauth.js without rewriting it
|
||||
r.client_id = r.id;
|
||||
r.client_secret_hash = r.metadata.client_secret_hash;
|
||||
@@ -48,6 +58,8 @@ class OAuthClient {
|
||||
r.scopes = r.metadata.scopes || ['openid', 'profile', 'email', 'groups'];
|
||||
r.allowed_groups = r.metadata.allowed_groups || [];
|
||||
r.token_lifetime = r.metadata.token_lifetime || { ...defaultLifetime };
|
||||
// Resource has no is_valid column; validity lives in metadata (absent = valid)
|
||||
r.is_valid = r.metadata.is_valid !== false;
|
||||
r.verifySecret = async (secret) => bcrypt.compare(secret, r.client_secret_hash);
|
||||
|
||||
r.rotateSecret = async () => {
|
||||
@@ -64,11 +76,11 @@ class OAuthClient {
|
||||
if (data.scopes !== undefined) r.metadata.scopes = data.scopes;
|
||||
if (data.allowed_groups !== undefined) r.metadata.allowed_groups = data.allowed_groups;
|
||||
if (data.token_lifetime !== undefined) r.metadata.token_lifetime = data.token_lifetime;
|
||||
|
||||
if (data.is_valid !== undefined) r.metadata.is_valid = data.is_valid;
|
||||
|
||||
const updateData = { metadata: r.metadata };
|
||||
if (data.name !== undefined) updateData.name = data.name;
|
||||
if (data.description !== undefined) updateData.description = data.description;
|
||||
if (data.is_valid !== undefined) updateData.is_valid = data.is_valid;
|
||||
|
||||
return originalUpdate(updateData);
|
||||
};
|
||||
@@ -81,6 +93,10 @@ class OAuthClient {
|
||||
return Promise.all(resources.map(r => this.get(r.id)));
|
||||
}
|
||||
|
||||
static async listDetail() {
|
||||
return this.list();
|
||||
}
|
||||
|
||||
static async verifySecret(client_id, secret) {
|
||||
const client = await this.get(client_id);
|
||||
return client.verifySecret(secret);
|
||||
|
||||
@@ -95,7 +95,7 @@ class OtpToken extends Token {
|
||||
}
|
||||
|
||||
static async issue(uid, method) {
|
||||
const existing = await this.find({uid});
|
||||
const existing = await this.list({where: {uid}});
|
||||
for (const t of existing) {
|
||||
if (t.is_valid) await t.update({is_valid: false});
|
||||
}
|
||||
@@ -104,7 +104,7 @@ class OtpToken extends Token {
|
||||
}
|
||||
|
||||
static async verify(uid, code) {
|
||||
const tokens = await this.listDetail({uid});
|
||||
const tokens = await this.list({where: {uid}});
|
||||
const match = tokens.find(t => t.is_valid && !t.isExpired && t.code === code);
|
||||
if (!match) return null;
|
||||
await match.update({is_valid: false});
|
||||
|
||||
@@ -891,6 +891,12 @@ User.invite = async function(data = {}){
|
||||
|
||||
User.login = async function(data){
|
||||
try{
|
||||
if (!data.uid && !data.username) {
|
||||
let error = new Error('Invalid Credentials, login failed.');
|
||||
error.name = 'LDAPLoginFailed';
|
||||
error.status = 401;
|
||||
throw error;
|
||||
}
|
||||
let user = await this.get(data.uid || data.username);
|
||||
|
||||
const loginClient = makeClient();
|
||||
|
||||
Generated
+1547
-340
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.1",
|
||||
"description": "A very simple LDAP management and SSO system",
|
||||
"author": [
|
||||
{
|
||||
@@ -24,7 +24,7 @@
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@simpleworkjs/conf": "^1.2.0",
|
||||
"@simpleworkjs/orm": "file:../../../simpleworkjs/orm",
|
||||
"@simpleworkjs/orm": "^0.2.8",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"compression": "^1.8.1",
|
||||
@@ -38,7 +38,7 @@
|
||||
"ldapts": "^8.1.2",
|
||||
"lru-cache": "^11.5.1",
|
||||
"marked": "^9.1.6",
|
||||
"model-redis": "^0.4.0",
|
||||
"model-redis": "^1.6.0",
|
||||
"moment": "^2.30.1",
|
||||
"mustache": "^4.2.0",
|
||||
"nodemailer": "^9.0.0",
|
||||
|
||||
@@ -203,7 +203,7 @@ router.post('/impersonate/:uid', middleware.auth, async function(req, res, next)
|
||||
const target = await User.get(req.params.uid);
|
||||
|
||||
// Clean up any existing impersonation for this target
|
||||
const existing = await ImpersonationToken.listDetail({ target_uid: target.uid });
|
||||
const existing = await ImpersonationToken.list({ where: { target_uid: target.uid } });
|
||||
for (const old of existing) {
|
||||
if (old.is_valid && !old.isExpired) {
|
||||
try { await target.removeTempPassword(old.temp_hash); } catch(_) {}
|
||||
@@ -237,7 +237,7 @@ router.delete('/impersonate/:uid', middleware.auth, async function(req, res, nex
|
||||
await permission.byGroup(req.user, ['app_sso_admin']);
|
||||
|
||||
const target = await User.get(req.params.uid);
|
||||
const existing = await ImpersonationToken.listDetail({ target_uid: target.uid });
|
||||
const existing = await ImpersonationToken.list({ where: { target_uid: target.uid } });
|
||||
|
||||
let revoked = 0;
|
||||
for (const token of existing) {
|
||||
|
||||
@@ -33,6 +33,7 @@ const DOCS = {
|
||||
accounts: {title: 'Accounts, Groups & Managers', file: path.join(__dirname, '../../docs/concepts-accounts.md')},
|
||||
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')},
|
||||
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
|
||||
directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')},
|
||||
|
||||
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
|
||||
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
'use strict';
|
||||
|
||||
const router = require('express').Router();
|
||||
const { OAuthClient } = require('../models/oauth_client');
|
||||
const permission = require('../utils/permission');
|
||||
|
||||
const ADMIN_GROUP = 'app_sso_oauth_admin';
|
||||
|
||||
router.get('/', async function(req, res, next) {
|
||||
try {
|
||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
||||
return res.json({ results: await OAuthClient.listDetail() });
|
||||
} catch(error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', async function(req, res, next) {
|
||||
try {
|
||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
||||
|
||||
req.body.created_by = req.user.uid;
|
||||
|
||||
// Parse redirect_uris if sent as newline-separated string from the form
|
||||
if (typeof req.body.redirect_uris === 'string') {
|
||||
req.body.redirect_uris = req.body.redirect_uris.split('\n').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
// Parse scopes if sent as space-separated string
|
||||
if (typeof req.body.scopes === 'string') {
|
||||
req.body.scopes = req.body.scopes.split(' ').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
// Parse allowed_groups if sent as newline-separated string
|
||||
if (typeof req.body.allowed_groups === 'string') {
|
||||
req.body.allowed_groups = req.body.allowed_groups.split('\n').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
// jQuery serializeObject sends nested fields as "token_lifetime[access_token]"
|
||||
if (req.body['token_lifetime[access_token]'] || req.body['token_lifetime[refresh_token]']) {
|
||||
req.body.token_lifetime = {
|
||||
access_token: Number(req.body['token_lifetime[access_token]']) || 3600,
|
||||
refresh_token: Number(req.body['token_lifetime[refresh_token]']) || 2592000,
|
||||
};
|
||||
delete req.body['token_lifetime[access_token]'];
|
||||
delete req.body['token_lifetime[refresh_token]'];
|
||||
}
|
||||
|
||||
const client = await OAuthClient.add(req.body);
|
||||
|
||||
const result = client.toJSON ? client.toJSON() : { ...client };
|
||||
result.client_id = client.client_id || client.id;
|
||||
|
||||
return res.json({
|
||||
results: result,
|
||||
client_secret: client._raw_secret,
|
||||
message: `OAuth client '${client.name}' created. Save the client secret — it will not be shown again.`,
|
||||
});
|
||||
} catch(error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:client_id', async function(req, res, next) {
|
||||
try {
|
||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
||||
return res.json({ results: await OAuthClient.get(req.params.client_id) });
|
||||
} catch(error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:client_id', async function(req, res, next) {
|
||||
try {
|
||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
||||
|
||||
const client = await OAuthClient.get(req.params.client_id);
|
||||
|
||||
if (typeof req.body.redirect_uris === 'string') {
|
||||
req.body.redirect_uris = req.body.redirect_uris.split('\n').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
if (typeof req.body.scopes === 'string') {
|
||||
req.body.scopes = req.body.scopes.split(' ').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
if (typeof req.body.allowed_groups === 'string') {
|
||||
req.body.allowed_groups = req.body.allowed_groups.split('\n').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
return res.json({
|
||||
results: await client.update(req.body),
|
||||
message: `OAuth client '${client.name}' updated.`,
|
||||
});
|
||||
} catch(error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:client_id', async function(req, res, next) {
|
||||
try {
|
||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
||||
|
||||
const client = await OAuthClient.get(req.params.client_id);
|
||||
await client.remove();
|
||||
|
||||
return res.json({
|
||||
client_id: req.params.client_id,
|
||||
message: `OAuth client '${client.name}' deleted.`,
|
||||
});
|
||||
} catch(error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:client_id/rotate', async function(req, res, next) {
|
||||
try {
|
||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
||||
|
||||
const client = await OAuthClient.get(req.params.client_id);
|
||||
const new_secret = await client.rotateSecret();
|
||||
|
||||
return res.json({
|
||||
client_secret: new_secret,
|
||||
message: `Client secret rotated for '${client.name}'. Save it — it will not be shown again.`,
|
||||
});
|
||||
} catch(error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+10
-4
@@ -23,8 +23,10 @@ router.get('/', async function(req, res, next){
|
||||
|
||||
router.get('/:name', async function(req, res, next){
|
||||
try{
|
||||
// ORM models: list() on the redis adapter always returns full rows;
|
||||
// detail is handled by serialization (isPrivate fields are excluded).
|
||||
return res.json({
|
||||
results: await tokens[req.params.name][req.query.detail ? "listDetail" : "list"]()
|
||||
results: await tokens[req.params.name].list()
|
||||
});
|
||||
}catch(error){
|
||||
next(error);
|
||||
@@ -34,9 +36,13 @@ router.get('/:name', async function(req, res, next){
|
||||
|
||||
router.get('/:name/:token', async function(req, res, next){
|
||||
try{
|
||||
return res.json({
|
||||
results: await tokens[req.params.name].get(req.params.token)
|
||||
});
|
||||
const result = await tokens[req.params.name].get(req.params.token);
|
||||
if (!result) {
|
||||
const error = new Error('Token not found');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
return res.json({ results: result });
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
const { createClient } = require('redis');
|
||||
|
||||
module.exports = async function() {
|
||||
const client = createClient();
|
||||
const redisUrl = process.env.REDIS_URL || undefined;
|
||||
const client = createClient(redisUrl ? { url: redisUrl } : {});
|
||||
await client.connect();
|
||||
|
||||
const keys = await client.keys('sso_manager_test_*');
|
||||
|
||||
@@ -3,6 +3,19 @@
|
||||
const crypto = require('crypto');
|
||||
const request = require('supertest');
|
||||
const app = require('../app');
|
||||
const { initORM } = require('../models');
|
||||
|
||||
beforeAll(async () => {
|
||||
await initORM();
|
||||
const { Token } = require('../models/token');
|
||||
try {
|
||||
if (Token.orm) {
|
||||
await Token.orm.adapter(Token).Table.redisClient.flushDb();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not flush Redis:', e.message);
|
||||
}
|
||||
});
|
||||
|
||||
const TEST_CREDS = { uid: 'test', password: 'MyTestPassword!2' };
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<div class="p-3 pb-0 text-muted small border-bottom">
|
||||
<i class="fa-solid fa-circle-info"></i> Manage infrastructure, services, and their relationships.
|
||||
<a href="/docs/directory" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="card-body table table-striped mb-0">
|
||||
|
||||
@@ -64,7 +64,7 @@ async function fetchUsernameSuggestions() {
|
||||
// simply can't bind). Disabling (not just hiding) keeps disabled
|
||||
// fields out of both form serialization and validation.
|
||||
$form.find('[name=mail]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
|
||||
$form.find('[name=mobile]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
|
||||
|
||||
$form.find('[name=userPassword]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
|
||||
$form.find('[name=passwordMatch]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
|
||||
|
||||
@@ -137,10 +137,7 @@ async function fetchUsernameSuggestions() {
|
||||
<input type="text" class="form-control shadow" name="sshPublicKey" placeholder="ssh-rsa AAAAB3NzaC1yc2EAAAADAQ..." />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Mobile Phone <small class="text-muted">(optional, include country code e.g. +14155551234)</small></label>
|
||||
<input type="text" class="form-control shadow" name="mobile" placeholder="+14155551234" />
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Location (Site) <small class="text-muted">(optional)</small></label>
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env bash
|
||||
# seed-test-user.sh — Create the test user in LDAP for the test suite.
|
||||
#
|
||||
# The test suite (tests/setup.js) logs in as uid=test / password=MyTestPassword!2.
|
||||
# This script creates that user in the LDAP directory along with its personal
|
||||
# posixGroup, and adds it to the app_sso_admin group so admin-gated tests pass.
|
||||
#
|
||||
# Environment variables (from docker-compose.test.yml):
|
||||
# LDAP_HOST — LDAP server hostname (default: ldap)
|
||||
# LDAP_PORT — LDAP server port (default: 389)
|
||||
# BIND_DN — LDAP admin bind DN (default: cn=admin,dc=test,dc=local)
|
||||
# BIND_PW — LDAP admin password (default: secret)
|
||||
# BASE_DN — LDAP base DN (default: dc=test,dc=local)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
LDAP_HOST="${LDAP_HOST:-ldap}"
|
||||
LDAP_PORT="${LDAP_PORT:-389}"
|
||||
BIND_DN="${BIND_DN:-cn=admin,dc=test,dc=local}"
|
||||
BIND_PW="${BIND_PW:-secret}"
|
||||
BASE_DN="${BASE_DN:-dc=test,dc=local}"
|
||||
|
||||
LDAP_URI="ldap://${LDAP_HOST}:${LDAP_PORT}"
|
||||
USER_UID="test"
|
||||
USER_PASSWORD="MyTestPassword!2"
|
||||
|
||||
info() { echo "[INFO] $*"; }
|
||||
error() { echo "[ERROR] $*" >&2; }
|
||||
|
||||
# ── Wait for LDAP to be reachable ────────────────────────────────────────────
|
||||
info "Waiting for LDAP at ${LDAP_URI}..."
|
||||
for i in $(seq 1 30); do
|
||||
if ldapsearch -x -H "$LDAP_URI" -b '' -s base '(objectClass=*)' >/dev/null 2>&1; then
|
||||
info "LDAP is reachable"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 30 ]; then
|
||||
error "LDAP not reachable after 30 attempts"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# ── Check if the test user already exists ────────────────────────────────────
|
||||
if ldapsearch -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" \
|
||||
-b "cn=${USER_UID},ou=people,${BASE_DN}" -s base '(objectClass=*)' >/dev/null 2>&1; then
|
||||
info "Test user '${USER_UID}' already exists — skipping seed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Generate the SSHA512 password hash ───────────────────────────────────────
|
||||
# Inline the hash function to avoid requiring the full model chain (which
|
||||
# tries to connect to Redis/LDAP during module loading and would hang).
|
||||
info "Generating password hash..."
|
||||
PASSWORD_HASH=$(node -e "
|
||||
const crypto = require('crypto');
|
||||
const salt = crypto.randomBytes(8);
|
||||
const hash = crypto.createHash('sha512').update('${USER_PASSWORD}').update(salt).digest();
|
||||
console.log('{SSHA512}' + Buffer.concat([hash, salt]).toString('base64'));
|
||||
")
|
||||
|
||||
info "Password hash generated"
|
||||
|
||||
# ── Create a temporary LDIF file ─────────────────────────────────────────────
|
||||
TMPFILE=$(mktemp /tmp/seed-test-user.XXXXXX)
|
||||
trap 'rm -f "$TMPFILE"' EXIT
|
||||
|
||||
cat > "$TMPFILE" << LDIF
|
||||
# Personal posixGroup for the test user
|
||||
dn: cn=${USER_UID},ou=groups,${BASE_DN}
|
||||
objectClass: posixGroup
|
||||
objectClass: top
|
||||
cn: ${USER_UID}
|
||||
gidNumber: 1500
|
||||
description: Personal group for test user
|
||||
|
||||
# Test user posixAccount
|
||||
dn: cn=${USER_UID},ou=people,${BASE_DN}
|
||||
objectClass: inetOrgPerson
|
||||
objectClass: posixAccount
|
||||
objectClass: top
|
||||
objectClass: theta42Person
|
||||
objectClass: ldapPublicKey
|
||||
objectClass: sudoRole
|
||||
cn: ${USER_UID}
|
||||
sn: Test
|
||||
uid: ${USER_UID}
|
||||
uidNumber: 1500
|
||||
gidNumber: 1500
|
||||
homeDirectory: /home/${USER_UID}
|
||||
loginShell: /bin/bash
|
||||
mail: test@test.local
|
||||
userPassword: ${PASSWORD_HASH}
|
||||
description: Test user for automated test suite
|
||||
sudoHost: ALL
|
||||
sudoCommand: ALL
|
||||
sudoUser: ${USER_UID}
|
||||
LDIF
|
||||
|
||||
# ── Add the entries to LDAP ──────────────────────────────────────────────────
|
||||
info "Creating test user '${USER_UID}' in LDAP..."
|
||||
ldapadd -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" -f "$TMPFILE" 2>/dev/null || true
|
||||
if ldapsearch -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" \
|
||||
-b "cn=${USER_UID},ou=people,${BASE_DN}" -s base '(objectClass=*)' >/dev/null 2>&1; then
|
||||
info "Test user '${USER_UID}' exists or was created"
|
||||
else
|
||||
error "Failed to create test user '${USER_UID}'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Add the test user to required SSO groups ─────────────────────────────────
|
||||
info "Adding test user to SSO admin groups..."
|
||||
for group in app_sso_admin app_sso_invite app_sso_oauth_admin; do
|
||||
ldapmodify -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" << EOF 2>/dev/null || true
|
||||
dn: cn=${group},ou=groups,${BASE_DN}
|
||||
changetype: modify
|
||||
add: member
|
||||
member: cn=${USER_UID},ou=people,${BASE_DN}
|
||||
EOF
|
||||
done
|
||||
info "Test user added to SSO admin groups"
|
||||
|
||||
# ── Create additional users needed by tests ─────────────────────────────────
|
||||
# wmantly is referenced by OTP (otp.test.js) and impersonation (impersonate.test.js)
|
||||
# tests as an existing non-admin user.
|
||||
WMANTLY_UID="wmantly"
|
||||
if ! ldapsearch -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" \
|
||||
-b "cn=${WMANTLY_UID},ou=people,${BASE_DN}" -s base '(objectClass=*)' >/dev/null 2>&1; then
|
||||
|
||||
info "Creating additional test user '${WMANTLY_UID}'..."
|
||||
|
||||
# Generate password hash for wmantly
|
||||
WMANTLY_HASH=$(node -e "
|
||||
const crypto = require('crypto');
|
||||
const salt = crypto.randomBytes(8);
|
||||
const hash = crypto.createHash('sha512').update('testpass').update(salt).digest();
|
||||
console.log('{SSHA512}' + Buffer.concat([hash, salt]).toString('base64'));
|
||||
")
|
||||
|
||||
cat > "$TMPFILE" << LDIF
|
||||
dn: cn=${WMANTLY_UID},ou=groups,${BASE_DN}
|
||||
objectClass: posixGroup
|
||||
objectClass: top
|
||||
cn: ${WMANTLY_UID}
|
||||
gidNumber: 1501
|
||||
|
||||
dn: cn=${WMANTLY_UID},ou=people,${BASE_DN}
|
||||
objectClass: inetOrgPerson
|
||||
objectClass: posixAccount
|
||||
objectClass: top
|
||||
objectClass: theta42Person
|
||||
objectClass: ldapPublicKey
|
||||
objectClass: sudoRole
|
||||
cn: ${WMANTLY_UID}
|
||||
sn: Mantly
|
||||
uid: ${WMANTLY_UID}
|
||||
uidNumber: 1501
|
||||
gidNumber: 1501
|
||||
homeDirectory: /home/${WMANTLY_UID}
|
||||
loginShell: /bin/bash
|
||||
mail: wmantly@test.local
|
||||
userPassword: ${WMANTLY_HASH}
|
||||
sudoHost: ALL
|
||||
sudoCommand: ALL
|
||||
sudoUser: ${WMANTLY_UID}
|
||||
LDIF
|
||||
ldapadd -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" -f "$TMPFILE" 2>/dev/null || true
|
||||
if ldapsearch -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" \
|
||||
-b "cn=${WMANTLY_UID},ou=people,${BASE_DN}" -s base '(objectClass=*)' >/dev/null 2>&1; then
|
||||
info "Additional test user '${WMANTLY_UID}' exists or was created"
|
||||
else
|
||||
error "Failed to create additional test user '${WMANTLY_UID}'"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
info "Seed complete — all test users are ready"
|
||||
@@ -0,0 +1,2 @@
|
||||
const { hashPasswordSSHA512 } = require('./nodejs/models/user_ldap.js');
|
||||
console.log(hashPasswordSSHA512('MyTestPassword!2'));
|
||||
Reference in New Issue
Block a user