ldap-rest 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/index.js CHANGED
@@ -1 +1 @@
1
- import{fileURLToPath as e}from"url";import{join as i,dirname as r}from"path";import s from"express";import o from"body-parser";import{parseConfig as t}from"../lib/parseConfig.js";import n from"../config/args.js";import g from"../lib/ldapActions.js";import{buildLogger as l}from"../logger/winston.js";import{setLogger as a}from"../lib/expressFormatedResponses.js";import h from"../plugins/priority.json.js";export{asyncHandler}from"../lib/utils.js";class DM{constructor(){this.hooks={},this.loadedPlugins={},this.config=t(n),this.app=s(),this.app.use(o.json()),this.app.use(o.urlencoded({extended:!0})),this.logger=l(this.config),this.ldap=new g(this),a(this.logger),this.operationSequence=0;const e=[];if(this.config.plugin){const i=this.config.plugin.find(e=>e.includes("configApi"));let r=this.config.plugin.filter(e=>!e.includes("configApi"));for(const i of h)r.includes(i)&&(r=r.filter(e=>e!==i),e.push(this.loadPlugin(i)));for(const i of r)e.push(this.loadPlugin(i));i&&e.push(Promise.all(e).then(()=>this.loadPlugin(i)))}this.ready=new Promise((i,r)=>{e.length>0?Promise.all(e).then(()=>i()).catch(e=>r(new Error("Error loading plugins: "+e))):i()})}run(){return this.app.use((e,i,r,s)=>{this.logger.error(`Unhandled error in request ${i.method} ${i.path}: ${e.message}`,{stack:e.stack,url:i.url,method:i.method}),r.headersSent||r.status(500).json({error:"Internal Server Error",message:this.config.debug?e.message:"An error occurred"})}),process.on("uncaughtException",e=>{this.logger.error(`Uncaught exception: ${e.message}`,{stack:e.stack})}),process.on("unhandledRejection",(e,i)=>{this.logger.error(`Unhandled promise rejection: ${e}`,{reason:e,promise:i})}),new Promise((e,i)=>{this.server=this.app.listen(this.config.port,r=>{r?(this.logger.error(`Error starting server: ${r}`),i(r)):(this.logger.debug(`Server started on port ${this.config.port}`),e())})})}stop(){this.app.removeAllListeners(),this.server?.close(),this.logger.debug("Server stopped")}loadPlugin(s){let o,t;if(/:/.test(s)){let e=s.substring(s.indexOf(":")+1);if(s=s.substring(0,s.indexOf(":")),/:/.test(e)){o=e.substring(0,e.indexOf(":")),o||(o=void 0),e=e.substring(e.indexOf(":")+1);try{t=JSON.parse(e),"object"!=typeof t?(this.logger.error(`Overrides for plugin ${s} are not valid: ${e}`),t=void 0):this.logger.debug(`Overrides for plugin ${o||s}: ${e}`)}catch(i){this.logger.error(`Failed to parse overrides for plugin ${s}: ${i}, using ${e}`),t=void 0}}else o=e,o||(o=void 0)}else o=void 0;return this.logger.debug(`Loading plugin ${s}`),s.startsWith("core/")&&(s=s.replace("core/",i(r(e(import.meta.url)),"..","plugins")+"/").replace(/$/,".js")),new Promise((e,i)=>{import(s).then(async r=>{let n;if(r&&r.default&&(r=r.default),t){const e={...this.config,...t};n=new r({...this,config:e})}else n=new r(this);if(!n)return i(new Error(`Unable to load ${s}`));e(await this.registerPlugin(s,n,o)),this.logger.debug(`Plugin ${n.name} loaded`)}).catch(e=>i(new Error(`Failed to load plugin ${s}: ${e}`)))})}async registerPlugin(e,i,r){if(i.name||(i.name=e),r&&(i.name=r),this.loadedPlugins[i.name])return this.logger.info(`Plugin ${e} already loaded as ${i.name}`),!1;if(this.logger.debug(`Registering plugin ${e} as ${i.name}`),i.dependencies)for(const e in i.dependencies)this.loadedPlugins[e]||(this.logger.debug(`Plugin ${i.name} depends on ${e}, loading it first`),await this.loadPlugin(i.dependencies[e]));if(i.api&&(this.logger.debug(`Plugin ${i.name} has API, registering it`),await i.api(this.app)),i.hooks)for(const e in i.hooks){this.logger.debug(`Plugin ${i.name} has hook ${e}, registering it`);const r=i.hooks[e];if(this.hooks[e]||(this.hooks[e]=[]),!r||"function"!=typeof r)throw new Error(`Plugin ${i.name}: hook ${e} is invalid`);this.hooks[e].push(r)}return this.loadedPlugins[i.name]=i,!0}}export{DM};
1
+ import{fileURLToPath as e}from"url";import{join as i,dirname as r}from"path";import s from"express";import o from"body-parser";import{parseConfig as t}from"../lib/parseConfig.js";import n from"../config/args.js";import g from"../lib/ldapActions.js";import{buildLogger as l}from"../logger/winston.js";import{setLogger as a}from"../lib/expressFormatedResponses.js";import h from"../plugins/priority.json.js";export{asyncHandler,getParentDn,getRdn,isChildOf,launchHooks,launchHooksChained,parseDn,transformSchemas}from"../lib/utils.js";class DM{constructor(){this.hooks={},this.loadedPlugins={},this.config=t(n),this.app=s(),this.app.use(o.json()),this.app.use(o.urlencoded({extended:!0})),this.logger=l(this.config),this.ldap=new g(this),a(this.logger),this.operationSequence=0;const e=[];if(this.config.plugin){const i=this.config.plugin.find(e=>e.includes("configApi"));let r=this.config.plugin.filter(e=>!e.includes("configApi"));for(const i of h)r.includes(i)&&(r=r.filter(e=>e!==i),e.push(this.loadPlugin(i)));for(const i of r)e.push(this.loadPlugin(i));i&&e.push(Promise.all(e).then(()=>this.loadPlugin(i)))}this.ready=new Promise((i,r)=>{e.length>0?Promise.all(e).then(()=>i()).catch(e=>r(new Error("Error loading plugins: "+e))):i()})}run(){return this.app.use((e,i,r,s)=>{this.logger.error(`Unhandled error in request ${i.method} ${i.path}: ${e.message}`,{stack:e.stack,url:i.url,method:i.method}),r.headersSent||r.status(500).json({error:"Internal Server Error",message:this.config.debug?e.message:"An error occurred"})}),process.on("uncaughtException",e=>{this.logger.error(`Uncaught exception: ${e.message}`,{stack:e.stack})}),process.on("unhandledRejection",(e,i)=>{this.logger.error(`Unhandled promise rejection: ${e}`,{reason:e,promise:i})}),new Promise((e,i)=>{this.server=this.app.listen(this.config.port,r=>{r?(this.logger.error(`Error starting server: ${r}`),i(r)):(this.logger.debug(`Server started on port ${this.config.port}`),e())})})}stop(){this.app.removeAllListeners(),this.server?.close(),this.logger.debug("Server stopped")}loadPlugin(s){let o,t;if(/:/.test(s)){let e=s.substring(s.indexOf(":")+1);if(s=s.substring(0,s.indexOf(":")),/:/.test(e)){o=e.substring(0,e.indexOf(":")),o||(o=void 0),e=e.substring(e.indexOf(":")+1);try{t=JSON.parse(e),"object"!=typeof t?(this.logger.error(`Overrides for plugin ${s} are not valid: ${e}`),t=void 0):this.logger.debug(`Overrides for plugin ${o||s}: ${e}`)}catch(i){this.logger.error(`Failed to parse overrides for plugin ${s}: ${i}, using ${e}`),t=void 0}}else o=e,o||(o=void 0)}else o=void 0;return this.logger.debug(`Loading plugin ${s}`),s.startsWith("core/")&&(s=s.replace("core/",i(r(e(import.meta.url)),"..","plugins")+"/").replace(/$/,".js")),new Promise((e,i)=>{import(s).then(async r=>{let n;if(r&&r.default&&(r=r.default),t){const e={...this.config,...t};n=new r({...this,config:e})}else n=new r(this);if(!n)return i(new Error(`Unable to load ${s}`));e(await this.registerPlugin(s,n,o)),this.logger.debug(`Plugin ${n.name} loaded`)}).catch(e=>i(new Error(`Failed to load plugin ${s}: ${e}`)))})}async registerPlugin(e,i,r){if(i.name||(i.name=e),r&&(i.name=r),this.loadedPlugins[i.name])return this.logger.info(`Plugin ${e} already loaded as ${i.name}`),!1;if(this.logger.debug(`Registering plugin ${e} as ${i.name}`),i.dependencies)for(const e in i.dependencies)this.loadedPlugins[e]||(this.logger.debug(`Plugin ${i.name} depends on ${e}, loading it first`),await this.loadPlugin(i.dependencies[e]));if(i.api&&(this.logger.debug(`Plugin ${i.name} has API, registering it`),await i.api(this.app)),i.hooks)for(const e in i.hooks){this.logger.debug(`Plugin ${i.name} has hook ${e}, registering it`);const r=i.hooks[e];if(this.hooks[e]||(this.hooks[e]=[]),!r||"function"!=typeof r)throw new Error(`Plugin ${i.name}: hook ${e} is invalid`);this.hooks[e].push(r)}return this.loadedPlugins[i.name]=i,!0}}export{DM};
package/dist/lib/utils.js CHANGED
@@ -1 +1 @@
1
- import{getLogger as t}from"./expressFormatedResponses.js";const r=t(),launchHooks=async(t,...e)=>{if(t)for(const n of t)if(n)try{await n(...e)}catch(t){r.error("Hook error",t)}},launchHooksChained=async(t,r)=>{if(t)for(const e of t)e&&(r=await e(r));return r},transformSchemas=(t,r)=>t.toString().replace(/__(\S+)__/g,(t,e)=>{if(!e||"string"!=typeof e)return t;const n=e.trim().toLowerCase();return r[n]?"object"!=typeof r[n]?r[n]:JSON.stringify(r[n]):t});function parseDn(t){const r=[];let e="",n=!1;for(let o=0;o<t.length;o++){const s=t[o];n?(e+=s,n=!1):"\\"===s?(e+=s,n=!0):","===s?(r.push(e.trim()),e=""):e+=s}return e&&r.push(e.trim()),r}function getParentDn(t){const r=parseDn(t);return r.length<=1?t:r.slice(1).join(",")}const asyncHandler=t=>(r,e,n)=>{Promise.resolve(t(r,e,n)).catch(n)};export{asyncHandler,getParentDn,launchHooks,launchHooksChained,parseDn,transformSchemas};
1
+ import{getLogger as t}from"./expressFormatedResponses.js";const e=t(),launchHooks=async(t,...n)=>{if(t)for(const r of t)if(r)try{await r(...n)}catch(t){e.error("Hook error",t)}},launchHooksChained=async(t,e)=>{if(t)for(const n of t)n&&(e=await n(e));return e},transformSchemas=(t,e)=>t.toString().replace(/__(\S+)__/g,(t,n)=>{if(!n||"string"!=typeof n)return t;const r=n.trim().toLowerCase();return e[r]?"object"!=typeof e[r]?e[r]:JSON.stringify(e[r]):t});function parseDn(t){const e=[];let n="",r=!1;for(let o=0;o<t.length;o++){const s=t[o];r?(n+=s,r=!1):"\\"===s?(n+=s,r=!0):","===s?(e.push(n.trim()),n=""):n+=s}return n&&e.push(n.trim()),e}function getParentDn(t){const e=parseDn(t);return e.length<=1?t:e.slice(1).join(",")}function getRdn(t){return parseDn(t)[0]||""}function isChildOf(t,e){const n=t.toLowerCase(),r=e.toLowerCase();if(!n.endsWith(r))return!1;if(n.length===r.length)return!1;return n.substring(0,n.length-r.length).endsWith(",")}const asyncHandler=t=>(e,n,r)=>{Promise.resolve(t(e,n,r)).catch(r)};export{asyncHandler,getParentDn,getRdn,isChildOf,launchHooks,launchHooksChained,parseDn,transformSchemas};
@@ -1 +1 @@
1
- import{createHmac as t}from"crypto";import{unauthorized as e}from"../../lib/expressFormatedResponses.js";import s from"../../lib/auth/base.js";class AuthTotp extends s{constructor(...t){super(...t),this.name="authTotp",this.roles=["auth"],this.totpUsers=[];const e=this.config.auth_totp;this.window=this.config.auth_totp_window??1,this.step=this.config.auth_totp_step??30,e&&Array.isArray(e)&&e.forEach((t,e)=>{const s=t.split(":");if(s.length>=2){const t=s[0].trim(),r=s[1].trim(),i=s[2]?parseInt(s[2].trim(),10):6;if(!this.isValidBase32(t))return void this.logger.warn(`Invalid Base32 secret for TOTP user "${r}" (index ${e})`);if(i<6||i>10)return void this.logger.warn(`Invalid digits count for TOTP user "${r}": ${i} (must be 6-10)`);this.totpUsers.push({name:r,secret:t,digits:i})}else this.logger.warn(`Invalid TOTP config format at index ${e}: expected "secret:name[:digits]"`)}),0===this.totpUsers.length?this.logger.warn("No valid TOTP users configured"):this.logger.info(`TOTP authentication initialized with ${this.totpUsers.length} user(s)`)}authMethod(t,s,r){let i=t.headers.authorization;if(!i||!/^Bearer .+/.test(i))return this.logger.warn("Missing or invalid Authorization header"),e(s);const o=i.split(" ")[1];for(const e of this.totpUsers)if(this.verifyTotp(e.secret,o,e.digits))return this.logger.debug(`TOTP authentication successful for user: ${e.name}`),t.user=e.name,r();return this.logger.warn(`Unauthorized TOTP token: ${o}`),e(s)}verifyTotp(t,e,s){const r=Math.floor(Date.now()/1e3);for(let i=-this.window;i<=this.window;i++){const o=Math.floor(r/this.step)+i;if(this.generateTotp(t,o,s)===e)return!0}return!1}generateTotp(e,s,r){const i=this.base32Decode(e),o=Buffer.alloc(8);o.writeBigUInt64BE(BigInt(s));const n=t("sha1",i);n.update(o);const a=n.digest(),h=15&a[a.length-1];return(((127&a[h])<<24|(255&a[h+1])<<16|(255&a[h+2])<<8|255&a[h+3])%Math.pow(10,r)).toString().padStart(r,"0")}base32Decode(t){let e=t.toUpperCase();for(;e.endsWith("=");)e=e.slice(0,-1);let s=0,r=0;const i=[];for(let t=0;t<e.length;t++){const o="ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".indexOf(e[t]);if(-1===o)throw new Error(`Invalid Base32 character: ${e[t]}`);r=r<<5|o,s+=5,s>=8&&(i.push(r>>>s-8&255),s-=8)}return Buffer.from(i)}isValidBase32(t){return/^[A-Z2-7]+=*$/.test(t.toUpperCase())}}export{AuthTotp as default};
1
+ import{createHmac as t}from"crypto";import{unauthorized as e}from"../../lib/expressFormatedResponses.js";import s from"../../lib/auth/base.js";class AuthTotp extends s{constructor(...t){super(...t),this.name="authTotp",this.roles=["auth"],this.totpUsers=[];const e=this.config.auth_totp;this.window=this.config.auth_totp_window??1,this.step=this.config.auth_totp_step??30,e&&Array.isArray(e)&&e.forEach((t,e)=>{const s=t.split(":");if(s.length>=2){const t=s[0].trim(),r=s[1].trim(),i=s[2]?parseInt(s[2].trim(),10):6;if(!this.isValidBase32(t))return void this.logger.warn(`Invalid Base32 secret for TOTP user "${r}" (index ${e})`);if(i<6||i>10)return void this.logger.warn(`Invalid digits count for TOTP user "${r}": ${i} (must be 6-10)`);this.totpUsers.push({name:r,secret:t,digits:i})}else this.logger.warn(`Invalid TOTP config format at index ${e}: expected "secret:name[:digits]"`)}),0===this.totpUsers.length?this.logger.warn("No valid TOTP users configured"):this.logger.info(`TOTP authentication initialized with ${this.totpUsers.length} user(s)`)}authMethod(t,s,r){const i=t.headers.authorization;if(!i||!/^Bearer .+/.test(i))return this.logger.warn("Missing or invalid Authorization header"),e(s);const o=i.split(" ")[1];for(const e of this.totpUsers)if(this.verifyTotp(e.secret,o,e.digits))return this.logger.debug(`TOTP authentication successful for user: ${e.name}`),t.user=e.name,r();return this.logger.warn(`Unauthorized TOTP token: ${o}`),e(s)}verifyTotp(t,e,s){const r=Math.floor(Date.now()/1e3);for(let i=-this.window;i<=this.window;i++){const o=Math.floor(r/this.step)+i;if(this.generateTotp(t,o,s)===e)return!0}return!1}generateTotp(e,s,r){const i=this.base32Decode(e),o=Buffer.alloc(8);o.writeBigUInt64BE(BigInt(s));const n=t("sha1",i);n.update(o);const a=n.digest(),h=15&a[a.length-1];return(((127&a[h])<<24|(255&a[h+1])<<16|(255&a[h+2])<<8|255&a[h+3])%Math.pow(10,r)).toString().padStart(r,"0")}base32Decode(t){let e=t.toUpperCase();for(;e.endsWith("=");)e=e.slice(0,-1);let s=0,r=0;const i=[];for(let t=0;t<e.length;t++){const o="ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".indexOf(e[t]);if(-1===o)throw new Error(`Invalid Base32 character: ${e[t]}`);r=r<<5|o,s+=5,s>=8&&(i.push(r>>>s-8&255),s-=8)}return Buffer.from(i)}isValidBase32(t){return/^[A-Z2-7]+=*$/.test(t.toUpperCase())}}export{AuthTotp as default};
@@ -1 +1 @@
1
- import e from"fs";import t from"../../abstract/plugin.js";import{wantJson as r,badRequest as i,ok as s,serverError as a,jsonBody as o,tryMethod as n,notFound as d}from"../../lib/expressFormatedResponses.js";import{transformSchemas as c,launchHooksChained as h,launchHooks as p}from"../../lib/utils.js";class LdapGroups extends t{constructor(t){if(super(t),this.name="ldapGroups",this.roles=["api","configurable"],this.hooks={ldapdeleterequest:async e=>{let t=e;return Array.isArray(t)||(t=[t]),this.logger.debug(`User deletion detected, removing from groups: ${t.join(", ")}`),await Promise.all(t.map(e=>this.deleteMemberFromAll(e))).catch(e=>{this.logger.error("Failed to process user deletion in groups:",e)}),e}},this.ldap=t.ldap,this.base=this.config.ldap_group_base,this.cn=this.config.ldap_groups_main_attribute,this.base||(this.base=this.config.ldap_base,this.logger.warn(`LDAP group base is not defined, using "${this.base}"`)),!this.base)throw new Error("LDAP base is not defined, please set --ldap-group-base");this.config.group_schema&&e.readFile(this.config.group_schema,(e,t)=>{if(e)this.logger.error(`Failed to load group schema from ${this.config.group_schema}: ${e}`);else try{this.schema=JSON.parse(c(t.toString(),this.config)),this.logger.debug("Group schema loaded")}catch(e){this.logger.error(`Failed to parse ${this.config.schemas_path}/group.json: ${e}`)}})}api(e){e.get(`${this.config.api_prefix}/v1/ldap/groups`,async(e,t)=>{if(r(e,t))try{const r={};if(e.query.match&&("string"!=typeof e.query.match||!/^[\w*=()&|]+$/.test(e.query.match)))return i(t,"Invalid match query");e.query.match&&(r.filter=/=/.test(e.query.match)?`${e.query.match}`:`(${this.cn}=${e.query.match})`),e.query.attributes&&"string"==typeof e.query.attributes&&(r.attributes=e.query.attributes.split(","));const a=await this.listGroups(r);return s(t,a)}catch(e){return a(t,e)}}),e.get(`${this.config.api_prefix}/v1/ldap/groups/:cn`,async(e,t)=>this.apiGet(e,t)),e.post(`${this.config.api_prefix}/v1/ldap/groups`,async(e,t)=>this.apiAdd(e,t,this.cn)),e.delete(`${this.config.api_prefix}/v1/ldap/groups/:cn`,async(e,t)=>this.apiDelete(e,t)),e.put(`${this.config.api_prefix}/v1/ldap/groups/:cn`,async(e,t)=>this.apiModify(e,t)),e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/members`,async(e,t)=>{const r=decodeURIComponent(e.params.cn);if(!r)return i(t,"cn is required");const s=o(e,t,"member");s&&await n(t,this.addMember.bind(this),r,s.member)}),e.delete(`${this.config.api_prefix}/v1/ldap/groups/:cn/members/:member`,async(e,t)=>{if(!r(e,t))return;const s=decodeURIComponent(e.params.cn),a=decodeURIComponent(e.params.member);if(!s||!a)return i(t,"cn and member are required");await n(t,this.deleteMember.bind(this),s,a)}),this.config.ldap_organization_link_attribute&&this.config.ldap_organization_path_attribute&&e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/move`,async(e,t)=>{if(!r(e,t))return;const s=decodeURIComponent(e.params.cn);if(!s)return i(t,"cn is required");const a=o(e,t,"targetOrgDn");a&&await n(t,this.moveGroup.bind(this),s,a.targetOrgDn,e)}),e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/rename`,async(e,t)=>{if(!r(e,t))return;const s=decodeURIComponent(e.params.cn);if(!s)return i(t,"cn is required");const a=o(e,t,"newCn");a&&await n(t,this.renameGroup.bind(this),s,a.newCn)})}async apiGet(e,t){if(!r(e,t))return;const i=decodeURIComponent(e.params.cn);try{const e=/,/.test(i)?i:`${this.cn}=${i},${this.base}`,r=await this.ldap.search({paged:!1,scope:"base"},e);if(0===r.searchEntries.length)return d(t,"Group not found");t.json(r.searchEntries[0])}catch(e){return e.code&&32===e.code?d(t,"Group not found"):a(t,e)}}async apiAdd(e,t,...r){const i=o(e,t,...r);if(!i)return;const s=i[this.cn],a=i.member?i.member:[],d=Object.fromEntries(Object.entries(i).filter(([e,t])=>e!==this.cn&&"member"!==e&&void 0!==t&&!(!0===this.schema?.attributes[e]?.fixed)));await n(t,this.addGroup.bind(this),s,a,d)}async apiDelete(e,t){if(!r(e,t))return;const s=decodeURIComponent(e.params.cn);if(!s)return i(t,"cn is required");await n(t,this.deleteGroup.bind(this),s)}async apiModify(e,t){const r=o(e,t);if(!r)return;const s=this.fixDn(decodeURIComponent(e.params.cn));if(!s)return i(t);const a=Object.fromEntries(Object.entries(r).filter(([e,t])=>!(!0===this.schema?.attributes[e]?.fixed)));await n(t,this.modifyGroup.bind(this),s,a)}async addGroup(e,t=[],r={}){let i;new RegExp(`^${this.cn}=`).test(e)?(i=e,e=e.replace(new RegExp(`^${this.cn}=([^,]+).*`),"$1")):i=`${this.cn}=${e},${this.base}`,await this.validateMembers(i,t),await this.validateNewGroup(i,{objectClass:this.config.group_class,cn:e,member:t,...r});let s=[...this.config.group_class];!(r.mail||r.twakeMailboxType||r.twakeDepartmentLink||r.twakeDepartmentPath)||s.includes("twakeGroup")||s.includes("twakeStaticGroup")||s.push("twakeGroup");let a,o={objectClass:s,...this.config.group_default_attributes,[this.cn]:e,member:[this.config.group_dummy_user],...r};t.length&&o.member.push(...t),[i,o]=await h(this.registeredHooks.ldapgroupadd,[i,o]);try{a=await this.ldap.add(i,o)}catch(e){throw new Error(`Failed to add group ${i}: ${e}`)}return p(this.registeredHooks.ldapgroupadddone,[i,o]),a}async modifyGroup(e,t){let r=/,/.test(e)?e:`${this.cn}=${e},${this.base}`;const i=this.opNumber();if([r,t]=await h(this.registeredHooks.ldapgroupmodify,[r,t,i]),t.add){if(t.add.member)throw new Error("Use dedicated API to add members");if(t.add[this.cn])throw new Error(`${this.cn} attribute iq unique, cannot add`)}if(t.delete){if(t.delete instanceof Object){if(t.delete.member)throw new Error("Use dedicated API to delete members");if(t.delete[this.cn])throw new Error(`Cannot delete ${this.cn} attribute`)}if(Array.isArray(t.delete)){if(t.delete.includes("member"))throw new Error("Use dedicated API to delete members");if(t.delete.includes(this.cn))throw new Error(`Cannot delete ${this.cn} attribute`)}}if(t.replace){if(t.replace.member)throw new Error("Use dedicated API to replace members");if(t.replace[this.cn])throw new Error(`Use dedicated API to change ${this.cn} attribute`)}await this.validateChanges(r,t);const s=await this.ldap.modify(r,t);return p(this.registeredHooks.ldapgroupmodifydone,[r,t,i]),s}async renameGroup(e,t){let r=/,/.test(e)?e:`${this.cn}=${e},${this.base}`,i=/,/.test(t)?t:`${this.cn}=${t},${this.base}`;[r,i]=await h(this.registeredHooks.ldapgrouprename,[r,i]);const s=await this.ldap.rename(r,i);return p(this.registeredHooks.ldapgrouprenamedone,[r,i]),s}async deleteGroup(e){let t=/,/.test(e)?e:`${this.cn}=${e},${this.base}`;t=await h(this.registeredHooks.ldapgroupdelete,t);const r=await this.ldap.delete(t);return p(this.registeredHooks.ldapgroupdeletedone,t),r}async addMember(e,t){const r=/,/.test(e)?e:`${this.cn}=${e},${this.base}`;return Array.isArray(t)||(t=[t]),[e,t]=await h(this.registeredHooks.ldapgroupaddmember,[e,t]),await this.validateMembers(r,t),await this.ldap.modify(r,{add:{member:t}}).catch(e=>{throw new Error(`Failed to add member(s) to ${r}: ${e}`)})}async deleteMember(e,t){const r=/,/.test(e)?e:`${this.cn}=${e},${this.base}`;if([e,t]=await h(this.registeredHooks.ldapgroupdeletemember,[e,t]),t===this.config.group_dummy_user)throw new Error("Cannot delete dummy member from group");return await this.ldap.modify(r,{delete:{member:t}}).catch(e=>{throw new Error(`Failed to delete member ${t} from ${r}: ${e}`)})}async deleteMemberFromAll(e){const t=await this.ldap.search({filter:`member=${e}`,paged:!1,attributes:[this.cn]},this.base).catch(e=>{throw new Error(`Failed to search groups from ${this.base}: ${e}`)});await Promise.all(t.searchEntries.map(t=>this.ldap.modify(t.dn,{delete:{member:e}}).catch(r=>this.logger.error(`Failed to remove ${e} from group ${t.dn}:`,r))))}async moveGroup(e,t,r){const i=this.config.ldap_organization_link_attribute,s=this.config.ldap_organization_path_attribute,a=/,/.test(e)?e:`${this.cn}=${e},${this.base}`,o=await this.ldap.search({paged:!1,scope:"base"},a);if(0===o.searchEntries.length)throw new Error(`Group ${a} not found`);const n=o.searchEntries[0][i];if(!n)throw new Error(`Group ${a} does not have ${i} attribute and cannot be moved`);if(n===t)throw new Error("Group is already in the target organization");let d;try{d=await this.ldap.search({paged:!1,scope:"base",attributes:[s,"ou","o"]},t)}catch(e){throw new Error(`Target organization ${t} not found: ${e instanceof Error&&e.message?e.message:String(e)}`)}if(0===d.searchEntries.length)throw new Error(`Target organization ${t} not found`);const c=d.searchEntries[0][s];if(!c)throw new Error(`Target organization ${t} does not have ${s} attribute`);return await this.ldap.modify(a,{replace:{[i]:t,[s]:c}},r),this.logger.info(`Group ${a} moved from ${n} to ${t}`),{success:!0}}async listGroups({filter:e,attributes:t}={}){const r=await this.ldap.search({filter:e||"(objectClass=*)",attributes:t||[this.cn,"member"],paged:!0},this.base).catch(e=>{throw new Error(`Failed to list groups from ${this.base}: ${e}`)});let i={};for await(const e of r)e.searchEntries.map(e=>{const t=e[this.cn];t&&(i[t]=e),Array.isArray(i[t].member)||(i[t].member=[i[t].member]),i[t].member=i[t].member.filter(e=>e!==this.config.group_dummy_user)});return i=await h(this.registeredHooks._ldapgrouplist,i),i}async searchGroupsByName(e,t=!1,r=[this.cn,"member"]){const i=t?`(${this.cn}=*${e}*)`:`(${this.cn}=${e})`;return await this.listGroups({filter:i,attributes:r})}fixDn(e){return!!e&&(/,/.test(e)?e:`${this.cn}=${e},${this.base}`)}async validateMembers(e,t){if([e,t]=await h(this.server.hooks.ldapgroupvalidatemembers,[e,t]),!this.config.groups_allow_unexistent_members&&t&&t.length)try{await Promise.all(t.map(e=>this.server.ldap.queryLimit(async()=>{try{await this.ldap.search({paged:!1,scope:"base"},e)}catch(t){throw new Error(`Member ${e} not found: ${t}`)}})))}catch(e){throw new Error(`Failed to find member(s): ${e}`)}}async validateNewGroup(e,t){if(!this.schema)return!0;[e,t]=await h(this.server.hooks.ldapgroupvalidatenew,[e,t]);for(const[e,r]of Object.entries(t))if(!await this._validateOneChange(e,r))throw new Error(`Invalid value for field ${e}`);for(const[e,r]of Object.entries(this.schema.attributes))if(r.required&&null==t[e])throw new Error(`Missing required field ${e}`);return!0}async validateChanges(e,t){if(!this.schema)return!0;if([e,t]=await h(this.server.hooks.ldapgroupvalidatechanges,[e,t]),t.add)for(const[e,r]of Object.entries(t.add))await this._validateOneChange(e,r);if(t.replace)for(const[e,r]of Object.entries(t.replace))await this._validateOneChange(e,r);if(t.delete&&t.delete instanceof Object)for(const e of Array.isArray(t.delete)?t.delete:Object.keys(t.delete))await this._validateOneChange(e,null);return!0}async _validateOneChange(e,t){if(!this.schema)return!0;const r=this.schema.attributes[e];if(!r){if(this.schema.strict)throw new Error(`Field ${e} is not allowed`);return!0}if(null==t){if(r.required)throw new Error(`Field ${e} is required`);return!0}if("array"===r.type){if(!Array.isArray(t))throw new Error(`Field ${e} must be an array`);if(!r.items)throw new Error(`Schema error: no item for array ${e}`);if("array"===r.items.type)throw new Error(`Schema error: array of array not supported for ${e}`);if(r.items.test){"string"==typeof r.items.test&&(r.items.test=new RegExp(r.items.test));for(let i of t){if(typeof i!==r.items.type)throw new Error(`Field ${e} must be of type ${r.items.type}`);if("string"!=typeof i&&(i=i.toString()),r.items.test&&!r.items.test.test(i))throw new Error(`Field ${e} has invalid value ${i}`)}}}else if("pointer"===r.type){if("string"!=typeof t)throw new Error(`Field ${e} must be a string (DN pointer)`);const i=t;if(r.branch&&r.branch.length>0){if(!r.branch.some(e=>new RegExp(`,?${e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}$`,"i").test(i)))throw new Error(`Field ${e} must point to a DN within allowed branches: ${r.branch.join(", ")}`)}try{const t=await this.ldap.search({paged:!1,scope:"base"},i);if(!t||!t.searchEntries||0===t.searchEntries.length)throw new Error(`Field ${e} points to non-existent DN: ${i}`)}catch(t){throw new Error(`Field ${e} points to invalid or non-existent DN: ${i}: ${t}`)}if(r.test&&("string"==typeof r.test&&(r.test=new RegExp(r.test)),r.test&&!r.test.test(i)))throw new Error(`Field ${e} has invalid value ${i}`)}else{if(typeof t!==r.type)return!1;if("string"!=typeof t&&(t=t.toString()),r.test&&("string"==typeof r.test&&(r.test=new RegExp(r.test)),r.test&&!r.test.test(t)))throw new Error(`Field ${e} has invalid value ${t}`)}return!0}getConfigApiData(){const e=this.config.api_prefix||"/api";let t;if(this.server.loadedPlugins.static&&this.config.group_schema){const e=this.config.static_name||"static",r=this.config.group_schema.indexOf("/schemas/");if(-1!==r){t=`/${e}${this.config.group_schema.substring(r)}`}}return{enabled:!0,base:this.base||"",mainAttribute:this.cn||"cn",objectClass:this.config.group_class||["top","groupOfNames"],schema:this.schema,schemaUrl:t,endpoints:{list:`${e}/v1/ldap/groups`,get:`${e}/v1/ldap/groups/:id`,create:`${e}/v1/ldap/groups`,update:`${e}/v1/ldap/groups/:id`,delete:`${e}/v1/ldap/groups/:id`,addMember:`${e}/v1/ldap/groups/:id/members`,removeMember:`${e}/v1/ldap/groups/:id/members/:memberId`,rename:`${e}/v1/ldap/groups/:id/rename`,move:`${e}/v1/ldap/groups/:id/move`}}}}export{LdapGroups as default};
1
+ import e from"fs";import t from"../../abstract/plugin.js";import{wantJson as r,badRequest as i,ok as s,serverError as a,jsonBody as o,tryMethod as n,notFound as d}from"../../lib/expressFormatedResponses.js";import{transformSchemas as c,launchHooksChained as h,launchHooks as p}from"../../lib/utils.js";class LdapGroups extends t{constructor(t){if(super(t),this.name="ldapGroups",this.roles=["api","configurable"],this.hooks={ldapdeleterequest:async e=>{let t=e;return Array.isArray(t)||(t=[t]),this.logger.debug(`User deletion detected, removing from groups: ${t.join(", ")}`),await Promise.all(t.map(e=>this.deleteMemberFromAll(e))).catch(e=>{this.logger.error("Failed to process user deletion in groups:",e)}),e}},this.ldap=t.ldap,this.base=this.config.ldap_group_base,this.cn=this.config.ldap_groups_main_attribute,this.base||(this.base=this.config.ldap_base,this.logger.warn(`LDAP group base is not defined, using "${this.base}"`)),!this.base)throw new Error("LDAP base is not defined, please set --ldap-group-base");this.config.group_schema&&e.readFile(this.config.group_schema,(e,t)=>{if(e)this.logger.error(`Failed to load group schema from ${this.config.group_schema}: ${e}`);else try{this.schema=JSON.parse(c(t.toString(),this.config)),this.logger.debug("Group schema loaded")}catch(e){this.logger.error(`Failed to parse ${this.config.schemas_path}/group.json: ${e}`)}})}api(e){e.get(`${this.config.api_prefix}/v1/ldap/groups`,async(e,t)=>{if(r(e,t))try{const r={};if(e.query.match&&("string"!=typeof e.query.match||!/^[\w*=()&|]+$/.test(e.query.match)))return i(t,"Invalid match query");e.query.match&&(r.filter=/=/.test(e.query.match)?`${e.query.match}`:`(${this.cn}=${e.query.match})`),e.query.attributes&&"string"==typeof e.query.attributes&&(r.attributes=e.query.attributes.split(","));const a=await this.listGroups(r);return s(t,a)}catch(e){return a(t,e)}}),e.get(`${this.config.api_prefix}/v1/ldap/groups/:cn`,async(e,t)=>this.apiGet(e,t)),e.post(`${this.config.api_prefix}/v1/ldap/groups`,async(e,t)=>this.apiAdd(e,t,this.cn)),e.delete(`${this.config.api_prefix}/v1/ldap/groups/:cn`,async(e,t)=>this.apiDelete(e,t)),e.put(`${this.config.api_prefix}/v1/ldap/groups/:cn`,async(e,t)=>this.apiModify(e,t)),e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/members`,async(e,t)=>{const r=decodeURIComponent(e.params.cn);if(!r)return i(t,"cn is required");const s=o(e,t,"member");s&&await n(t,this.addMember.bind(this),r,s.member)}),e.delete(`${this.config.api_prefix}/v1/ldap/groups/:cn/members/:member`,async(e,t)=>{if(!r(e,t))return;const s=decodeURIComponent(e.params.cn),a=decodeURIComponent(e.params.member);if(!s||!a)return i(t,"cn and member are required");await n(t,this.deleteMember.bind(this),s,a)}),this.config.ldap_organization_link_attribute&&this.config.ldap_organization_path_attribute&&e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/move`,async(e,t)=>{if(!r(e,t))return;const s=decodeURIComponent(e.params.cn);if(!s)return i(t,"cn is required");const a=o(e,t,"targetOrgDn");a&&await n(t,this.moveGroup.bind(this),s,a.targetOrgDn,e)}),e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/rename`,async(e,t)=>{if(!r(e,t))return;const s=decodeURIComponent(e.params.cn);if(!s)return i(t,"cn is required");const a=o(e,t,"newCn");a&&await n(t,this.renameGroup.bind(this),s,a.newCn)})}async apiGet(e,t){if(!r(e,t))return;const i=decodeURIComponent(e.params.cn);try{const e=/,/.test(i)?i:`${this.cn}=${i},${this.base}`,r=await this.ldap.search({paged:!1,scope:"base"},e);if(0===r.searchEntries.length)return d(t,"Group not found");t.json(r.searchEntries[0])}catch(e){return e.code&&32===e.code?d(t,"Group not found"):a(t,e)}}async apiAdd(e,t,...r){const i=o(e,t,...r);if(!i)return;const s=i[this.cn],a=i.member?i.member:[],d=Object.fromEntries(Object.entries(i).filter(([e,t])=>e!==this.cn&&"member"!==e&&void 0!==t&&!(!0===this.schema?.attributes[e]?.fixed)));await n(t,this.addGroup.bind(this),s,a,d)}async apiDelete(e,t){if(!r(e,t))return;const s=decodeURIComponent(e.params.cn);if(!s)return i(t,"cn is required");await n(t,this.deleteGroup.bind(this),s)}async apiModify(e,t){const r=o(e,t);if(!r)return;const s=this.fixDn(decodeURIComponent(e.params.cn));if(!s)return i(t);const a=Object.fromEntries(Object.entries(r).filter(([e,t])=>!(!0===this.schema?.attributes[e]?.fixed)));await n(t,this.modifyGroup.bind(this),s,a)}async addGroup(e,t=[],r={}){let i;new RegExp(`^${this.cn}=`).test(e)?(i=e,e=e.replace(new RegExp(`^${this.cn}=([^,]+).*`),"$1")):i=`${this.cn}=${e},${this.base}`,await this.validateMembers(i,t),await this.validateNewGroup(i,{objectClass:this.config.group_class,cn:e,member:t,...r});const s=[...this.config.group_class];!(r.mail||r.twakeMailboxType||r.twakeDepartmentLink||r.twakeDepartmentPath)||s.includes("twakeGroup")||s.includes("twakeStaticGroup")||s.push("twakeGroup");let a,o={objectClass:s,...this.config.group_default_attributes,[this.cn]:e,member:[this.config.group_dummy_user],...r};t.length&&o.member.push(...t),[i,o]=await h(this.registeredHooks.ldapgroupadd,[i,o]);try{a=await this.ldap.add(i,o)}catch(e){throw new Error(`Failed to add group ${i}: ${e}`)}return p(this.registeredHooks.ldapgroupadddone,[i,o]),a}async modifyGroup(e,t){let r=/,/.test(e)?e:`${this.cn}=${e},${this.base}`;const i=this.opNumber();if([r,t]=await h(this.registeredHooks.ldapgroupmodify,[r,t,i]),t.add){if(t.add.member)throw new Error("Use dedicated API to add members");if(t.add[this.cn])throw new Error(`${this.cn} attribute iq unique, cannot add`)}if(t.delete){if(t.delete instanceof Object){if(t.delete.member)throw new Error("Use dedicated API to delete members");if(t.delete[this.cn])throw new Error(`Cannot delete ${this.cn} attribute`)}if(Array.isArray(t.delete)){if(t.delete.includes("member"))throw new Error("Use dedicated API to delete members");if(t.delete.includes(this.cn))throw new Error(`Cannot delete ${this.cn} attribute`)}}if(t.replace){if(t.replace.member)throw new Error("Use dedicated API to replace members");if(t.replace[this.cn])throw new Error(`Use dedicated API to change ${this.cn} attribute`)}await this.validateChanges(r,t);const s=await this.ldap.modify(r,t);return p(this.registeredHooks.ldapgroupmodifydone,[r,t,i]),s}async renameGroup(e,t){let r=/,/.test(e)?e:`${this.cn}=${e},${this.base}`,i=/,/.test(t)?t:`${this.cn}=${t},${this.base}`;[r,i]=await h(this.registeredHooks.ldapgrouprename,[r,i]);const s=await this.ldap.rename(r,i);return p(this.registeredHooks.ldapgrouprenamedone,[r,i]),s}async deleteGroup(e){let t=/,/.test(e)?e:`${this.cn}=${e},${this.base}`;t=await h(this.registeredHooks.ldapgroupdelete,t);const r=await this.ldap.delete(t);return p(this.registeredHooks.ldapgroupdeletedone,t),r}async addMember(e,t){const r=/,/.test(e)?e:`${this.cn}=${e},${this.base}`;return Array.isArray(t)||(t=[t]),[e,t]=await h(this.registeredHooks.ldapgroupaddmember,[e,t]),await this.validateMembers(r,t),await this.ldap.modify(r,{add:{member:t}}).catch(e=>{throw new Error(`Failed to add member(s) to ${r}: ${e}`)})}async deleteMember(e,t){const r=/,/.test(e)?e:`${this.cn}=${e},${this.base}`;if([e,t]=await h(this.registeredHooks.ldapgroupdeletemember,[e,t]),t===this.config.group_dummy_user)throw new Error("Cannot delete dummy member from group");return await this.ldap.modify(r,{delete:{member:t}}).catch(e=>{throw new Error(`Failed to delete member ${t} from ${r}: ${e}`)})}async deleteMemberFromAll(e){const t=await this.ldap.search({filter:`member=${e}`,paged:!1,attributes:[this.cn]},this.base).catch(e=>{throw new Error(`Failed to search groups from ${this.base}: ${e}`)});await Promise.all(t.searchEntries.map(t=>this.ldap.modify(t.dn,{delete:{member:e}}).catch(r=>this.logger.error(`Failed to remove ${e} from group ${t.dn}:`,r))))}async moveGroup(e,t,r){const i=this.config.ldap_organization_link_attribute,s=this.config.ldap_organization_path_attribute,a=/,/.test(e)?e:`${this.cn}=${e},${this.base}`,o=await this.ldap.search({paged:!1,scope:"base"},a);if(0===o.searchEntries.length)throw new Error(`Group ${a} not found`);const n=o.searchEntries[0][i];if(!n)throw new Error(`Group ${a} does not have ${i} attribute and cannot be moved`);if(n===t)throw new Error("Group is already in the target organization");let d;try{d=await this.ldap.search({paged:!1,scope:"base",attributes:[s,"ou","o"]},t)}catch(e){throw new Error(`Target organization ${t} not found: ${e instanceof Error&&e.message?e.message:String(e)}`)}if(0===d.searchEntries.length)throw new Error(`Target organization ${t} not found`);const c=d.searchEntries[0][s];if(!c)throw new Error(`Target organization ${t} does not have ${s} attribute`);return await this.ldap.modify(a,{replace:{[i]:t,[s]:c}},r),this.logger.info(`Group ${a} moved from ${n} to ${t}`),{success:!0}}async listGroups({filter:e,attributes:t}={}){const r=await this.ldap.search({filter:e||"(objectClass=*)",attributes:t||[this.cn,"member"],paged:!0},this.base).catch(e=>{throw new Error(`Failed to list groups from ${this.base}: ${e}`)});let i={};for await(const e of r)e.searchEntries.map(e=>{const t=e[this.cn];t&&(i[t]=e),Array.isArray(i[t].member)||(i[t].member=[i[t].member]),i[t].member=i[t].member.filter(e=>e!==this.config.group_dummy_user)});return i=await h(this.registeredHooks._ldapgrouplist,i),i}async searchGroupsByName(e,t=!1,r=[this.cn,"member"]){const i=t?`(${this.cn}=*${e}*)`:`(${this.cn}=${e})`;return await this.listGroups({filter:i,attributes:r})}fixDn(e){return!!e&&(/,/.test(e)?e:`${this.cn}=${e},${this.base}`)}async validateMembers(e,t){if([e,t]=await h(this.server.hooks.ldapgroupvalidatemembers,[e,t]),!this.config.groups_allow_unexistent_members&&t&&t.length)try{await Promise.all(t.map(e=>this.server.ldap.queryLimit(async()=>{try{await this.ldap.search({paged:!1,scope:"base"},e)}catch(t){throw new Error(`Member ${e} not found: ${t}`)}})))}catch(e){throw new Error(`Failed to find member(s): ${e}`)}}async validateNewGroup(e,t){if(!this.schema)return!0;[e,t]=await h(this.server.hooks.ldapgroupvalidatenew,[e,t]);for(const[e,r]of Object.entries(t))if(!await this._validateOneChange(e,r))throw new Error(`Invalid value for field ${e}`);for(const[e,r]of Object.entries(this.schema.attributes))if(r.required&&null==t[e])throw new Error(`Missing required field ${e}`);return!0}async validateChanges(e,t){if(!this.schema)return!0;if([e,t]=await h(this.server.hooks.ldapgroupvalidatechanges,[e,t]),t.add)for(const[e,r]of Object.entries(t.add))await this._validateOneChange(e,r);if(t.replace)for(const[e,r]of Object.entries(t.replace))await this._validateOneChange(e,r);if(t.delete&&t.delete instanceof Object)for(const e of Array.isArray(t.delete)?t.delete:Object.keys(t.delete))await this._validateOneChange(e,null);return!0}async _validateOneChange(e,t){if(!this.schema)return!0;const r=this.schema.attributes[e];if(!r){if(this.schema.strict)throw new Error(`Field ${e} is not allowed`);return!0}if(null==t){if(r.required)throw new Error(`Field ${e} is required`);return!0}if("array"===r.type){if(!Array.isArray(t))throw new Error(`Field ${e} must be an array`);if(!r.items)throw new Error(`Schema error: no item for array ${e}`);if("array"===r.items.type)throw new Error(`Schema error: array of array not supported for ${e}`);if(r.items.test){"string"==typeof r.items.test&&(r.items.test=new RegExp(r.items.test));for(let i of t){if(typeof i!==r.items.type)throw new Error(`Field ${e} must be of type ${r.items.type}`);if("string"!=typeof i&&(i=i.toString()),r.items.test&&!r.items.test.test(i))throw new Error(`Field ${e} has invalid value ${i}`)}}}else if("pointer"===r.type){if("string"!=typeof t)throw new Error(`Field ${e} must be a string (DN pointer)`);const i=t;if(r.branch&&r.branch.length>0){if(!r.branch.some(e=>new RegExp(`,?${e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}$`,"i").test(i)))throw new Error(`Field ${e} must point to a DN within allowed branches: ${r.branch.join(", ")}`)}try{const t=await this.ldap.search({paged:!1,scope:"base"},i);if(!t||!t.searchEntries||0===t.searchEntries.length)throw new Error(`Field ${e} points to non-existent DN: ${i}`)}catch(t){throw new Error(`Field ${e} points to invalid or non-existent DN: ${i}: ${t}`)}if(r.test&&("string"==typeof r.test&&(r.test=new RegExp(r.test)),r.test&&!r.test.test(i)))throw new Error(`Field ${e} has invalid value ${i}`)}else{if(typeof t!==r.type)return!1;if("string"!=typeof t&&(t=t.toString()),r.test&&("string"==typeof r.test&&(r.test=new RegExp(r.test)),r.test&&!r.test.test(t)))throw new Error(`Field ${e} has invalid value ${t}`)}return!0}getConfigApiData(){const e=this.config.api_prefix||"/api";let t;if(this.server.loadedPlugins.static&&this.config.group_schema){const e=this.config.static_name||"static",r=this.config.group_schema.indexOf("/schemas/");if(-1!==r){t=`/${e}${this.config.group_schema.substring(r)}`}}return{enabled:!0,base:this.base||"",mainAttribute:this.cn||"cn",objectClass:this.config.group_class||["top","groupOfNames"],schema:this.schema,schemaUrl:t,endpoints:{list:`${e}/v1/ldap/groups`,get:`${e}/v1/ldap/groups/:id`,create:`${e}/v1/ldap/groups`,update:`${e}/v1/ldap/groups/:id`,delete:`${e}/v1/ldap/groups/:id`,addMember:`${e}/v1/ldap/groups/:id/members`,removeMember:`${e}/v1/ldap/groups/:id/members/:memberId`,rename:`${e}/v1/ldap/groups/:id/rename`,move:`${e}/v1/ldap/groups/:id/move`}}}}export{LdapGroups as default};
@@ -18,7 +18,7 @@ import type { Hooks } from '../hooks';
18
18
  import ldapActions from '../lib/ldapActions';
