From 177f00474044d502513a59769cacee97f17f467e Mon Sep 17 00:00:00 2001 From: root Date: Wed, 20 May 2026 01:44:25 +0200 Subject: [PATCH] feat: add LDAP/LDAPS authentication ldap(s) with distinction between admin and user role by group membership - Add ldapService.ts with bind/search/group-check logic - Add ldapLoginUser() async wrapper in authService.ts - Fall back to local login if user not found in LDAP - Support LDAP_ALLOWED_GROUP for access control - Support LDAP_ADMIN_GROUP for role mapping - Support LDAP_TLS_CA for custom CA certificates - ldapts added as dependency ENV vars: LDAP_URL, LDAP_BIND_DN, LDAP_BIND_PW, LDAP_BASE, LDAP_FILTER, LDAP_ADMIN_GROUP, LDAP_ALLOWED_GROUP, LDAP_TLS_CA --- server/package.json | 1 + server/src/routes/auth.ts | 3 +- server/src/services/authService.ts | 78 ++++++++++++++++++++++- server/src/services/ldapService.ts | 99 ++++++++++++++++++++++++++++++ 4 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 server/src/services/ldapService.ts diff --git a/server/package.json b/server/package.json index a29ce811..fefb4072 100644 --- a/server/package.json +++ b/server/package.json @@ -25,6 +25,7 @@ "helmet": "^8.1.0", "jimp": "^1.6.1", "jsonwebtoken": "^9.0.2", + "ldapts": "^8.1.7", "multer": "^2.1.1", "node-cron": "^4.2.1", "nodemailer": "^8.0.5", diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index d124ad44..629ad16a 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -13,6 +13,7 @@ import { validateInviteToken, registerUser, loginUser, + ldapLoginUser, getCurrentUser, changePassword, deleteAccount, @@ -150,7 +151,7 @@ router.post('/register', authLimiter, (req: Request, res: Response) => { router.post('/login', authLimiter, async (req: Request, res: Response) => { const started = Date.now(); - const result = loginUser(req.body); + const result = await ldapLoginUser(req.body); if (result.auditAction) { writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails }); } diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts index 794fe5c1..35f75623 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -432,7 +432,7 @@ export function loginUser(body: { auditAction?: string; auditDetails?: Record; } { - if (isOidcOnlyMode()) { + if (isOidcOnlyMode() && !process.env.LDAP_URL) { return { error: 'Password authentication is disabled. Please sign in with SSO.', status: 403 }; } @@ -489,6 +489,82 @@ export function loginUser(body: { }; } + +// --------------------------------------------------------------------------- +// LDAP login — async wrapper (FreeIPA / OpenLDAP) +// --------------------------------------------------------------------------- +export async function ldapLoginUser(body: { + email?: string; + password?: string; +}): Promise> { + const { getLdapConfig, ldapAuthenticate } = await import('./ldapService'); + + if (!getLdapConfig()) { + return loginUser(body); + } + + const { email: usernameOrEmail, password } = body; + if (!usernameOrEmail || !password) { + return { error: 'Email and password are required', status: 400 }; + } + + const username = usernameOrEmail.includes('@') + ? usernameOrEmail.split('@')[0] + : usernameOrEmail; + + let ldapUser; + try { + ldapUser = await ldapAuthenticate(username, password); + } catch (err) { + console.error('[LDAP] Authentication error:', err); + return { error: 'LDAP authentication failed', status: 502 }; + } + + if (!ldapUser) { + // User nicht in LDAP — lokalen Login versuchen (z.B. lokaler Admin) + return loginUser(body); + } + + const role: 'admin' | 'user' = ldapUser.isAdmin ? 'admin' : 'user'; + let user = db.prepare( + 'SELECT * FROM users WHERE LOWER(email) = LOWER(?)' + ).get(ldapUser.email) as User | undefined; + + if (user) { + if (user.role !== role) { + db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, user.id); + user = { ...user, role } as User; + } + } else { + let uname = ldapUser.uid.replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 30) || 'user'; + const conflict = db.prepare( + 'SELECT id FROM users WHERE LOWER(username) = LOWER(?)' + ).get(uname); + if (conflict) uname = uname + '_' + String(Date.now() % 10000); + + const result = db.prepare( + 'INSERT INTO users (username, email, password_hash, role, first_seen_version, login_count) VALUES (?, ?, ?, ?, ?, 0)' + ).run(uname, ldapUser.email, '!ldap', role, process.env.APP_VERSION || '0.0.0'); + + user = db.prepare('SELECT * FROM users WHERE id = ?').get( + Number(result.lastInsertRowid) + ) as User; + } + + db.prepare( + 'UPDATE users SET login_count = login_count + 1, last_login = CURRENT_TIMESTAMP WHERE id = ?' + ).run(user.id); + + const token = generateToken(user); + const userSafe = stripUserForClient(user) as Record; + return { + token, + user: { ...userSafe, avatar_url: avatarUrl(user) }, + auditUserId: Number(user.id), + auditAction: 'user.login', + auditDetails: { method: 'ldap', username }, + }; +} // --------------------------------------------------------------------------- // Session // --------------------------------------------------------------------------- diff --git a/server/src/services/ldapService.ts b/server/src/services/ldapService.ts new file mode 100644 index 00000000..7e5c0a3b --- /dev/null +++ b/server/src/services/ldapService.ts @@ -0,0 +1,99 @@ +import { Client, InvalidCredentialsError } from 'ldapts'; +import fs from 'node:fs'; + +export interface LdapConfig { + url: string; + bindDn: string; + bindPassword: string; + searchBase: string; + searchFilter: string; + adminGroup: string; + allowGroup: string; + tlsCa?: string; +} + +export interface LdapUser { + dn: string; + uid: string; + email: string; + displayName: string; + isAdmin: boolean; +} + +export function getLdapConfig(): LdapConfig | null { + if (!process.env.LDAP_URL) return null; + return { + url: process.env.LDAP_URL, + bindDn: process.env.LDAP_BIND_DN || '', + bindPassword: process.env.LDAP_BIND_PW || '', + searchBase: process.env.LDAP_BASE || '', + searchFilter: process.env.LDAP_FILTER || '(uid={{username}})', + adminGroup: process.env.LDAP_ADMIN_GROUP || '', + allowGroup: process.env.LDAP_ALLOWED_GROUP, + tlsCa: process.env.LDAP_TLS_CA, + }; +} + +export async function ldapAuthenticate( + username: string, + password: string, +): Promise { + const config = getLdapConfig(); + if (!config) return null; + + const tlsOptions = config.tlsCa + ? { ca: [fs.readFileSync(config.tlsCa)] } + : undefined; + + const client = new Client({ url: config.url, tlsOptions }); + + try { + await client.bind(config.bindDn, config.bindPassword); + + const filter = config.searchFilter.replace('{{username}}', username); + const { searchEntries } = await client.search(config.searchBase, { + scope: 'sub', + filter, + attributes: ['uid', 'mail', 'cn', 'memberOf'], + }); + + if (!searchEntries.length) return null; + + const entry = searchEntries[0]; + const dn = entry.dn; + + try { + await client.bind(dn, password); + } catch (err) { + if (err instanceof InvalidCredentialsError) return null; + throw err; + } + + const raw = entry['memberOf']; + const groups: string[] = Array.isArray(raw) + ? raw.map(String) + : raw ? [String(raw)] : []; + + const isAdmin = config.adminGroup + ? groups.some(g => g.toLowerCase() === config.adminGroup.toLowerCase()) + : false; + + // Access control: Only members of the allowed group are permitted to enter + // Exception: Admins are always allowed in + const allowedGroup = process.config.allowGroup; + if (allowedGroup && !isAdmin) { + const allowed = groups.some(g => g.toLowerCase() === allowedGroup.toLowerCase()); + if (!allowed) return null; + } + + return { + dn, + uid: String(entry['uid'] || username), + email: String(entry['mail'] || ''), + displayName: String(entry['cn'] || username), + isAdmin, + }; + } finally { + await client.unbind(); + } +}