'use strict'; const { Client, Attribute, Change } = require('ldapts'); const { LRUCache } = require('lru-cache'); const crypto = require('crypto'); const {Mail} = require('./email'); const {Token, InviteToken, PasswordResetToken} = require('./token'); const {Group} = require('./group_ldap'); const {UserVerification} = require('./verification'); const conf = require('@simpleworkjs/conf').ldap; function hashPasswordSSHA512(password) { const salt = crypto.randomBytes(8); const hash = crypto.createHash('sha512').update(password).update(salt).digest(); return '{SSHA512}' + Buffer.concat([hash, salt]).toString('base64'); } const cache = new LRUCache({ // how long to live in ms ttlAutopurge: true, ttl: 1000 * 60 * 5, }); function makeClient() { return new Client({ url: conf.url }); } async function withClient(fn) { const client = makeClient(); try { await client.bind(conf.bindDN, conf.bindPassword); return await fn(client); } finally { await client.unbind().catch(() => {}); } } // Helper to escape LDAP filter values (crucial for security) function escapeLDAPSearchValue(val) { return val.replace(/\\/g, '\\5c') .replace(/\*/g, '\\2a') .replace(/\(/g, '\\28') .replace(/\)/g, '\\29') .replace(/\0/g, '\\00'); } async function addPosixGroup(client, data){ try{ const groups = (await client.search(conf.groupBase, { scope: 'sub', filter: '(&(objectClass=posixGroup))', })).searchEntries; data.gidNumber = (Math.max(...groups.map(i => i.gidNumber))+1)+''; await client.add(`cn=${data.cn},${conf.groupBase}`, { cn: data.cn, gidNumber: data.gidNumber, objectclass: [ 'posixGroup', 'top' ] }); return data; }catch(error){ throw error; } } async function addPosixAccount(client, data){ try{ const people = (await client.search(conf.userBase, { scope: 'sub', filter: conf.userFilter, })).searchEntries; data.uidNumber = (Math.max(...people.map(i => i.uidNumber))+1)+''; const entry = { cn: data.cn, sn: data.sn, uid: data.uid, uidNumber: data.uidNumber, gidNumber: data.gidNumber, givenName: data.givenName, mail: data.mail, loginShell: data.loginShell, homeDirectory: data.homeDirectory, userPassword: data.userPassword, description: data.description || ' ', sudoHost: 'ALL', sudoCommand: 'ALL', sudoUser: data.uid, objectclass: ['inetOrgPerson', 'sudoRole', 'ldapPublicKey', 'posixAccount', 'top', 'theta42Person'], }; if (data.mobile) { entry.mobile = data.mobile; } if (data.sshPublicKey) { entry.sshPublicKey = data.sshPublicKey; } if (data.dob) { entry.dateOfBirth = data.dob; } await client.add(`cn=${data.cn},${conf.userBase}`, entry); return data }catch(error){ throw error; } } async function addLdapUser(client, data){ var group; try{ if (!data.uid) { data.uid = `${data.givenName[0]}${data.sn}`.toLowerCase(); } data.cn = data.uid; data.loginShell = '/bin/bash'; data.homeDirectory= `/home/${data.uid}`; data.userPassword = hashPasswordSSHA512(data.userPassword); console.log('addLdapUser', data) group = await addPosixGroup(client, data); data = await addPosixAccount(client, group); return data; }catch(error){ await deleteLdapDN(client, `cn=${data.uid},${conf.groupBase}`, true); throw error; } } async function deleteLdapUser(client, data){ try{ await client.del(`cn=${data.cn},${conf.groupBase}`); }catch(error){ if (error.code !== 0x20) throw error; // ignore NoSuchObject — personal group may not exist } await client.del(data.dn); } async function deleteLdapDN(client, dn, ignoreError){ try{ await client.del(dn); }catch(error){ if(!ignoreError) throw error; console.error('ERROR: deleteLdapDN', error) } } const user_parse = function(data){ if(data[conf.userNameAttribute]){ data.username = data[conf.userNameAttribute] data.userPassword = undefined; } // Use truthy strings so jq-repeat section blocks ({{#isActive}}) fire correctly data.isActive = data.pwdAccountLockedTime ? '' : 'active'; data.isInactive = data.pwdAccountLockedTime ? 'inactive' : ''; return data; } var User = {} User.backing = "LDAP"; User.clearCache = function() { cache.clear(); }; User.list = async function(){ try{ return await withClient(async (client) => { const res = await client.search(conf.userBase, { scope: 'sub', filter: conf.userFilter, attributes: ['*', '+'], }); return res.searchEntries.map(function(user){return user.uid}); }); }catch(error){ throw error; } }; User.listDetail = async function(){ try{ const hit = cache.get('__list__'); if (hit) return hit; const searchEntries = await withClient(async (client) => { const res = await client.search(conf.userBase, { scope: 'sub', filter: conf.userFilter, attributes: ['*', '+'], }); return res.searchEntries; }); const users = await Promise.all(searchEntries.map(async (entry) => { const rawPassword = entry.userPassword ? entry.userPassword.toString() : ''; const isLegacyMD5 = rawPassword.toUpperCase().startsWith('{MD5}'); let obj = Object.create(this); Object.assign(obj, user_parse(entry)); const verif = await UserVerification.getOrCreate(obj.uid); if (isLegacyMD5 && !verif.password_must_change) { await verif.update({ password_must_change: true }); } const passwordMustChange = isLegacyMD5 || verif.password_must_change; obj.emailVerified = verif.email_verified ? 'verified' : ''; obj.phoneVerified = verif.phone_verified ? 'verified' : ''; obj.tosAccepted = verif.tos_accepted ? 'accepted' : ''; obj.tosNotAccepted = verif.tos_accepted ? '' : 'pending'; obj.passwordMustChange = passwordMustChange ? 'yes' : ''; obj.onboardingNeeds = [ !verif.tos_accepted && 'tos', !obj.dateOfBirth && 'dob', passwordMustChange && 'password', ].filter(Boolean); obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : ''; return obj; })); cache.set('__list__', users); return users; }catch(error){ throw error; } }; User.get = async function(data, key) { if (typeof data !== 'object') { data = { uid: data }; } const searchKey = data.searchKey || key || conf.userNameAttribute; const searchValue = escapeLDAPSearchValue(data.searchValue || data.uid); const filter = `(&${conf.userFilter}(${searchKey}=${searchValue}))`; // Check cache for an existing result or active promise const cached = cache.get(filter); if (cached) return cached; // Define the execution logic as a discrete promise const fetchPromise = (async () => { const res = await withClient(async (client) => { return await client.search(conf.userBase, { scope: 'sub', filter: filter, attributes: ['*', '+'], }); }); const user = res.searchEntries[0]; if (!user) { let error = new Error('UserNotFound'); error.name = 'UserNotFound'; error.message = `LDAP:${searchValue} does not exist`; error.status = 404; throw error; } // Check password hash type before user_parse wipes the field const rawPassword = user.userPassword ? user.userPassword.toString() : ''; const isLegacyMD5 = rawPassword.toUpperCase().startsWith('{MD5}'); let obj = Object.create(this); Object.assign(obj, user_parse(user)); const verif = await UserVerification.getOrCreate(obj.uid); // Auto-flag legacy MD5 password users — persist so subsequent cache hits see it if (isLegacyMD5 && !verif.password_must_change) { await verif.update({ password_must_change: true }); } const passwordMustChange = isLegacyMD5 || verif.password_must_change; obj.emailVerified = verif.email_verified ? 'verified' : ''; obj.phoneVerified = verif.phone_verified ? 'verified' : ''; obj.tosAccepted = verif.tos_accepted ? 'accepted' : ''; obj.tosNotAccepted = verif.tos_accepted ? '' : 'pending'; obj.passwordMustChange = passwordMustChange ? 'yes' : ''; obj.onboardingNeeds = [ !verif.tos_accepted && 'tos', !obj.dateOfBirth && 'dob', passwordMustChange && 'password', ].filter(Boolean); obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : ''; // Replace the promise in the cache with the actual parsed object cache.set(filter, obj); return obj; })(); // Cache the promise immediately to prevent stampedes cache.set(filter, fetchPromise); // If the promise fails, evict it from the cache immediately fetchPromise.catch(() => { cache.delete(filter); }); return fetchPromise; }; User.exists = async function(data, key){ try{ return await this.get(data, key); }catch(error){ return null; } }; User.add = async function(data) { try{ if (await this.exists(data.mail, 'mail')) { throw Object.assign(new Error('Email already in use'), {status: 409, name: 'EmailInUse'}); } if (data.mobile && await this.exists(data.mobile, 'mobile')) { throw Object.assign(new Error('Phone number already in use'), {status: 409, name: 'PhoneInUse'}); } await withClient(async (client) => { await addLdapUser(client, data); }); cache.clear(); let user = await this.get(data.uid); await UserVerification.getOrCreate(user.uid); try { await Mail.sendTemplate( user.mail, 'welcome', { user: user } ); } catch(mailErr) { console.error(`User.add: welcome email failed for ${user.uid}:`, mailErr.message); } return user; }catch(error){ if(error.message && error.message.includes('exists')){ let err = new Error('UserNameUsed'); err.name = 'UserNameUsed'; err.message = `LDAP:${data.uid} already exists`; err.status = 409; throw err; } throw error; } }; User.update = async function(data){ try{ if (data.mobile) { const existing = await User.exists(data.mobile, 'mobile'); if (existing && existing.uid !== this.uid) { throw Object.assign(new Error('Phone number already in use'), {status: 409, name: 'PhoneInUse'}); } } let editableFeilds = ['mobile', 'description']; await withClient(async (client) => { for(let field of editableFeilds){ if(data[field]){ await client.modify(this.dn, [ new Change({ operation: 'replace', modification: new Attribute({ type: field, values: [data[field]] }) }), ]); this[field] = data[field]; } } if(data.sshPublicKey){ await client.modify(this.dn, [ new Change({ operation: 'replace', modification: new Attribute({ type: 'sshPublicKey', values: [data.sshPublicKey] }), }), ]); this.sshPublicKey = data.sshPublicKey; } if(data.dateOfBirth){ // Ensure the auxiliary objectClass is present before setting the attribute try { await client.modify(this.dn, [ new Change({ operation: 'add', modification: new Attribute({ type: 'objectClass', values: ['theta42Person'] }), }), ]); } catch(e) { if(e.name !== 'TypeOrValueExistsError') throw e; } await client.modify(this.dn, [ new Change({ operation: 'replace', modification: new Attribute({ type: 'dateOfBirth', values: [data.dateOfBirth] }), }), ]); this.dateOfBirth = data.dateOfBirth; } }); cache.clear(); return this; }catch(error){ throw error; } }; User.usernameSuggestions = async function(givenName, sn, dob) { const fn = (givenName || '').toLowerCase().replace(/[^a-z0-9]/g, ''); const ln = (sn || '').toLowerCase().replace(/[^a-z0-9]/g, ''); if (!fn || !ln) return []; const gi = fn[0]; const li = ln[0]; const candidates = [ `${gi}${ln}`, // jsmith `${fn}${ln}`, // johnsmith `${fn}_${ln}`, // john_smith `${fn}${li}`, // johns `${ln}${gi}`, // smithj ]; if (dob) { const year = new Date(dob).getFullYear(); if (!isNaN(year)) { const y4 = String(year); const y2 = y4.slice(2); candidates.push( `${gi}${ln}${y2}`, // jsmith90 `${fn}${ln}${y2}`, // johnsmith90 `${fn}_${ln}${y2}`, // john_smith90 `${gi}${ln}${y4}`, // jsmith1990 `${fn}${ln}${y4}`, // johnsmith1990 `${fn}_${ln}${y4}`, // john_smith1990 ); } } const available = []; for (const uid of [...new Set(candidates)]) { if (!(await this.exists(uid))) available.push(uid); } if (!available.length) { for (let i = 2; i <= 9; i++) { const uid = `${gi}${ln}${i}`; if (!(await this.exists(uid))) { available.push(uid); break; } } } return available; }; User.addByInvite = async function(data){ try{ let token = await InviteToken.get(data.token); if(!token.is_valid && data.mailToken !== token.mail_token){ let error = new Error('Token Invalid'); error.name = 'Token Invalid'; error.message = `Token is not valid or as allready been used. ${data.token}`; error.status = 401; throw error; } data.mail = token.mail; const suggestions = await this.usernameSuggestions(data.givenName, data.sn, data.dob); if (!data.uid || !suggestions.includes(data.uid)) { const err = new Error('Invalid username selection'); err.status = 400; throw err; } let user = await this.add(data); if(user){ await token.consume({claimed_by: user.uid}); const verif = await UserVerification.getOrCreate(user.uid); await verif.markEmailVerified(); await verif.markTosAccepted(); await verif.update({ password_must_change: false }); cache.clear(); // evict the cached user so the next get() reads fresh verification flags const groupNames = JSON.parse(token.groups || '[]'); for (const groupName of groupNames) { try { const group = await Group.get(groupName); await group.addMember(user); } catch(e) { console.error(`invite: could not add ${user.uid} to group ${groupName}:`, e.message); } } return user; } }catch(error){ throw error; } }; User.verifyEmail = async function(data){ try{ let exists = await this.exists(data.mail, 'mail'); if(exists) throw new Error('EmailInUse'); let token = await InviteToken.get(data.token); const mail_token = crypto.randomUUID(); await token.update({mail: data.mail, mail_token}); try { await Mail.sendTemplate( data.mail, 'validate_link', { link:`${data.url}/login/invite/${token.token}/${token.mail_token}` } ); } catch(mailErr) { console.error(`verifyEmail: email failed for ${data.mail}:`, mailErr.message); } return this; }catch(error){ throw error; } }; User.passwordReset = async function(url, mail){ try{ let user = await User.get({ searchKey: 'mail', searchValue: mail }); let token = await PasswordResetToken.create({created_by: user.uid}); try { await Mail.sendTemplate( user.mail, 'reset_link', { user: user, link:`${url}/login/resetpassword/${token.token}` } ); } catch(mailErr) { console.error(`passwordReset: email failed for ${user.uid}:`, mailErr.message); } return true; }catch(error){ // if(error.name === 'UserNotFound') return false; throw error; } }; User.remove = async function(data){ try{ await withClient(async (client) => { await deleteLdapUser(client, this); }); cache.clear(); return true; }catch(error){ throw error; } }; User.setPassword = async function(data){ try{ await withClient(async (client) => { await client.modify(this.dn, [ new Change({ operation: 'replace', modification: new Attribute({ type: 'userPassword', values: [hashPasswordSSHA512(data.userPassword)] })}), ]); }); return this; }catch(error){ throw error; } }; User.addTempPassword = async function(hash) { await withClient(async (client) => { await client.modify(this.dn, [ new Change({ operation: 'add', modification: new Attribute({ type: 'userPassword', values: [hash] }) }), ]); }); }; User.removeTempPassword = async function(hash) { await withClient(async (client) => { await client.modify(this.dn, [ new Change({ operation: 'delete', modification: new Attribute({ type: 'userPassword', values: [hash] }) }), ]); }); }; User.setActive = async function(active) { try { await withClient(async (client) => { if (active) { await client.modify(this.dn, [ new Change({ operation: 'delete', modification: new Attribute({ type: 'pwdAccountLockedTime', values: [] }) }), ]); } else { await client.modify(this.dn, [ new Change({ operation: 'replace', modification: new Attribute({ type: 'pwdAccountLockedTime', values: ['000001010000Z'] }) }), ]); } }); } catch (e) { if (active && e.name === 'NoSuchAttributeError') { // Already active — nothing to do } else if (e.name === 'UndefinedTypeError' || (e.message && e.message.includes('pwdAccountLockedTime'))) { const err = new Error('OpenLDAP ppolicy overlay is not configured. See README for setup instructions.'); err.status = 503; throw err; } else { throw e; } } this.pwdAccountLockedTime = active ? undefined : '000001010000Z'; this.isActive = active ? 'active' : ''; this.isInactive = active ? '' : 'inactive'; cache.clear(); return this; }; User.addSSHkey = async function(data) { const user = await this.get(data.uid); let result; try { await withClient(async (client) => { await client.modify(user.dn, [ new Change({ operation: 'add', modification: new Attribute({ type: 'sshPublicKey', values: [data.key] }), }), ]); }); result = true; } catch(e) { if (e.name === 'TypeOrValueExistsError') { result = 'Key already added'; } else { throw e; } } if (result === true) cache.clear(); return result; }; User.invite = async function(data = {}){ try{ let token = await InviteToken.create({ created_by: this.uid, groups: JSON.stringify([].concat(data.groups || [])), }); if (data.mail) { await User.verifyEmail({ token: token.token, mail: data.mail, url: data.url }); return InviteToken.get(token.token); } return token; }catch(error){ throw error; } }; User.login = async function(data){ try{ let user = await this.get(data.uid || data.username); const loginClient = makeClient(); try { await loginClient.bind(user.dn, data.password); } finally { await loginClient.unbind().catch(() => {}); } return user; }catch(error){ console.error("USER LOGIN error:", error); throw error; } }; module.exports = {User, hashPasswordSSHA512}; // (async function(){ // try{ // console.log(await User.list()); // console.log(await User.listDetail()); // console.log(await User.get('wmantly')) // }catch(error){ // console.error(error) // } // })()