Files
sso-manager-node/nodejs/models/user_ldap.js
T
2026-07-01 11:10:09 -04:00

759 lines
19 KiB
JavaScript

'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)
// }
// })()