Updated frontend
This commit is contained in:
@@ -18,10 +18,11 @@ Auth.errors.login = function(){
|
||||
Auth.login = async function(data){
|
||||
try{
|
||||
let user = await User.login(data);
|
||||
let token = await AuthToken.add(user);
|
||||
let token = await AuthToken.create(user);
|
||||
|
||||
return {user, token}
|
||||
}catch(error){
|
||||
console.error("AUTH LOGIN error:", error);
|
||||
throw this.errors.login();
|
||||
}
|
||||
};
|
||||
@@ -33,6 +34,7 @@ Auth.checkToken = async function(data){
|
||||
if(token.is_valid){
|
||||
return await User.get(token.created_by);
|
||||
}
|
||||
throw new Error('invalid token');
|
||||
}catch(error){
|
||||
throw this.errors.login();
|
||||
}
|
||||
|
||||
+33
-11
@@ -1,20 +1,42 @@
|
||||
'use strict';
|
||||
|
||||
const sgMail = require('@sendgrid/mail');
|
||||
const nodemailer = require('nodemailer');
|
||||
const mustache = require('mustache');
|
||||
const conf = require('../app').conf;
|
||||
|
||||
sgMail.setApiKey(conf.SENDGRID_API_KEY);
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
|
||||
var Mail = {};
|
||||
|
||||
Mail.send = async function(to, subject, message, from){
|
||||
await sgMail.send({
|
||||
to: to,
|
||||
from: from || `${conf.name} Accounts <noreply@sendgrid.theta42.com>`,
|
||||
subject: subject,
|
||||
text: message,
|
||||
html: message,
|
||||
Mail.send = function(to, subject, message, from){
|
||||
return new Promise(function(resolve, reject){
|
||||
var transportOpts = {
|
||||
host: conf.smtp.host || 'localhost',
|
||||
port: conf.smtp.port || 25,
|
||||
secure: conf.smtp.secure !== undefined ? conf.smtp.secure : false
|
||||
};
|
||||
|
||||
if (conf.smtp.user && conf.smtp.pass) {
|
||||
transportOpts.auth = {
|
||||
user: conf.smtp.user,
|
||||
pass: conf.smtp.pass
|
||||
};
|
||||
}
|
||||
|
||||
var transporter = nodemailer.createTransport(transportOpts);
|
||||
|
||||
var mailOpts = {
|
||||
from: from || conf.smtp.from || `${conf.name} Accounts <noreply@theta42.com>`,
|
||||
to: to,
|
||||
subject: subject,
|
||||
html: message
|
||||
};
|
||||
|
||||
transporter.sendMail(mailOpts, function(err, info){
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(info);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+120
-185
@@ -1,281 +1,216 @@
|
||||
'use strict';
|
||||
|
||||
const { Client, Attribute, Change } = require('ldapts');
|
||||
const conf = require('../app').conf.ldap;
|
||||
const { LRUCache } = require('lru-cache');
|
||||
const conf = require('@simpleworkjs/conf').ldap;
|
||||
|
||||
const client = new Client({
|
||||
url: conf.url,
|
||||
});
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function getGroups(client, member){
|
||||
try{
|
||||
let memberFilter = member ? `(member=${member})`: ''
|
||||
|
||||
let memberFilter = member ? `(member=${member})`: ''
|
||||
let groups = (await client.search(conf.groupBase, {
|
||||
scope: 'sub',
|
||||
filter: `(&(objectClass=groupOfNames)${memberFilter})`,
|
||||
attributes: ['cn', 'description', 'member', 'owner', 'createTimestamp', 'modifyTimestamp'],
|
||||
})).searchEntries;
|
||||
|
||||
let groups = (await client.search(conf.groupBase, {
|
||||
scope: 'sub',
|
||||
filter: `(&(objectClass=groupOfNames)${memberFilter})`,
|
||||
attributes: ['*', 'createTimestamp', 'modifyTimestamp'],
|
||||
})).searchEntries;
|
||||
|
||||
return groups.map(function(group){
|
||||
if(!Array.isArray(group.member)) group.member = [group.member];
|
||||
if(!Array.isArray(group.owner)) group.owner = [group.owner];
|
||||
return group
|
||||
});
|
||||
}catch(error){
|
||||
throw error;
|
||||
}
|
||||
return groups.map(function(group){
|
||||
if(!Array.isArray(group.member)) group.member = [group.member];
|
||||
if(!Array.isArray(group.owner)) group.owner = [group.owner];
|
||||
return group
|
||||
});
|
||||
}
|
||||
|
||||
async function addGroup(client, data){
|
||||
try{
|
||||
await client.add(`cn=${data.name},${conf.groupBase}`, {
|
||||
cn: data.name,
|
||||
member: data.owner,
|
||||
description: data.description,
|
||||
owner: data.owner,
|
||||
objectclass: [ 'groupOfNames', 'top' ]
|
||||
});
|
||||
|
||||
await client.add(`cn=${data.name},${conf.groupBase}`, {
|
||||
cn: data.name,
|
||||
member: data.owner,
|
||||
description: data.description,
|
||||
owner: data.owner,
|
||||
objectclass: [ 'groupOfNames', 'top' ]
|
||||
});
|
||||
|
||||
return data;
|
||||
|
||||
}catch(error){
|
||||
throw error;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function addMember(client, group, user){
|
||||
try{
|
||||
await client.modify(group.dn, [
|
||||
new Change({
|
||||
operation: 'add',
|
||||
modification: new Attribute({
|
||||
type: 'member',
|
||||
values: [user.dn]
|
||||
})
|
||||
}),
|
||||
]);
|
||||
}catch(error){
|
||||
// if(error = "TypeOrValueExistsError"){
|
||||
// console.error('addMember error skipped', error)
|
||||
// return ;
|
||||
// }
|
||||
throw error;
|
||||
}
|
||||
await client.modify(group.dn, [
|
||||
new Change({
|
||||
operation: 'add',
|
||||
modification: new Attribute({
|
||||
type: 'member',
|
||||
values: [user.dn]
|
||||
})
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
async function removeMember(client, group, user){
|
||||
try{
|
||||
await client.modify(group.dn, [
|
||||
new Change({
|
||||
operation: 'delete',
|
||||
modification: new Attribute({
|
||||
type: 'member',
|
||||
values: [user.dn]
|
||||
values: [user.dn]
|
||||
})}),
|
||||
]);
|
||||
}catch(error){
|
||||
if(error = "TypeOrValueExistsError")return ;
|
||||
throw error;
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
async function addOwner(client, group, user){
|
||||
try{
|
||||
await client.modify(group.dn, [
|
||||
new Change({
|
||||
operation: 'add',
|
||||
modification: new Attribute({
|
||||
type: 'owner',
|
||||
values: [user.dn]
|
||||
})
|
||||
}),
|
||||
]);
|
||||
}catch(error){
|
||||
// if(error = "TypeOrValueExistsError"){
|
||||
// console.error('addMember error skipped', error)
|
||||
// return ;
|
||||
// }
|
||||
throw error;
|
||||
}
|
||||
await client.modify(group.dn, [
|
||||
new Change({
|
||||
operation: 'add',
|
||||
modification: new Attribute({
|
||||
type: 'owner',
|
||||
values: [user.dn]
|
||||
})
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
async function removeOwner(client, group, user){
|
||||
try{
|
||||
await client.modify(group.dn, [
|
||||
new Change({
|
||||
operation: 'delete',
|
||||
modification: new Attribute({
|
||||
type: 'owner',
|
||||
values: [user.dn]
|
||||
values: [user.dn]
|
||||
})}),
|
||||
]);
|
||||
}catch(error){
|
||||
if(error = "TypeOrValueExistsError")return ;
|
||||
throw error;
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
const cache = new LRUCache({ max: 1, ttl: 1000 * 60 * 5, ttlAutopurge: true });
|
||||
|
||||
async function cachedListDetail() {
|
||||
const hit = cache.get('all');
|
||||
if (hit) return hit;
|
||||
const promise = withClient(async (client) => {
|
||||
const groups = await getGroups(client);
|
||||
return groups.map(g => ({...g}));
|
||||
}).then(plain => {
|
||||
cache.set('all', plain);
|
||||
return plain;
|
||||
}).catch(err => {
|
||||
cache.delete('all');
|
||||
throw err;
|
||||
});
|
||||
// Store promise immediately to prevent stampede on concurrent requests
|
||||
cache.set('all', promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
var Group = {};
|
||||
|
||||
Group.list = async function(member){
|
||||
try{
|
||||
await client.bind(conf.bindDN, conf.bindPassword);
|
||||
|
||||
let groups = await getGroups(client, member)
|
||||
|
||||
await client.unbind();
|
||||
|
||||
return groups.map(group => group.cn);
|
||||
}catch(error){
|
||||
throw error;
|
||||
if (member) {
|
||||
return withClient(async (client) => {
|
||||
const groups = await getGroups(client, member);
|
||||
return groups.map(group => group.cn);
|
||||
});
|
||||
}
|
||||
return (await cachedListDetail()).map(group => group.cn);
|
||||
}
|
||||
|
||||
Group.listDetail = async function(member){
|
||||
try{
|
||||
await client.bind(conf.bindDN, conf.bindPassword);
|
||||
|
||||
let groups = await getGroups(client, member)
|
||||
|
||||
await client.unbind();
|
||||
|
||||
|
||||
return groups;
|
||||
}catch(error){
|
||||
throw error;
|
||||
if (member) {
|
||||
return withClient(async (client) => getGroups(client, member));
|
||||
}
|
||||
return cachedListDetail();
|
||||
}
|
||||
|
||||
Group.get = async function(data){
|
||||
try{
|
||||
|
||||
if(typeof data !== 'object'){
|
||||
let name = data;
|
||||
data = {};
|
||||
data.name = name;
|
||||
}
|
||||
|
||||
await client.bind(conf.bindDN, conf.bindPassword);
|
||||
if(typeof data !== 'object'){
|
||||
let name = data;
|
||||
data = {};
|
||||
data.name = name;
|
||||
}
|
||||
|
||||
return withClient(async (client) => {
|
||||
let group = (await client.search(conf.groupBase, {
|
||||
scope: 'sub',
|
||||
filter: `(&(objectClass=groupOfNames)(cn=${data.name}))`,
|
||||
attributes: ['*', 'createTimestamp', 'modifyTimestamp'],
|
||||
attributes: ['cn', 'description', 'member', 'owner', 'createTimestamp', 'modifyTimestamp'],
|
||||
})).searchEntries[0];
|
||||
|
||||
await client.unbind();
|
||||
|
||||
if(!Array.isArray(group.member)) group.member = [group.member];
|
||||
if(!Array.isArray(group.owner)) group.owner = [group.owner];
|
||||
|
||||
if(group){
|
||||
if(!Array.isArray(group.member)) group.member = [group.member];
|
||||
if(!Array.isArray(group.owner)) group.owner = [group.owner];
|
||||
let obj = Object.create(this);
|
||||
Object.assign(obj, group);
|
||||
|
||||
return obj;
|
||||
}else{
|
||||
let error = new Error('GroupNotFound');
|
||||
error.name = 'GroupNotFound';
|
||||
error.message = `LDAP:${data.cn} does not exists`;
|
||||
error.message = `LDAP:${data.name} does not exists`;
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
}catch(error){
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Group.add = async function(data){
|
||||
try{
|
||||
await client.bind(conf.bindDN, conf.bindPassword);
|
||||
|
||||
return withClient(async (client) => {
|
||||
await addGroup(client, data);
|
||||
|
||||
await client.unbind();
|
||||
|
||||
cache.clear();
|
||||
return this.get(data);
|
||||
|
||||
}catch(error){
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Group.addMember = async function(user){
|
||||
try{
|
||||
await client.bind(conf.bindDN, conf.bindPassword);
|
||||
|
||||
await addMember(client, this, user);
|
||||
|
||||
await client.unbind();
|
||||
|
||||
return this;
|
||||
|
||||
}catch(error){
|
||||
throw error;
|
||||
}
|
||||
await withClient(async (client) => addMember(client, this, user));
|
||||
this.member = [].concat(this.member || []).concat([user.dn]);
|
||||
cache.clear();
|
||||
return this;
|
||||
};
|
||||
|
||||
Group.removeMember = async function(user){
|
||||
try{
|
||||
await client.bind(conf.bindDN, conf.bindPassword);
|
||||
|
||||
await removeMember(client, this, user);
|
||||
|
||||
await client.unbind();
|
||||
|
||||
return this;
|
||||
|
||||
await withClient(async (client) => removeMember(client, this, user));
|
||||
}catch(error){
|
||||
if(error.name === "NoSuchAttributeError") return this;
|
||||
throw error;
|
||||
}
|
||||
this.member = [].concat(this.member || []).filter(dn => dn !== user.dn);
|
||||
cache.clear();
|
||||
return this;
|
||||
};
|
||||
|
||||
|
||||
Group.addOwner = async function(user){
|
||||
try{
|
||||
await client.bind(conf.bindDN, conf.bindPassword);
|
||||
|
||||
await addOwner(client, this, user);
|
||||
|
||||
await client.unbind();
|
||||
|
||||
return this;
|
||||
|
||||
}catch(error){
|
||||
throw error;
|
||||
}
|
||||
await withClient(async (client) => addOwner(client, this, user));
|
||||
this.owner = [].concat(this.owner || []).concat([user.dn]);
|
||||
cache.clear();
|
||||
return this;
|
||||
};
|
||||
|
||||
Group.removeOwner = async function(user){
|
||||
try{
|
||||
await client.bind(conf.bindDN, conf.bindPassword);
|
||||
|
||||
await removeOwner(client, this, user);
|
||||
|
||||
await client.unbind();
|
||||
|
||||
return this;
|
||||
|
||||
await withClient(async (client) => removeOwner(client, this, user));
|
||||
}catch(error){
|
||||
if(error.name === "NoSuchAttributeError") return this;
|
||||
throw error;
|
||||
}
|
||||
this.owner = [].concat(this.owner || []).filter(dn => dn !== user.dn);
|
||||
cache.clear();
|
||||
return this;
|
||||
};
|
||||
|
||||
Group.remove = async function(){
|
||||
try{
|
||||
await client.bind(conf.bindDN, conf.bindPassword);
|
||||
|
||||
await client.del(this.dn);
|
||||
|
||||
await client.unbind();
|
||||
|
||||
return true;
|
||||
}catch(error){
|
||||
throw error;
|
||||
}
|
||||
await withClient(async (client) => client.del(this.dn));
|
||||
cache.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = {Group};
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
'use strict';
|
||||
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const {setUpTable} = require('model-redis');
|
||||
|
||||
const Table = setUpTable(conf.redis);
|
||||
|
||||
module.exports = Table;
|
||||
|
||||
require('./token');
|
||||
require('./verification');
|
||||
require('./oauth_client');
|
||||
require('./oauth_code');
|
||||
@@ -0,0 +1,25 @@
|
||||
'use strict';
|
||||
|
||||
const Table = require('.');
|
||||
const UUID = function b(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b)};
|
||||
|
||||
class Notification extends Table {
|
||||
static _key = 'notification_id';
|
||||
static _keyMap = {
|
||||
notification_id: { default: UUID, type: 'string' },
|
||||
created_by: { isRequired: true, type: 'string' },
|
||||
created_on: { default: () => Date.now() },
|
||||
subject: { isRequired: true, type: 'string' },
|
||||
message: { isRequired: true, type: 'string' },
|
||||
filter_type: { isRequired: true, type: 'string' },
|
||||
filter_value: { default: '', type: 'string' },
|
||||
active_only: { default: false, type: 'boolean' },
|
||||
status: { default: 'sending', type: 'string' },
|
||||
sent_count: { default: 0, type: 'number' },
|
||||
failed_count: { default: 0, type: 'number' },
|
||||
sent_at: { default: 0, type: 'number' },
|
||||
};
|
||||
}
|
||||
Notification.register();
|
||||
|
||||
module.exports = { Notification };
|
||||
@@ -0,0 +1,49 @@
|
||||
'use strict';
|
||||
|
||||
const Table = require('.');
|
||||
const bcrypt = require('bcrypt');
|
||||
const UUID = function b(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b)};
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
|
||||
const defaultLifetime = (conf.oauth && conf.oauth.token_lifetime) || {
|
||||
access_token: 3600,
|
||||
refresh_token: 2592000
|
||||
};
|
||||
|
||||
class OAuthClient extends Table {
|
||||
static _key = 'client_id';
|
||||
static _keyMap = {
|
||||
'client_id': {default: UUID, type: 'string'},
|
||||
'client_secret_hash': {isRequired: true, type: 'string', isPrivate: true},
|
||||
'name': {isRequired: true, type: 'string', min: 1, max: 255},
|
||||
'description': {default: '', type: 'string'},
|
||||
'redirect_uris': {default: [], type: 'object'},
|
||||
'scopes': {default: ['openid', 'profile', 'email'], type: 'object'},
|
||||
'token_lifetime': {default: function(){ return Object.assign({}, defaultLifetime) }, type: 'object'},
|
||||
'created_by': {isRequired: true, type: 'string'},
|
||||
'created_on': {default: function(){ return (new Date).getTime() }},
|
||||
'is_valid': {default: true, type: 'boolean'},
|
||||
}
|
||||
|
||||
static async add(data) {
|
||||
const raw_secret = UUID();
|
||||
data.client_secret_hash = await bcrypt.hash(raw_secret, 10);
|
||||
data.client_id = UUID();
|
||||
const client = await this.create(data);
|
||||
client._raw_secret = raw_secret;
|
||||
return client;
|
||||
}
|
||||
|
||||
async verifySecret(secret) {
|
||||
return bcrypt.compare(secret, this.client_secret_hash);
|
||||
}
|
||||
|
||||
async rotateSecret() {
|
||||
const raw_secret = UUID();
|
||||
await this.update({ client_secret_hash: await bcrypt.hash(raw_secret, 10) });
|
||||
return raw_secret;
|
||||
}
|
||||
}
|
||||
OAuthClient.register();
|
||||
|
||||
module.exports = { OAuthClient };
|
||||
@@ -0,0 +1,81 @@
|
||||
'use strict';
|
||||
|
||||
const Table = require('.');
|
||||
const UUID = function b(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b)};
|
||||
|
||||
// Shared base keyMap matching Token's schema so these behave as tokens
|
||||
const tokenKeyMap = {
|
||||
'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
|
||||
'created_on': {default: function(){return (new Date).getTime()}},
|
||||
'updated_on': {default: function(){return (new Date).getTime()}, always: true},
|
||||
'token': {default: UUID, type: 'string', min: 36, max: 36, isPrivate: true},
|
||||
'is_valid': {default: true, type: 'boolean'},
|
||||
};
|
||||
|
||||
class OAuthCode extends Table {
|
||||
static _key = 'token';
|
||||
static _keyMap = {
|
||||
...tokenKeyMap,
|
||||
'client_id': {isRequired: true, type: 'string'},
|
||||
'redirect_uri': {isRequired: true, type: 'string'},
|
||||
'scope': {isRequired: true, type: 'string'},
|
||||
'username': {isRequired: true, type: 'string'},
|
||||
'code_challenge': {default: '', type: 'string'},
|
||||
'code_challenge_method': {default: 'S256', type: 'string'},
|
||||
'expires_at': {default: function(){return (new Date).getTime() + 600000}, type: 'number'},
|
||||
}
|
||||
|
||||
get isExpired() {
|
||||
return (new Date).getTime() > this.expires_at;
|
||||
}
|
||||
|
||||
static async add(data) {
|
||||
data.created_by = data.username;
|
||||
return this.create(data);
|
||||
}
|
||||
}
|
||||
OAuthCode.register();
|
||||
|
||||
class OAuthAccessToken extends Table {
|
||||
static _key = 'token';
|
||||
static _keyMap = {
|
||||
...tokenKeyMap,
|
||||
'client_id': {isRequired: true, type: 'string'},
|
||||
'username': {isRequired: true, type: 'string'},
|
||||
'scope': {isRequired: true, type: 'string'},
|
||||
'expires_at': {isRequired: true, type: 'number'},
|
||||
}
|
||||
|
||||
get isExpired() {
|
||||
return (new Date).getTime() > this.expires_at;
|
||||
}
|
||||
|
||||
static async add(data) {
|
||||
data.created_by = data.username;
|
||||
return this.create(data);
|
||||
}
|
||||
}
|
||||
OAuthAccessToken.register();
|
||||
|
||||
class OAuthRefreshToken extends Table {
|
||||
static _key = 'token';
|
||||
static _keyMap = {
|
||||
...tokenKeyMap,
|
||||
'client_id': {isRequired: true, type: 'string'},
|
||||
'username': {isRequired: true, type: 'string'},
|
||||
'scope': {isRequired: true, type: 'string'},
|
||||
'expires_at': {isRequired: true, type: 'number'},
|
||||
}
|
||||
|
||||
get isExpired() {
|
||||
return (new Date).getTime() > this.expires_at;
|
||||
}
|
||||
|
||||
static async add(data) {
|
||||
data.created_by = data.username;
|
||||
return this.create(data);
|
||||
}
|
||||
}
|
||||
OAuthRefreshToken.register();
|
||||
|
||||
module.exports = { OAuthCode, OAuthAccessToken, OAuthRefreshToken };
|
||||
@@ -0,0 +1,42 @@
|
||||
'use strict';
|
||||
|
||||
const https = require('https');
|
||||
const conf = require('@simpleworkjs/conf').voipms;
|
||||
|
||||
function toE164Digits(number) {
|
||||
const digits = String(number).replace(/\D/g, '');
|
||||
if (digits.length === 10) return '1' + digits;
|
||||
return digits;
|
||||
}
|
||||
|
||||
async function send(to, message) {
|
||||
const params = new URLSearchParams({
|
||||
api_username: conf.username,
|
||||
api_password: conf.password,
|
||||
method: 'sendSMS',
|
||||
did: conf.did,
|
||||
dst: toE164Digits(to),
|
||||
message,
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(`https://voip.ms/api/v1/rest.php?${params}`, res => {
|
||||
let body = '';
|
||||
res.on('data', d => body += d);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const json = JSON.parse(body);
|
||||
if (json.status !== 'success') {
|
||||
reject(new Error(`VoIP.ms error: ${json.status}`));
|
||||
} else {
|
||||
resolve(json);
|
||||
}
|
||||
} catch(e) {
|
||||
reject(new Error('VoIP.ms returned invalid JSON'));
|
||||
}
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {SMS: {send}};
|
||||
+110
-54
@@ -1,71 +1,127 @@
|
||||
'use strict';
|
||||
|
||||
const redis_model = require('../utils/redis_model')
|
||||
const Table = require('.');
|
||||
const UUID = function b(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b)};
|
||||
|
||||
|
||||
const Token = function(data){
|
||||
return redis_model({
|
||||
_name: `token_${data.name}`,
|
||||
_key: 'token',
|
||||
_keyMap: Object.assign({}, {
|
||||
'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
|
||||
'created_on': {default: function(){return (new Date).getTime()}},
|
||||
'updated_on': {default: function(){return (new Date).getTime()}, always: true},
|
||||
'token': {default: UUID, type: 'string', min: 36, max: 36},
|
||||
'is_valid': {default: true, type: 'boolean'}
|
||||
}, data.keyMap || {})
|
||||
});
|
||||
};
|
||||
|
||||
Token.check = async function(data){
|
||||
try{
|
||||
return this.is_valid;
|
||||
}catch(error){
|
||||
return false
|
||||
class Token extends Table{
|
||||
static _key = 'token';
|
||||
static _keyMap = {
|
||||
'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
|
||||
'created_on': {default: function(){return (new Date).getTime()}},
|
||||
'updated_on': {default: function(){return (new Date).getTime()}, always: true},
|
||||
'token': {default: UUID, type: 'string', min: 36, max: 36, isPrivate: true},
|
||||
'is_valid': {default: true, type: 'boolean'},
|
||||
}
|
||||
}
|
||||
|
||||
var InviteToken = Object.create(Token({
|
||||
name: 'invite',
|
||||
keyMap:{
|
||||
claimed_by: {default:"__NONE__", isRequired: false, type: 'string',},
|
||||
mail: {default:"__NONE__", isRequired: false, type: 'string',},
|
||||
mail_token: {default: UUID, type: 'string', min: 36, max: 36},
|
||||
constructor(...args){
|
||||
super(...args);
|
||||
}
|
||||
}));
|
||||
|
||||
InviteToken.consume = async function(data){
|
||||
try{
|
||||
if(this.is_valid){
|
||||
data['is_valid'] = false;
|
||||
|
||||
await this.update(data);
|
||||
return true;
|
||||
async check(){
|
||||
try{
|
||||
return this.is_valid;
|
||||
}catch(error){
|
||||
return false
|
||||
}
|
||||
return false;
|
||||
|
||||
}catch(error){
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
var AuthToken = Object.create(Token({
|
||||
name: 'auth',
|
||||
}));
|
||||
Token.register();
|
||||
|
||||
AuthToken.add = async function(data){
|
||||
data.created_by = data.uid;
|
||||
return AuthToken.__proto__.add(data);
|
||||
};
|
||||
class AuthToken extends Token{
|
||||
static _keyMap = {
|
||||
...super._keyMap,
|
||||
user: {model: 'User', rel: 'one', localKey: 'created_by'},
|
||||
}
|
||||
|
||||
var PasswordResetToken = Object.create(Token({
|
||||
name: 'auth',
|
||||
}));
|
||||
static async create(data){
|
||||
data.created_by = data.username;
|
||||
return super.create(data)
|
||||
|
||||
PasswordResetToken.add = async function(data){
|
||||
data.created_by = data.uid;
|
||||
return PasswordResetToken.__proto__.add(data);
|
||||
};
|
||||
}
|
||||
}
|
||||
AuthToken.register();
|
||||
|
||||
module.exports = {Token, InviteToken, AuthToken, PasswordResetToken};
|
||||
class InviteToken extends Token{
|
||||
static _keyMap = {
|
||||
...super._keyMap,
|
||||
claimed_by: {default: '__NONE__', isRequired: false, type: 'string'},
|
||||
mail: {default: '__NONE__', type: 'string'},
|
||||
mail_token: {default: '__NONE__', type: 'string'},
|
||||
groups: {default: '[]', type: 'string'},
|
||||
}
|
||||
|
||||
async consume(data){
|
||||
try{
|
||||
if(this.is_valid){
|
||||
data['is_valid'] = false;
|
||||
|
||||
await this.update(data);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
}catch(error){
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
InviteToken.register();
|
||||
|
||||
class ImpersonationToken extends Token {
|
||||
static _keyMap = {
|
||||
...super._keyMap,
|
||||
target_uid: {isRequired: true, type: 'string', min: 1, max: 200},
|
||||
temp_hash: {isRequired: true, type: 'string', min: 1, max: 500},
|
||||
expires_at: {default: function(){ return (new Date).getTime() + 7200000 }, type: 'number'},
|
||||
}
|
||||
|
||||
get isExpired() {
|
||||
return (new Date).getTime() > this.expires_at;
|
||||
}
|
||||
|
||||
static async add(data) {
|
||||
data.created_by = data.admin_uid;
|
||||
return this.create(data);
|
||||
}
|
||||
}
|
||||
ImpersonationToken.register();
|
||||
|
||||
class PasswordResetToken extends Token {}
|
||||
PasswordResetToken.register();
|
||||
|
||||
class OtpToken extends Token {
|
||||
static _keyMap = {
|
||||
...Token._keyMap,
|
||||
uid: {isRequired: true, type: 'string'},
|
||||
code: {isRequired: true, type: 'string'},
|
||||
method: {isRequired: true, type: 'string'},
|
||||
expires_at: {default: function(){ return (new Date).getTime() + 600000 }, type: 'number'},
|
||||
};
|
||||
|
||||
get isExpired() {
|
||||
return (new Date).getTime() > this.expires_at;
|
||||
}
|
||||
|
||||
// Factory method — named `issue` to avoid shadowing Token's `create(data)`
|
||||
static async issue(uid, method) {
|
||||
const existing = await this.listDetail({uid});
|
||||
for (const t of existing) {
|
||||
if (t.is_valid) await t.update({is_valid: false});
|
||||
}
|
||||
const code = String(Math.floor(100000 + Math.random() * 900000));
|
||||
return this.create({uid, code, method, created_by: uid});
|
||||
}
|
||||
|
||||
static async verify(uid, code) {
|
||||
const tokens = await this.listDetail({uid});
|
||||
const match = tokens.find(t => t.is_valid && !t.isExpired && t.code === code);
|
||||
if (!match) return null;
|
||||
await match.update({is_valid: false});
|
||||
return match;
|
||||
}
|
||||
}
|
||||
OtpToken.register();
|
||||
|
||||
module.exports = {Token, InviteToken, AuthToken, ImpersonationToken, PasswordResetToken, OtpToken};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
const conf = require('../app').conf;
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
|
||||
const User = require(`./user_${conf.userModel}`)
|
||||
|
||||
|
||||
+474
-163
@@ -1,16 +1,50 @@
|
||||
'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 conf = require('../app').conf.ldap;
|
||||
const {Group} = require('./group_ldap');
|
||||
const {UserVerification} = require('./verification');
|
||||
const conf = require('@simpleworkjs/conf').ldap;
|
||||
|
||||
const client = new Client({
|
||||
url: conf.url,
|
||||
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{
|
||||
@@ -43,7 +77,7 @@ async function addPosixAccount(client, data){
|
||||
|
||||
data.uidNumber = (Math.max(...people.map(i => i.uidNumber))+1)+'';
|
||||
|
||||
await client.add(`cn=${data.cn},${conf.userBase}`, {
|
||||
const entry = {
|
||||
cn: data.cn,
|
||||
sn: data.sn,
|
||||
uid: data.uid,
|
||||
@@ -51,17 +85,29 @@ async function addPosixAccount(client, data){
|
||||
gidNumber: data.gidNumber,
|
||||
givenName: data.givenName,
|
||||
mail: data.mail,
|
||||
mobile: data.mobile,
|
||||
loginShell: data.loginShell,
|
||||
homeDirectory: data.homeDirectory,
|
||||
userPassword: data.userPassword,
|
||||
description: data.description || ' ',
|
||||
description: data.description || ' ',
|
||||
sudoHost: 'ALL',
|
||||
sudoCommand: 'ALL',
|
||||
sudoUser: data.uid,
|
||||
sshPublicKey: data.sshPublicKey,
|
||||
objectclass: ['inetOrgPerson', 'sudoRole', 'ldapPublicKey', 'posixAccount', 'top' ]
|
||||
});
|
||||
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
|
||||
|
||||
@@ -75,36 +121,40 @@ async function addLdapUser(client, data){
|
||||
|
||||
var group;
|
||||
|
||||
try{
|
||||
data.uid = `${data.givenName[0]}${data.sn}`.toLowerCase();
|
||||
data.cn = data.uid;
|
||||
data.loginShell = '/bin/bash';
|
||||
data.homeDirectory= `/home/${data.uid}`;
|
||||
data.userPassword = '{MD5}'+crypto.createHash('md5').update(data.userPassword, "binary").digest('base64');
|
||||
|
||||
group = await addPosixGroup(client, data);
|
||||
data = await addPosixAccount(client, 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);
|
||||
|
||||
return data;
|
||||
console.log('addLdapUser', data)
|
||||
group = await addPosixGroup(client, data);
|
||||
data = await addPosixAccount(client, group);
|
||||
|
||||
}catch(error){
|
||||
await deleteLdapDN(client, `cn=${data.uid},${conf.groupBase}`, true);
|
||||
throw error;
|
||||
}
|
||||
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}`);
|
||||
await client.del(data.dn);
|
||||
}catch(error){
|
||||
throw 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{
|
||||
client.del(dn)
|
||||
await client.del(dn);
|
||||
}catch(error){
|
||||
if(!ignoreError) throw error;
|
||||
console.error('ERROR: deleteLdapDN', error)
|
||||
@@ -116,6 +166,9 @@ const user_parse = function(data){
|
||||
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;
|
||||
}
|
||||
@@ -124,19 +177,18 @@ var User = {}
|
||||
|
||||
User.backing = "LDAP";
|
||||
|
||||
User.clearCache = function() { cache.clear(); };
|
||||
|
||||
User.list = async function(){
|
||||
try{
|
||||
await client.bind(conf.bindDN, conf.bindPassword);
|
||||
|
||||
const res = await client.search(conf.userBase, {
|
||||
scope: 'sub',
|
||||
filter: conf.userFilter,
|
||||
attributes: ['*', 'createTimestamp', 'modifyTimestamp'],
|
||||
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});
|
||||
});
|
||||
|
||||
await client.unbind();
|
||||
|
||||
return res.searchEntries.map(function(user){return user.uid});
|
||||
}catch(error){
|
||||
throw error;
|
||||
}
|
||||
@@ -144,26 +196,49 @@ User.list = async function(){
|
||||
|
||||
User.listDetail = async function(){
|
||||
try{
|
||||
await client.bind(conf.bindDN, conf.bindPassword);
|
||||
const hit = cache.get('__list__');
|
||||
if (hit) return hit;
|
||||
|
||||
const res = await client.search(conf.userBase, {
|
||||
scope: 'sub',
|
||||
filter: conf.userFilter,
|
||||
attributes: ['*', 'createTimestamp', 'modifyTimestamp'],
|
||||
const searchEntries = await withClient(async (client) => {
|
||||
const res = await client.search(conf.userBase, {
|
||||
scope: 'sub',
|
||||
filter: conf.userFilter,
|
||||
attributes: ['*', '+'],
|
||||
});
|
||||
return res.searchEntries;
|
||||
});
|
||||
|
||||
await client.unbind();
|
||||
const users = await Promise.all(searchEntries.map(async (entry) => {
|
||||
const rawPassword = entry.userPassword ? entry.userPassword.toString() : '';
|
||||
const isLegacyMD5 = rawPassword.toUpperCase().startsWith('{MD5}');
|
||||
|
||||
let users = []
|
||||
|
||||
for(let user of res.searchEntries){
|
||||
let obj = Object.create(this);
|
||||
Object.assign(obj, user_parse(user));
|
||||
|
||||
users.push(obj)
|
||||
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){
|
||||
@@ -171,89 +246,132 @@ User.listDetail = async function(){
|
||||
}
|
||||
};
|
||||
|
||||
User.get = async function(data, key){
|
||||
try{
|
||||
if(typeof data !== 'object'){
|
||||
let uid = data;
|
||||
data = {};
|
||||
data.uid = uid;
|
||||
}
|
||||
|
||||
User.get = async function(data, key) {
|
||||
if (typeof data !== 'object') {
|
||||
data = { uid: data };
|
||||
}
|
||||
|
||||
await client.bind(conf.bindDN, conf.bindPassword);
|
||||
const searchKey = data.searchKey || key || conf.userNameAttribute;
|
||||
const searchValue = escapeLDAPSearchValue(data.searchValue || data.uid);
|
||||
const filter = `(&${conf.userFilter}(${searchKey}=${searchValue}))`;
|
||||
|
||||
data.searchKey = data.searchKey || key || conf.userNameAttribute;
|
||||
data.searchValue = data.searchValue || data.uid;
|
||||
// Check cache for an existing result or active promise
|
||||
const cached = cache.get(filter);
|
||||
if (cached) return cached;
|
||||
|
||||
let filter = `(&${conf.userFilter}(${data.searchKey}=${data.searchValue}))`;
|
||||
// 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 res = await client.search(conf.userBase, {
|
||||
scope: 'sub',
|
||||
filter: filter,
|
||||
attributes: ['*', 'createTimestamp', 'modifyTimestamp'],
|
||||
});
|
||||
const user = res.searchEntries[0];
|
||||
|
||||
await client.unbind();
|
||||
if (!user) {
|
||||
let error = new Error('UserNotFound');
|
||||
error.name = 'UserNotFound';
|
||||
error.message = `LDAP:${searchValue} does not exist`;
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
let user = res.searchEntries[0]
|
||||
// Check password hash type before user_parse wipes the field
|
||||
const rawPassword = user.userPassword ? user.userPassword.toString() : '';
|
||||
const isLegacyMD5 = rawPassword.toUpperCase().startsWith('{MD5}');
|
||||
|
||||
if(user){
|
||||
let obj = Object.create(this);
|
||||
Object.assign(obj, user_parse(user));
|
||||
|
||||
return obj;
|
||||
}else{
|
||||
let error = new Error('UserNotFound');
|
||||
error.name = 'UserNotFound';
|
||||
error.message = `LDAP:${data.searchValue} does not exists`;
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
}catch(error){
|
||||
throw error;
|
||||
}
|
||||
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){
|
||||
// Return true or false if the requested entry exists ignoring error's.
|
||||
try{
|
||||
await this.get(data, key);
|
||||
|
||||
return true
|
||||
return await this.get(data, key);
|
||||
}catch(error){
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
User.add = async function(data) {
|
||||
try{
|
||||
await client.bind(conf.bindDN, conf.bindPassword);
|
||||
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 addLdapUser(client, data);
|
||||
|
||||
await client.unbind();
|
||||
await withClient(async (client) => {
|
||||
await addLdapUser(client, data);
|
||||
});
|
||||
cache.clear();
|
||||
|
||||
let user = await this.get(data.uid);
|
||||
|
||||
await UserVerification.getOrCreate(user.uid);
|
||||
|
||||
await Mail.sendTemplate(
|
||||
user.mail,
|
||||
'welcome',
|
||||
{
|
||||
user: user
|
||||
}
|
||||
)
|
||||
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.includes('exists')){
|
||||
let error = new Error('UserNameUsed');
|
||||
error.name = 'UserNameUsed';
|
||||
error.message = `LDAP:${data.uid} already exists`;
|
||||
error.status = 409;
|
||||
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 error;
|
||||
throw err;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -261,25 +379,63 @@ User.add = async function(data) {
|
||||
|
||||
User.update = async function(data){
|
||||
try{
|
||||
let editableFeilds = ['mobile', 'sshPublicKey', 'description'];
|
||||
|
||||
await client.bind(conf.bindDN, conf.bindPassword);
|
||||
|
||||
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]]
|
||||
})
|
||||
}),
|
||||
]);
|
||||
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'});
|
||||
}
|
||||
}
|
||||
|
||||
await client.unbind()
|
||||
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;
|
||||
|
||||
@@ -288,6 +444,50 @@ User.update = async function(data){
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -302,10 +502,33 @@ User.addByInvite = async function(data){
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -323,15 +546,20 @@ User.verifyEmail = async function(data){
|
||||
if(exists) throw new Error('EmailInUse');
|
||||
|
||||
let token = await InviteToken.get(data.token);
|
||||
await token.update({mail: data.mail})
|
||||
const mail_token = crypto.randomUUID();
|
||||
await token.update({mail: data.mail, mail_token});
|
||||
|
||||
await Mail.sendTemplate(
|
||||
data.mail,
|
||||
'validate_link',
|
||||
{
|
||||
link:`${data.url}/login/invite/${token.token}/${token.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){
|
||||
@@ -347,16 +575,20 @@ User.passwordReset = async function(url, mail){
|
||||
searchValue: mail
|
||||
});
|
||||
|
||||
let token = await PasswordResetToken.add(user);
|
||||
let token = await PasswordResetToken.create({created_by: user.uid});
|
||||
|
||||
await Mail.sendTemplate(
|
||||
user.mail,
|
||||
'reset_link',
|
||||
{
|
||||
user: user,
|
||||
link:`${url}/login/resetpassword/${token.token}`
|
||||
}
|
||||
)
|
||||
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){
|
||||
@@ -369,11 +601,10 @@ User.passwordReset = async function(url, mail){
|
||||
User.remove = async function(data){
|
||||
try{
|
||||
|
||||
await client.bind(conf.bindDN, conf.bindPassword);
|
||||
|
||||
await deleteLdapUser(client, this);
|
||||
|
||||
await client.unbind();
|
||||
await withClient(async (client) => {
|
||||
await deleteLdapUser(client, this);
|
||||
});
|
||||
cache.clear();
|
||||
|
||||
return true;
|
||||
|
||||
@@ -385,18 +616,16 @@ User.remove = async function(data){
|
||||
User.setPassword = async function(data){
|
||||
try{
|
||||
|
||||
await client.bind(conf.bindDN, conf.bindPassword);
|
||||
|
||||
await client.modify(this.dn, [
|
||||
new Change({
|
||||
operation: 'replace',
|
||||
modification: new Attribute({
|
||||
type: 'userPassword',
|
||||
values: ['{MD5}'+crypto.createHash('md5').update(data.userPassword, "binary").digest('base64')]
|
||||
})}),
|
||||
]);
|
||||
|
||||
await client.unbind();
|
||||
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){
|
||||
@@ -404,12 +633,90 @@ User.setPassword = async function(data){
|
||||
}
|
||||
};
|
||||
|
||||
User.invite = async function(){
|
||||
try{
|
||||
let token = await InviteToken.add({created_by: this.uid});
|
||||
|
||||
return token;
|
||||
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;
|
||||
}
|
||||
@@ -417,21 +724,25 @@ User.invite = async function(){
|
||||
|
||||
User.login = async function(data){
|
||||
try{
|
||||
let user = await this.get(data.uid);
|
||||
let user = await this.get(data.uid || data.username);
|
||||
|
||||
await client.bind(user.dn, data.password);
|
||||
|
||||
await client.unbind();
|
||||
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};
|
||||
module.exports = {User, hashPasswordSSHA512};
|
||||
|
||||
|
||||
// (async function(){
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
'use strict';
|
||||
|
||||
const Table = require('.');
|
||||
|
||||
class UserVerification extends Table {
|
||||
static _key = 'uid';
|
||||
static _keyMap = {
|
||||
uid: {isRequired: true, type: 'string'},
|
||||
created_by: {isRequired: true, type: 'string'},
|
||||
email_verified: {default: false, type: 'boolean'},
|
||||
phone_verified: {default: false, type: 'boolean'},
|
||||
tos_accepted: {default: false, type: 'boolean'},
|
||||
password_must_change: {default: false, type: 'boolean'},
|
||||
email_verified_at: {type: 'number'},
|
||||
phone_verified_at: {type: 'number'},
|
||||
tos_accepted_at: {type: 'number'},
|
||||
};
|
||||
|
||||
static async getOrCreate(uid) {
|
||||
const list = await this.listDetail({uid});
|
||||
if (list.length) return list[0];
|
||||
return this.create({uid, created_by: uid});
|
||||
}
|
||||
|
||||
async markEmailVerified() {
|
||||
return this.update({email_verified: true, email_verified_at: Date.now()});
|
||||
}
|
||||
|
||||
async markPhoneVerified() {
|
||||
return this.update({phone_verified: true, phone_verified_at: Date.now()});
|
||||
}
|
||||
|
||||
async markTosAccepted() {
|
||||
return this.update({tos_accepted: true, tos_accepted_at: Date.now()});
|
||||
}
|
||||
}
|
||||
UserVerification.register();
|
||||
|
||||
module.exports = {UserVerification};
|
||||
Reference in New Issue
Block a user