2ac1c30112
- User.update/addSSHkey now ensure the ldapPublicKey objectClass is present before writing sshPublicKey, so accounts predating that objectClass (e.g. the bootstrap admin) no longer 500 on PUT /api/user/:uid. - populateHostDropdown in directory.ejs was missing an `oauth` branch, leaving the parent-Service picker blank when adding an OAuth Integration. - Bump to 1.5.1. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
937 lines
26 KiB
JavaScript
937 lines
26 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;
|
|
// Connection + escaping come from the shared @simpleworkjs/ldap package. The
|
|
// wrappers below preserve this file's no-arg call signatures (makeClient() /
|
|
// withClient(fn)) so no call site changes; sso's makeClient passes no
|
|
// tlsOptions, which the shared client forwards as undefined — identical to the
|
|
// previous `new Client({ url: conf.url })`.
|
|
const { makeClient: _makeClient, withClient: _withClient, escapeFilter, escapeDN } = require('@simpleworkjs/ldap');
|
|
const escapeLDAPSearchValue = escapeFilter;
|
|
const escapeLDAPDNValue = escapeDN;
|
|
|
|
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 _makeClient(conf);
|
|
}
|
|
|
|
async function withClient(fn) {
|
|
return _withClient(conf, fn);
|
|
}
|
|
|
|
// Compute the next available uid/gidNumber: the highest existing value below
|
|
// conf.uidGidReservedFloor, plus one -- or conf.uidGidMin if there are no
|
|
// such entries yet. Entries at/above the reserved floor (e.g. a bootstrap
|
|
// admin deliberately given a high, easily-recognizable id -- see
|
|
// theta-env's bootstrap.js) are ignored, so they don't drag every real
|
|
// user's id up into that same range. Math.max() on an empty array is
|
|
// -Infinity in JS, not 0 -- without the explicit floor here, a fresh
|
|
// directory with zero existing entries produces an invalid ("-Infinity")
|
|
// LDAP attribute value and the add fails with InvalidSyntaxError.
|
|
function nextPosixId(entries, key){
|
|
const existing = entries
|
|
.map(i => Number(i[key]))
|
|
.filter(n => Number.isFinite(n) && n < conf.uidGidReservedFloor);
|
|
|
|
return String(Math.max(conf.uidGidMin - 1, ...existing) + 1);
|
|
}
|
|
|
|
async function addPosixGroup(client, data){
|
|
|
|
try{
|
|
const groups = (await client.search(conf.groupBase, {
|
|
scope: 'sub',
|
|
filter: '(&(objectClass=posixGroup))',
|
|
})).searchEntries;
|
|
|
|
data.gidNumber = nextPosixId(groups, 'gidNumber');
|
|
|
|
const safeCn = escapeLDAPDNValue(data.cn);
|
|
await client.add(`cn=${safeCn},${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 = nextPosixId(people, 'uidNumber');
|
|
|
|
const safeCn = escapeLDAPDNValue(data.cn);
|
|
const entry = {
|
|
cn: data.cn,
|
|
sn: data.sn,
|
|
uid: data.uid,
|
|
uidNumber: data.uidNumber,
|
|
gidNumber: data.gidNumber,
|
|
givenName: data.givenName,
|
|
loginShell: data.loginShell,
|
|
homeDirectory: data.homeDirectory,
|
|
description: data.description || ' ',
|
|
sudoHost: 'ALL',
|
|
sudoCommand: 'ALL',
|
|
sudoUser: data.uid,
|
|
objectclass: ['inetOrgPerson', 'sudoRole', 'ldapPublicKey', 'posixAccount', 'top', 'theta42Person'],
|
|
};
|
|
|
|
// mail is optional in the inetOrgPerson schema, but ldapts/slapd reject an
|
|
// attribute given an explicit undefined value ("no values for attribute
|
|
// type") rather than just omitting it -- service accounts (a Unix account
|
|
// an app/service runs as) commonly have no real mailbox.
|
|
if (data.mail) {
|
|
entry.mail = data.mail;
|
|
}
|
|
|
|
if (data.mobile) {
|
|
entry.mobile = data.mobile;
|
|
}
|
|
|
|
if (data.sshPublicKey) {
|
|
entry.sshPublicKey = data.sshPublicKey;
|
|
}
|
|
|
|
if (data.dob) {
|
|
entry.dateOfBirth = data.dob;
|
|
}
|
|
|
|
if (data.location) {
|
|
entry.l = data.location;
|
|
}
|
|
|
|
// userPassword is optional -- a service account with no password set
|
|
// simply can't bind (no special enforcement needed, that's the default
|
|
// LDAP simple-bind behavior for an entry lacking the attribute).
|
|
if (data.userPassword) {
|
|
entry.userPassword = data.userPassword;
|
|
}
|
|
|
|
// manager (COSINE, SUP distinguishedName) is naturally multi-valued --
|
|
// every account gets at least the DN of whoever created it.
|
|
if (data.manager && [].concat(data.manager).length) {
|
|
entry.manager = [].concat(data.manager);
|
|
}
|
|
|
|
await client.add(`cn=${safeCn},${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 = data.loginShell || '/bin/bash';
|
|
data.homeDirectory = data.homeDirectory || `/home/${data.uid}`;
|
|
if (data.userPassword) {
|
|
data.userPassword = hashPasswordSSHA512(data.userPassword);
|
|
} else {
|
|
delete data.userPassword;
|
|
}
|
|
|
|
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;
|
|
}
|
|
data.location = data.l ? String(data.l) : '';
|
|
// Use truthy strings so jq-repeat section blocks ({{#isActive}}) fire correctly
|
|
data.isActive = data.pwdAccountLockedTime ? '' : 'active';
|
|
data.isInactive = data.pwdAccountLockedTime ? 'inactive' : '';
|
|
|
|
// manager (COSINE, SUP distinguishedName) and memberOf (from the memberof
|
|
// overlay) are both multi-valued; ldapts returns a bare string for a
|
|
// single value and an array for multiple -- normalize both to always be
|
|
// an array, or app-base.js's `for(let group of user.memberOf)` silently
|
|
// iterates a single DN string character-by-character instead of once.
|
|
data.manager = [].concat(data.manager || []).filter(Boolean);
|
|
data.memberOf = [].concat(data.memberOf || []).filter(Boolean);
|
|
|
|
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;
|
|
});
|
|
|
|
// Members of app_sso_service_account are non-person accounts (media
|
|
// managers, app service users, ...) -- fetched once here rather than
|
|
// relying on the memberof overlay's reverse attribute, which isn't
|
|
// reliably returned by every LDAP server this app might point at.
|
|
let serviceAccountDNs = new Set();
|
|
try{
|
|
const svcGroup = await Group.get('app_sso_service_account');
|
|
serviceAccountDNs = new Set((svcGroup.member || []).map(dn => dn.toLowerCase()));
|
|
}catch(error){ /* group not seeded yet on an old deployment -- treat as none */ }
|
|
|
|
const dnToUid = new Map(searchEntries.map(e => [String(e.dn).toLowerCase(), e.uid]));
|
|
|
|
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' : '';
|
|
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
|
|
obj.managerUids = obj.manager.map(dn => dnToUid.get(String(dn).toLowerCase()) || dn);
|
|
|
|
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);
|
|
|
|
// Same membership check as User.listDetail() -- see the comment there.
|
|
try{
|
|
const svcGroup = await Group.get('app_sso_service_account');
|
|
const serviceAccountDNs = new Set((svcGroup.member || []).map(dn => dn.toLowerCase()));
|
|
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
|
|
}catch(error){ obj.isServiceAccount = ''; }
|
|
|
|
// 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', 'homeDirectory', 'loginShell'];
|
|
|
|
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){
|
|
// Ensure the auxiliary objectClass is present before setting the attribute
|
|
// -- accounts created before ldapPublicKey was added to addPosixAccount's
|
|
// objectclass list (e.g. the bootstrap admin) won't have it yet.
|
|
try {
|
|
await client.modify(this.dn, [
|
|
new Change({
|
|
operation: 'add',
|
|
modification: new Attribute({ type: 'objectClass', values: ['ldapPublicKey'] }),
|
|
}),
|
|
]);
|
|
} catch(e) {
|
|
if(e.name !== 'TypeOrValueExistsError') throw e;
|
|
}
|
|
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;
|
|
}
|
|
|
|
if(data.location !== undefined){
|
|
await client.modify(this.dn, [
|
|
new Change({
|
|
operation: 'replace',
|
|
modification: new Attribute({ type: 'l', values: [data.location] }),
|
|
}),
|
|
]);
|
|
this.location = data.location;
|
|
}
|
|
|
|
if(data.manager !== undefined){
|
|
// Client sends uids; resolve each to a DN before writing --
|
|
// manager (COSINE, SUP distinguishedName) stores DNs, not uids.
|
|
const uids = [].concat(data.manager || []).filter(Boolean);
|
|
const managers = await Promise.all(uids.map(uid => User.get(uid)));
|
|
const dns = managers.map(u => u.dn);
|
|
await client.modify(this.dn, [
|
|
new Change({
|
|
operation: 'replace',
|
|
modification: new Attribute({ type: 'manager', values: dns }),
|
|
}),
|
|
]);
|
|
this.manager = dns;
|
|
}
|
|
});
|
|
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;
|
|
|
|
// Default manager: whoever sent the invite.
|
|
try {
|
|
const inviter = await this.get(token.created_by);
|
|
data.manager = [inviter.dn];
|
|
} catch(e) { /* inviter no longer exists -- leave manager unset */ }
|
|
|
|
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) => {
|
|
// Ensure the auxiliary objectClass is present before setting the attribute
|
|
// -- accounts created before ldapPublicKey was added to addPosixAccount's
|
|
// objectclass list (e.g. the bootstrap admin) won't have it yet.
|
|
try {
|
|
await client.modify(user.dn, [
|
|
new Change({
|
|
operation: 'add',
|
|
modification: new Attribute({ type: 'objectClass', values: ['ldapPublicKey'] }),
|
|
}),
|
|
]);
|
|
} catch(e) {
|
|
if (e.name !== 'TypeOrValueExistsError') throw e;
|
|
}
|
|
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;
|
|
};
|
|
|
|
// Every user gets a personal Unix group of the same name at creation (see
|
|
// addPosixGroup) -- just a GID holder, cn always equal to the user's uid.
|
|
// memberUid (RFC 2307, posixGroup) is a bare username, not a DN, unlike
|
|
// groupOfNames' `member` used by app_sso_* groups in group_ldap.js.
|
|
function personalGroupDN(uid){
|
|
return `cn=${escapeLDAPDNValue(uid)},${conf.groupBase}`;
|
|
}
|
|
|
|
User.getPersonalGroupMembers = async function(uid) {
|
|
try {
|
|
return await withClient(async (client) => {
|
|
const res = await client.search(personalGroupDN(uid), {
|
|
scope: 'base',
|
|
filter: '(objectClass=posixGroup)',
|
|
attributes: ['memberUid'],
|
|
});
|
|
const entry = res.searchEntries[0];
|
|
return [].concat((entry && entry.memberUid) || []).filter(Boolean);
|
|
});
|
|
} catch(error) {
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
User.addPersonalGroupMember = async function(uid, memberUid) {
|
|
await this.get(memberUid); // throws UserNotFound if the target uid doesn't exist
|
|
await withClient(async (client) => {
|
|
await client.modify(personalGroupDN(uid), [
|
|
new Change({
|
|
operation: 'add',
|
|
modification: new Attribute({ type: 'memberUid', values: [memberUid] }),
|
|
}),
|
|
]);
|
|
});
|
|
};
|
|
|
|
User.removePersonalGroupMember = async function(uid, memberUid) {
|
|
await withClient(async (client) => {
|
|
await client.modify(personalGroupDN(uid), [
|
|
new Change({
|
|
operation: 'delete',
|
|
modification: new Attribute({ type: 'memberUid', values: [memberUid] }),
|
|
}),
|
|
]);
|
|
});
|
|
};
|
|
|
|
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{
|
|
if (!data.uid && !data.username) {
|
|
let error = new Error('Invalid Credentials, login failed.');
|
|
error.name = 'LDAPLoginFailed';
|
|
error.status = 401;
|
|
throw error;
|
|
}
|
|
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.name, error.message);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
|
|
module.exports = {User, hashPasswordSSHA512, nextPosixId};
|
|
|
|
|
|
// (async function(){
|
|
// try{
|
|
// console.log(await User.list());
|
|
|
|
// console.log(await User.listDetail());
|
|
|
|
// console.log(await User.get('wmantly'))
|
|
|
|
// }catch(error){
|
|
// console.error(error)
|
|
// }
|
|
// })()
|