mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
Merge 177f004740 into e050814c42
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user