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 +1 -1
- package/dist/lib/utils.js +1 -1
- package/dist/plugins/auth/totp.js +1 -1
- package/dist/plugins/ldap/groups.js +1 -1
- package/dist/src/bin/index.d.ts +1 -1
- package/dist/src/lib/utils.d.ts +1 -1
- package/dist/src/plugins/auth/totp.d.ts +0 -7
- package/docs/README.md +2 -0
- package/docs/authentication.md +30 -26
- package/docs/plugins/DEVELOPMENT.md +38 -24
- package/docs/testing-with-embedded-ldap.md +9 -4
- package/docs/weblogs.md +1 -0
- package/examples/web/README.md +10 -1
- package/examples/web/ldap-tree-viewer.html +1 -1
- package/examples/web/totp-client.html +46 -48
- package/package.json +98 -98
- package/static/browser/shared/utils/hmac.js +3 -3
- package/static/browser/shared/utils/hmac.js.map +1 -1
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
|
|
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){
|
|
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};
|
package/dist/src/bin/index.d.ts
CHANGED
|
@@ -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
|
|
21
|
+
export * from '../lib/utils';
|
|
22
22
|
/**
|
|
23
23
|
* @class DM
|
|
24
24
|
*/
|
package/dist/src/lib/utils.d.ts
CHANGED
|
@@ -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)
|
package/docs/authentication.md
CHANGED
|
@@ -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
|
|
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
|
|
562
|
-
|
|
|
563
|
-
| **Request Integrity**
|
|
564
|
-
| **Replay Protection**
|
|
565
|
-
| **Signature Per Request**
|
|
566
|
-
| **Body Tampering Detect**
|
|
567
|
-
| **Path/Query Protection**
|
|
568
|
-
| **Setup Complexity**
|
|
569
|
-
| **Best For**
|
|
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
|
|
860
|
-
| ---------------------- | ------------- | ----------------- |
|
|
861
|
-
| **Setup Complexity** | Simple | Simple | Medium
|
|
862
|
-
| **User Management** | None | Manual | Manual (service-based)| External (LLNG) | External (Provider) |
|
|
863
|
-
| **SSO Support** | No | No | No
|
|
864
|
-
| **MFA Support** | No | Yes (TOTP itself) | N/A
|
|
865
|
-
| **Session Management** | Stateless | Stateless | Stateless
|
|
866
|
-
| **Code Expiration** | Never | 30-60 seconds | Per-request
|
|
867
|
-
| **Request Integrity** | No | No | Yes (body + path hash)| No | No |
|
|
868
|
-
| **Replay Protection** | No | Yes (time-based) | Yes (timestamp)
|
|
869
|
-
| **Best For** | APIs, Scripts | APIs, Enhanced | Backend Services
|
|
870
|
-
| **Dependencies** | None | None | None
|
|
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(
|
|
88
|
-
|
|
89
|
-
res
|
|
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(
|
|
681
|
-
|
|
682
|
-
res
|
|
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(
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
703
|
-
|
|
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();
|
|
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();
|
|
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
|
package/examples/web/README.md
CHANGED
|
@@ -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',
|
|
167
|
+
searchInput.addEventListener('keypress', e => {
|
|
168
168
|
if (e.key === 'Enter') {
|
|
169
169
|
searchBtn.click();
|
|
170
170
|
}
|