19
19
  import type DmPlugin from '../abstract/plugin';
20
20
  export type { Config };
21
- export { asyncHandler } from '../lib/utils';
21
+ export * from '../lib/utils';
22
22
  /**
23
23
  * @class DM
24
24
  */
@@ -2,8 +2,8 @@
2
2
  * Utility functions
3
3
  * @author Xavier Guimard <xguimard@linagora.com>
4
4
  */
5
- import type { Config } from '../bin';
6
5
  import type { Request, Response, NextFunction, RequestHandler } from 'express';
6
+ import type { Config } from '../bin';
7
7
  export declare const launchHooks: (hooks: Function[] | undefined, ...args: unknown[]) => Promise<void>;
8
8
  export declare const launchHooksChained: <T>(hooks: Function[] | undefined, args: T) => Promise<T>;
9
9
  export declare const transformSchemas: (schemas: string | Buffer, config: Config) => string;
@@ -1,10 +1,3 @@
1
- /**
2
- * @module plugins/auth/totp
3
- * @author Xavier Guimard <xguimard@linagora.com>
4
- *
5
- * TOTP-based authentication plugin
6
- * @group Plugins
7
- */
8
1
  import type { Response } from 'express';
9
2
  import AuthBase, { type DmRequest } from '../../lib/auth/base';
