diff --git a/backend/migrations/20260625000000-create-ldap-settings.js b/backend/migrations/20260625000000-create-ldap-settings.js new file mode 100644 index 0000000..64b5f02 --- /dev/null +++ b/backend/migrations/20260625000000-create-ldap-settings.js @@ -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'); +} diff --git a/backend/models/ldapSettings.js b/backend/models/ldapSettings.js new file mode 100644 index 0000000..238b093 --- /dev/null +++ b/backend/models/ldapSettings.js @@ -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; diff --git a/backend/package-lock.json b/backend/package-lock.json index e5ae6ac..e8cae09 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 07f13f7..d6f1aab 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/routes/ldap/edit.js b/backend/routes/ldap/edit.js new file mode 100644 index 0000000..689b658 --- /dev/null +++ b/backend/routes/ldap/edit.js @@ -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; +} diff --git a/backend/routes/ldap/index.js b/backend/routes/ldap/index.js new file mode 100644 index 0000000..f8e880e --- /dev/null +++ b/backend/routes/ldap/index.js @@ -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 }; diff --git a/backend/routes/ldap/ldapClient.js b/backend/routes/ldap/ldapClient.js new file mode 100644 index 0000000..7fd0ba6 --- /dev/null +++ b/backend/routes/ldap/ldapClient.js @@ -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 }; diff --git a/backend/routes/ldap/ldapClient.test.js b/backend/routes/ldap/ldapClient.test.js new file mode 100644 index 0000000..67aa926 --- /dev/null +++ b/backend/routes/ldap/ldapClient.test.js @@ -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)'); + }); +}); diff --git a/backend/routes/ldap/test.js b/backend/routes/ldap/test.js new file mode 100644 index 0000000..66cb474 --- /dev/null +++ b/backend/routes/ldap/test.js @@ -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; +} diff --git a/backend/routes/users/signin.js b/backend/routes/users/signin.js index ae5f3f3..7fc098f 100644 --- a/backend/routes/users/signin.js +++ b/backend/routes/users/signin.js @@ -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'); } }); diff --git a/backend/server.js b/backend/server.js index 4f0a894..8492ea0 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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'; diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 97680f4..876621f 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -149,6 +149,7 @@ }, "Admin": { "user_management": "Benutzerverwaltung", + "ldap": "LDAP", "avatar": "Avatar", "id": "ID", "email": "E-Mail", @@ -434,5 +435,27 @@ "tag_error_create": "Tag konnte nicht erstellt werden. Bitte versuche es erneut.", "tag_error_update": "Tag konnte nicht aktualisiert werden. Bitte versuche es erneut.", "tag_error_delete": "Tag konnte nicht gelöscht werden. Bitte versuche es erneut." + }, + "Ldap": { + "ldap_settings": "LDAP-Einstellungen", + "ldap_description": "Benutzer über ein LDAP-Verzeichnis authentifizieren.", + "enable_ldap": "LDAP aktivieren", + "enable_ldap_desc": "Benutzern erlauben, sich mit ihren Verzeichnis-Anmeldedaten anzumelden.", + "server_url": "Server-URL", + "bind_dn": "Bind-DN", + "bind_credentials": "Bind-Passwort", + "bind_credentials_placeholder": "Leer lassen, um das aktuelle Passwort beizubehalten", + "search_base": "Suchbasis", + "search_filter": "Suchfilter", + "email_attribute": "E-Mail-Attribut", + "username_attribute": "Benutzername-Attribut", + "save": "Speichern", + "saved": "LDAP-Einstellungen gespeichert", + "save_error": "LDAP-Einstellungen konnten nicht gespeichert werden", + "test_connection": "Verbindung testen", + "test_username": "Test-Benutzername", + "test_password": "Test-Passwort", + "test_success": "Verbindung erfolgreich", + "test_failed": "Verbindung fehlgeschlagen" } } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index c2cebf4..6dd0e79 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -149,6 +149,7 @@ }, "Admin": { "user_management": "User Management", + "ldap": "LDAP", "avatar": "Avatar", "id": "ID", "email": "Email", @@ -434,5 +435,27 @@ "tag_error_create": "Failed to create tag. Please try again.", "tag_error_update": "Failed to update tag. Please try again.", "tag_error_delete": "Failed to delete tag. Please try again." + }, + "Ldap": { + "ldap_settings": "LDAP Settings", + "ldap_description": "Authenticate users against an LDAP directory.", + "enable_ldap": "Enable LDAP", + "enable_ldap_desc": "Allow users to sign in with their directory credentials.", + "server_url": "Server URL", + "bind_dn": "Bind DN", + "bind_credentials": "Bind Password", + "bind_credentials_placeholder": "Leave blank to keep current password", + "search_base": "Search Base", + "search_filter": "Search Filter", + "email_attribute": "Email Attribute", + "username_attribute": "Username Attribute", + "save": "Save", + "saved": "LDAP settings saved", + "save_error": "Failed to save LDAP settings", + "test_connection": "Test Connection", + "test_username": "Test Username", + "test_password": "Test Password", + "test_success": "Connection successful", + "test_failed": "Connection failed" } } diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index 6ea13a8..c772c02 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -149,6 +149,7 @@ }, "Admin": { "user_management": "ユーザー管理", + "ldap": "LDAP", "avatar": "アバター", "id": "ID", "email": "メールアドレス", @@ -434,5 +435,27 @@ "tag_error_create": "タグの作成に失敗しました。もう一度お試しください。", "tag_error_update": "タグの更新に失敗しました。もう一度お試しください。", "tag_error_delete": "タグの削除に失敗しました。もう一度お試しください。" + }, + "Ldap": { + "ldap_settings": "LDAP 設定", + "ldap_description": "LDAP ディレクトリでユーザーを認証します。", + "enable_ldap": "LDAP を有効化", + "enable_ldap_desc": "ユーザーがディレクトリの資格情報でサインインできるようにします。", + "server_url": "サーバー URL", + "bind_dn": "バインド DN", + "bind_credentials": "バインドパスワード", + "bind_credentials_placeholder": "現在のパスワードを保持する場合は空欄", + "search_base": "検索ベース", + "search_filter": "検索フィルター", + "email_attribute": "メール属性", + "username_attribute": "ユーザー名属性", + "save": "保存", + "saved": "LDAP 設定を保存しました", + "save_error": "LDAP 設定の保存に失敗しました", + "test_connection": "接続テスト", + "test_username": "テストユーザー名", + "test_password": "テストパスワード", + "test_success": "接続に成功しました", + "test_failed": "接続に失敗しました" } } diff --git a/frontend/messages/pt-BR.json b/frontend/messages/pt-BR.json index ce0ca77..33b46b8 100644 --- a/frontend/messages/pt-BR.json +++ b/frontend/messages/pt-BR.json @@ -149,6 +149,7 @@ }, "Admin": { "user_management": "Gerenciamento de Usuários", + "ldap": "LDAP", "avatar": "Avatar", "id": "ID", "email": "E-mail", @@ -434,5 +435,27 @@ "tag_error_create": "Falha ao criar tag. Por favor, tente novamente.", "tag_error_update": "Falha ao atualizar tag. Por favor, tente novamente.", "tag_error_delete": "Falha ao excluir tag. Por favor, tente novamente." + }, + "Ldap": { + "ldap_settings": "Configurações de LDAP", + "ldap_description": "Autenticar usuários em um diretório LDAP.", + "enable_ldap": "Ativar LDAP", + "enable_ldap_desc": "Permitir que os usuários entrem com suas credenciais do diretório.", + "server_url": "URL do servidor", + "bind_dn": "Bind DN", + "bind_credentials": "Senha de Bind", + "bind_credentials_placeholder": "Deixe em branco para manter a senha atual", + "search_base": "Base de busca", + "search_filter": "Filtro de busca", + "email_attribute": "Atributo de e-mail", + "username_attribute": "Atributo de nome de usuário", + "save": "Salvar", + "saved": "Configurações de LDAP salvas", + "save_error": "Falha ao salvar as configurações de LDAP", + "test_connection": "Testar conexão", + "test_username": "Usuário de teste", + "test_password": "Senha de teste", + "test_success": "Conexão bem-sucedida", + "test_failed": "Falha na conexão" } } diff --git a/frontend/messages/zh-CN.json b/frontend/messages/zh-CN.json index 911a55b..61656bc 100644 --- a/frontend/messages/zh-CN.json +++ b/frontend/messages/zh-CN.json @@ -149,6 +149,7 @@ }, "Admin": { "user_management": "用户管理", + "ldap": "LDAP", "avatar": "头像", "id": "ID", "email": "邮箱", @@ -434,5 +435,27 @@ "tag_error_create": "标签创建失败,请重试。", "tag_error_update": "标签更新失败,请重试。", "tag_error_delete": "标签删除失败,请重试。" + }, + "Ldap": { + "ldap_settings": "LDAP 设置", + "ldap_description": "通过 LDAP 目录验证用户身份。", + "enable_ldap": "启用 LDAP", + "enable_ldap_desc": "允许用户使用目录凭据登录。", + "server_url": "服务器地址", + "bind_dn": "绑定 DN", + "bind_credentials": "绑定密码", + "bind_credentials_placeholder": "留空以保留当前密码", + "search_base": "搜索基准", + "search_filter": "搜索过滤器", + "email_attribute": "邮箱属性", + "username_attribute": "用户名属性", + "save": "保存", + "saved": "LDAP 设置已保存", + "save_error": "保存 LDAP 设置失败", + "test_connection": "测试连接", + "test_username": "测试用户名", + "test_password": "测试密码", + "test_success": "连接成功", + "test_failed": "连接失败" } } diff --git a/frontend/messages/zh-TW.json b/frontend/messages/zh-TW.json index a997497..7ce6472 100644 --- a/frontend/messages/zh-TW.json +++ b/frontend/messages/zh-TW.json @@ -149,6 +149,7 @@ }, "Admin": { "user_management": "使用者管理", + "ldap": "LDAP", "avatar": "頭像", "id": "ID", "email": "電子郵件", @@ -434,5 +435,27 @@ "tag_error_create": "標籤建立失敗,請重試。", "tag_error_update": "標籤更新失敗,請重試。", "tag_error_delete": "標籤刪除失敗,請重試。" + }, + "Ldap": { + "ldap_settings": "LDAP 設定", + "ldap_description": "透過 LDAP 目錄驗證使用者身分。", + "enable_ldap": "啟用 LDAP", + "enable_ldap_desc": "允許使用者使用目錄憑證登入。", + "server_url": "伺服器位址", + "bind_dn": "繫結 DN", + "bind_credentials": "繫結密碼", + "bind_credentials_placeholder": "留空以保留目前密碼", + "search_base": "搜尋基準", + "search_filter": "搜尋過濾器", + "email_attribute": "電子郵件屬性", + "username_attribute": "使用者名稱屬性", + "save": "儲存", + "saved": "已儲存 LDAP 設定", + "save_error": "儲存 LDAP 設定失敗", + "test_connection": "測試連線", + "test_username": "測試使用者名稱", + "test_password": "測試密碼", + "test_success": "連線成功", + "test_failed": "連線失敗" } } diff --git a/frontend/src/app/[locale]/admin/AdminNav.tsx b/frontend/src/app/[locale]/admin/AdminNav.tsx new file mode 100644 index 0000000..ed0a04c --- /dev/null +++ b/frontend/src/app/[locale]/admin/AdminNav.tsx @@ -0,0 +1,30 @@ +'use client'; +import { Tabs, Tab } from '@heroui/react'; +import { usePathname } from 'next/navigation'; +import { useRouter } from '@/src/i18n/routing'; +import { LocaleCodeType } from '@/types/locale'; + +type Props = { + locale: LocaleCodeType; + userManagementLabel: string; + ldapLabel: string; +}; + +export default function AdminNav({ locale, userManagementLabel, ldapLabel }: Props) { + const router = useRouter(); + const pathname = usePathname(); + const selected = pathname.endsWith('/admin/ldap') ? 'ldap' : 'users'; + + return ( + { + router.push(key === 'ldap' ? '/admin/ldap' : '/admin', { locale }); + }} + > + + + + ); +} diff --git a/frontend/src/app/[locale]/admin/AdminPage.tsx b/frontend/src/app/[locale]/admin/AdminPage.tsx index 1946e84..b3c5d9a 100644 --- a/frontend/src/app/[locale]/admin/AdminPage.tsx +++ b/frontend/src/app/[locale]/admin/AdminPage.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useContext } from 'react'; import { Button, addToast } from '@heroui/react'; import UsersTable from './UsersTable'; import PasswordResetDialog from './PasswordResetDialog'; +import AdminNav from './AdminNav'; import DeleteConfirmDialog from '@/components/DeleteConfirmDialog'; import { UserType, AdminMessages } from '@/types/user'; import { TokenContext } from '@/utils/TokenProvider'; @@ -149,6 +150,7 @@ export default function AdminPage({ messages, locale }: Props) { return ( <>
+

{messages.userManagement}

diff --git a/frontend/src/app/[locale]/admin/ldap/LdapSettingsPage.tsx b/frontend/src/app/[locale]/admin/ldap/LdapSettingsPage.tsx new file mode 100644 index 0000000..aa13c05 --- /dev/null +++ b/frontend/src/app/[locale]/admin/ldap/LdapSettingsPage.tsx @@ -0,0 +1,190 @@ +'use client'; +import { useState, useEffect, useContext } from 'react'; +import { Button, Input, Switch, Card, CardHeader, CardBody, CardFooter, addToast } from '@heroui/react'; +import AdminNav from '../AdminNav'; +import { TokenContext } from '@/utils/TokenProvider'; +import { LocaleCodeType } from '@/types/locale'; +import { LdapSettingsType, LdapMessages } from '@/types/ldap'; +import { fetchLdapSettings, updateLdapSettings, testLdapConnection } from '@/utils/ldapControl'; +import { logError } from '@/utils/errorHandler'; + +type Props = { + messages: LdapMessages; + locale: LocaleCodeType; +}; + +const emptySettings: LdapSettingsType = { + enabled: false, + url: '', + bindDn: '', + bindCredentials: '', + bindCredentialsSet: false, + searchBase: '', + searchFilter: '(mail={{username}})', + emailAttribute: 'mail', + usernameAttribute: 'cn', +}; + +export default function LdapSettingsPage({ messages, locale }: Props) { + const context = useContext(TokenContext); + const [settings, setSettings] = useState(emptySettings); + const [isSaving, setIsSaving] = useState(false); + const [isTesting, setIsTesting] = useState(false); + const [testUsername, setTestUsername] = useState(''); + const [testPassword, setTestPassword] = useState(''); + + useEffect(() => { + async function load() { + if (!context.isAdmin()) { + return; + } + try { + const data = await fetchLdapSettings(context.token.access_token); + setSettings(data); + } catch (error: unknown) { + logError('Error fetching LDAP settings:', error); + } + } + load(); + }, [context]); + + const update = (patch: Partial) => setSettings((prev) => ({ ...prev, ...patch })); + + const handleSave = async () => { + setIsSaving(true); + try { + const saved = await updateLdapSettings(context.token.access_token, settings); + setSettings(saved); + addToast({ title: 'Success', color: 'success', description: messages.saved }); + } catch (error: unknown) { + logError('Error saving LDAP settings:', error); + addToast({ title: 'Error', color: 'danger', description: messages.saveError }); + } finally { + setIsSaving(false); + } + }; + + const handleTest = async () => { + setIsTesting(true); + try { + const result = await testLdapConnection(context.token.access_token, settings, testUsername, testPassword); + if (result.ok) { + addToast({ title: 'Success', color: 'success', description: messages.testSuccess }); + } else { + addToast({ title: 'Error', color: 'danger', description: `${messages.testFailed}: ${result.message ?? ''}` }); + } + } finally { + setIsTesting(false); + } + }; + + if (!context.isAdmin()) { + return null; + } + + return ( +
+ + + + +
+

{messages.ldapSettings}

+

{messages.ldapDescription}

+
+ update({ enabled: value })}> + {messages.enableLdap} + +
+ + update({ url: e.target.value })} + /> + update({ bindDn: e.target.value })} + /> + update({ bindCredentials: e.target.value })} + /> + update({ searchBase: e.target.value })} + /> + update({ searchFilter: e.target.value })} + /> +
+ update({ emailAttribute: e.target.value })} + /> + update({ usernameAttribute: e.target.value })} + /> +
+
+ + + +
+ + + +

