mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: move sso and ldap logins into plugins (C2)
This commit is contained in:
1 parent
5c18435105
commit
925da4a661
223 files changed
+25778
-6820
No files matched your search
@@ -0,0 +1,34 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { providers } from "./tables.js";
|
||||
import { createProviderStore } from "./providers.js";
|
||||
import { createLdapLogin, METHOD_ID } from "./ldap.js";
|
||||
import { registerLdapRoutes } from "./routes.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
const table = await ctx.db.define(providers);
|
||||
const store = createProviderStore(ctx, table);
|
||||
|
||||
// One form per enabled directory.
|
||||
ctx.auth.registerLoginMethod({
|
||||
id: METHOD_ID,
|
||||
labelKey: "loginWithLdap",
|
||||
icon: "server",
|
||||
kind: "form",
|
||||
external: true,
|
||||
describe: async () =>
|
||||
(await store.listRows()).map((row) => ({
|
||||
id: String(row.id),
|
||||
label: row.name,
|
||||
enabled: !!row.enabled,
|
||||
type: "ldap",
|
||||
})),
|
||||
verify: createLdapLogin(ctx, store),
|
||||
});
|
||||
|
||||
registerLdapRoutes(ctx.http.router<Router>(), ctx, store);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
// Everything above was registered through ctx and is disposed by core.
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* LDAP sign-in as a form login method: a service bind to find the user, a
|
||||
* bind as the user to check the password, then an optional admin group
|
||||
* lookup. Core finds or provisions the user and issues the session.
|
||||
*/
|
||||
|
||||
import ldap from "ldapjs";
|
||||
import {
|
||||
LoginMethodError,
|
||||
type PluginContext,
|
||||
type PluginLoginRequest,
|
||||
type PluginVerifiedIdentity,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { ProviderStore } from "./providers.js";
|
||||
import type { LdapConfig } from "./types.js";
|
||||
|
||||
export const METHOD_ID = "ldap";
|
||||
|
||||
export function ldapEscapeFilter(value: string): string {
|
||||
return value.replace(
|
||||
/[\\*()\x00]/g,
|
||||
(c) => `\\${c.charCodeAt(0).toString(16).padStart(2, "0")}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** The provider id identities from this directory are stored under. */
|
||||
export function identityProvider(providerId: number): string {
|
||||
return `ldap:${providerId}`;
|
||||
}
|
||||
|
||||
function createClient(config: LdapConfig): ldap.Client {
|
||||
const useTLS = !!config.useTLS;
|
||||
const url = `${useTLS ? "ldaps" : "ldap"}://${config.host}:${config.port || 389}`;
|
||||
return ldap.createClient({
|
||||
url,
|
||||
tlsOptions: useTLS ? { rejectUnauthorized: false } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function bind(client: ldap.Client, dn: string, password: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
client.bind(dn, password, (err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
function search(
|
||||
client: ldap.Client,
|
||||
base: string,
|
||||
filter: string,
|
||||
attributes: string[],
|
||||
): Promise<ldap.SearchEntry[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const entries: ldap.SearchEntry[] = [];
|
||||
client.search(base, { filter, attributes, scope: "sub" }, (err, res) => {
|
||||
if (err) return reject(err);
|
||||
res.on("searchEntry", (entry) => entries.push(entry));
|
||||
res.on("error", reject);
|
||||
res.on("end", () => resolve(entries));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function unbind(client: ldap.Client): void {
|
||||
try {
|
||||
client.unbind();
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
function firstValue(entry: ldap.SearchEntry, key: string): string {
|
||||
const attr = entry.attributes.find((a) => a.type === key);
|
||||
if (!attr) return "";
|
||||
return Array.isArray(attr.values) ? attr.values[0] : String(attr.values);
|
||||
}
|
||||
|
||||
export function createLdapLogin(ctx: PluginContext, store: ProviderStore) {
|
||||
return async function verify(
|
||||
request: PluginLoginRequest,
|
||||
instanceId: string | null,
|
||||
): Promise<PluginVerifiedIdentity> {
|
||||
const body = request.body ?? {};
|
||||
const providerId = Number(instanceId ?? body.providerId);
|
||||
const username = typeof body.username === "string" ? body.username : "";
|
||||
const password = typeof body.password === "string" ? body.password : "";
|
||||
|
||||
if (!Number.isInteger(providerId) || !username || !password) {
|
||||
throw new LoginMethodError(
|
||||
"providerId, username, and password are required",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
// Same limits as password login: an LDAP bind is a password guess.
|
||||
const clientIp = request.ip || "unknown";
|
||||
const rateLimitKey = `${providerId}:${username}`;
|
||||
const lock = await ctx.auth.loginRateLimit.isLocked(clientIp, rateLimitKey);
|
||||
if (lock.locked) {
|
||||
const error = new LoginMethodError(
|
||||
"Too many login attempts. Please try again later.",
|
||||
429,
|
||||
);
|
||||
Object.assign(error, { remainingTime: lock.remainingTime });
|
||||
throw error;
|
||||
}
|
||||
|
||||
const provider = await store.find(providerId);
|
||||
if (!provider || !provider.enabled) {
|
||||
throw new LoginMethodError("LDAP provider not found", 404);
|
||||
}
|
||||
const config = provider.config;
|
||||
if (
|
||||
!config.host ||
|
||||
!config.bindDN ||
|
||||
!config.userSearchBase ||
|
||||
!config.userSearchFilter
|
||||
) {
|
||||
throw new LoginMethodError("LDAP provider is misconfigured", 500);
|
||||
}
|
||||
|
||||
const refuse = async (reason: string) => {
|
||||
await ctx.auth.loginRateLimit.recordFailure(clientIp, rateLimitKey);
|
||||
ctx.log.warn(`LDAP login refused for ${username}: ${reason}`);
|
||||
return new LoginMethodError("Invalid username or password", 401);
|
||||
};
|
||||
|
||||
const serviceClient = createClient(config);
|
||||
try {
|
||||
await bind(serviceClient, config.bindDN, config.bindPassword);
|
||||
|
||||
const filter = config.userSearchFilter.replace(
|
||||
/\{\{username\}\}/g,
|
||||
ldapEscapeFilter(username),
|
||||
);
|
||||
const entries = await search(
|
||||
serviceClient,
|
||||
config.userSearchBase,
|
||||
filter,
|
||||
[
|
||||
config.usernameAttribute || "uid",
|
||||
config.displayNameAttribute || "cn",
|
||||
"mail",
|
||||
"email",
|
||||
],
|
||||
);
|
||||
if (entries.length === 0) throw await refuse("user not found");
|
||||
|
||||
const userEntry = entries[0];
|
||||
const userDN = userEntry.dn.toString();
|
||||
const uid =
|
||||
firstValue(userEntry, config.usernameAttribute || "uid") || username;
|
||||
const displayName =
|
||||
firstValue(userEntry, config.displayNameAttribute || "cn") || username;
|
||||
const email =
|
||||
firstValue(userEntry, "mail") || firstValue(userEntry, "email") || "";
|
||||
|
||||
const userClient = createClient(config);
|
||||
try {
|
||||
await bind(userClient, userDN, password);
|
||||
} catch {
|
||||
throw await refuse("wrong password");
|
||||
} finally {
|
||||
unbind(userClient);
|
||||
}
|
||||
|
||||
let isAdmin: boolean | undefined;
|
||||
if (config.adminGroup && config.groupSearchBase) {
|
||||
isAdmin = false;
|
||||
try {
|
||||
const groups = await search(
|
||||
serviceClient,
|
||||
config.groupSearchBase,
|
||||
`(member=${ldapEscapeFilter(userDN)})`,
|
||||
["cn", "dn"],
|
||||
);
|
||||
isAdmin = groups.some(
|
||||
(group) =>
|
||||
firstValue(group, "cn") === config.adminGroup ||
|
||||
group.dn.toString() === config.adminGroup,
|
||||
);
|
||||
} catch (error) {
|
||||
ctx.log.warn(`LDAP group check failed: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "external",
|
||||
provider: identityProvider(providerId),
|
||||
subject: uid,
|
||||
email: email || null,
|
||||
name: displayName,
|
||||
isAdmin,
|
||||
allowedUsers: config.allowedUsers ?? null,
|
||||
legacy: {
|
||||
identifier: `ldap:${providerId}:${uid}`,
|
||||
providerRowId: providerId,
|
||||
},
|
||||
rememberMe: !!body.rememberMe,
|
||||
rateLimitKey,
|
||||
};
|
||||
} finally {
|
||||
unbind(serviceClient);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { and, asc, desc, eq } from "drizzle-orm";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { LdapConfig, ProviderRow } from "./types.js";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// ctx.db.define hands the table back untyped and the drizzle handle is the
|
||||
// server's own, so both are typed at this module's edge.
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
const LEGACY_PREFIXES = ["encoded:", "encrypted:"];
|
||||
|
||||
export type ProviderStore = ReturnType<typeof createProviderStore>;
|
||||
|
||||
export function createProviderStore(ctx: PluginContext, table: Table) {
|
||||
const client = () => ctx.db.client<Drizzle>();
|
||||
|
||||
/** Opens the bind password: sealed, or the 2.8 base64 forms. */
|
||||
async function openSecret(value: string): Promise<string> {
|
||||
const prefix = LEGACY_PREFIXES.find((p) => value.startsWith(p));
|
||||
if (prefix) {
|
||||
return Buffer.from(value.slice(prefix.length), "base64").toString("utf8");
|
||||
}
|
||||
return (await ctx.secrets.unseal(value)) ?? value;
|
||||
}
|
||||
|
||||
async function parseConfig(raw: string): Promise<LdapConfig> {
|
||||
let config: Record<string, unknown>;
|
||||
try {
|
||||
config = JSON.parse(raw);
|
||||
} catch {
|
||||
config = {};
|
||||
}
|
||||
if (typeof config.bindPassword === "string" && config.bindPassword) {
|
||||
config.bindPassword = await openSecret(config.bindPassword);
|
||||
}
|
||||
return config as unknown as LdapConfig;
|
||||
}
|
||||
|
||||
async function sealConfig(config: Record<string, unknown>): Promise<string> {
|
||||
const out = { ...config };
|
||||
if (typeof out.bindPassword === "string" && out.bindPassword) {
|
||||
out.bindPassword = await ctx.secrets.seal(out.bindPassword);
|
||||
}
|
||||
return JSON.stringify(out);
|
||||
}
|
||||
|
||||
async function findRow(id: number): Promise<ProviderRow | null> {
|
||||
const drizzle = await client();
|
||||
const rows = (await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(eq(table.id, id))
|
||||
.limit(1)) as ProviderRow[];
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
parseConfig,
|
||||
findRow,
|
||||
|
||||
async listRows(): Promise<ProviderRow[]> {
|
||||
const drizzle = await client();
|
||||
return (await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.orderBy(asc(table.displayOrder), asc(table.id))) as ProviderRow[];
|
||||
},
|
||||
|
||||
async find(id: number): Promise<{
|
||||
row: ProviderRow;
|
||||
enabled: boolean;
|
||||
config: LdapConfig;
|
||||
} | null> {
|
||||
const row = await findRow(id);
|
||||
if (!row) return null;
|
||||
return {
|
||||
row,
|
||||
enabled: !!row.enabled,
|
||||
config: await parseConfig(row.config),
|
||||
};
|
||||
},
|
||||
|
||||
async create(input: {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
displayOrder: number;
|
||||
config: Record<string, unknown>;
|
||||
}): Promise<ProviderRow> {
|
||||
const drizzle = await client();
|
||||
const now = new Date().toISOString();
|
||||
await drizzle.insert(table).values({
|
||||
name: input.name,
|
||||
enabled: input.enabled,
|
||||
displayOrder: input.displayOrder,
|
||||
config: await sealConfig(input.config),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.persist();
|
||||
// No returning() on every engine: find the row by what was written.
|
||||
const rows = (await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(and(eq(table.name, input.name), eq(table.createdAt, now)))
|
||||
.orderBy(desc(table.id))
|
||||
.limit(1)) as ProviderRow[];
|
||||
return rows[0];
|
||||
},
|
||||
|
||||
async update(
|
||||
id: number,
|
||||
values: Partial<{
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
displayOrder: number;
|
||||
config: Record<string, unknown>;
|
||||
}>,
|
||||
): Promise<ProviderRow | null> {
|
||||
const drizzle = await client();
|
||||
const { config, ...rest } = values;
|
||||
await drizzle
|
||||
.update(table)
|
||||
.set({
|
||||
...rest,
|
||||
...(config ? { config: await sealConfig(config) } : {}),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(table.id, id));
|
||||
await ctx.db.persist();
|
||||
return findRow(id);
|
||||
},
|
||||
|
||||
async remove(id: number): Promise<void> {
|
||||
const drizzle = await client();
|
||||
await drizzle.delete(table).where(eq(table.id, id));
|
||||
await ctx.db.persist();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import type { Request, Response, Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { identityProvider } from "./ldap.js";
|
||||
import type { ProviderStore } from "./providers.js";
|
||||
import { REQUIRED_FIELDS, type ProviderRow } from "./types.js";
|
||||
|
||||
function fail(
|
||||
ctx: PluginContext,
|
||||
res: Response,
|
||||
message: string,
|
||||
error: unknown,
|
||||
) {
|
||||
ctx.log.error(
|
||||
message,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
|
||||
function missingFields(config: Record<string, unknown>): string[] {
|
||||
return REQUIRED_FIELDS.filter((field) => !config[field]);
|
||||
}
|
||||
|
||||
export function registerLdapRoutes(
|
||||
router: Router,
|
||||
ctx: PluginContext,
|
||||
store: ProviderStore,
|
||||
): void {
|
||||
router.use(ctx.rbac.require("manage") as never);
|
||||
|
||||
async function present(row: ProviderRow) {
|
||||
const { bindPassword, ...config } = (await store.parseConfig(
|
||||
row.config,
|
||||
)) as unknown as Record<string, unknown>;
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
enabled: !!row.enabled,
|
||||
displayOrder: row.displayOrder,
|
||||
config,
|
||||
hasBindPassword: typeof bindPassword === "string" && !!bindPassword,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ldap/providers:
|
||||
* get:
|
||||
* summary: List LDAP directories
|
||||
* description: Every configured LDAP directory. The bind password is never sent, only whether one is set. Needs ldap.manage.
|
||||
* tags:
|
||||
* - LDAP
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Directories.
|
||||
* 403:
|
||||
* description: Missing permission.
|
||||
*/
|
||||
router.get("/providers", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const rows = await store.listRows();
|
||||
res.json({ providers: await Promise.all(rows.map(present)) });
|
||||
} catch (error) {
|
||||
fail(ctx, res, "Failed to list LDAP providers", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ldap/providers:
|
||||
* post:
|
||||
* summary: Add an LDAP directory
|
||||
* description: Needs ldap.manage.
|
||||
* tags:
|
||||
* - LDAP
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* config:
|
||||
* type: object
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Directory added.
|
||||
* 400:
|
||||
* description: Missing fields.
|
||||
* 403:
|
||||
* description: Missing permission.
|
||||
*/
|
||||
router.post("/providers", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
enabled = true,
|
||||
displayOrder = 0,
|
||||
config = {},
|
||||
} = req.body ?? {};
|
||||
if (typeof name !== "string" || !name.trim()) {
|
||||
res.status(400).json({ error: "Provider name is required" });
|
||||
return;
|
||||
}
|
||||
const missing = missingFields(config);
|
||||
if (missing.length > 0) {
|
||||
res.status(400).json({
|
||||
error: `Missing required LDAP fields: ${missing.join(", ")}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const row = await store.create({
|
||||
name: name.trim(),
|
||||
enabled: !!enabled,
|
||||
displayOrder: Number(displayOrder) || 0,
|
||||
config: { ...(config as Record<string, unknown>) },
|
||||
});
|
||||
await ctx.audit.record({
|
||||
action: "ldap_provider_create",
|
||||
resourceType: "ldap_provider",
|
||||
resourceId: String(row.id),
|
||||
resourceName: row.name,
|
||||
success: true,
|
||||
});
|
||||
res.status(201).json(await present(row));
|
||||
} catch (error) {
|
||||
fail(ctx, res, "Failed to create LDAP provider", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ldap/providers/{id}:
|
||||
* put:
|
||||
* summary: Update an LDAP directory
|
||||
* description: Config fields are merged over the stored ones; an empty bind password keeps the stored one. Needs ldap.manage.
|
||||
* tags:
|
||||
* - LDAP
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Directory updated.
|
||||
* 400:
|
||||
* description: Missing fields.
|
||||
* 403:
|
||||
* description: Missing permission.
|
||||
* 404:
|
||||
* description: Directory not found.
|
||||
*/
|
||||
router.put("/providers/:id", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = Number.parseInt(String(req.params.id), 10);
|
||||
const row = Number.isInteger(id) ? await store.findRow(id) : null;
|
||||
if (!row) {
|
||||
res.status(404).json({ error: "Provider not found" });
|
||||
return;
|
||||
}
|
||||
const { name, enabled, displayOrder, config } = req.body ?? {};
|
||||
const values: Parameters<ProviderStore["update"]>[1] = {};
|
||||
if (name !== undefined) {
|
||||
if (typeof name !== "string" || !name.trim()) {
|
||||
res.status(400).json({ error: "Provider name is required" });
|
||||
return;
|
||||
}
|
||||
values.name = name.trim();
|
||||
}
|
||||
if (enabled !== undefined) values.enabled = !!enabled;
|
||||
if (displayOrder !== undefined) {
|
||||
values.displayOrder = Number(displayOrder) || 0;
|
||||
}
|
||||
if (config && typeof config === "object") {
|
||||
const incoming = { ...(config as Record<string, unknown>) };
|
||||
if (!incoming.bindPassword) delete incoming.bindPassword;
|
||||
const merged = {
|
||||
...((await store.parseConfig(row.config)) as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>),
|
||||
...incoming,
|
||||
};
|
||||
const missing = missingFields(merged);
|
||||
if (missing.length > 0) {
|
||||
res.status(400).json({
|
||||
error: `Missing required LDAP fields: ${missing.join(", ")}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
values.config = merged;
|
||||
}
|
||||
const updated = await store.update(id, values);
|
||||
await ctx.audit.record({
|
||||
action: "ldap_provider_update",
|
||||
resourceType: "ldap_provider",
|
||||
resourceId: String(id),
|
||||
resourceName: updated?.name ?? row.name,
|
||||
success: true,
|
||||
});
|
||||
res.json(await present(updated ?? row));
|
||||
} catch (error) {
|
||||
fail(ctx, res, "Failed to update LDAP provider", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ldap/providers/{id}:
|
||||
* delete:
|
||||
* summary: Delete an LDAP directory
|
||||
* description: Refused while users still sign in with it. Needs ldap.manage.
|
||||
* tags:
|
||||
* - LDAP
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Directory deleted.
|
||||
* 403:
|
||||
* description: Missing permission.
|
||||
* 404:
|
||||
* description: Directory not found.
|
||||
* 409:
|
||||
* description: Users are still linked to it.
|
||||
*/
|
||||
router.delete("/providers/:id", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = Number.parseInt(String(req.params.id), 10);
|
||||
const row = Number.isInteger(id) ? await store.findRow(id) : null;
|
||||
if (!row) {
|
||||
res.status(404).json({ error: "Provider not found" });
|
||||
return;
|
||||
}
|
||||
if ((await ctx.auth.countLinkedUsers(identityProvider(id))) > 0) {
|
||||
res.status(409).json({
|
||||
error:
|
||||
"Users still sign in with this directory. Disable it instead, or remove those users first.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
await store.remove(id);
|
||||
await ctx.audit.record({
|
||||
action: "ldap_provider_delete",
|
||||
resourceType: "ldap_provider",
|
||||
resourceId: String(id),
|
||||
resourceName: row.name,
|
||||
success: true,
|
||||
});
|
||||
res.json({ message: "LDAP provider deleted" });
|
||||
} catch (error) {
|
||||
fail(ctx, res, "Failed to delete LDAP provider", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
boolean,
|
||||
defineTable,
|
||||
id,
|
||||
integer,
|
||||
text,
|
||||
timestamp,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
|
||||
/**
|
||||
* LDAP directories. Rows from 2.8 are copied in from sso_providers by core's
|
||||
* boot migration, keeping their ids, because identities point at them.
|
||||
*/
|
||||
export const providers = defineTable("providers", {
|
||||
id: id(),
|
||||
name: text().notNull(),
|
||||
enabled: boolean().notNull().default(true),
|
||||
displayOrder: integer().notNull().default(0),
|
||||
config: text().notNull(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const tables = [providers];
|
||||
@@ -0,0 +1,34 @@
|
||||
export interface LdapConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
useTLS: boolean;
|
||||
bindDN: string;
|
||||
bindPassword: string;
|
||||
userSearchBase: string;
|
||||
userSearchFilter: string;
|
||||
usernameAttribute: string;
|
||||
displayNameAttribute: string;
|
||||
groupSearchBase?: string;
|
||||
adminGroup?: string;
|
||||
allowedUsers?: string;
|
||||
}
|
||||
|
||||
export interface ProviderRow {
|
||||
id: number;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
displayOrder: number;
|
||||
config: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export const REQUIRED_FIELDS = [
|
||||
"host",
|
||||
"port",
|
||||
"bindDN",
|
||||
"bindPassword",
|
||||
"userSearchBase",
|
||||
"userSearchFilter",
|
||||
"usernameAttribute",
|
||||
] as const;
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button, Input } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
useTranslation,
|
||||
type LoginMethodUIProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
|
||||
const labelClass =
|
||||
"text-[10px] font-bold uppercase tracking-widest text-muted-foreground";
|
||||
|
||||
/** A collapsible username and password form per enabled directory. */
|
||||
export function LdapLoginForms({
|
||||
instances,
|
||||
disabled,
|
||||
submit,
|
||||
}: LoginMethodUIProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
}, [expandedId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{instances.map((instance) => {
|
||||
const expanded = expandedId === instance.id;
|
||||
return (
|
||||
<div key={instance.id} className="flex flex-col border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedId(expanded ? null : instance.id)}
|
||||
className="flex items-center justify-between w-full px-3 py-2.5 text-xs font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<span>{t("loginWithProvider", { name: instance.label })}</span>
|
||||
{expanded ? (
|
||||
<ChevronUp className="size-3.5" />
|
||||
) : (
|
||||
<ChevronDown className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
{expanded && (
|
||||
<form
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
if (!username.trim() || !password) {
|
||||
toast.error(t("requiredField"));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await submit(
|
||||
{ username: username.trim(), password },
|
||||
instance.id,
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
className="flex flex-col gap-3 p-3 border-t border-border"
|
||||
>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className={labelClass}>{t("username")}</span>
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={disabled || loading}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className={labelClass}>{t("password")}</span>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={disabled || loading}
|
||||
className="font-mono"
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-accent-brand hover:bg-accent-brand/90 text-background font-bold"
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{loading ? t("loading") : t("signIn")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Switch,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { createLdapApi, type LdapProvider } from "./ldap-api";
|
||||
|
||||
type Fields = {
|
||||
host: string;
|
||||
port: string;
|
||||
useTLS: boolean;
|
||||
bindDN: string;
|
||||
bindPassword: string;
|
||||
userSearchBase: string;
|
||||
userSearchFilter: string;
|
||||
usernameAttribute: string;
|
||||
displayNameAttribute: string;
|
||||
groupSearchBase: string;
|
||||
adminGroup: string;
|
||||
allowedUsers: string;
|
||||
};
|
||||
|
||||
const EMPTY_FIELDS: Fields = {
|
||||
host: "",
|
||||
port: "389",
|
||||
useTLS: false,
|
||||
bindDN: "",
|
||||
bindPassword: "",
|
||||
userSearchBase: "",
|
||||
userSearchFilter: "(uid={{username}})",
|
||||
usernameAttribute: "uid",
|
||||
displayNameAttribute: "cn",
|
||||
groupSearchBase: "",
|
||||
adminGroup: "",
|
||||
allowedUsers: "",
|
||||
};
|
||||
|
||||
function errorMessage(error: unknown, fallback: string): string {
|
||||
const err = error as {
|
||||
response?: { data?: { error?: string } };
|
||||
message?: string;
|
||||
};
|
||||
return err.response?.data?.error || err.message || fallback;
|
||||
}
|
||||
|
||||
const labelClass =
|
||||
"text-[10px] font-semibold text-muted-foreground uppercase tracking-widest";
|
||||
|
||||
function Field({
|
||||
label,
|
||||
required,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className={labelClass}>
|
||||
{label}
|
||||
{required && <span className="text-accent-brand ml-1">*</span>}
|
||||
</label>
|
||||
{hint && (
|
||||
<span className="text-[10px] text-muted-foreground">{hint}</span>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
provider,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
provider: LdapProvider | null;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const api = createLdapApi(usePluginApi());
|
||||
const [name, setName] = useState("");
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [fields, setFields] = useState<Fields>(EMPTY_FIELDS);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(provider?.name ?? "");
|
||||
setEnabled(provider?.enabled ?? true);
|
||||
const config = provider?.config ?? {};
|
||||
const next = { ...EMPTY_FIELDS };
|
||||
for (const key of Object.keys(next) as Array<keyof Fields>) {
|
||||
const value = config[key];
|
||||
if (key === "useTLS") next.useTLS = value === true;
|
||||
else if (key === "port" && value !== undefined) next.port = String(value);
|
||||
else if (typeof value === "string") next[key] = value as never;
|
||||
}
|
||||
next.bindPassword = "";
|
||||
setFields(next);
|
||||
}, [open, provider]);
|
||||
|
||||
const set = (key: Exclude<keyof Fields, "useTLS">) => (value: string) =>
|
||||
setFields((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
async function save() {
|
||||
if (!name.trim()) {
|
||||
toast.error(t("providers.nameRequired"));
|
||||
return;
|
||||
}
|
||||
const config: Record<string, unknown> = {
|
||||
host: fields.host.trim(),
|
||||
port: Number.parseInt(fields.port, 10) || 389,
|
||||
useTLS: fields.useTLS,
|
||||
bindDN: fields.bindDN.trim(),
|
||||
userSearchBase: fields.userSearchBase.trim(),
|
||||
userSearchFilter: fields.userSearchFilter.trim(),
|
||||
usernameAttribute: fields.usernameAttribute.trim() || "uid",
|
||||
displayNameAttribute: fields.displayNameAttribute.trim() || "cn",
|
||||
groupSearchBase: fields.groupSearchBase.trim() || undefined,
|
||||
adminGroup: fields.adminGroup.trim() || undefined,
|
||||
allowedUsers: fields.allowedUsers.trim() || undefined,
|
||||
};
|
||||
if (fields.bindPassword) config.bindPassword = fields.bindPassword;
|
||||
setSaving(true);
|
||||
try {
|
||||
const input = { name: name.trim(), enabled, config };
|
||||
if (provider) await api.update(provider.id, input);
|
||||
else await api.create(input);
|
||||
toast.success(t("providers.saved"));
|
||||
onSaved();
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error, t("providers.saveFailed")));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{provider ? t("providers.edit") : t("providers.add")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<Field label={t("providers.name")} required>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("providers.namePlaceholder")}
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className={labelClass}>{t("providers.enabled")}</label>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 border-t border-border pt-3">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
<a
|
||||
href="https://docs.termix.site/features/authentication/ldap"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent-brand hover:underline"
|
||||
>
|
||||
{t("providers.docsLink")}
|
||||
</a>
|
||||
</span>
|
||||
<Field label={t("fields.host")} required>
|
||||
<Input
|
||||
value={fields.host}
|
||||
onChange={(e) => set("host")(e.target.value)}
|
||||
placeholder="ldap.example.com"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Field label={t("fields.port")} required>
|
||||
<Input
|
||||
value={fields.port}
|
||||
onChange={(e) => set("port")(e.target.value)}
|
||||
placeholder="389"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex items-end pb-1.5 gap-2">
|
||||
<Switch
|
||||
checked={fields.useTLS}
|
||||
onCheckedChange={(useTLS) =>
|
||||
setFields((prev) => ({ ...prev, useTLS }))
|
||||
}
|
||||
aria-label={t("fields.useTls")}
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("fields.useTls")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Field label={t("fields.bindDn")} required>
|
||||
<Input
|
||||
value={fields.bindDN}
|
||||
onChange={(e) => set("bindDN")(e.target.value)}
|
||||
placeholder="cn=admin,dc=example,dc=com"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("fields.bindPassword")}
|
||||
required={!provider?.hasBindPassword}
|
||||
hint={
|
||||
provider?.hasBindPassword ? t("fields.secretKeep") : undefined
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={fields.bindPassword}
|
||||
onChange={(e) => set("bindPassword")(e.target.value)}
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.userSearchBase")} required>
|
||||
<Input
|
||||
value={fields.userSearchBase}
|
||||
onChange={(e) => set("userSearchBase")(e.target.value)}
|
||||
placeholder="ou=users,dc=example,dc=com"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.userSearchFilter")} required>
|
||||
<Input
|
||||
value={fields.userSearchFilter}
|
||||
onChange={(e) => set("userSearchFilter")(e.target.value)}
|
||||
placeholder="(uid={{username}})"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.usernameAttr")} required>
|
||||
<Input
|
||||
value={fields.usernameAttribute}
|
||||
onChange={(e) => set("usernameAttribute")(e.target.value)}
|
||||
placeholder="uid"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.displayNameAttr")} required>
|
||||
<Input
|
||||
value={fields.displayNameAttribute}
|
||||
onChange={(e) => set("displayNameAttribute")(e.target.value)}
|
||||
placeholder="cn"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<div className="border-t border-border pt-3 flex flex-col gap-3">
|
||||
<Field label={t("fields.groupSearchBase")}>
|
||||
<Input
|
||||
value={fields.groupSearchBase}
|
||||
onChange={(e) => set("groupSearchBase")(e.target.value)}
|
||||
placeholder="ou=groups,dc=example,dc=com"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.adminGroup")}>
|
||||
<Input
|
||||
value={fields.adminGroup}
|
||||
onChange={(e) => set("adminGroup")(e.target.value)}
|
||||
placeholder="cn=admins,ou=groups,dc=example,dc=com"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.allowedUsers")}>
|
||||
<Input
|
||||
value={fields.allowedUsers}
|
||||
onChange={(e) => set("allowedUsers")(e.target.value)}
|
||||
placeholder="user1,user2,@domain.com"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("providers.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? t("providers.saving") : t("providers.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The "providers" custom field on the plugin's admin settings page: the
|
||||
* directory list with an editor.
|
||||
*/
|
||||
export function ProvidersSetting() {
|
||||
const { t } = useTranslation();
|
||||
const api = createLdapApi(usePluginApi());
|
||||
const [providers, setProviders] = useState<LdapProvider[] | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<LdapProvider | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setProviders(await api.list());
|
||||
} catch (error) {
|
||||
setProviders([]);
|
||||
toast.error(errorMessage(error, t("providers.loadFailed")));
|
||||
}
|
||||
// api is rebuilt each render from the same client.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function toggleEnabled(provider: LdapProvider) {
|
||||
try {
|
||||
await api.update(provider.id, { enabled: !provider.enabled });
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error, t("providers.saveFailed")));
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(provider: LdapProvider) {
|
||||
if (!window.confirm(t("providers.deleteConfirm"))) return;
|
||||
try {
|
||||
await api.remove(provider.id);
|
||||
toast.success(t("providers.deleted"));
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error, t("providers.deleteFailed")));
|
||||
}
|
||||
}
|
||||
|
||||
if (providers === null) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{providers.length === 0 ? (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("providers.none")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{providers.map((provider) => (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="flex items-center gap-2 p-2 border border-border bg-background"
|
||||
>
|
||||
<span className="flex-1 min-w-0 text-xs font-medium truncate">
|
||||
{provider.name}
|
||||
</span>
|
||||
<Switch
|
||||
checked={provider.enabled}
|
||||
onCheckedChange={() => void toggleEnabled(provider)}
|
||||
aria-label={t("providers.enabled")}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setEditing(provider);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
title={t("providers.edit")}
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => void remove(provider)}
|
||||
title={t("providers.delete")}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
{t("providers.add")}
|
||||
</Button>
|
||||
<ProviderDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
provider={editing}
|
||||
onSaved={() => void load()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import { LdapLoginForms } from "./LdapLoginForms";
|
||||
import { ProvidersSetting } from "./ProvidersSetting";
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
app.registerLoginMethod({
|
||||
id: "ldap",
|
||||
titleKey: "loginWithLdap",
|
||||
component: LdapLoginForms,
|
||||
});
|
||||
app.registerSettingsComponent("providers", ProvidersSetting);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
export interface LdapProvider {
|
||||
id: number;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
displayOrder: number;
|
||||
config: Record<string, unknown>;
|
||||
hasBindPassword: boolean;
|
||||
}
|
||||
|
||||
export interface LdapProviderInput {
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Paths are relative to /plugin-api/ldap/. */
|
||||
export function createLdapApi(api: PluginApiClient) {
|
||||
return {
|
||||
async list() {
|
||||
return (await api.get<{ providers: LdapProvider[] }>("/providers")).data
|
||||
.providers;
|
||||
},
|
||||
async create(input: LdapProviderInput) {
|
||||
return (await api.post<LdapProvider>("/providers", input)).data;
|
||||
},
|
||||
async update(id: number, input: LdapProviderInput) {
|
||||
return (await api.put<LdapProvider>(`/providers/${id}`, input)).data;
|
||||
},
|
||||
async remove(id: number) {
|
||||
await api.delete(`/providers/${id}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user