10
3
  import type { Role } from '../../abstract/plugin';
package/docs/README.md CHANGED
@@ -237,6 +237,7 @@ export DM_LDAP_URL="ldap://ldap1.example.com,ldap://ldap2.example.com,ldap://lda
237
237
  ```
238
238
 
239
239
  The system will:
240
+
240
241
  1. Try to connect to each URL in order
241
242
  2. Use the first successful connection
242
243
  3. Automatically failover to the next URL if the current connection fails
@@ -477,6 +478,7 @@ ldap-rest --log-level debug ...
477
478
  ```
478
479
 
479
480
  Available log levels (following syslog convention):
481
+
480
482
  - `error` - Only errors
481
483
  - `warn` - Warnings and errors
482
484
  - `notice` - Web access logs, warnings and errors (recommended for production)
@@ -214,13 +214,13 @@ import {
214
214
  TotpAuthClient,
215
215
  generateTotp,
216
216
  getRemainingSeconds,
217
- isValidBase32
217
+ isValidBase32,
218
218
  } from 'ldap-rest/browser-shared-utils-totp';
219
219
 
220
220
  const client = new TotpAuthClient({
221
221
  secret: 'JBSWY3DPEHPK3PXP',
222
222
  digits: 6,
223
- step: 30
223
+ step: 30,
224
224
  });
