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,48 @@
import express from 'express';
import { DataTypes } from 'sequelize';
import defineLdapSetting from '../../models/ldapSettings.js';
import authMiddleware from '../../middleware/auth.js';
import { getLdapSettings } from './ldapClient.js';
import { serialize } from './index.js';
const router = express.Router();
const editableFields = [
'enabled',
'url',
'bindDn',
'searchBase',
'searchFilter',
'emailAttribute',
'usernameAttribute',
];
export default function (sequelize) {
const { verifySignedIn, verifyAdmin } = authMiddleware(sequelize);
const LdapSetting = defineLdapSetting(sequelize, DataTypes);
router.put('/', verifySignedIn, verifyAdmin, async (req, res) => {
try {
const settings = await getLdapSettings(LdapSetting);
const updates = {};
for (const field of editableFields) {
if (req.body[field] !== undefined) {
updates[field] = req.body[field];
}
}
// Only overwrite the bind password when a non-empty value is supplied,
// so the masked GET value can be saved back without wiping it.
if (req.body.bindCredentials) {
updates.bindCredentials = req.body.bindCredentials;
}
await settings.update(updates);
res.json(serialize(settings));
} catch (error) {
console.error(error);
res.status(500).send('Internal Server Error');
}
});
return router;
}

View File

@@ -0,0 +1,33 @@
import express from 'express';
import { DataTypes } from 'sequelize';
import defineLdapSetting from '../../models/ldapSettings.js';
import authMiddleware from '../../middleware/auth.js';
import { getLdapSettings } from './ldapClient.js';
const router = express.Router();
// Never expose the stored bind password. Report only whether one is set.
function serialize(settings) {
const json = settings.toJSON();
json.bindCredentialsSet = Boolean(json.bindCredentials);
json.bindCredentials = '';
return json;
}
export default function (sequelize) {
const { verifySignedIn, verifyAdmin } = authMiddleware(sequelize);
const LdapSetting = defineLdapSetting(sequelize, DataTypes);
router.get('/', verifySignedIn, verifyAdmin, async (req, res) => {
try {
const settings = await getLdapSettings(LdapSetting);
res.json(serialize(settings));
} catch (error) {
console.error(error);
res.status(500).send('Internal Server Error');
}
});
return router;
}
export { serialize };

View File

@@ -0,0 +1,97 @@
import { Client } from 'ldapts';
/**
* Escape a value before putting it into an LDAP search filter (RFC 4515).
* Prevents LDAP filter injection through the typed username.
*/
function escapeFilterValue(value) {
return String(value).replace(/[\\*()]/g, (char) => {
switch (char) {
case '\\':
return '\\5c';
case '*':
return '\\2a';
case '(':
return '\\28';
case ')':
return '\\29';
default:
return char;
}
});
}
function buildFilter(template, username) {
return template.replace(/\{\{username\}\}/g, escapeFilterValue(username));
}
function firstValue(attributeValue) {
if (Array.isArray(attributeValue)) {
return attributeValue.length > 0 ? attributeValue[0].toString() : '';
}
return attributeValue !== undefined && attributeValue !== null ? attributeValue.toString() : '';
}
/**
* Get the single-row LDAP settings, creating defaults on first access.
*/
async function getLdapSettings(LdapSetting) {
let settings = await LdapSetting.findOne({ order: [['id', 'ASC']] });
if (!settings) {
settings = await LdapSetting.create({});
}
return settings;
}
/**
* Authenticate a user against LDAP.
* Binds with the service account, searches for the user, then binds as
* the user to verify the password. Returns { email, username } on success.
* Throws an Error with a human-readable message on any failure.
*/
async function authenticateLdap(settings, username, password) {
// Reject empty password: many directories treat an empty password as an
// anonymous (unauthenticated) bind, which would be an auth bypass.
if (!password) {
throw new Error('Password is required');
}
if (!settings.url || !settings.searchBase) {
throw new Error('LDAP is not configured');
}
const client = new Client({ url: settings.url });
try {
await client.bind(settings.bindDn, settings.bindCredentials);
const filter = buildFilter(settings.searchFilter, username);
const { searchEntries } = await client.search(settings.searchBase, {
scope: 'sub',
filter,
attributes: [settings.emailAttribute, settings.usernameAttribute],
});
if (searchEntries.length === 0) {
throw new Error('User not found in directory');
}
const entry = searchEntries[0];
// Verify the password by binding as the found user.
await client.bind(entry.dn, password);
const email = firstValue(entry[settings.emailAttribute]);
if (!email) {
throw new Error(`LDAP entry has no "${settings.emailAttribute}" attribute`);
}
const displayName = firstValue(entry[settings.usernameAttribute]) || email;
return { email, username: displayName };
} finally {
try {
await client.unbind();
} catch {
// ignore unbind errors
}
}
}
export { authenticateLdap, getLdapSettings, buildFilter, escapeFilterValue };

