diff --git a/README.md b/README.md index 3ad25fd6c..8c091288b 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,7 @@ Valid environment variables for the .env file. See [.env.example](/.env.example) | `ACCESS_GROUPS_STATIC_ENABLED` | string | Yes | Flag to enable/disable automatic assignment of predefined access groups to all users. | true | | `ACCESS_GROUPS_STATIC_VALUES` | string | Yes | Comma-separated list of access groups automatically assigned to all users. Example: "scicat, user". | | | `ACCESS_GROUPS_OIDCPAYLOAD_ENABLED` | string | Yes | Flag to enable/disable fetching access groups directly from OIDC response. Requires specifying a field via `OIDC_ACCESS_GROUPS_PROPERTY` to extract access groups. | false | +| `ACCESS_GROUPS_LDAPPAYLOAD_ENABLED` | string | Yes | Flag to enable/disable fetching access groups directly from Ldap response. Requires specifying a field via `LDAP_ACCESS_GROUPS_PROPERTY` to extract access groups. | false | | `DOI_PREFIX` | string | | The facility DOI prefix, with trailing slash. | | | `DOI_SHORT_SUFFIX` | string | | By default `uuidv4` is used to generate the DOI suffix but if this flag is `true` the shorter version of 10 random characters is used as DOI suffix. | | | `DOI_USERNAME` | string | | The facility DOI DataCite username. | | @@ -187,6 +188,10 @@ Valid environment variables for the .env file. See [.env.example](/.env.example) | `LDAP_BIND_CREDENTIALS` | string | Yes | Credentials for your LDAP server. | | | `LDAP_SEARCH_BASE` | string | Yes | Search base for your LDAP server. | | | `LDAP_SEARCH_FILTER` | string | Yes | Search filter for your LDAP server. | | +| `LDAP_GROUP_SEARCH_BASE` | string | Yes | Search base for the user groups. | | +| `LDAP_GROUP_SEARCH_FILTER` | string | Yes | Search filter for the user groups. | | +| `LDAP_ACCESS_GROUPS_PROPERTY`| string | Yes | Target field to get the access groups value from Ldap response. | | +| `LDAP_USERNAME_ATTR`| string | Yes | Target field to get the username from the Ldap response. Defaults to displayName. | | | `OIDC_ISSUER` | string | Yes | URL of the OIDC server providing the authentication service. Example: https://identity.esss.dk/realm/ess. | | | `OIDC_CLIENT_ID` | string | Yes | Identity of the client used to obtain the user token. Example: scicat. | | | `OIDC_ADDITIONAL_AUTHORIZED_PARTIES` | string | No | Comma-separated list of additional OIDC client IDs allowed to present tokens to this backend. Used for token exchange scenarios where a third-party client obtains a token on behalf of a user. The client ID must appear as the `azp` claim in the token. Example: `additional-client1, additional-client2`. | | diff --git a/docs/index.md b/docs/index.md index 22e9632ac..4b5168a80 100644 --- a/docs/index.md +++ b/docs/index.md @@ -124,6 +124,7 @@ Valid environment variables for the .env file. See [.env.example](/.env.example) | `ACCESS_GROUPS_STATIC_ENABLED` | string | Yes | Flag to enable/disable automatic assignment of predefined access groups to all users. | true | | `ACCESS_GROUPS_STATIC_VALUES` | string | Yes | Comma-separated list of access groups automatically assigned to all users. Example: "scicat, user". | | | `ACCESS_GROUPS_OIDCPAYLOAD_ENABLED` | string | Yes | Flag to enable/disable fetching access groups directly from OIDC response. Requires specifying a field via `OIDC_ACCESS_GROUPS_PROPERTY` to extract access groups. | false | +| `ACCESS_GROUPS_LDAPPAYLOAD_ENABLED` | string | Yes | Flag to enable/disable fetching access groups directly from Ldap response. Requires specifying a field via `LDAP_ACCESS_GROUPS_PROPERTY` to extract access groups. | false | | `DOI_PREFIX` | string | | The facility DOI prefix, with trailing slash. | | | `EXPRESS_SESSION_SECRET` | string | No | Secret used to set up express session. Required if using OIDC authentication | | | `HTTP_MAX_REDIRECTS` | number | Yes | Max redirects for HTTP requests. | 5 | @@ -135,6 +136,10 @@ Valid environment variables for the .env file. See [.env.example](/.env.example) | `LDAP_BIND_CREDENTIALS` | string | Yes | Credentials for your LDAP server. | | | `LDAP_SEARCH_BASE` | string | Yes | Search base for your LDAP server. | | | `LDAP_SEARCH_FILTER` | string | Yes | Search filter for your LDAP server. | | +| `LDAP_GROUP_SEARCH_BASE` | string | Yes | Search base for the user groups. | | +| `LDAP_GROUP_SEARCH_FILTER` | string | Yes | Search filter for the user groups. | | +| `LDAP_ACCESS_GROUPS_PROPERTY`| string | Yes | Target field to get the access groups value from Ldap response. | | +| `LDAP_USERNAME_ATTR`| string | Yes | Target field to get the username from the Ldap response. Defaults to displayName. | | | `OIDC_ISSUER` | string | Yes | URL of the OIDC server providing the authentication service. Example: https://identity.esss.dk/realm/ess. | | | `OIDC_CLIENT_ID` | string | Yes | Identity of the client used to obtain the user token. Example: scicat. | | | `OIDC_CLIENT_SECRET` | string | Yes | Secret to provide to the OIDC service to obtain the user token. Example: Aa1JIw3kv3mQlGFWhRrE3gOdkH6xreAwro. | | diff --git a/src/auth/access-group-provider/access-group-from-ldap.service.spec.ts b/src/auth/access-group-provider/access-group-from-ldap.service.spec.ts new file mode 100644 index 000000000..9f9cb9ddb --- /dev/null +++ b/src/auth/access-group-provider/access-group-from-ldap.service.spec.ts @@ -0,0 +1,48 @@ +import { ConfigService } from "@nestjs/config"; +import { Test, TestingModule } from "@nestjs/testing"; +import { UserPayload } from "../interfaces/userPayload.interface"; +import { AccessGroupFromLdapService } from "./access-group-from-ldap.service"; + +describe("AccessGroupFromLdapService", () => { + let service: AccessGroupFromLdapService; + + const mockAccessService = new AccessGroupFromLdapService("cn"); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [AccessGroupFromLdapService, ConfigService], + }) + .overrideProvider(AccessGroupFromLdapService) + .useValue(mockAccessService) + .compile(); + + service = module.get( + AccessGroupFromLdapService, + ); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + it("Should resolve access groups", async () => { + const userPayload = { + userId: "test_user", + payload: { + _groups: [ + { + dn: "cn=test_group,cn=groups,cn=accounts,dc=example,dc=com", + cn: "testgroup", + }, + { + dn: "cn=example_group,cn=groups,cn=accounts,dc=example,dc=com", + cn: "examplegroup", + }, + ], + }, + }; + const expected = ["testgroup", "examplegroup"]; + const actual = await service.getAccessGroups(userPayload as UserPayload); + expect(actual).toEqual(expected); + }); +}); diff --git a/src/auth/access-group-provider/access-group-from-ldap.service.ts b/src/auth/access-group-provider/access-group-from-ldap.service.ts new file mode 100644 index 000000000..0d3d0e258 --- /dev/null +++ b/src/auth/access-group-provider/access-group-from-ldap.service.ts @@ -0,0 +1,35 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { UserPayload } from "../interfaces/userPayload.interface"; +import { AccessGroupService } from "./access-group.service"; + +/** + * This service is used to get the access groups from the payload of the ldap IDP. + */ +@Injectable() +export class AccessGroupFromLdapService extends AccessGroupService { + constructor(private accessGroupProperty: string) { + super(); + } + + async getAccessGroups(userPayload: UserPayload): Promise { + const accessGroups: string[] = []; + + const accessGroupsProperty = this.accessGroupProperty; + if (accessGroupsProperty) { + const payload: Record | undefined = userPayload.payload; + if (payload !== undefined && Array.isArray(payload["_groups"])) { + for (const group of payload["_groups"]) { + if ( + typeof group === "object" && + accessGroupsProperty in group && + typeof group[accessGroupsProperty] === "string" + ) { + accessGroups.push(group[accessGroupsProperty]); + } + } + } + Logger.log(accessGroups, "AccessGroupFromLdapService"); + } + return accessGroups; + } +} diff --git a/src/auth/access-group-provider/access-group-from-payload.service.ts b/src/auth/access-group-provider/access-group-from-payload.service.ts index bc07ebb42..acc04b1ce 100644 --- a/src/auth/access-group-provider/access-group-from-payload.service.ts +++ b/src/auth/access-group-provider/access-group-from-payload.service.ts @@ -15,7 +15,7 @@ export class AccessGroupFromPayloadService extends AccessGroupService { async getAccessGroups(userPayload: UserPayload): Promise { //const defaultAccessGroups: string[] = []; - let accessGroups: string[] = []; + const accessGroups: string[] = []; const accessGroupsProperty = userPayload.accessGroupProperty; if (accessGroupsProperty) { @@ -24,10 +24,11 @@ export class AccessGroupFromPayloadService extends AccessGroupService { payload !== undefined && Array.isArray(payload[accessGroupsProperty]) ) { - accessGroups = - payload[accessGroupsProperty] !== undefined - ? (payload[accessGroupsProperty] as string[]) - : []; + for (const group of payload[accessGroupsProperty]) { + if (typeof group === "string") { + accessGroups.push(group); + } + } } Logger.log(accessGroups, "AccessGroupFromPayloadService"); } diff --git a/src/auth/access-group-provider/access-group-service-factory.ts b/src/auth/access-group-provider/access-group-service-factory.ts index 91c48fe69..2d7571955 100644 --- a/src/auth/access-group-provider/access-group-service-factory.ts +++ b/src/auth/access-group-provider/access-group-service-factory.ts @@ -4,6 +4,7 @@ import { AccessGroupService } from "./access-group.service"; import { AccessGroupFromGraphQLApiService } from "./access-group-from-graphql-api-call.service"; import { AccessGroupFromPayloadService } from "./access-group-from-payload.service"; import { AccessGroupFromRestApiService } from "./access-group-from-rest-api-call.service"; +import { AccessGroupFromLdapService } from "./access-group-from-ldap.service"; import { HttpService } from "@nestjs/axios"; import { AccessGroupFromMultipleProvidersService } from "./access-group-from-multiple-providers.service"; import { Logger } from "@nestjs/common"; @@ -23,6 +24,9 @@ export const accessGroupServiceFactory = { const accessGroupsOIDCPayloadConfig = configService.get( "accessGroupsOIDCPayloadConfig", ); + const accessGroupsLdapPayloadConfig = configService.get( + "accessGroupsLdapPayloadConfig", + ); const accessGroupsRestConfig = configService.get("accessGroupsRestConfig"); @@ -45,6 +49,18 @@ export const accessGroupServiceFactory = { new AccessGroupFromPayloadService(configService), ); } + if (accessGroupsLdapPayloadConfig?.enabled == true) { + Logger.log( + JSON.stringify(accessGroupsLdapPayloadConfig), + "loading ldap processor", + ); + + accessGroupServices.push( + new AccessGroupFromLdapService( + accessGroupsLdapPayloadConfig?.accessGroupProperty, + ), + ); + } if (accessGroupsGraphQlConfig?.enabled == true) { Logger.log( diff --git a/src/auth/strategies/ldap.strategy.ts b/src/auth/strategies/ldap.strategy.ts index babbbd171..82b4de856 100644 --- a/src/auth/strategies/ldap.strategy.ts +++ b/src/auth/strategies/ldap.strategy.ts @@ -15,14 +15,16 @@ import { LdapConfig } from "src/config/configuration"; @Injectable() export class LdapStrategy extends PassportStrategy(Strategy, "ldap") { + readonly ldapOptions: LdapConfig; + constructor( configService: ConfigService, private usersService: UsersService, private accessGroupService: AccessGroupService, ) { const ldapOptions = configService.get("ldap")!; - super(ldapOptions); + this.ldapOptions = ldapOptions; } async validate( @@ -30,10 +32,11 @@ export class LdapStrategy extends PassportStrategy(Strategy, "ldap") { ): Promise> { // add exception if displayName is empty + const username = this.getUsername(payload); const userFilter: FilterQuery = { $or: [ - { username: `ldap.${payload.displayName}` }, - { username: payload.displayName as string }, + { username: `ldap.${username}` }, + { username: username as string }, { email: payload.mail as string }, ], }; @@ -41,7 +44,7 @@ export class LdapStrategy extends PassportStrategy(Strategy, "ldap") { if (!userExists) { const createUser: CreateUserDto = { - username: payload.displayName as string, //`ldap.${payload.displayName}`, + username: username as string, email: payload.mail as string, authStrategy: "ldap", }; @@ -58,6 +61,7 @@ export class LdapStrategy extends PassportStrategy(Strategy, "ldap") { userId: user.id as string, username: user.username, email: user.email, + payload: payload, }; const accessGroups = await this.accessGroupService.getAccessGroups(userPayload); @@ -67,9 +71,9 @@ export class LdapStrategy extends PassportStrategy(Strategy, "ldap") { credentials: {}, externalId: payload.sAMAccountName as string, profile: { - displayName: payload.displayName as string, + displayName: username as string, email: payload.mail as string, - username: payload.displayName as string, + username: username as string, thumbnailPhoto: payload.thumbnailPhoto ? "data:image/jpeg;base64," + Buffer.from(payload.thumbnailPhoto as string, "binary").toString( @@ -99,6 +103,7 @@ export class LdapStrategy extends PassportStrategy(Strategy, "ldap") { userId: user.id as string, username: user.username, email: user.email, + payload: payload, }; const userIdentity = await this.usersService.findByIdUserIdentity( user._id, @@ -121,13 +126,24 @@ export class LdapStrategy extends PassportStrategy(Strategy, "ldap") { return user; } + private getUsername(payload: Record) { + const userattr = this.ldapOptions.server.usernameAttr; + if (userattr in payload) { + return payload[userattr] as string; + } + throw new InternalServerErrorException( + "usernameAttr incorrectly configured: " + userattr, + ); + } + getProfile(payload: Record) { type ldapProfile = Profile & UserProfile; const profile = {} as ldapProfile; + const username = this.getUsername(payload); - profile.displayName = payload.displayName as string; + profile.displayName = username as string; profile.email = payload.mail as string; - profile.username = payload.displayName as string; + profile.username = username as string; profile.thumbnailPhoto = payload.thumbnailPhoto ? "data:image/jpeg;base64," + Buffer.from(payload.thumbnailPhoto as string, "binary").toString( diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 757736c68..d5b3e1ede 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -306,6 +306,10 @@ const configuration = () => { apiUrl: process.env.ACCESS_GROUPS_SERVICE_REST_API_URL, userIdField: process.env.ACCESS_GROUPS_SERVICE_REST_USER_ID_FIELD, }, + accessGroupsLdapPayloadConfig: { + enabled: boolean(process.env?.ACCESS_GROUPS_LDAPPAYLOAD_ENABLED || false), + accessGroupProperty: process.env?.LDAP_ACCESS_GROUPS_PROPERTY || "cn", // Examples: "cn" or "ou" + }, doiPrefix: process.env.DOI_PREFIX, expressSession: { secret: process.env.EXPRESS_SESSION_SECRET, @@ -329,9 +333,11 @@ const configuration = () => { bindCredentials: process.env.LDAP_BIND_CREDENTIALS || "", searchBase: process.env.LDAP_SEARCH_BASE || "", searchFilter: process.env.LDAP_SEARCH_FILTER || "", + groupSearchBase: process.env.LDAP_GROUP_SEARCH_BASE || "", + groupSearchFilter: process.env.LDAP_GROUP_SEARCH_FILTER || "", Mode: process.env.LDAP_MODE ?? "ad", externalIdAttr: process.env.LDAP_EXTERNAL_ID ?? "sAMAccountName", - usernameAttr: process.env.LDAP_USERNAME ?? "displayName", + usernameAttr: process.env.LDAP_USERNAME_ATTR ?? "displayName", }, }, oidc: {