225
225
 
226
226
  // Automatic authentication with current TOTP code
@@ -254,6 +254,7 @@ console.log(`Current TOTP code: ${code}`);
254
254
  ```
255
255
 
256
256
  **In logs:**
257
+
257
258
  ```
258
259
  INFO: TOTP authentication successful for user: admin
259
260
  INFO: TOTP authentication successful for user: api-service
@@ -396,6 +397,7 @@ DM_AUTH_HMAC_WINDOW=120000 # Default: 120000ms (2 minutes)
396
397
  #### Request Signing Process
397
398
 
398
399
  1. **Client calculates signature:**
400
+
399
401
  ```
400
402
  signature = HMAC-SHA256(
401
403
  secret,
@@ -410,6 +412,7 @@ DM_AUTH_HMAC_WINDOW=120000 # Default: 120000ms (2 minutes)
410
412
  - `body-hash`: SHA256(request_body) for POST/PATCH/PUT, empty string for GET/DELETE/HEAD
411
413
 
412
414
  2. **Client sends Authorization header:**
415
+
413
416
  ```
414
417
  Authorization: HMAC-SHA256 service-id:timestamp:signature
415
418
  ```
@@ -492,7 +495,7 @@ import { HmacAuthClient } from 'ldap-rest/browser-shared-utils-hmac';
492
495
 
493
496
  const client = new HmacAuthClient({
494
497
  serviceId: 'registration-service',
495
- secret: 'secret-key-minimum-32-chars'
498
+ secret: 'secret-key-minimum-32-chars',
496
499
  });
497
500
 
498
501
  // Automatic authentication with HMAC signature
@@ -502,7 +505,7 @@ const users = await response.json();
502
505
  // POST with automatic body hashing and signing
503
506
  await client.post('/api/v1/ldap/users', {
504
507
  uid: 'user1',
505
- mail: 'user1@example.com'
508
+ mail: 'user1@example.com',
506
509
  });
507
510
 
508
511
  // Other methods: put, patch, delete
@@ -520,7 +523,7 @@ const signature = await generateHmacSignature(
520
523
  'GET',
521
524
  '/api/v1/ldap/users',
522
525
  Date.now(),
523
- undefined // no body for GET
526
+ undefined // no body for GET
524
527
  );
525
528
 
526
529
  const authHeader = `HMAC-SHA256 registration-service:${Date.now()}:${signature}`;
@@ -558,15 +561,15 @@ const authHeader = `HMAC-SHA256 registration-service:${Date.now()}:${signature}`
558
561
 
559
562
  ### Advantages Over Other Methods
560
563
 
561
- | Feature | HMAC | Token | TOTP |
562
- | -------------------------- | ------------------ | ------------------ | ------------------ |
563
- | **Request Integrity** | ✅ Yes (body hash) | ❌ No | ❌ No |
564
- | **Replay Protection** | ✅ Yes (timestamp) | ❌ No | ✅ Yes (time-based)|
565
- | **Signature Per Request** | ✅ Yes | ❌ No (static) | ✅ Yes (30s codes) |
566
- | **Body Tampering Detect** | ✅ Yes | ❌ No | ❌ No |
567
- | **Path/Query Protection** | ✅ Yes | ❌ No | ❌ No |
568
- | **Setup Complexity** | Medium | Simple | Simple |
569
- | **Best For** | Backend services | Simple APIs | MFA, user auth |
564
+ | Feature | HMAC | Token | TOTP |
565
+ | ------------------------- | ------------------ | -------------- | ------------------- |
566
+ | **Request Integrity** | ✅ Yes (body hash) | ❌ No | ❌ No |
567
+ | **Replay Protection** | ✅ Yes (timestamp) | ❌ No | ✅ Yes (time-based) |
568
+ | **Signature Per Request** | ✅ Yes | ❌ No (static) | ✅ Yes (30s codes) |
569
+ | **Body Tampering Detect** | ✅ Yes | ❌ No | ❌ No |
570
+ | **Path/Query Protection** | ✅ Yes | ❌ No | ❌ No |
571
+ | **Setup Complexity** | Medium | Simple | Simple |
572
+ | **Best For** | Backend services | Simple APIs | MFA, user auth |
570
573
 
571
574
  ### Comparison with Standards
572
575
 
@@ -577,6 +580,7 @@ This implementation is similar to:
577
580
  - **HMAC-Based Authentication**: Industry-standard pattern for service authentication
578
581
 
579
582
  Our approach provides a good balance between security and simplicity:
583
+
580
584
  - ✅ Simpler than AWS Signature V4 (easier to implement)
581
585
  - ✅ More secure than static tokens (request-specific signatures)
582
586
  - ✅ Better integrity than TOTP (includes body and path hashing)
@@ -856,18 +860,18 @@ hooks: {
856
860
 
857
861
  ## Choosing an Authentication Method
858
862
 
859
- | Feature | Token | TOTP | HMAC | LemonLDAP::NG | OpenID Connect |
860
- | ---------------------- | ------------- | ----------------- | --------------------- | --------------------- | -------------------------------- |
861
- | **Setup Complexity** | Simple | Simple | Medium | Medium | Medium |
862
- | **User Management** | None | Manual | Manual (service-based)| External (LLNG) | External (Provider) |
863
- | **SSO Support** | No | No | No | Yes | Yes |
864
- | **MFA Support** | No | Yes (TOTP itself) | N/A | Yes (via LLNG) | Yes (via Provider) |
865
- | **Session Management** | Stateless | Stateless | Stateless | LLNG Sessions | OIDC Sessions |
866
- | **Code Expiration** | Never | 30-60 seconds | Per-request | Session-based | Session-based |
867
- | **Request Integrity** | No | No | Yes (body + path hash)| No | No |
868
- | **Replay Protection** | No | Yes (time-based) | Yes (timestamp) | Session-based | Session-based |
869
- | **Best For** | APIs, Scripts | APIs, Enhanced | Backend Services | Enterprises with LLNG | Cloud/SaaS, Enterprises with SSO |
870
- | **Dependencies** | None | None | None | lemonldap-ng-handler | express-openid-connect |
863
+ | Feature | Token | TOTP | HMAC | LemonLDAP::NG | OpenID Connect |
864
+ | ---------------------- | ------------- | ----------------- | ---------------------- | --------------------- | -------------------------------- |
865
+ | **Setup Complexity** | Simple | Simple | Medium | Medium | Medium |
866
+ | **User Management** | None | Manual | Manual (service-based) | External (LLNG) | External (Provider) |
867
+ | **SSO Support** | No | No | No | Yes | Yes |
868
+ | **MFA Support** | No | Yes (TOTP itself) | N/A | Yes (via LLNG) | Yes (via Provider) |
869
+ | **Session Management** | Stateless | Stateless | Stateless | LLNG Sessions | OIDC Sessions |
870
+ | **Code Expiration** | Never | 30-60 seconds | Per-request | Session-based | Session-based |
871
+ | **Request Integrity** | No | No | Yes (body + path hash) | No | No |
872
+ | **Replay Protection** | No | Yes (time-based) | Yes (timestamp) | Session-based | Session-based |
873
+ | **Best For** | APIs, Scripts | APIs, Enhanced | Backend Services | Enterprises with LLNG | Cloud/SaaS, Enterprises with SSO |
874
+ | **Dependencies** | None | None | None | lemonldap-ng-handler | express-openid-connect |
871
875
 
872
876
  ## Combining with Authorization
873
877
 
@@ -68,6 +68,7 @@ LDAP-Rest includes robust error handling to ensure the server remains operationa
68
68
  #### Global Error Handling
69
69
 
70
70
  The server automatically catches and handles:
71
+
71
72
  - **Uncaught exceptions** in plugin code
72
73
  - **Unhandled promise rejections**
73
74
  - **Errors in async route handlers**
@@ -84,10 +85,13 @@ import DmPlugin, { asyncHandler } from '../../abstract/plugin';
84
85
  export default class MyPlugin extends DmPlugin {
85
86
  api(app: Express): void {
86
87
  // ✅ Correct: Errors are automatically caught
87
- app.get('/api/data', asyncHandler(async (req, res) => {
88
- const data = await someAsyncOperation();
89
- res.json(data);
90
- }));
88
+ app.get(
89
+ '/api/data',
90
+ asyncHandler(async (req, res) => {
91
+ const data = await someAsyncOperation();
92
+ res.json(data);
93
+ })
94
+ );
91
95
 
92
96
  // ❌ Wrong: Errors can crash the server
93
97
  app.get('/api/data', async (req, res) => {
@@ -99,6 +103,7 @@ export default class MyPlugin extends DmPlugin {
99
103
  ```
