ldap-rest 2.1.0 → 2.2.0
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/lib/authz/base.js +1 -1
- package/dist/lib/ldapFilter.js +1 -0
- package/dist/plugins/auth/auth.options.js +1 -1
- package/dist/plugins/auth/authzLinid1.js +1 -1
- package/dist/plugins/auth/authzScope.js +1 -1
- package/dist/plugins/auth/crowdsec.js +1 -1
- package/dist/plugins/auth/rateLimit.js +1 -1
- package/dist/plugins/auth/totp.js +1 -1
- package/dist/plugins/auth/trustedProxy.js +1 -1
- package/dist/plugins/ldap/bulkImport.js +1 -1
- package/dist/plugins/ldap/raw.js +1 -1
- package/dist/plugins/weblogs.js +1 -1
- package/dist/src/bin/index.d.ts +2 -2
- package/dist/src/browser/shared/utils/totp.d.ts +5 -1
- package/dist/src/lib/authz/base.d.ts +23 -9
- package/dist/src/lib/ldapFilter.d.ts +19 -0
- package/dist/src/plugins/auth/auth.options.d.ts +1 -0
- package/dist/src/plugins/auth/authzLinid1.d.ts +103 -9
- package/dist/src/plugins/auth/authzScope.d.ts +4 -0
- package/dist/src/plugins/auth/crowdsec.d.ts +7 -0
- package/dist/src/plugins/auth/rateLimit.d.ts +7 -0
- package/dist/src/plugins/auth/totp.d.ts +11 -0
- package/dist/src/plugins/auth/trustedProxy.d.ts +23 -0
- package/dist/src/plugins/ldap/bulkImport.d.ts +7 -0
- package/docs/client-development/schemas/README.md +8 -7
- package/docs/usage/configuration.md +8 -7
- package/docs/usage/plugins/auth/README.md +12 -12
- package/docs/usage/plugins/auth/authz-linid1.md +69 -0
- package/docs/usage/plugins/auth/authz-scope.md +14 -0
- package/docs/usage/plugins/auth/rate-limit.md +11 -6
- package/docs/usage/plugins/auth/totp.md +37 -17
- package/docs/usage/plugins/auth/trusted-proxy.md +9 -1
- package/docs/usage/upgrading.md +26 -1
- package/examples/web/totp-client.html +16 -9
- package/package.json +1 -1
- package/static/browser/ldap-unit-editor/LdapUnitEditor.js +2 -1
- package/static/browser/ldap-unit-editor/LdapUnitEditor.js.map +1 -1
- package/static/browser/ldap-unit-editor.esm.js +23 -1
- package/static/browser/ldap-unit-editor.esm.js.map +1 -1
- package/static/browser/ldap-unit-editor.js +23 -1
- package/static/browser/ldap-unit-editor.js.map +1 -1
- package/static/browser/ldap-unit-editor.min.js +1 -1
- package/static/browser/ldap-unit-editor.min.js.map +1 -1
- package/static/browser/shared/utils/totp.js +2 -1
- package/static/browser/shared/utils/totp.js.map +1 -1
- package/static/browser/shared.esm.js +2 -1
- package/static/browser/shared.esm.js.map +1 -1
- package/static/browser/shared.js +2 -1
- package/static/browser/shared.js.map +1 -1
- package/static/browser/shared.min.js +1 -1
- package/static/browser/shared.min.js.map +1 -1
- package/static/schemas/twake/groups.json +15 -0
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module plugins/auth/authzLinid1
|
|
3
|
-
* @author Xavier Guimard <xguimard@linagora.com>
|
|
4
|
-
*
|
|
5
|
-
* Authorization plugin based on twakeLocalAdminLink LDAP attribute
|
|
6
|
-
* Grants permissions to users referenced in organization's twakeLocalAdminLink
|
|
7
|
-
* @group Plugins
|
|
8
|
-
*/
|
|
9
1
|
import type { DM } from '../../bin/index.js';
|
|
10
2
|
import type { Hooks } from '../../hooks.js';
|
|
11
3
|
import type { BranchPermissions } from '../../config/args.js';
|
|
@@ -21,6 +13,7 @@ export default class AuthzLinid1 extends AuthzBase {
|
|
|
21
13
|
authz_global_branch: import("../../config/options.js").ArrayOption;
|
|
22
14
|
authz_global_write_branch: import("../../config/options.js").ArrayOption;
|
|
23
15
|
authz_global_organization: import("../../config/options.js").StringOption;
|
|
16
|
+
authz_group_admin_attribute: import("../../config/options.js").StringOption;
|
|
24
17
|
};
|
|
25
18
|
name: string;
|
|
26
19
|
permissionsCache: Map<string, CachedPermissions>;
|
|
@@ -30,12 +23,97 @@ export default class AuthzLinid1 extends AuthzBase {
|
|
|
30
23
|
* into the cache afterwards.
|
|
31
24
|
*/
|
|
32
25
|
private permissionsGeneration;
|
|
33
|
-
|
|
26
|
+
/** The delegation attribute, when the feature is on. */
|
|
27
|
+
private readonly groupAdminAttr;
|
|
28
|
+
private readonly adminAttr;
|
|
29
|
+
private reducedFilterNames?;
|
|
30
|
+
/**
|
|
31
|
+
* Marks a search whose projection we widened for the delegation attribute.
|
|
32
|
+
* As in the base class, a symbol survives the copies another plugin makes.
|
|
33
|
+
*/
|
|
34
|
+
private static readonly widenedForDelegation;
|
|
35
|
+
hooks: AuthzBase['hooks'] & Pick<Hooks, 'ldapmodifydone' | 'ldapdeletedone' | 'ldaprenamedone' | 'ldapsearchrequest' | 'ldapsearchfilter' | 'ldapmodifyrequest'>;
|
|
34
36
|
/** What the classes above declared, read once this one is constructed */
|
|
35
37
|
private readonly declaredByBase;
|
|
36
38
|
constructor(server: DM);
|
|
37
39
|
/** Drop every cached permission lookup. */
|
|
38
40
|
forgetPermissions(): void;
|
|
41
|
+
/**
|
|
42
|
+
* Add the delegation attribute to a search projection the caller did not
|
|
43
|
+
* ask it in: the filter below cannot tell a delegated group from any other
|
|
44
|
+
* entry without it. It is removed again once the result is judged.
|
|
45
|
+
*/
|
|
46
|
+
private widenForDelegation;
|
|
47
|
+
/**
|
|
48
|
+
* Keep a delegated group the attachment filter would hide, and show only
|
|
49
|
+
* the members of it this caller manages. A group the caller administers
|
|
50
|
+
* through its organization keeps the ordinary answer, members included:
|
|
51
|
+
* the delegation is an addition to that path, not a restriction on it.
|
|
52
|
+
*/
|
|
53
|
+
private filterDelegatedGroups;
|
|
54
|
+
/** Remove the delegation attribute a search only carried for this judgement. */
|
|
55
|
+
private stripWidened;
|
|
56
|
+
/**
|
|
57
|
+
* Judge a change to a group whose membership is delegated: a delegated
|
|
58
|
+
* administrator may add or remove members they manage, and change nothing
|
|
59
|
+
* else on the group.
|
|
60
|
+
*
|
|
61
|
+
* An ordinary administrator of the group goes through the base checks
|
|
62
|
+
* untouched, as does a caller the delegation does not reach.
|
|
63
|
+
*/
|
|
64
|
+
private judgeDelegatedGroup;
|
|
65
|
+
/** The fields a group schema gives its members and owners. */
|
|
66
|
+
private groupAttributes;
|
|
67
|
+
/**
|
|
68
|
+
* The filter names that test the member and owner values we reduce: their
|
|
69
|
+
* own names and OIDs, and every supertype they answer under. A filter on
|
|
70
|
+
* `distinguishedName`, which both inherit, tests both.
|
|
71
|
+
*/
|
|
72
|
+
private memberFilterNames;
|
|
73
|
+
private resolveMemberFilterNames;
|
|
74
|
+
/** Whether `dn` is in a branch only its organization's administrators read. */
|
|
75
|
+
private isGlobalBranch;
|
|
76
|
+
/**
|
|
77
|
+
* Keep only the members and owners this caller manages, in place.
|
|
78
|
+
*
|
|
79
|
+
* @param entry group entry about to be answered
|
|
80
|
+
* @param delegations the organizations the group delegates to
|
|
81
|
+
* @param manages tells whether a member's organization is one of them
|
|
82
|
+
*/
|
|
83
|
+
private keepManagedMembers;
|
|
84
|
+
/** The organizations a group delegates membership management to. */
|
|
85
|
+
private delegationsOf;
|
|
86
|
+
/** Whether a member's organization lies in one of the delegated branches. */
|
|
87
|
+
private withinDelegation;
|
|
88
|
+
/** The organization a member is attached to, and nothing else. */
|
|
89
|
+
private storedOrganization;
|
|
90
|
+
/** The delegation of a group, and its stored entry. */
|
|
91
|
+
private storedGroup;
|
|
92
|
+
/** A DN as a key two spellings of it share. */
|
|
93
|
+
private dnKey;
|
|
94
|
+
/** `isDnInBranch`, with a malformed DN judged as outside. */
|
|
95
|
+
private within;
|
|
96
|
+
/**
|
|
97
|
+
* Refuse a delegated change that is not an add or delete of a member the
|
|
98
|
+
* caller manages.
|
|
99
|
+
*/
|
|
100
|
+
private assertDelegatedChanges;
|
|
101
|
+
/** Whether the caller manages a member: it is attached to one of theirs. */
|
|
102
|
+
private managesMember;
|
|
103
|
+
/** The group plugin's placeholder, which takes the last member's place. */
|
|
104
|
+
private isDummyMember;
|
|
105
|
+
/**
|
|
106
|
+
* The groups a caller manages the members of through a delegation, for
|
|
107
|
+
* `GET /authz/scope`. Read on each call: a group's delegation is a handful
|
|
108
|
+
* of entries, and the scope is not on a request's hot path.
|
|
109
|
+
*
|
|
110
|
+
* @param user the caller's DN
|
|
111
|
+
* @returns the delegated groups, empty when the option is off
|
|
112
|
+
*/
|
|
113
|
+
delegatedGroups(user: string): Promise<{
|
|
114
|
+
dn: string;
|
|
115
|
+
manageMembers: boolean;
|
|
116
|
+
}[]>;
|
|
39
117
|
/**
|
|
40
118
|
* The organizations naming `dn` an administrator. Taking one of those
|
|
41
119
|
* accounts over grants its branches, so the base refuses a caller who
|
|
@@ -77,8 +155,24 @@ export default class AuthzLinid1 extends AuthzBase {
|
|
|
77
155
|
protected sourcePermission(branch: string): keyof BranchPermissions;
|
|
78
156
|
/**
|
|
79
157
|
* Resolve user - for authzLinid1, convert uid to userDn via LDAP
|
|
158
|
+
*
|
|
159
|
+
* An account the schema marks with a state other than `enabled` is
|
|
160
|
+
* refused: the identity provider may still let it sign in, and the
|
|
161
|
+
* lifecycle state is the directory's way to stop it.
|
|
80
162
|
*/
|
|
81
163
|
resolveUser(uid: string): Promise<string | null>;
|
|
164
|
+
/**
|
|
165
|
+
* The attribute carrying an account's lifecycle state, and the value that
|
|
166
|
+
* means enabled, from the flat entity schema holding `dn`.
|
|
167
|
+
*/
|
|
168
|
+
private accountStatus;
|
|
169
|
+
/**
|
|
170
|
+
* Whether the account carries the enabled state, or carries no status at
|
|
171
|
+
* all: an entry the schema does not manage keeps the access it had.
|
|
172
|
+
*/
|
|
173
|
+
private enabled;
|
|
174
|
+
/** The account status attributes the loaded user schemas declare. */
|
|
175
|
+
private statusAttributes;
|
|
82
176
|
/**
|
|
83
177
|
* Get user DN from uid
|
|
84
178
|
*
|
|
@@ -15,6 +15,10 @@ export declare const ScopeResponse: import("@sinclair/typebox").TObject<{
|
|
|
15
15
|
path: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
16
16
|
}>>;
|
|
17
17
|
transit: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TString, import("@sinclair/typebox").TNull]>>;
|
|
18
|
+
delegatedGroups: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
|
|
19
|
+
dn: import("@sinclair/typebox").TString;
|
|
20
|
+
manageMembers: import("@sinclair/typebox").TBoolean;
|
|
21
|
+
}>>>;
|
|
18
22
|
linksAnyDomain: import("@sinclair/typebox").TBoolean;
|
|
19
23
|
passwordPolicies: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
|
|
20
24
|
entities: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
|
|
@@ -27,8 +27,15 @@ export default class CrowdSec extends DmPlugin {
|
|
|
27
27
|
private apiKey;
|
|
28
28
|
private cacheTtl;
|
|
29
29
|
private cache;
|
|
30
|
+
private lastSweep;
|
|
31
|
+
/** Distinct addresses remembered at once, so a rotating header cannot grow the map. */
|
|
32
|
+
private static readonly MAX_CLIENTS;
|
|
30
33
|
constructor(...args: ConstructorParameters<typeof DmPlugin>);
|
|
31
34
|
api(app: Express): void;
|
|
32
35
|
private checkIpBanned;
|
|
36
|
+
/** Drop expired decisions, at most once a TTL, and keep the map bounded. */
|
|
37
|
+
private prune;
|
|
38
|
+
/** Drop the oldest decision: `Map` keeps insertion order. */
|
|
39
|
+
private evictOldest;
|
|
33
40
|
private getClientIp;
|
|
34
41
|
}
|
|
@@ -24,7 +24,14 @@ export default class RateLimit extends DmPlugin {
|
|
|
24
24
|
private store;
|
|
25
25
|
private windowMs;
|
|
26
26
|
private maxRequests;
|
|
27
|
+
private lastSweep;
|
|
28
|
+
/** Distinct addresses remembered at once, so a rotating header cannot grow the map. */
|
|
29
|
+
private static readonly MAX_CLIENTS;
|
|
27
30
|
constructor(...args: ConstructorParameters<typeof DmPlugin>);
|
|
28
31
|
api(app: Express): void;
|
|
32
|
+
/** Drop expired counters, at most once a window, and keep the map bounded. */
|
|
33
|
+
private prune;
|
|
34
|
+
/** Drop the oldest counter: `Map` keeps insertion order. */
|
|
35
|
+
private evictOldest;
|
|
29
36
|
private getClientIp;
|
|
30
37
|
}
|
|
@@ -14,12 +14,23 @@ export default class AuthTotp extends AuthBase {
|
|
|
14
14
|
private totpUsers;
|
|
15
15
|
private window;
|
|
16
16
|
private step;
|
|
17
|
+
/** Last counter accepted per user, so an observed code is not reusable. */
|
|
18
|
+
private lastCounters;
|
|
17
19
|
constructor(...args: ConstructorParameters<typeof AuthBase>);
|
|
18
20
|
authMethod(req: DmRequest, res: Response, next: () => void): void;
|
|
21
|
+
/**
|
|
22
|
+
* Whether a counter is new for this user: a code is accepted once, so an
|
|
23
|
+
* observed one is not a bearer credential for the rest of its window.
|
|
24
|
+
*/
|
|
25
|
+
private acceptCounter;
|
|
19
26
|
/**
|
|
20
27
|
* Verify a TOTP token against a secret
|
|
28
|
+
*
|
|
29
|
+
* @returns the counter it matched, or null
|
|
21
30
|
*/
|
|
22
31
|
private verifyTotp;
|
|
32
|
+
/** Compare two codes without letting a difference shorten the comparison. */
|
|
33
|
+
private sameCode;
|
|
23
34
|
/**
|
|
24
35
|
* Generate a TOTP token using HMAC-SHA1
|
|
25
36
|
*/
|
|
@@ -5,6 +5,8 @@ declare global {
|
|
|
5
5
|
interface Request {
|
|
6
6
|
trustedProxy?: boolean;
|
|
7
7
|
proxyAuthUser?: string;
|
|
8
|
+
/** The client address, read through the trusted proxy chain. */
|
|
9
|
+
clientIp?: string;
|
|
8
10
|
}
|
|
9
11
|
}
|
|
10
12
|
}
|
|
@@ -19,6 +21,27 @@ export default class TrustedProxy extends DmPlugin {
|
|
|
19
21
|
private authHeader;
|
|
20
22
|
constructor(...args: ConstructorParameters<typeof DmPlugin>);
|
|
21
23
|
api(app: Express): void;
|
|
24
|
+
/**
|
|
25
|
+
* The client address in a proxy chain: walk `X-Forwarded-For` from the
|
|
26
|
+
* right, skip the addresses this server trusts as proxies, and take the
|
|
27
|
+
* first one it does not. A port is stripped from the entry (`ip:port`,
|
|
28
|
+
* `[v6]:port`) since some proxies write one.
|
|
29
|
+
*
|
|
30
|
+
* An entry that is not an address stops the walk and the peer is used: the
|
|
31
|
+
* entries to its left are the ones a client wrote, so continuing would let
|
|
32
|
+
* it choose the address. A chain made only of trusted addresses falls back
|
|
33
|
+
* to the peer too.
|
|
34
|
+
*
|
|
35
|
+
* @param xff the header as received from a trusted peer
|
|
36
|
+
* @param remoteAddr the TCP peer
|
|
37
|
+
* @returns the address to key rate limits and logs on
|
|
38
|
+
*/
|
|
39
|
+
private clientIpFromXff;
|
|
40
|
+
/**
|
|
41
|
+
* An `X-Forwarded-For` entry as an address: a plain one, a bracketed IPv6
|
|
42
|
+
* address, or either carrying a port. Anything else is undefined.
|
|
43
|
+
*/
|
|
44
|
+
private forwardedAddress;
|
|
22
45
|
/**
|
|
23
46
|
* Check if an IP address belongs to a trusted proxy network
|
|
24
47
|
*/
|
|
@@ -17,6 +17,13 @@ export default class LdapBulkImport extends DmPlugin {
|
|
|
17
17
|
private readonly settings;
|
|
18
18
|
private resources;
|
|
19
19
|
private upload;
|
|
20
|
+
/**
|
|
21
|
+
* The branches the import writes into. An authorization plugin judges an
|
|
22
|
+
* added entry on its link rather than on a flat store it lands in.
|
|
23
|
+
*
|
|
24
|
+
* @returns the distinct resource bases
|
|
25
|
+
*/
|
|
26
|
+
bases(): string[];
|
|
20
27
|
constructor(parent: DM);
|
|
21
28
|
api(app: Express): void;
|
|
22
29
|
/**
|
|
@@ -602,13 +602,14 @@ Semantic roles assign special meaning to attributes for UI and business logic ha
|
|
|
602
602
|
|
|
603
603
|
### Available Roles
|
|
604
604
|
|
|
605
|
-
| Role
|
|
606
|
-
|
|
|
607
|
-
| `identifier`
|
|
608
|
-
| `displayName`
|
|
609
|
-
| `primaryEmail`
|
|
610
|
-
| `emailAliases`
|
|
611
|
-
| `emailQuota`
|
|
605
|
+
| Role | Description | Example Attribute | Usage |
|
|
606
|
+
| ------------------------- | ----------------------------------------------------------------- | ---------------------------------------- | ------------------------------------- |
|
|
607
|
+
| `identifier` | Primary identifier (RDN attribute) | `uid`, `cn`, `sAMAccountName` | Used as the entry's unique identifier |
|
|
608
|
+
| `displayName` | Human-readable display name | `cn`, `displayName` | Shown in lists and UI elements |
|
|
609
|
+
| `primaryEmail` | Main email address | `mail`, `userPrincipalName` | Primary contact email |
|
|
610
|
+
| `emailAliases` | Additional email addresses | `mailAlternateAddress`, `proxyAddresses` | Alternative email addresses |
|
|
611
|
+
| `emailQuota` | Email storage quota | `mailQuota`, `mailQuotaSize` | Storage limit for mailbox |
|
|
612
|
+
| `groupAdminOrganizations` | Organizations whose administrators manage the group's own members | `twakeGroupAdminOrgLink` | Delegated group membership |
|
|
612
613
|
|
|
613
614
|
### Role Usage Examples
|
|
614
615
|
|
|
@@ -456,13 +456,14 @@ whichever of the two names it selects. A value other than `req.user` or
|
|
|
456
456
|
|
|
457
457
|
#### `core/auth/authzLinid1`
|
|
458
458
|
|
|
459
|
-
| CLI | Env | Default | Description
|
|
460
|
-
| --------------------------------------------------------------- | -------------------------------- | ------------------------- |
|
|
461
|
-
| `--authz-local-admin-attribute` | `DM_AUTHZ_LOCAL_ADMIN_ATTRIBUTE` | `twakeLocalAdminLink` | Local admin attribute
|
|
462
|
-
| `--authz-linid1-cache-ttl` | `DM_AUTHZ_LINID1_CACHE_TTL` | `300` | Seconds a user's resolved permissions are cached; a write touching the admin attribute clears the cache at once
|
|
463
|
-
| `--authz-global-branch` / `--authz-global-branches` | `DM_AUTHZ_GLOBAL_BRANCHES` | | Branches outside the tree only the administrators of `--authz-global-organization` read and write, [details](plugins/auth/authz-linid1.md#global-branches)
|
|
464
|
-
| `--authz-global-write-branch` / `--authz-global-write-branches` | `DM_AUTHZ_GLOBAL_WRITE_BRANCHES` | | Branches outside the tree every administrator reads and only the administrators of `--authz-global-organization` write and delete in, [details](plugins/auth/authz-linid1.md#global-branches)
|
|
465
|
-
| `--authz-global-organization` | `DM_AUTHZ_GLOBAL_ORGANIZATION` | `--ldap-top-organization` | The organization holding the global branches and global write branches
|
|
459
|
+
| CLI | Env | Default | Description |
|
|
460
|
+
| --------------------------------------------------------------- | -------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
461
|
+
| `--authz-local-admin-attribute` | `DM_AUTHZ_LOCAL_ADMIN_ATTRIBUTE` | `twakeLocalAdminLink` | Local admin attribute |
|
|
462
|
+
| `--authz-linid1-cache-ttl` | `DM_AUTHZ_LINID1_CACHE_TTL` | `300` | Seconds a user's resolved permissions are cached; a write touching the admin attribute clears the cache at once |
|
|
463
|
+
| `--authz-global-branch` / `--authz-global-branches` | `DM_AUTHZ_GLOBAL_BRANCHES` | | Branches outside the tree only the administrators of `--authz-global-organization` read and write, [details](plugins/auth/authz-linid1.md#global-branches) |
|
|
464
|
+
| `--authz-global-write-branch` / `--authz-global-write-branches` | `DM_AUTHZ_GLOBAL_WRITE_BRANCHES` | | Branches outside the tree every administrator reads and only the administrators of `--authz-global-organization` write and delete in, [details](plugins/auth/authz-linid1.md#global-branches) |
|
|
465
|
+
| `--authz-global-organization` | `DM_AUTHZ_GLOBAL_ORGANIZATION` | `--ldap-top-organization` | The organization holding the global branches and global write branches |
|
|
466
|
+
| `--authz-group-admin-attribute` | `DM_AUTHZ_GROUP_ADMIN_ATTRIBUTE` | | Attribute on a group naming the organizations whose administrators manage their own members; empty means the delegation is off, [details](plugins/auth/authz-linid1.md#delegated-group-membership) |
|
|
466
467
|
|
|
467
468
|
### Security Plugins
|
|
468
469
|
|
|
@@ -181,18 +181,18 @@ base class, but nothing obliges a third implementation to.
|
|
|
181
181
|
|
|
182
182
|
## Choosing an Authentication Method
|
|
183
183
|
|
|
184
|
-
| Feature | Token | TOTP
|
|
185
|
-
| ---------------------- | ------------- |
|
|
186
|
-
| **Setup Complexity** | Simple | Simple
|
|
187
|
-
| **User Management** | None | Manual
|
|
188
|
-
| **SSO Support** | No | No
|
|
189
|
-
| **MFA Support** | No | Yes (TOTP itself)
|
|
190
|
-
| **Session Management** | Stateless | Stateless
|
|
191
|
-
| **Code Expiration** | Never | 30-60 seconds
|
|
192
|
-
| **Request Integrity** | No | No
|
|
193
|
-
| **Replay Protection** | No | Yes (
|
|
194
|
-
| **Best For** | APIs, Scripts |
|
|
195
|
-
| **Dependencies** | None | None
|
|
184
|
+
| Feature | Token | TOTP | HMAC | LemonLDAP::NG | OpenID Connect |
|
|
185
|
+
| ---------------------- | ------------- | ------------------------- | ---------------------- | --------------------- | -------------------------------- |
|
|
186
|
+
| **Setup Complexity** | Simple | Simple | Medium | Medium | Medium |
|
|
187
|
+
| **User Management** | None | Manual | Manual (service-based) | External (LLNG) | External (Provider) |
|
|
188
|
+
| **SSO Support** | No | No | No | Yes | Yes |
|
|
189
|
+
| **MFA Support** | No | Yes (TOTP itself) | N/A | Yes (via LLNG) | Yes (via Provider) |
|
|
190
|
+
| **Session Management** | Stateless | Stateless | Stateless | LLNG Sessions | OIDC Sessions |
|
|
191
|
+
| **Code Expiration** | Never | 30-60 seconds | Per-request | Session-based | Session-based |
|
|
192
|
+
| **Request Integrity** | No | No | Yes (body + path hash) | No | No |
|
|
193
|
+
| **Replay Protection** | No | Yes (single-use code) | Yes (timestamp) | Session-based | Session-based |
|
|
194
|
+
| **Best For** | APIs, Scripts | MFA, one request per step | Backend Services | Enterprises with LLNG | Cloud/SaaS, Enterprises with SSO |
|
|
195
|
+
| **Dependencies** | None | None | None | lemonldap-ng-handler | express-openid-connect |
|
|
196
196
|
|
|
197
197
|
## Serving several populations from one server
|
|
198
198
|
|
|
@@ -11,6 +11,7 @@ The `authzLinid1` plugin provides dynamic, LDAP-driven access control by reading
|
|
|
11
11
|
- **Full CRUD rights** - Local admins get read, write, and delete permissions
|
|
12
12
|
- **Sub-branch inheritance** - Permissions apply recursively to sub-branches
|
|
13
13
|
- **Cross-branch access** - Users/groups can be managed if their `twakeDepartmentLink` points to an authorized organization
|
|
14
|
+
- **Delegated group membership** - A group can let other organizations' administrators manage their own members ([details](#delegated-group-membership))
|
|
14
15
|
- **Permission caching** - Configurable TTL reduces LDAP queries (default: 5 minutes)
|
|
15
16
|
|
|
16
17
|
## How It Works
|
|
@@ -238,6 +239,56 @@ other: the global branch would hide the write branch it holds from the
|
|
|
238
239
|
administrators meant to read it. In either case the server refuses to
|
|
239
240
|
start.
|
|
240
241
|
|
|
242
|
+
### Delegated group membership
|
|
243
|
+
|
|
244
|
+
A group may delegate membership management to the administrators of other
|
|
245
|
+
organizations, so that a group spanning several organizations can be
|
|
246
|
+
maintained by each of them without opening the others' members. Off by
|
|
247
|
+
default: name the attribute to turn it on.
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
--authz-group-admin-attribute twakeGroupAdminOrgLink
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
The attribute is multi-valued and holds organization DNs. The Twake group
|
|
254
|
+
schema declares it as `twakeGroupAdminOrgLink` (`groupAdminOrganizations`
|
|
255
|
+
role, `referentialIntegrity: remove`), and `twakeGroup` and
|
|
256
|
+
`twakeStaticGroup` must allow it (`MAY`).
|
|
257
|
+
|
|
258
|
+
Let `D` be the organizations a group delegates to, `L` the organization a
|
|
259
|
+
member is attached to (its link attribute), and `C` the caller's written
|
|
260
|
+
branches. Then:
|
|
261
|
+
|
|
262
|
+
- `C` reads the group when a branch of `C` lies in one of the `D`.
|
|
263
|
+
- `C` reads a member or owner of the group only when it is attached to an
|
|
264
|
+
`L` inside some `D` and `C` writes `L`. A member attached to no
|
|
265
|
+
organization (in transit) is neither read nor managed through the
|
|
266
|
+
delegation.
|
|
267
|
+
- `C` adds or removes a `member` under the same rule. Nothing else on the
|
|
268
|
+
group changes: not its description, delegation, name, position or
|
|
269
|
+
existence. The delegation grants no creation, rename, move or delete of a
|
|
270
|
+
group.
|
|
271
|
+
- The group's own administrators keep the whole group and every right they
|
|
272
|
+
had. A group attached to no organization is already readable by every
|
|
273
|
+
administrator; the delegation only adds the member write there. A member
|
|
274
|
+
that is a group follows the same link rule.
|
|
275
|
+
|
|
276
|
+
Removing the last real member keeps the group plugin's rule: the placeholder
|
|
277
|
+
takes its place, or the request is refused with `409`.
|
|
278
|
+
|
|
279
|
+
A search whose filter tests `member`, `owner`, or a supertype they inherit
|
|
280
|
+
(`distinguishedName`) does not return a delegated group: the directory judged
|
|
281
|
+
the full group, and answering from the filtered membership would tell the
|
|
282
|
+
caller whether a user of another organization belongs to it. The group stays
|
|
283
|
+
in the ordinary listing and can be read directly.
|
|
284
|
+
|
|
285
|
+
`GET /api/v1/authz/scope` lists the delegated groups under
|
|
286
|
+
`delegatedGroups`, so a client does not duplicate the rule
|
|
287
|
+
([scope](authz-scope.md#delegated-groups)).
|
|
288
|
+
|
|
289
|
+
The delegation is read from the group entry, so a change takes effect at
|
|
290
|
+
once; the plugin does not keep a second copy of it.
|
|
291
|
+
|
|
241
292
|
## Configuration
|
|
242
293
|
|
|
243
294
|
No configuration file needed - permissions are read from LDAP. Simply load the plugin:
|
|
@@ -293,6 +344,17 @@ directly in the directory waits out the TTL.
|
|
|
293
344
|
`GET /api/v1/authz/scope` resolves the identity outside that cache, so
|
|
294
345
|
during such a window it can describe a scope the hooks no longer grant.
|
|
295
346
|
|
|
347
|
+
### Account status
|
|
348
|
+
|
|
349
|
+
When the user schema declares an `accountStatus` attribute, the resolved
|
|
350
|
+
caller must carry its `states.enabled` value; any other value is refused with
|
|
351
|
+
`403`, like an identity that does not resolve. A disabled, no-access or
|
|
352
|
+
to-delete administrator therefore loses the API even when the identity
|
|
353
|
+
provider still lets them sign in, and cannot re-enable themselves through the
|
|
354
|
+
console. A write through this server that changes the status drops the
|
|
355
|
+
resolved identity at once; a change made directly in the directory waits for
|
|
356
|
+
`--authz-linid1-cache-ttl`.
|
|
357
|
+
|
|
296
358
|
## Permission Resolution
|
|
297
359
|
|
|
298
360
|
For each LDAP operation, the plugin:
|
|
@@ -520,6 +582,13 @@ User makes request → Check cache → (if expired) Search LDAP once → Cache f
|
|
|
520
582
|
|
|
521
583
|
- `twakeDepartmentLink` (single-valued) - DN of parent organization
|
|
522
584
|
|
|
585
|
+
**Groups, when `--authz-group-admin-attribute` is set:**
|
|
586
|
+
|
|
587
|
+
- `twakeGroupAdminOrgLink` (multi-valued) - DNs of the organizations whose
|
|
588
|
+
administrators manage their own members
|
|
589
|
+
([delegated group membership](#delegated-group-membership)). The directory's
|
|
590
|
+
`twakeGroup` and `twakeStaticGroup` object classes must allow it.
|
|
591
|
+
|
|
523
592
|
### Example Schema
|
|
524
593
|
|
|
525
594
|
```ldif
|
|
@@ -93,6 +93,12 @@ GET /api/v1/authz/scope
|
|
|
93
93
|
"delete": true
|
|
94
94
|
}
|
|
95
95
|
],
|
|
96
|
+
"delegatedGroups": [
|
|
97
|
+
{
|
|
98
|
+
"dn": "cn=all-staff,ou=groups,dc=example,dc=com",
|
|
99
|
+
"manageMembers": true
|
|
100
|
+
}
|
|
101
|
+
],
|
|
96
102
|
"entities": [
|
|
97
103
|
{ "name": "users", "base": "ou=users,dc=example,dc=com", "create": true },
|
|
98
104
|
{ "name": "groups", "base": "ou=groups,dc=example,dc=com", "create": true },
|
|
@@ -105,6 +111,14 @@ GET /api/v1/authz/scope
|
|
|
105
111
|
}
|
|
106
112
|
```
|
|
107
113
|
|
|
114
|
+
### Delegated groups
|
|
115
|
+
|
|
116
|
+
`delegatedGroups` lists the groups the caller reads, and whose members of
|
|
117
|
+
their own organizations they may add or remove, through
|
|
118
|
+
[`--authz-group-admin-attribute`](authz-linid1.md#delegated-group-membership).
|
|
119
|
+
It is absent when that option is off or the caller has none. A client that
|
|
120
|
+
does not know the field keeps working.
|
|
121
|
+
|
|
108
122
|
`name` and `path` come from the branch entry itself, so an interface can show
|
|
109
123
|
the scope in the directory's own words rather than as a raw DN.
|
|
110
124
|
|
|
@@ -52,16 +52,21 @@ For correct IP detection behind reverse proxies:
|
|
|
52
52
|
--rate-limit-max 100
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
-
The
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
The limiter reads the client address `trustedProxy` publishes on the request
|
|
56
|
+
(`req.clientIp`). That plugin walks `X-Forwarded-For` from the right, skips
|
|
57
|
+
the addresses it trusts as proxies, and takes the first one it does not, so a
|
|
58
|
+
value a client writes at the left of the chain never becomes its key. Without
|
|
59
|
+
`trustedProxy`, the limiter uses the TCP peer and a reverse proxy makes every
|
|
60
|
+
client share one counter; the plugin says so at startup:
|
|
58
61
|
|
|
59
62
|
```
|
|
60
|
-
Auth rate limiting
|
|
61
|
-
loaded
|
|
63
|
+
Auth rate limiting ignores X-Forwarded-For because core/auth/trustedProxy is
|
|
64
|
+
not loaded and uses the TCP peer: requests through a proxy share one counter.
|
|
62
65
|
```
|
|
63
66
|
|
|
64
|
-
|
|
67
|
+
Load `trustedProxy` when a reverse proxy sits in front. It is no longer
|
|
68
|
+
necessary for the proxy to overwrite the header: appending
|
|
69
|
+
(`$proxy_add_x_forwarded_for`) is read correctly.
|
|
65
70
|
|
|
66
71
|
## Security Considerations
|
|
67
72
|
|
|
@@ -58,7 +58,7 @@ Include the current TOTP code in the `Authorization` header:
|
|
|
58
58
|
|
|
59
59
|
```bash
|
|
60
60
|
# Get current TOTP code from your authenticator app
|
|
61
|
-
curl -H "Authorization: Bearer 123456" \
|
|
61
|
+
curl -H "Authorization: Bearer admin:123456" \
|
|
62
62
|
http://localhost:8081/api/v1/ldap/users
|
|
63
63
|
```
|
|
64
64
|
|
|
@@ -80,6 +80,7 @@ import {
|
|
|
80
80
|
|
|
81
81
|
const client = new TotpAuthClient({
|
|
82
82
|
secret: 'JBSWY3DPEHPK3PXP',
|
|
83
|
+
user: 'admin',
|
|
83
84
|
digits: 6,
|
|
84
85
|
step: 30,
|
|
85
86
|
});
|
|
@@ -97,12 +98,18 @@ console.log(`Current TOTP code: ${code}`);
|
|
|
97
98
|
|
|
98
99
|
## How It Works
|
|
99
100
|
|
|
100
|
-
1. Extracts
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
101
|
+
1. Extracts the user name and TOTP code from
|
|
102
|
+
`Authorization: Bearer <name>:<code>`
|
|
103
|
+
2. Looks up that user and generates the expected codes for the current time
|
|
104
|
+
window
|
|
105
|
+
3. Validates the code against that user's secret, in constant time
|
|
106
|
+
4. Accepts a counter once per user: a replay, or the same code twice inside
|
|
107
|
+
one time step, is refused
|
|
108
|
+
5. Supports multiple time windows (±window × step seconds) for clock drift
|
|
109
|
+
tolerance
|
|
110
|
+
6. Sets `req.user` to the user's name on successful validation
|
|
111
|
+
7. Returns 401 Unauthorized if the name is missing or unknown, or the code is
|
|
112
|
+
missing, invalid, replayed or expired
|
|
106
113
|
|
|
107
114
|
## Multi-User Example
|
|
108
115
|
|
|
@@ -126,7 +133,7 @@ INFO: TOTP authentication successful for user: api-service
|
|
|
126
133
|
- **Enhanced Security**: Dynamic codes that expire every 30 seconds
|
|
127
134
|
- **No Shared Secrets**: Each user/service has unique TOTP secret
|
|
128
135
|
- **Compatible**: Works with Google Authenticator, Authy, 1Password, etc.
|
|
129
|
-
- **API Access**:
|
|
136
|
+
- **API Access**: One code per request; a client making several calls must wait for the next step or use a session token
|
|
130
137
|
- **Multi-Factor**: Can be combined with other authentication methods
|
|
131
138
|
|
|
132
139
|
## Security Considerations
|
|
@@ -136,7 +143,11 @@ INFO: TOTP authentication successful for user: api-service
|
|
|
136
143
|
- **Clock Synchronization**: Ensure server and client clocks are synchronized (NTP recommended)
|
|
137
144
|
- **Window Size**: Larger windows are more tolerant but slightly less secure
|
|
138
145
|
- **Secret Rotation**: Rotate TOTP secrets periodically for better security
|
|
139
|
-
- **Rate Limiting**:
|
|
146
|
+
- **Rate Limiting**: Load `core/auth/rateLimit`; a six-digit code is still guessable without it
|
|
147
|
+
- **Single use**: A code is accepted once per user, so a second request with
|
|
148
|
+
the same code in the same step is refused. The record is per process: with
|
|
149
|
+
several replicas, a code can be used once against each until they share
|
|
150
|
+
state
|
|
140
151
|
|
|
141
152
|
## Browser Client Features
|
|
142
153
|
|
|
@@ -147,14 +158,14 @@ import {
|
|
|
147
158
|
generateTotp,
|
|
148
159
|
getRemainingSeconds,
|
|
149
160
|
isValidBase32,
|
|
150
|
-
TotpAuthClient
|
|
161
|
+
TotpAuthClient,
|
|
151
162
|
} from 'ldap-rest/browser-shared-utils-totp';
|
|
152
163
|
|
|
153
164
|
// Generate TOTP code
|
|
154
165
|
const code = await generateTotp({
|
|
155
166
|
secret: 'JBSWY3DPEHPK3PXP',
|
|
156
167
|
digits: 6,
|
|
157
|
-
step: 30
|
|
168
|
+
step: 30,
|
|
158
169
|
});
|
|
159
170
|
|
|
160
171
|
// Check validity time
|
|
@@ -169,16 +180,25 @@ if (isValidBase32('JBSWY3DPEHPK3PXP')) {
|
|
|
169
180
|
// HTTP client with automatic TOTP authentication
|
|
170
181
|
const client = new TotpAuthClient({
|
|
171
182
|
secret: 'JBSWY3DPEHPK3PXP',
|
|
183
|
+
user: 'admin',
|
|
172
184
|
digits: 6,
|
|
173
|
-
step: 30
|
|
185
|
+
step: 30,
|
|
174
186
|
});
|
|
175
187
|
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
await client.
|
|
179
|
-
|
|
188
|
+
// Every request carries the current code; a code is accepted once, so this
|
|
189
|
+
// works for one call per time step. Wait for the next code before the next.
|
|
190
|
+
const created = await client.post('/api/v1/ldap/users', {
|
|
191
|
+
uid: 'user1',
|
|
192
|
+
mail: 'user1@example.com',
|
|
193
|
+
});
|
|
180
194
|
```
|
|
181
195
|
|
|
196
|
+
**One request per code step.** TOTP has no session: each request is
|
|
197
|
+
authenticated on its own. A code is accepted once per user, so a second
|
|
198
|
+
request in the same 30-second step is refused. A client making several calls
|
|
199
|
+
must wait for the next code, or use another authentication plugin for the
|
|
200
|
+
session after the first TOTP call.
|
|
201
|
+
|
|
182
202
|
**Module Export:** `ldap-rest/browser-shared-utils-totp`
|
|
183
203
|
|
|
184
204
|
**Live Demo:** Run the server and open [http://localhost:8081/static/examples/web/totp-client.html](http://localhost:8081/static/examples/web/totp-client.html)
|
|
@@ -193,7 +213,7 @@ await client.delete('/api/v1/ldap/users/user1');
|
|
|
193
213
|
2. Check system clock synchronization (use NTP)
|
|
194
214
|
3. Increase `--auth-totp-window` if experiencing timing issues
|
|
195
215
|
4. Ensure code is used immediately (codes expire every 30s)
|
|
196
|
-
5. Verify `Authorization: Bearer
|
|
216
|
+
5. Verify the `Authorization: Bearer <name>:<code>` format and that the name is one configured in `--auth-totp`
|
|
197
217
|
|
|
198
218
|
**Problem:** Codes don't match authenticator app
|
|
199
219
|
|