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:
61
backend/migrations/20260625000000-create-ldap-settings.js
Normal file
61
backend/migrations/20260625000000-create-ldap-settings.js
Normal file
@@ -0,0 +1,61 @@
|
||||
export async function up(queryInterface, Sequelize) {
|
||||
await queryInterface.createTable('ldapSettings', {
|
||||
id: {
|
||||
type: Sequelize.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
enabled: {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
url: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: '',
|
||||
},
|
||||
bindDn: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: '',
|
||||
},
|
||||
bindCredentials: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: '',
|
||||
},
|
||||
searchBase: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: '',
|
||||
},
|
||||
searchFilter: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: '(mail={{username}})',
|
||||
},
|
||||
emailAttribute: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'mail',
|
||||
},
|
||||
usernameAttribute: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'cn',
|
||||
},
|
||||
createdAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(queryInterface) {
|
||||
await queryInterface.dropTable('ldapSettings');
|
||||
}
|
||||
49
backend/models/ldapSettings.js
Normal file
49
backend/models/ldapSettings.js
Normal file
@@ -0,0 +1,49 @@
|
||||
function defineLdapSetting(sequelize, DataTypes) {
|
||||
const LdapSetting = sequelize.define('LdapSetting', {
|
||||
enabled: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
url: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: '',
|
||||
},
|
||||
bindDn: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: '',
|
||||
},
|
||||
bindCredentials: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: '',
|
||||
},
|
||||
searchBase: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: '',
|
||||
},
|
||||
// {{username}} is replaced with the value typed in the sign-in form.
|
||||
searchFilter: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: '(mail={{username}})',
|
||||
},
|
||||
emailAttribute: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'mail',
|
||||
},
|
||||
usernameAttribute: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'cn',
|
||||
},
|
||||
});
|
||||
|
||||
return LdapSetting;
|
||||
}
|
||||
|
||||
export default defineLdapSetting;
|
||||
77
backend/package-lock.json
generated
77
backend/package-lock.json
generated
@@ -14,6 +14,7 @@
|
||||
"express": "^4.21.0",
|
||||
"express-rate-limit": "^7.4.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"ldapts": "^8.1.8",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"papaparse": "^5.5.2",
|
||||
"sequelize": "^6.37.7",
|
||||
@@ -1010,6 +1011,29 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
|
||||
@@ -1099,7 +1123,6 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
@@ -1840,6 +1863,18 @@
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ldapts": {
|
||||
"version": "8.1.8",
|
||||
"resolved": "https://registry.npmjs.org/ldapts/-/ldapts-8.1.8.tgz",
|
||||
"integrity": "sha512-Wpdvo+/0DREIynYf2he2efHtjptr5zD7dpesSkZWJqfQdMEgSTytEIsSZVuoDSiiE1+SpXf3emZ7ewxGBTo7Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"strict-event-emitter-types": "2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
@@ -3068,6 +3103,12 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strict-event-emitter-types": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz",
|
||||
"integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
@@ -4266,6 +4307,26 @@
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="
|
||||
},
|
||||
"encoding": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"iconv-lite": "^0.6.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"end-of-stream": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
|
||||
@@ -4328,7 +4389,6 @@
|
||||
"version": "4.21.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
@@ -4863,6 +4923,14 @@
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"ldapts": {
|
||||
"version": "8.1.8",
|
||||
"resolved": "https://registry.npmjs.org/ldapts/-/ldapts-8.1.8.tgz",
|
||||
"integrity": "sha512-Wpdvo+/0DREIynYf2he2efHtjptr5zD7dpesSkZWJqfQdMEgSTytEIsSZVuoDSiiE1+SpXf3emZ7ewxGBTo7Pw==",
|
||||
"requires": {
|
||||
"strict-event-emitter-types": "2.0.0"
|
||||
}
|
||||
},
|
||||
"lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
@@ -5707,6 +5775,11 @@
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="
|
||||
},
|
||||
"strict-event-emitter-types": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz",
|
||||
"integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA=="
|
||||
},
|
||||
"string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"express": "^4.21.0",
|
||||
"express-rate-limit": "^7.4.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"ldapts": "^8.1.8",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"papaparse": "^5.5.2",
|
||||
"sequelize": "^6.37.7",
|
||||
|
||||
48
backend/routes/ldap/edit.js
Normal file
48
backend/routes/ldap/edit.js
Normal 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;
|
||||
}
|
||||
33
backend/routes/ldap/index.js
Normal file
33
backend/routes/ldap/index.js
Normal 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 };
|
||||
97
backend/routes/ldap/ldapClient.js
Normal file
97
backend/routes/ldap/ldapClient.js
Normal 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 };
|
||||
14
backend/routes/ldap/ldapClient.test.js
Normal file
14
backend/routes/ldap/ldapClient.test.js
Normal 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)');
|
||||
});
|
||||
});
|
||||
57
backend/routes/ldap/test.js
Normal file
57
backend/routes/ldap/test.js
Normal 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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -70,6 +70,14 @@ app.use('/users', usersUpdateRoleRoute(sequelize));
|
||||
app.use('/users', signUpRoute(sequelize));
|
||||
app.use('/users', signInRoute(sequelize));
|
||||
|
||||
// "/ldap"
|
||||
import ldapIndexRoute from './routes/ldap/index.js';
|
||||
import ldapEditRoute from './routes/ldap/edit.js';
|
||||
import ldapTestRoute from './routes/ldap/test.js';
|
||||
app.use('/ldap', ldapIndexRoute(sequelize));
|
||||
app.use('/ldap', ldapEditRoute(sequelize));
|
||||
app.use('/ldap', ldapTestRoute(sequelize));
|
||||
|
||||
// "/projects"
|
||||
import projectsIndexRoute from './routes/projects/index.js';
|
||||
import projectsShowRoute from './routes/projects/show.js';
|
||||
|
||||
Reference in New Issue
Block a user