diff --git a/packages/auth/providers/credentials/authorization/ldap-authorization.ts b/packages/auth/providers/credentials/authorization/ldap-authorization.ts index c286468d6..d54101898 100644 --- a/packages/auth/providers/credentials/authorization/ldap-authorization.ts +++ b/packages/auth/providers/credentials/authorization/ldap-authorization.ts @@ -1,8 +1,8 @@ import { CredentialsSignin } from "@auth/core/errors"; -import type { Database } from "@homarr/db"; -import { and, createId, eq } from "@homarr/db"; -import { users } from "@homarr/db/schema/sqlite"; +import type { Database, InferInsertModel } from "@homarr/db"; +import { and, createId, eq, inArray } from "@homarr/db"; +import { groupMembers, groups, users } from "@homarr/db/schema/sqlite"; import { logger } from "@homarr/log"; import type { validation } from "@homarr/validation"; import { z } from "@homarr/validation"; @@ -99,21 +99,39 @@ export const authorizeWithLdapCredentialsAsync = async ( emailVerified: true, provider: true, }, + with: { + groups: { + with: { + group: { + columns: { + id: true, + name: true, + }, + }, + }, + }, + }, where: and(eq(users.email, mailResult.data), eq(users.provider, "ldap")), }); if (!user) { logger.info(`User ${credentials.name} not found in the database. Creating...`); - user = { + const insertUser = { id: createId(), name: credentials.name, email: mailResult.data, emailVerified: new Date(), // assume email is verified image: null, provider: "ldap", + } satisfies InferInsertModel; + + await db.insert(users).values(insertUser); + + user = { + ...insertUser, + groups: [], }; - await db.insert(users).values(user); logger.info(`User ${credentials.name} created successfully.`); } @@ -128,6 +146,58 @@ export const authorizeWithLdapCredentialsAsync = async ( logger.info(`User ${credentials.name} updated successfully.`); } + const ldapGroupsUserIsNotIn = userGroups.filter( + (group) => !user.groups.some((userGroup) => userGroup.group.name === group), + ); + + if (ldapGroupsUserIsNotIn.length > 0) { + logger.debug( + `Homarr does not have the user in certain groups. user=${user.name} count=${ldapGroupsUserIsNotIn.length}`, + ); + + const groupIds = await db.query.groups.findMany({ + columns: { + id: true, + }, + where: inArray(groups.name, ldapGroupsUserIsNotIn), + }); + + logger.debug(`Homarr has found groups in the database user is not in. user=${user.name} count=${groupIds.length}`); + + if (groupIds.length > 0) { + await db.insert(groupMembers).values( + groupIds.map((group) => ({ + userId: user.id, + groupId: group.id, + })), + ); + + logger.info(`Added user to groups successfully. user=${user.name} count=${groupIds.length}`); + } else { + logger.debug(`User is already in all groups of Homarr. user=${user.name}`); + } + } + + const homarrGroupsUserIsNotIn = user.groups.filter((userGroup) => !userGroups.includes(userGroup.group.name)); + + if (homarrGroupsUserIsNotIn.length > 0) { + logger.debug( + `Homarr has the user in certain groups that LDAP does not have. user=${user.name} count=${homarrGroupsUserIsNotIn.length}`, + ); + + await db.delete(groupMembers).where( + and( + eq(groupMembers.userId, user.id), + inArray( + groupMembers.groupId, + homarrGroupsUserIsNotIn.map(({ groupId }) => groupId), + ), + ), + ); + + logger.info(`Removed user from groups successfully. user=${user.name} count=${homarrGroupsUserIsNotIn.length}`); + } + return { id: user.id, name: user.name, diff --git a/packages/auth/providers/test/ldap-authorization.spec.ts b/packages/auth/providers/test/ldap-authorization.spec.ts index 3871a1ed9..16f2fa697 100644 --- a/packages/auth/providers/test/ldap-authorization.spec.ts +++ b/packages/auth/providers/test/ldap-authorization.spec.ts @@ -3,10 +3,9 @@ import { describe, expect, test, vi } from "vitest"; import type { Database } from "@homarr/db"; import { and, createId, eq } from "@homarr/db"; -import { users } from "@homarr/db/schema/sqlite"; +import { groupMembers, groups, users } from "@homarr/db/schema/sqlite"; import { createDb } from "@homarr/db/test"; -import { createSaltAsync, hashPasswordAsync } from "../../security"; import { authorizeWithLdapCredentialsAsync } from "../credentials/authorization/ldap-authorization"; import * as ldapClient from "../credentials/ldap-client"; @@ -15,6 +14,7 @@ vi.mock("../../env.mjs", () => ({ AUTH_LDAP_BIND_DN: "bind_dn", AUTH_LDAP_BIND_PASSWORD: "bind_password", AUTH_LDAP_USER_MAIL_ATTRIBUTE: "mail", + AUTH_LDAP_GROUP_CLASS: "group", }, })); @@ -171,7 +171,6 @@ describe("authorizeWithLdapCredentials", () => { // Arrange const db = createDb(); const spy = vi.spyOn(ldapClient, "LdapClient"); - const salt = await createSaltAsync(); spy.mockImplementation( () => ({ @@ -190,8 +189,6 @@ describe("authorizeWithLdapCredentials", () => { await db.insert(users).values({ id: createId(), name: "test", - salt, - password: await hashPasswordAsync("test", salt), email: "test@gmail.com", provider: "credentials", }); @@ -224,14 +221,28 @@ describe("authorizeWithLdapCredentials", () => { test("should authorize user with correct credentials and update name", async () => { // Arrange + const spy = vi.spyOn(ldapClient, "LdapClient"); + spy.mockImplementation( + () => + ({ + bindAsync: vi.fn(() => Promise.resolve()), + searchAsync: vi.fn(() => + Promise.resolve([ + { + dn: "test55", + mail: "test@gmail.com", + }, + ]), + ), + disconnectAsync: vi.fn(), + }) as unknown as ldapClient.LdapClient, + ); + const userId = createId(); const db = createDb(); - const salt = await createSaltAsync(); await db.insert(users).values({ id: userId, name: "test-old", - salt, - password: await hashPasswordAsync("test", salt), email: "test@gmail.com", provider: "ldap", }); @@ -256,4 +267,127 @@ describe("authorizeWithLdapCredentials", () => { expect(dbUser?.email).toBe("test@gmail.com"); expect(dbUser?.provider).toBe("ldap"); }); + + test("should authorize user with correct credentials and add him to the groups that he is in LDAP but not in Homar", async () => { + // Arrange + const spy = vi.spyOn(ldapClient, "LdapClient"); + spy.mockImplementation( + () => + ({ + bindAsync: vi.fn(() => Promise.resolve()), + searchAsync: vi.fn((argument: { options: { filter: string } }) => + argument.options.filter.includes("group") + ? Promise.resolve([ + { + cn: "homarr_example", + }, + ]) + : Promise.resolve([ + { + dn: "test55", + mail: "test@gmail.com", + }, + ]), + ), + disconnectAsync: vi.fn(), + }) as unknown as ldapClient.LdapClient, + ); + const db = createDb(); + const userId = createId(); + await db.insert(users).values({ + id: userId, + name: "test", + email: "test@gmail.com", + provider: "ldap", + }); + + const groupId = createId(); + await db.insert(groups).values({ + id: groupId, + name: "homarr_example", + }); + + // Act + const result = await authorizeWithLdapCredentialsAsync(db, { + name: "test", + password: "test", + credentialType: "ldap", + }); + + // Assert + expect(result).toEqual({ id: userId, name: "test" }); + + const dbGroupMembers = await db.query.groupMembers.findMany(); + expect(dbGroupMembers).toHaveLength(1); + }); + + test("should authorize user with correct credentials and remove him from groups he is in Homarr but not in LDAP", async () => { + // Arrange + const spy = vi.spyOn(ldapClient, "LdapClient"); + spy.mockImplementation( + () => + ({ + bindAsync: vi.fn(() => Promise.resolve()), + searchAsync: vi.fn((argument: { options: { filter: string } }) => + argument.options.filter.includes("group") + ? Promise.resolve([ + { + cn: "homarr_example", + }, + ]) + : Promise.resolve([ + { + dn: "test55", + mail: "test@gmail.com", + }, + ]), + ), + disconnectAsync: vi.fn(), + }) as unknown as ldapClient.LdapClient, + ); + const db = createDb(); + const userId = createId(); + await db.insert(users).values({ + id: userId, + name: "test", + email: "test@gmail.com", + provider: "ldap", + }); + + const groupIds = [createId(), createId()] as const; + await db.insert(groups).values([ + { + id: groupIds[0], + name: "homarr_example", + }, + { + id: groupIds[1], + name: "homarr_no_longer_member", + }, + ]); + await db.insert(groupMembers).values([ + { + userId, + groupId: groupIds[0], + }, + { + userId, + groupId: groupIds[1], + }, + ]); + + // Act + const result = await authorizeWithLdapCredentialsAsync(db, { + name: "test", + password: "test", + credentialType: "ldap", + }); + + // Assert + expect(result).toEqual({ id: userId, name: "test" }); + + const dbGroupMembers = await db.query.groupMembers.findMany(); + expect(dbGroupMembers).toHaveLength(1); + expect(dbGroupMembers[0]?.groupId).toBe(groupIds[0]); + }); });