diff --git a/backend/package-lock.json b/backend/package-lock.json index 51630b0c2..8fa0aaa63 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -20,6 +20,7 @@ "@nestjs/throttler": "^5.2.0", "@prisma/client": "^5.16.1", "@types/jmespath": "^0.15.2", + "@types/ldapjs": "^3.0.6", "archiver": "^7.0.1", "argon2": "^0.40.3", "body-parser": "^1.20.2", @@ -30,6 +31,7 @@ "content-disposition": "^0.5.4", "cookie-parser": "^1.4.6", "jmespath": "^0.16.0", + "ldapjs": "^3.0.7", "mime-types": "^2.1.35", "moment": "^2.30.1", "nanoid": "^3.3.7", @@ -1126,6 +1128,101 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@ldapjs/asn1": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ldapjs/asn1/-/asn1-2.0.0.tgz", + "integrity": "sha512-G9+DkEOirNgdPmD0I8nu57ygQJKOOgFEMKknEuQvIHbGLwP3ny1mY+OTUYLCbCaGJP4sox5eYgBJRuSUpnAddA==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT" + }, + "node_modules/@ldapjs/attribute": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ldapjs/attribute/-/attribute-1.0.0.tgz", + "integrity": "sha512-ptMl2d/5xJ0q+RgmnqOi3Zgwk/TMJYG7dYMC0Keko+yZU6n+oFM59MjQOUht5pxJeS4FWrImhu/LebX24vJNRQ==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/change": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ldapjs/change/-/change-1.0.0.tgz", + "integrity": "sha512-EOQNFH1RIku3M1s0OAJOzGfAohuFYXFY4s73wOhRm4KFGhmQQ7MChOh2YtYu9Kwgvuq1B0xKciXVzHCGkB5V+Q==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/attribute": "1.0.0" + } + }, + "node_modules/@ldapjs/controls": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@ldapjs/controls/-/controls-2.1.0.tgz", + "integrity": "sha512-2pFdD1yRC9V9hXfAWvCCO2RRWK9OdIEcJIos/9cCVP9O4k72BY1bLDQQ4KpUoJnl4y/JoD4iFgM+YWT3IfITWw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "^1.2.0", + "@ldapjs/protocol": "^1.2.1" + } + }, + "node_modules/@ldapjs/controls/node_modules/@ldapjs/asn1": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ldapjs/asn1/-/asn1-1.2.0.tgz", + "integrity": "sha512-KX/qQJ2xxzvO2/WOvr1UdQ+8P5dVvuOLk/C9b1bIkXxZss8BaR28njXdPgFCpj5aHaf1t8PmuVnea+N9YG9YMw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT" + }, + "node_modules/@ldapjs/dn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@ldapjs/dn/-/dn-1.1.0.tgz", + "integrity": "sha512-R72zH5ZeBj/Fujf/yBu78YzpJjJXG46YHFo5E4W1EqfNpo1UsVPqdLrRMXeKIsJT3x9dJVIfR6OpzgINlKpi0A==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/filter": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@ldapjs/filter/-/filter-2.1.1.tgz", + "integrity": "sha512-TwPK5eEgNdUO1ABPBUQabcZ+h9heDORE4V9WNZqCtYLKc06+6+UAJ3IAbr0L0bYTnkkWC/JEQD2F+zAFsuikNw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/messages": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ldapjs/messages/-/messages-1.3.0.tgz", + "integrity": "sha512-K7xZpXJ21bj92jS35wtRbdcNrwmxAtPwy4myeh9duy/eR3xQKvikVycbdWVzkYEAVE5Ce520VXNOwCHjomjCZw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "^2.0.0", + "@ldapjs/attribute": "^1.0.0", + "@ldapjs/change": "^1.0.0", + "@ldapjs/controls": "^2.1.0", + "@ldapjs/dn": "^1.1.0", + "@ldapjs/filter": "^2.1.1", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.2.0" + } + }, + "node_modules/@ldapjs/protocol": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@ldapjs/protocol/-/protocol-1.2.1.tgz", + "integrity": "sha512-O89xFDLW2gBoZWNXuXpBSM32/KealKCTb3JGtJdtUQc7RjAk8XzrRgyz02cPAwGKwKPxy0ivuC7UP9bmN87egQ==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT" + }, "node_modules/@ljharb/through": { "version": "2.3.13", "resolved": "https://registry.npmjs.org/@ljharb/through/-/through-2.3.13.tgz", @@ -2016,6 +2113,15 @@ "@types/node": "*" } }, + "node_modules/@types/ldapjs": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/ldapjs/-/ldapjs-3.0.6.tgz", + "integrity": "sha512-E2Tn1ltJDYBsidOT9QG4engaQeQzRQ9aYNxVmjCkD33F7cIeLPgrRDXAYs0O35mK2YDU20c/+ZkNjeAPRGLM0Q==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/luxon": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz", @@ -2550,6 +2656,12 @@ "node": ">=6.5" } }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -2952,7 +3064,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", - "dev": true, "engines": { "node": ">=0.8" } @@ -2988,6 +3099,18 @@ "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.6.tgz", "integrity": "sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==" }, + "node_modules/backoff": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/backoff/-/backoff-2.5.0.tgz", + "integrity": "sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==", + "license": "MIT", + "dependencies": { + "precond": "0.2" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -4514,7 +4637,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", - "dev": true, "engines": [ "node >=0.6.0" ] @@ -5723,6 +5845,49 @@ "node": ">= 0.6.3" } }, + "node_modules/ldapjs": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/ldapjs/-/ldapjs-3.0.7.tgz", + "integrity": "sha512-1ky+WrN+4CFMuoekUOv7Y1037XWdjKpu0xAPwSP+9KdvmV9PG+qOKlssDV6a+U32apwxdD3is/BZcWOYzN30cg==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "^2.0.0", + "@ldapjs/attribute": "^1.0.0", + "@ldapjs/change": "^1.0.0", + "@ldapjs/controls": "^2.1.0", + "@ldapjs/dn": "^1.1.0", + "@ldapjs/filter": "^2.1.1", + "@ldapjs/messages": "^1.3.0", + "@ldapjs/protocol": "^1.2.1", + "abstract-logging": "^2.0.1", + "assert-plus": "^1.0.0", + "backoff": "^2.5.0", + "once": "^1.4.0", + "vasync": "^2.2.1", + "verror": "^1.10.1" + } + }, + "node_modules/ldapjs/node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT" + }, + "node_modules/ldapjs/node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -6293,7 +6458,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "dependencies": { "wrappy": "1" } @@ -6830,6 +6994,14 @@ "node": ">=10" } }, + "node_modules/precond": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz", + "integrity": "sha512-QCYG84SgGyGzqJ/vlMsxeXd/pgL/I94ixdNFyh1PusWmTCyVfPJjZ1K1jvHtsbfnXQs2TSkEP2fR7QiMZAnKFQ==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6910,6 +7082,12 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, + "node_modules/process-warning": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-2.3.2.tgz", + "integrity": "sha512-n9wh8tvBe5sFmsqlg+XQhaQLumwpqoAUruLwjCopgTmUBjJ/fjtBsJzKleCaIGBOMXYEhp1YfKl4d7rJ5ZKJGA==", + "license": "MIT" + }, "node_modules/promise-coalesce": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/promise-coalesce/-/promise-coalesce-1.1.2.tgz", @@ -7478,6 +7656,7 @@ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.4.tgz", "integrity": "sha512-7i/dt5kGl7qR4gwPRD2biwD2/SvBn3O04J77XKFgL2OnZtQw+AG9wnuS/csmu80nPRHLYE9E41fyEiG8nhH6/Q==", "hasInstallScript": true, + "license": "Apache-2.0", "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", @@ -8314,11 +8493,22 @@ "node": ">= 0.8" } }, + "node_modules/vasync": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/vasync/-/vasync-2.2.1.tgz", + "integrity": "sha512-Hq72JaTpcTFdWiNA4Y22Amej2GH3BFmBaKPPlDZ4/oC8HNn2ISHLkFrJU4Ds8R3jcUi7oo5Y9jcMHKjES+N9wQ==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "verror": "1.10.0" + } + }, "node_modules/verror": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", - "dev": true, "engines": [ "node >=0.6.0" ], @@ -8331,8 +8521,7 @@ "node_modules/verror/node_modules/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "dev": true + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" }, "node_modules/wait-on": { "version": "7.2.0", @@ -8528,8 +8717,7 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/xmlbuilder": { "version": "15.1.1", diff --git a/backend/package.json b/backend/package.json index 64f63c1f5..3fbfb9602 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,6 +25,7 @@ "@nestjs/throttler": "^5.2.0", "@prisma/client": "^5.16.1", "@types/jmespath": "^0.15.2", + "@types/ldapjs": "^3.0.6", "archiver": "^7.0.1", "argon2": "^0.40.3", "body-parser": "^1.20.2", @@ -35,6 +36,7 @@ "content-disposition": "^0.5.4", "cookie-parser": "^1.4.6", "jmespath": "^0.16.0", + "ldapjs": "^3.0.7", "mime-types": "^2.1.35", "moment": "^2.30.1", "nanoid": "^3.3.7", diff --git a/backend/prisma/migrations/20240803232708_ldap_support/migration.sql b/backend/prisma/migrations/20240803232708_ldap_support/migration.sql new file mode 100644 index 000000000..c3920a51e --- /dev/null +++ b/backend/prisma/migrations/20240803232708_ldap_support/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - A unique constraint covering the columns `[ldapDN]` on the table `User` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "ldapDN" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "User_ldapDN_key" ON "User"("ldapDN"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index ce87009ab..c8c9c4971 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -16,6 +16,7 @@ model User { email String @unique password String? isAdmin Boolean @default(false) + ldapDN String? @unique shares Share[] refreshTokens RefreshToken[] diff --git a/backend/prisma/seed/config.seed.ts b/backend/prisma/seed/config.seed.ts index 6ddc57318..a88f4efc2 100644 --- a/backend/prisma/seed/config.seed.ts +++ b/backend/prisma/seed/config.seed.ts @@ -144,6 +144,42 @@ const configVariables: ConfigVariables = { obscured: true, }, }, + ldap: { + enabled: { + type: "boolean", + defaultValue: "false", + secret: false, + }, + + url: { + type: "string", + defaultValue: "", + }, + + bindDn: { + type: "string", + defaultValue: "", + }, + bindPassword: { + type: "string", + defaultValue: "", + obscured: true, + }, + + searchBase: { + type: "string", + defaultValue: "", + }, + searchQuery: { + type: "string", + defaultValue: "" + }, + + adminGroups: { + type: "string", + defaultValue: "" + } + }, oauth: { "allowRegistration": { type: "boolean", @@ -308,7 +344,7 @@ async function migrateConfigVariables() { for (const existingConfigVariable of existingConfigVariables) { const configVariable = configVariables[existingConfigVariable.category]?.[ - existingConfigVariable.name + existingConfigVariable.name ]; // Delete the config variable if it doesn't exist in the seed diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index a96ab2fa8..13083cb69 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -5,6 +5,8 @@ import { AuthController } from "./auth.controller"; import { AuthService } from "./auth.service"; import { AuthTotpService } from "./authTotp.service"; import { JwtStrategy } from "./strategy/jwt.strategy"; +import { LdapService } from "./ldap.service"; +import { UserModule } from "../user/user.module"; @Module({ imports: [ @@ -12,9 +14,10 @@ import { JwtStrategy } from "./strategy/jwt.strategy"; global: true, }), EmailModule, + UserModule, ], controllers: [AuthController], - providers: [AuthService, AuthTotpService, JwtStrategy], + providers: [AuthService, AuthTotpService, JwtStrategy, LdapService], exports: [AuthService], }) -export class AuthModule {} +export class AuthModule { } diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index e66b24c16..1efef0826 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -16,6 +16,9 @@ import { EmailService } from "src/email/email.service"; import { PrismaService } from "src/prisma/prisma.service"; import { AuthRegisterDTO } from "./dto/authRegister.dto"; import { AuthSignInDTO } from "./dto/authSignIn.dto"; +import { LdapService } from "./ldap.service"; +import { inspect } from "util"; +import { UserSevice } from "../user/user.service"; @Injectable() export class AuthService { @@ -24,7 +27,9 @@ export class AuthService { private jwtService: JwtService, private config: ConfigService, private emailService: EmailService, - ) {} + private ldapService: LdapService, + private userService: UserSevice, + ) { } private readonly logger = new Logger(AuthService.name); async signUp(dto: AuthRegisterDTO, ip: string, isAdmin?: boolean) { @@ -64,24 +69,33 @@ export class AuthService { if (!dto.email && !dto.username) throw new BadRequestException("Email or username is required"); - if (this.config.get("oauth.disablePassword")) - throw new ForbiddenException("Password sign in is disabled"); + if (!this.config.get("oauth.disablePassword")) { + const user = await this.prisma.user.findFirst({ + where: { + OR: [{ email: dto.email }, { username: dto.username }], + }, + }); - const user = await this.prisma.user.findFirst({ - where: { - OR: [{ email: dto.email }, { username: dto.username }], - }, - }); + if (user && await argon.verify(user.password, dto.password)) { + this.logger.log(`Successful password login for user ${user.email} from IP ${ip}`); + return this.generateToken(user); + } + } - if (!user || !(await argon.verify(user.password, dto.password))) { - this.logger.log( - `Failed login attempt for user ${dto.email} from IP ${ip}`, - ); - throw new UnauthorizedException("Wrong email or password"); + if (this.config.get("ldap.enabled")) { + this.logger.debug(`Trying LDAP login for user ${dto.username}`); + const ldapUser = await this.ldapService.authenticateUser(dto.username, dto.password); + if (ldapUser) { + const user = await this.userService.findOrCreateFromLDAP(dto.username, ldapUser); + this.logger.log(`Successful LDAP login for user ${user.email} from IP ${ip}`); + return this.generateToken(user); + } } - this.logger.log(`Successful login for user ${user.email} from IP ${ip}`); - return this.generateToken(user); + this.logger.log( + `Failed login attempt for user ${dto.email || dto.username} from IP ${ip}`, + ); + throw new UnauthorizedException("Wrong email or password"); } async generateToken(user: User, isOAuth = false) { diff --git a/backend/src/auth/ldap.service.ts b/backend/src/auth/ldap.service.ts new file mode 100644 index 000000000..9a6511f40 --- /dev/null +++ b/backend/src/auth/ldap.service.ts @@ -0,0 +1,154 @@ +import { Inject, Injectable, Logger } from "@nestjs/common"; +import * as ldap from "ldapjs"; +import { AttributeJson, InvalidCredentialsError, SearchCallbackResponse, SearchOptions } from "ldapjs"; +import { inspect } from "node:util"; +import { ConfigService } from "../config/config.service"; + +type LdapSearchEntry = { + objectName: string, + attributes: AttributeJson[], +}; + +async function ldapExecuteSearch(client: ldap.Client, base: string, options: SearchOptions): Promise { + const searchResponse = await new Promise((resolve, reject) => { + client.search(base, options, (err, res) => { + if (err) { + reject(err); + } else { + resolve(res); + } + }); + }); + + return await new Promise((resolve, reject) => { + const entries: LdapSearchEntry[] = []; + searchResponse.on("searchEntry", entry => entries.push({ attributes: entry.pojo.attributes, objectName: entry.pojo.objectName })); + searchResponse.once("error", reject); + searchResponse.once("end", () => resolve(entries)); + }); +} + +async function ldapBindUser(client: ldap.Client, dn: string, password: string): Promise { + return new Promise((resolve, reject) => { + client.bind(dn, password, error => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }) +} + +async function ldapCreateConnection(logger: Logger, url: string): Promise { + const ldapClient = ldap.createClient({ + url: url.split(","), + connectTimeout: 10_000, + timeout: 10_000 + }); + + await new Promise((resolve, reject) => { + ldapClient.once("error", reject); + ldapClient.on("setupError", reject); + ldapClient.on("socketTimeout", reject); + ldapClient.on("connectRefused", () => reject(new Error("connection has been refused"))); + ldapClient.on("connectTimeout", () => reject(new Error("connect timed out"))); + ldapClient.on("connectError", reject); + + ldapClient.on("connect", resolve); + }).catch(error => { + logger.error(`Connect error: ${inspect(error)}`); + ldapClient.destroy(); + throw error; + }); + + return ldapClient; +} + +export type LdapAuthenticateResult = { + userDn: string, + attributes: Record +}; + +@Injectable() +export class LdapService { + private readonly logger = new Logger(LdapService.name); + constructor( + @Inject(ConfigService) + private readonly serviceConfig: ConfigService, + ) { } + + private async createLdapConnection(): Promise { + const ldapUrl = this.serviceConfig.get("ldap.url"); + if (!ldapUrl) { + throw new Error("LDAP server URL is not defined"); + } + + const ldapClient = await ldapCreateConnection(this.logger, ldapUrl); + try { + const bindDn = this.serviceConfig.get("ldap.bindDn") || null; + if (bindDn) { + try { + await ldapBindUser(ldapClient, bindDn, this.serviceConfig.get("ldap.bindPassword")) + } catch (error) { + this.logger.warn(`Failed to bind to default user: ${error}`); + throw new Error("failed to bind to default user"); + } + } + + return ldapClient; + } catch (error) { + ldapClient.destroy(); + throw error; + } + } + + public async authenticateUser(username: string, password: string): Promise { + if (!username.match(/^[a-zA-Z0-0]+$/)) { + return null; + } + + const searchBase = this.serviceConfig.get("ldap.searchBase"); + const searchQuery = this.serviceConfig.get("ldap.searchQuery") + .replaceAll("%username%", username); + + const ldapClient = await this.createLdapConnection(); + try { + const [result] = await ldapExecuteSearch(ldapClient, searchBase, { + filter: searchQuery, + scope: "sub" + }); + + if (!result) { + /* user not found */ + return null; + } + + try { + await ldapBindUser(ldapClient, result.objectName, password); + + /* + * In theory we could query the user attributes now, + * but as we must query the user attributes for validation anyways + * we'll create a second ldap server connection. + */ + return { + userDn: result.objectName, + attributes: Object.fromEntries(result.attributes.map(attribute => [attribute.type, attribute.values])), + }; + } catch (error) { + if (error instanceof InvalidCredentialsError) { + return null; + } + + this.logger.warn(`LDAP user bind failure: ${inspect(error)}`); + return null; + } finally { + ldapClient.destroy(); + } + } catch (error) { + this.logger.warn(`LDAP connect error: ${inspect(error)}`); + return null; + } + } +} \ No newline at end of file diff --git a/backend/src/user/dto/user.dto.ts b/backend/src/user/dto/user.dto.ts index b11d5d7f5..7d5da722d 100644 --- a/backend/src/user/dto/user.dto.ts +++ b/backend/src/user/dto/user.dto.ts @@ -25,16 +25,21 @@ export class UserDTO { @Expose() isAdmin: boolean; + @Expose() + isLdap: boolean; + + ldapDN?: string; + @Expose() totpVerified: boolean; from(partial: Partial) { - return plainToClass(UserDTO, partial, { excludeExtraneousValues: true }); + const result = plainToClass(UserDTO, partial, { excludeExtraneousValues: true }); + result.isLdap = partial.ldapDN?.length > 0; + return result; } fromList(partial: Partial[]) { - return partial.map((part) => - plainToClass(UserDTO, part, { excludeExtraneousValues: true }), - ); + return partial.map((part) => this.from(part)); } } diff --git a/backend/src/user/user.module.ts b/backend/src/user/user.module.ts index 334654adc..b9fb497e7 100644 --- a/backend/src/user/user.module.ts +++ b/backend/src/user/user.module.ts @@ -8,5 +8,6 @@ import { FileModule } from "src/file/file.module"; imports: [EmailModule, FileModule], providers: [UserSevice], controllers: [UserController], + exports: [UserSevice] }) -export class UserModule {} +export class UserModule { } diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index 1e968e64e..93d2cc455 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -7,6 +7,8 @@ import { PrismaService } from "src/prisma/prisma.service"; import { FileService } from "../file/file.service"; import { CreateUserDTO } from "./dto/createUser.dto"; import { UpdateUserDto } from "./dto/updateUser.dto"; +import { ConfigService } from "../config/config.service"; +import { LdapAuthenticateResult } from "../auth/ldap.service"; @Injectable() export class UserSevice { @@ -14,7 +16,8 @@ export class UserSevice { private prisma: PrismaService, private emailService: EmailService, private fileService: FileService, - ) {} + private configService: ConfigService, + ) { } async list() { return await this.prisma.user.findMany(); @@ -88,4 +91,41 @@ export class UserSevice { return await this.prisma.user.delete({ where: { id } }); } + + async findOrCreateFromLDAP(username: string, ldap: LdapAuthenticateResult) { + const passwordHash = await argon.hash(crypto.randomUUID()); + const userEmail = ldap.attributes["userPrincipalName"]?.at(0) ?? `${crypto.randomUUID()}@ldap.local`; + const adminGroup = this.configService.get("ldap.adminGroups"); + const isAdmin = ldap.attributes["memberOf"]?.includes(adminGroup) ?? false; + try { + return await this.prisma.user.upsert({ + create: { + username, + email: userEmail, + password: passwordHash, + isAdmin, + ldapDN: ldap.userDn, + }, + update: { + username, + email: userEmail, + + isAdmin, + ldapDN: ldap.userDn, + }, + where: { + ldapDN: ldap.userDn + } + }); + } catch (e) { + if (e instanceof PrismaClientKnownRequestError) { + if (e.code == "P2002") { + const duplicatedField: string = e.meta.target[0]; + throw new BadRequestException( + `A user with this ${duplicatedField} already exists`, + ); + } + } + } + } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0b5dd9127..d9be754fd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -3145,11 +3145,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/cookie": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.3.3.tgz", - "integrity": "sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow==" - }, "node_modules/@types/eslint": { "version": "8.4.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.1.tgz", @@ -7238,14 +7233,6 @@ } } }, - "node_modules/next-cookies": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/next-cookies/-/next-cookies-2.0.3.tgz", - "integrity": "sha512-YVCQzwZx+sz+KqLO4y9niHH9jjz6jajlEQbAKfsYVT6DOfngb/0k5l6vFK4rmpExVug96pGag8OBsdSRL9FZhQ==", - "dependencies": { - "universal-cookie": "^4.0.2" - } - }, "node_modules/next-http-proxy-middleware": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/next-http-proxy-middleware/-/next-http-proxy-middleware-1.2.6.tgz", @@ -9141,15 +9128,6 @@ "node": ">=8" } }, - "node_modules/universal-cookie": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-4.0.4.tgz", - "integrity": "sha512-lbRVHoOMtItjWbM7TwDLdl8wug7izB0tq3/YVKhT/ahB4VDvWMyvnADfnJI8y6fSvsjh51Ix7lTGC6Tn4rMPhw==", - "dependencies": { - "@types/cookie": "^0.3.3", - "cookie": "^0.4.0" - } - }, "node_modules/universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", @@ -11837,11 +11815,6 @@ "tslib": "^2.4.0" } }, - "@types/cookie": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.3.3.tgz", - "integrity": "sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow==" - }, "@types/eslint": { "version": "8.4.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.1.tgz", @@ -14856,14 +14829,6 @@ "styled-jsx": "5.1.1" } }, - "next-cookies": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/next-cookies/-/next-cookies-2.0.3.tgz", - "integrity": "sha512-YVCQzwZx+sz+KqLO4y9niHH9jjz6jajlEQbAKfsYVT6DOfngb/0k5l6vFK4rmpExVug96pGag8OBsdSRL9FZhQ==", - "requires": { - "universal-cookie": "^4.0.2" - } - }, "next-http-proxy-middleware": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/next-http-proxy-middleware/-/next-http-proxy-middleware-1.2.6.tgz", @@ -16185,15 +16150,6 @@ "crypto-random-string": "^2.0.0" } }, - "universal-cookie": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-4.0.4.tgz", - "integrity": "sha512-lbRVHoOMtItjWbM7TwDLdl8wug7izB0tq3/YVKhT/ahB4VDvWMyvnADfnJI8y6fSvsjh51Ix7lTGC6Tn4rMPhw==", - "requires": { - "@types/cookie": "^0.3.3", - "cookie": "^0.4.0" - } - }, "universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", diff --git a/frontend/src/components/admin/configuration/ConfigurationNavBar.tsx b/frontend/src/components/admin/configuration/ConfigurationNavBar.tsx index 9df1a19c2..0fc0d0b69 100644 --- a/frontend/src/components/admin/configuration/ConfigurationNavBar.tsx +++ b/frontend/src/components/admin/configuration/ConfigurationNavBar.tsx @@ -11,7 +11,14 @@ import { } from "@mantine/core"; import Link from "next/link"; import { Dispatch, SetStateAction } from "react"; -import { TbAt, TbMail, TbShare, TbSocial, TbSquare } from "react-icons/tb"; +import { + TbAt, + TbMail, + TbShare, + TbSocial, + TbSquare, + TbBinaryTree, +} from "react-icons/tb"; import { FormattedMessage } from "react-intl"; const categories = [ @@ -20,6 +27,7 @@ const categories = [ { name: "Share", icon: }, { name: "SMTP", icon: }, { name: "OAuth", icon: }, + { name: "LDAP", icon: }, ]; const useStyles = createStyles((theme) => ({ diff --git a/frontend/src/components/admin/users/ManageUserTable.tsx b/frontend/src/components/admin/users/ManageUserTable.tsx index 6c29d54d2..3923a0ff5 100644 --- a/frontend/src/components/admin/users/ManageUserTable.tsx +++ b/frontend/src/components/admin/users/ManageUserTable.tsx @@ -1,4 +1,4 @@ -import { ActionIcon, Box, Group, Skeleton, Table } from "@mantine/core"; +import { ActionIcon, Badge, Box, Group, Skeleton, Table } from "@mantine/core"; import { useModals } from "@mantine/modals"; import { TbCheck, TbEdit, TbTrash } from "react-icons/tb"; import User from "../../../types/user.type"; @@ -40,21 +40,28 @@ const ManageUserTable = ({ ? skeletonRows : users.map((user) => ( - {user.username} + + {user.username}{" "} + {user.isLdap ? ( + LDAP + ) : null} + {user.email} {user.isAdmin && } - - showUpdateUserModal(modals, user, getUsers) - } - > - - + {user.isLdap ? null : ( + + showUpdateUserModal(modals, user, getUsers) + } + > + + + )} { { - - - <FormattedMessage id="account.card.password.title" /> - -
- authService - .updatePassword(values.oldPassword, values.password) - .then(async () => { - refreshUser(); - toast.success(t("account.notify.password.success")); - passwordForm.reset(); - }) - .catch(toast.axiosError), - )} - > - - {user?.hasPassword ? ( + {user?.isLdap ? null : ( + + + <FormattedMessage id="account.card.password.title" /> + + + authService + .updatePassword(values.oldPassword, values.password) + .then(async () => { + refreshUser(); + toast.success(t("account.notify.password.success")); + passwordForm.reset(); + }) + .catch(toast.axiosError), + )} + > + + {user?.hasPassword ? ( + + ) : ( + + + + )} - ) : ( - - - - )} - - - - - - - + + + +
+ +
+ )} {oauth.length > 0 && ( diff --git a/frontend/src/types/user.type.ts b/frontend/src/types/user.type.ts index cb3809252..faa9baf2e 100644 --- a/frontend/src/types/user.type.ts +++ b/frontend/src/types/user.type.ts @@ -3,6 +3,7 @@ type User = { username: string; email: string; isAdmin: boolean; + isLdap: boolean; totpVerified: boolean; hasPassword: boolean; };