feat: add LDAP authentication settings and toggle

Add an admin-only LDAP configuration UI with an enable toggle and full
sign-in integration.

Backend:
- ldapSettings model + migration (single-row config)
- GET/PUT/test routes under /ldap (admin-gated; bind password masked)
- shared ldapClient with RFC 4515 filter escaping and empty-password guard
- signin tries local auth first, then LDAP when enabled (find-or-create
  local user) so the bootstrap admin is never locked out

Frontend:
- LDAP settings page (Switch + form + test connection) under /admin/ldap
- AdminNav tabs between user management and LDAP
- ldapControl util, types, and Ldap i18n namespace for all 6 locales

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
LittleYellow
2026-06-26 07:20:08 +08:00
parent 02fa631f02
commit 1c977f9266
25 changed files with 992 additions and 19 deletions

View File

@@ -0,0 +1,57 @@
import express from 'express';
import { DataTypes } from 'sequelize';
import { Client } from 'ldapts';
import defineLdapSetting from '../../models/ldapSettings.js';
import authMiddleware from '../../middleware/auth.js';
import { authenticateLdap, getLdapSettings } from './ldapClient.js';
const router = express.Router();
export default function (sequelize) {
const { verifySignedIn, verifyAdmin } = authMiddleware(sequelize);
const LdapSetting = defineLdapSetting(sequelize, DataTypes);
router.post('/test', verifySignedIn, verifyAdmin, async (req, res) => {
try {
const stored = await getLdapSettings(LdapSetting);
// Use the values from the form, falling back to the stored bind
// password when the (masked) field is left empty.
const settings = {
url: req.body.url ?? stored.url,
bindDn: req.body.bindDn ?? stored.bindDn,
bindCredentials: req.body.bindCredentials || stored.bindCredentials,
searchBase: req.body.searchBase ?? stored.searchBase,
searchFilter: req.body.searchFilter ?? stored.searchFilter,
emailAttribute: req.body.emailAttribute ?? stored.emailAttribute,
usernameAttribute: req.body.usernameAttribute ?? stored.usernameAttribute,
};
if (!settings.url) {
return res.status(400).json({ ok: false, message: 'Server URL is required' });
}
const { testUsername, testPassword } = req.body;
if (testUsername && testPassword) {
const user = await authenticateLdap(settings, testUsername, testPassword);
return res.json({ ok: true, user });
}
// No test user supplied: just verify connectivity and the service bind.
const client = new Client({ url: settings.url });
try {
await client.bind(settings.bindDn, settings.bindCredentials);
} finally {
try {
await client.unbind();
} catch {
// ignore
}
}
return res.json({ ok: true });
} catch (error) {
return res.status(200).json({ ok: false, message: error.message || 'LDAP test failed' });
}
});
return router;
}