This commit is contained in:
Alex Bander
2026-05-27 15:14:21 +02:00
committed by GitHub
4 changed files with 179 additions and 2 deletions
+1
View File
@@ -38,6 +38,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",
+2 -1
View File
@@ -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 });
}
+77 -1
View File
@@ -432,7 +432,7 @@ export function loginUser(body: {
auditAction?: string;
auditDetails?: Record<string, unknown>;
} {
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<ReturnType<typeof loginUser>> {
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<string, unknown>;
return {
token,
user: { ...userSafe, avatar_url: avatarUrl(user) },
auditUserId: Number(user.id),
auditAction: 'user.login',
auditDetails: { method: 'ldap', username },
};
}
// ---------------------------------------------------------------------------
// Session
// ---------------------------------------------------------------------------
+99
View File
@@ -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<LdapUser | null> {
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();
}
}