View File

@@ -0,0 +1,14 @@
import { describe, it, expect } from 'vitest';
import { buildFilter, escapeFilterValue } from './ldapClient.js';
describe('LDAP filter building', () => {
it('substitutes {{username}} into the filter template', () => {
expect(buildFilter('(mail={{username}})', 'alice@example.com')).toBe('(mail=alice@example.com)');
});
it('escapes RFC 4515 special characters to prevent filter injection', () => {
// An attacker-supplied "*" or parentheses must not alter the filter shape.
expect(escapeFilterValue('*)(uid=*')).toBe('\\2a\\29\\28uid=\\2a');
expect(buildFilter('(uid={{username}})', 'a*b')).toBe('(uid=a\\2ab)');
});
});

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;
}

View File

@@ -4,37 +4,65 @@ import { DataTypes } from 'sequelize';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import defineUser from '../../models/users.js';
import { defaultDangerKey } from './authSettings.js';
import defineLdapSetting from '../../models/ldapSettings.js';
import { authenticateLdap, getLdapSettings } from '../ldap/ldapClient.js';
import { roles, defaultDangerKey } from './authSettings.js';
export default function (sequelize) {
const User = defineUser(sequelize, DataTypes);
const LdapSetting = defineLdapSetting(sequelize, DataTypes);
const secretKey = process.env.SECRET_KEY || defaultDangerKey;
function issueToken(res, user) {
const accessToken = jwt.sign({ userId: user.id }, secretKey, {
expiresIn: '24h',
});
const expiresAt = Date.now() + 3600 * 1000 * 24; // expire date(ms)
res.status(200).json({ access_token: accessToken, expires_at: expiresAt, user });
}
router.post('/signin', async (req, res) => {
try {
const { email, password } = req.body;
const user = await User.findOne({
where: {
email: email,
},
});
if (!user) {
return res.status(401).json({ error: 'Authentication failed' });
// Local authentication first. This keeps existing accounts (including
// the bootstrap administrator) usable even when LDAP is enabled.
const user = await User.findOne({ where: { email } });
if (user) {
const passwordMatch = await bcrypt.compare(password, user.password);
if (passwordMatch) {
return issueToken(res, user);
}
}
const passwordMatch = await bcrypt.compare(password, user.password);
if (!passwordMatch) {
return res.status(401).json({ error: 'Authentication failed' });
// Fall back to LDAP when enabled. The email field carries the LDAP login
// identifier (substituted into the configured search filter).
const ldapSettings = await getLdapSettings(LdapSetting);
if (ldapSettings.enabled) {
try {
const ldapUser = await authenticateLdap(ldapSettings, email, password);
// Find-or-create a local user keyed by the directory email.
let localUser = user || (await User.findOne({ where: { email: ldapUser.email } }));
if (!localUser) {
const userRoleIndex = roles.findIndex((entry) => entry.uid === 'user');
const randomPassword = await bcrypt.hash(jwt.sign({ t: Date.now() }, secretKey), 10);
localUser = await User.create({
email: ldapUser.email,
password: randomPassword,
username: ldapUser.username,
role: userRoleIndex,
});
}
return issueToken(res, localUser);
} catch (ldapError) {
console.error('LDAP authentication failed:', ldapError.message);
}
}
const accessToken = jwt.sign({ userId: user.id }, secretKey, {
expiresIn: '24h',
});
const expiresAt = Date.now() + 3600 * 1000 * 24; // expire date(ms)
res.status(200).json({ access_token: accessToken, expires_at: expiresAt, user });
return res.status(401).json({ error: 'Authentication failed' });
} catch (error) {
console.error(error);
res.status(500).send('Sign up failed');
res.status(500).send('Sign in failed');
}
});