100
104
 
101
105
  The `asyncHandler` is exported from:
106
+
102
107
  - `ldap-rest/abstract/plugin` (recommended for plugins)
103
108
  - `ldap-rest` (for external use)
104
109
 
@@ -666,6 +671,7 @@ The `asyncHandler` wrapper automatically catches errors and passes them to the g
666
671
  - **Clean code**: No need for try/catch blocks in every route
667
672
 
668
673
  **Without asyncHandler (DON'T DO THIS):**
674
+
669
675
  ```typescript
670
676
  // ❌ BAD: Unhandled async errors can crash the server
671
677
  app.get('/api/data', async (req, res) => {
@@ -675,34 +681,42 @@ app.get('/api/data', async (req, res) => {
675
681
  ```
676
682
 
677
683
  **With asyncHandler (RECOMMENDED):**
684
+
678
685
  ```typescript
679
686
  // ✅ GOOD: Errors are caught and handled gracefully
680
- app.get('/api/data', asyncHandler(async (req, res) => {
681
- const data = await fetchData(); // If this throws, error is logged and 500 returned
682
- res.json(data);
683
- }));
687
+ app.get(
688
+ '/api/data',
689
+ asyncHandler(async (req, res) => {
690
+ const data = await fetchData(); // If this throws, error is logged and 500 returned
691
+ res.json(data);
692
+ })
693
+ );
684
694
  ```
685
695
 
686
696
  **Custom error handling:**
697
+
687
698
  ```typescript
688
699
  // You can still handle specific errors manually
689
- app.post('/api/process', asyncHandler(async (req, res) => {
690
- try {
691
- const result = await processData(req.body);
692
- res.json({ success: true, data: result });
693
- } catch (error) {
694
- if (error instanceof ValidationError) {
695
- // Handle specific error types
696
- return res.status(400).json({
697
- success: false,
698
- error: 'Validation failed',
699
- details: error.details
700
- });
700
+ app.post(
701
+ '/api/process',
702
+ asyncHandler(async (req, res) => {
703
+ try {
704
+ const result = await processData(req.body);
705
+ res.json({ success: true, data: result });
706
+ } catch (error) {
707
+ if (error instanceof ValidationError) {
708
+ // Handle specific error types
709
+ return res.status(400).json({
710
+ success: false,
711
+ error: 'Validation failed',
712
+ details: error.details,
713
+ });
714
+ }
715
+ // Re-throw to let asyncHandler handle it
716
+ throw error;
701
717
  }
702
- // Re-throw to let asyncHandler handle it
703
- throw error;
704
- }
705
- }));
718
+ })
719
+ );
706
720
  ```
707
721
 
708
722
  ### Logging
@@ -47,6 +47,7 @@ The test framework automatically detects which LDAP server to use:
47
47
  ### Test Output Examples
48
48
 
49
49
  **With embedded LDAP (default):**
50
+
50
51
  ```
51
52
  🚀 Setting up global test environment...
52
53
 
@@ -62,6 +63,7 @@ Loading initial LDIF from .../base-structure.ldif...
62
63
  ```
63
64
 
64
65
  **With external LDAP:**
66
+
65
67
  ```
66
68
  🚀 Setting up global test environment...
67
69
 
@@ -75,12 +77,14 @@ Loading initial LDIF from .../base-structure.ldif...
75
77
  ### Running Tests
76
78
 
77
79
  **Using embedded LDAP (no setup required):**
80
+
78
81
  ```bash
79
82
  npm test
80
83
  npm run test:one test/plugins/ldap/flatGeneric.test.ts
81
84
  ```
82
85
 
83
86
  **Using external LDAP:**
87
+
84
88
  ```bash
85
89
  export DM_LDAP_URL=ldap://localhost:389
86
90
  export DM_LDAP_DN=cn=admin,dc=example,dc=com
@@ -93,6 +97,7 @@ npm test
93
97
  ### In CI/CD
94
98
 
95
99
  **GitHub Actions example:**
100
+
96
101
  ```yaml
97
102
  name: Tests
98
103
 
@@ -213,7 +218,7 @@ describe('My plugin', () => {
213
218
  let server: DM;
214
219
 
215
220
  before(async () => {
216
- server = new DM(); // Reads env vars automatically
221
+ server = new DM(); // Reads env vars automatically
217
222
  await server.ready;
218
223
  });
219
224
 
@@ -279,7 +284,7 @@ docker rm -f $(docker ps -a | grep ldap-test | awk '{print $1}')
279
284
  Increase timeout in your test if LDAP is slow to start:
280
285
 
281
286
  ```typescript
282
- before(async function() {
287
+ before(async function () {
283
288
  this.timeout(120000); // 2 minutes
284
289
  // ...
285
290
  });
@@ -305,7 +310,7 @@ No changes needed! Tests using `skipIfMissingEnvVars()` will now always pass:
305
310
  import { skipIfMissingEnvVars, LDAP_ENV_VARS } from '../../helpers/env';
306
311
 
307
312
  describe('My test', () => {
308
- before(function() {
313
+ before(function () {
309
314
  skipIfMissingEnvVars(this, [...LDAP_ENV_VARS]); // Still works!
310
315
  });
311
316
  });
@@ -324,7 +329,7 @@ describe('New plugin', () => {
324
329
  let server: DM;
325
330
 
326
331
  before(async () => {
327
- server = new DM(); // That's it!
332
+ server = new DM(); // That's it!
328
333
  await server.ready;
329
334
  });
330
335
 
package/docs/weblogs.md CHANGED
@@ -175,6 +175,7 @@ Configure log level to control verbosity:
175
175
  ```
176
176
 
177
177
  The log level hierarchy (following syslog convention):
178
+
178
179
  - **error** (0) - Error messages only
179
180
  - **warn** (1) - Warnings and errors
180
181
  - **notice** (2) - Web access logs, warnings and errors
@@ -9,6 +9,7 @@ This directory contains interactive HTML examples demonstrating the use of LDAP-
9
9
  Interactive demonstration of the TOTP authentication client library.
10
10
 
11
11
  **Features:**
12
+
12
13
  - Real-time TOTP code generation
13
14
  - Countdown timer with visual indicator
14
15
  - Copy to clipboard functionality
@@ -17,6 +18,7 @@ Interactive demonstration of the TOTP authentication client library.
17
18
  - Code examples for npm usage
18
19
 
19
20
  **Usage:**
21
+
20
22
  ```bash
21
23
  # Start the LDAP-Rest server
22
24
  npm start
@@ -26,12 +28,13 @@ http://localhost:8081/static/examples/web/totp-client.html
26
28
  ```
27
29
 
28
30
  **NPM Module:**
31
+
29
32
  ```typescript
30
33
  import {
31
34
  generateTotp,
32
35
  TotpAuthClient,
33
36
  getRemainingSeconds,
34
- isValidBase32
37
+ isValidBase32,
35
38
  } from 'ldap-rest/browser-shared-utils-totp';
36
39
  ```
37
40
 
@@ -40,6 +43,7 @@ import {
40
43
  Visualize and navigate LDAP directory structure.
41
44
 
42
45
  **Features:**
46
+
43
47
  - Interactive tree navigation
44
48
  - Node details display
45
49
  - Expand/collapse controls
@@ -50,6 +54,7 @@ Visualize and navigate LDAP directory structure.
50
54
  Browse and edit LDAP user entries.
51
55
 
52
56
  **Features:**
57
+
53
58
  - User list and tree view
54
59
  - Edit user properties
55
60
  - Move users between branches
@@ -60,6 +65,7 @@ Browse and edit LDAP user entries.
60
65
  Manage LDAP groups and memberships.
61
66
 
62
67
  **Features:**
68
+
63
69
  - Group tree navigation
64
70
  - Edit group properties
65
71
  - Manage members
@@ -70,6 +76,7 @@ Manage LDAP groups and memberships.
70
76
  Manage organizational units.
71
77
 
72
78
  **Features:**
79
+
73
80
  - Unit tree structure
74
81
  - Edit unit properties
75
82
  - Move units
@@ -78,6 +85,7 @@ Manage organizational units.
78
85
  ## Running Examples
79
86
 
80
87
  1. **Start the server:**
88
+
81
89
  ```bash
82
90
  npm start
83
91
  # or
@@ -87,6 +95,7 @@ Manage organizational units.
87
95
  2. **Configure authentication:**
88
96
 
89
97
  For TOTP example:
98
+
90
99
  ```bash
91
100
  npm start -- \
92
101
  --plugin core/auth/totp \
@@ -164,7 +164,7 @@
164
164
  const dn = searchBtn.getAttribute('data-parent-dn');
165
165
  performSearch(dn, searchInput.value);
166
166
  });
167
- searchInput.addEventListener('keypress', (e) => {
167
+ searchInput.addEventListener('keypress', e => {
168
168
  if (e.key === 'Enter') {
169
169
  searchBtn.click();
170
170
  }