{messages.testConnection}

+
+ +
+ setTestUsername(e.target.value)} + /> + setTestPassword(e.target.value)} + /> +
+
+ + + +
+
+ ); +} diff --git a/frontend/src/app/[locale]/admin/ldap/page.tsx b/frontend/src/app/[locale]/admin/ldap/page.tsx new file mode 100644 index 0000000..5db503f --- /dev/null +++ b/frontend/src/app/[locale]/admin/ldap/page.tsx @@ -0,0 +1,49 @@ +import { getTranslations } from 'next-intl/server'; +import { useTranslations } from 'next-intl'; +import LdapSettingsPage from './LdapSettingsPage'; +import { PageType } from '@/types/base'; +import { LocaleCodeType } from '@/types/locale'; +import { LdapMessages } from '@/types/ldap'; + +export async function generateMetadata({ params: { locale } }: { params: { locale: LocaleCodeType } }) { + const t = await getTranslations({ locale, namespace: 'Ldap' }); + return { + title: `${t('ldap_settings')} | UnitTCMS`, + robots: { index: false, follow: false }, + }; +} + +export default function Page({ params }: PageType) { + const t = useTranslations('Ldap'); + const tAdmin = useTranslations('Admin'); + const messages: LdapMessages = { + userManagement: tAdmin('user_management'), + ldap: tAdmin('ldap'), + ldapSettings: t('ldap_settings'), + ldapDescription: t('ldap_description'), + enableLdap: t('enable_ldap'), + enableLdapDesc: t('enable_ldap_desc'), + serverUrl: t('server_url'), + bindDn: t('bind_dn'), + bindCredentials: t('bind_credentials'), + bindCredentialsPlaceholder: t('bind_credentials_placeholder'), + searchBase: t('search_base'), + searchFilter: t('search_filter'), + emailAttribute: t('email_attribute'), + usernameAttribute: t('username_attribute'), + save: t('save'), + saved: t('saved'), + saveError: t('save_error'), + testConnection: t('test_connection'), + testUsername: t('test_username'), + testPassword: t('test_password'), + testSuccess: t('test_success'), + testFailed: t('test_failed'), + }; + + return ( +
+ +
+ ); +} diff --git a/frontend/src/app/[locale]/admin/page.tsx b/frontend/src/app/[locale]/admin/page.tsx index f71ac06..530ebf4 100644 --- a/frontend/src/app/[locale]/admin/page.tsx +++ b/frontend/src/app/[locale]/admin/page.tsx @@ -17,6 +17,7 @@ export default function Page({ params }: PageType) { const t = useTranslations('Admin'); const messages: AdminMessages = { userManagement: t('user_management'), + ldap: t('ldap'), avatar: t('avatar'), id: t('id'), email: t('email'), diff --git a/frontend/types/ldap.ts b/frontend/types/ldap.ts new file mode 100644 index 0000000..82963d8 --- /dev/null +++ b/frontend/types/ldap.ts @@ -0,0 +1,36 @@ +export type LdapSettingsType = { + enabled: boolean; + url: string; + bindDn: string; + bindCredentials: string; + bindCredentialsSet?: boolean; + searchBase: string; + searchFilter: string; + emailAttribute: string; + usernameAttribute: string; +}; + +export type LdapMessages = { + userManagement: string; + ldap: string; + ldapSettings: string; + ldapDescription: string; + enableLdap: string; + enableLdapDesc: string; + serverUrl: string; + bindDn: string; + bindCredentials: string; + bindCredentialsPlaceholder: string; + searchBase: string; + searchFilter: string; + emailAttribute: string; + usernameAttribute: string; + save: string; + saved: string; + saveError: string; + testConnection: string; + testUsername: string; + testPassword: string; + testSuccess: string; + testFailed: string; +}; diff --git a/frontend/types/user.ts b/frontend/types/user.ts index 45e2a27..f473118 100644 --- a/frontend/types/user.ts +++ b/frontend/types/user.ts @@ -70,6 +70,7 @@ export type AuthMessages = { export type AdminMessages = { userManagement: string; + ldap: string; avatar: string; id: string; email: string; diff --git a/frontend/utils/ldapControl.ts b/frontend/utils/ldapControl.ts new file mode 100644 index 0000000..9330395 --- /dev/null +++ b/frontend/utils/ldapControl.ts @@ -0,0 +1,57 @@ +import { LdapSettingsType } from '@/types/ldap'; +import Config from '@/config/config'; +import { logError } from '@/utils/errorHandler'; +const apiServer = Config.apiServer; + +async function fetchLdapSettings(jwt: string): Promise { + const response = await fetch(`${apiServer}/ldap`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + }); + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + return response.json(); +} + +async function updateLdapSettings(jwt: string, settings: LdapSettingsType): Promise { + const response = await fetch(`${apiServer}/ldap`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify(settings), + }); + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + return response.json(); +} + +async function testLdapConnection( + jwt: string, + settings: LdapSettingsType, + testUsername: string, + testPassword: string +): Promise<{ ok: boolean; message?: string }> { + try { + const response = await fetch(`${apiServer}/ldap/test`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify({ ...settings, testUsername, testPassword }), + }); + return response.json(); + } catch (error: unknown) { + logError('Error testing LDAP connection:', error); + return { ok: false, message: 'Request failed' }; + } +} + +export { fetchLdapSettings, updateLdapSettings, testLdapConnection };