mirror of
https://github.com/stonith404/pingvin-share.git
synced 2025-02-19 01:55:48 -05:00
feat: improve the LDAP implementation (#615)
* feat(logging): add PV_LOG_LEVEL environment variable to set backend log level
* feat(ldap): Adding a more verbose logging output to debug LDAP issues
* fix(ldap): fixed user logins with special characters within the users dn by switching to ldapts
* feat(ldap): made the member of and email attribute names configurable
* fix(ldap): properly handle email like usernames and fixing #601
* Revert "fix: disable email login if ldap is enabled"
This reverts commit d9cfe697d6
.
* feat(ldap): disable the ability for a user to change his email when it's a LDAP user
* feat(ldap): relaxed username pattern by allowing the @ character in usernames
This commit is contained in:
parent
adc4af996d
commit
3310fe53b3
13 changed files with 271 additions and 213 deletions
89
backend/package-lock.json
generated
89
backend/package-lock.json
generated
|
@ -32,6 +32,7 @@
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"jmespath": "^0.16.0",
|
"jmespath": "^0.16.0",
|
||||||
"ldapjs": "^3.0.7",
|
"ldapjs": "^3.0.7",
|
||||||
|
"ldapts": "^7.2.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"nanoid": "^3.3.7",
|
"nanoid": "^3.3.7",
|
||||||
|
@ -1780,6 +1781,14 @@
|
||||||
"@types/readdir-glob": "*"
|
"@types/readdir-glob": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/asn1": {
|
||||||
|
"version": "0.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/asn1/-/asn1-0.2.4.tgz",
|
||||||
|
"integrity": "sha512-V91DSJ2l0h0gRhVP4oBfBzRBN9lAbPUkGDMCnwedqPKX2d84aAMc9CulOvxdw1f7DfEYx99afab+Rsm3e52jhA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/body-parser": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.2",
|
"version": "1.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz",
|
||||||
|
@ -2758,7 +2767,6 @@
|
||||||
"version": "0.2.6",
|
"version": "0.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
|
||||||
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
|
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safer-buffer": "~2.1.0"
|
"safer-buffer": "~2.1.0"
|
||||||
}
|
}
|
||||||
|
@ -3699,12 +3707,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.3.4",
|
"version": "4.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||||
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
|
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "2.1.2"
|
"ms": "^2.1.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0"
|
"node": ">=6.0"
|
||||||
|
@ -5555,6 +5562,53 @@
|
||||||
"node": ">=0.6.0"
|
"node": ">=0.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ldapts": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ldapts/-/ldapts-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-jFo3JI46nveXgILcEhUxR7N9it9d6gIooGAaem5OdXbXFjb6kIGdtI6FE2Y6SnT+XRvZvHy3diM5sdWzMsMK5w==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/asn1": ">=0.2.4",
|
||||||
|
"asn1": "~0.2.6",
|
||||||
|
"debug": "~4.3.7",
|
||||||
|
"strict-event-emitter-types": "~2.0.0",
|
||||||
|
"uuid": "~10.0.0",
|
||||||
|
"whatwg-url": "~14.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ldapts/node_modules/tr46": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==",
|
||||||
|
"dependencies": {
|
||||||
|
"punycode": "^2.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ldapts/node_modules/webidl-conversions": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ldapts/node_modules/whatwg-url": {
|
||||||
|
"version": "14.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.0.0.tgz",
|
||||||
|
"integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "^5.0.0",
|
||||||
|
"webidl-conversions": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/levn": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
|
@ -5882,9 +5936,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||||
},
|
},
|
||||||
"node_modules/mute-stream": {
|
"node_modules/mute-stream": {
|
||||||
"version": "0.0.8",
|
"version": "0.0.8",
|
||||||
|
@ -6767,10 +6821,9 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.1.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
|
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
|
@ -7248,11 +7301,6 @@
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/send/node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
|
||||||
},
|
|
||||||
"node_modules/serialised-error": {
|
"node_modules/serialised-error": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/serialised-error/-/serialised-error-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/serialised-error/-/serialised-error-1.1.3.tgz",
|
||||||
|
@ -7511,6 +7559,11 @@
|
||||||
"bare-events": "^2.2.0"
|
"bare-events": "^2.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strict-event-emitter-types": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA=="
|
||||||
|
},
|
||||||
"node_modules/string_decoder": {
|
"node_modules/string_decoder": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
|
|
@ -25,7 +25,6 @@
|
||||||
"@nestjs/throttler": "^6.2.1",
|
"@nestjs/throttler": "^6.2.1",
|
||||||
"@prisma/client": "^5.19.1",
|
"@prisma/client": "^5.19.1",
|
||||||
"@types/jmespath": "^0.15.2",
|
"@types/jmespath": "^0.15.2",
|
||||||
"@types/ldapjs": "^3.0.6",
|
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"argon2": "^0.41.1",
|
"argon2": "^0.41.1",
|
||||||
"body-parser": "^1.20.3",
|
"body-parser": "^1.20.3",
|
||||||
|
@ -36,7 +35,7 @@
|
||||||
"content-disposition": "^0.5.4",
|
"content-disposition": "^0.5.4",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"jmespath": "^0.16.0",
|
"jmespath": "^0.16.0",
|
||||||
"ldapjs": "^3.0.7",
|
"ldapts": "^7.2.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"nanoid": "^3.3.7",
|
"nanoid": "^3.3.7",
|
||||||
|
@ -84,4 +83,4 @@
|
||||||
"typescript": "^5.6.2",
|
"typescript": "^5.6.2",
|
||||||
"wait-on": "^8.0.1"
|
"wait-on": "^8.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -178,6 +178,15 @@ const configVariables: ConfigVariables = {
|
||||||
adminGroups: {
|
adminGroups: {
|
||||||
type: "string",
|
type: "string",
|
||||||
defaultValue: ""
|
defaultValue: ""
|
||||||
|
},
|
||||||
|
|
||||||
|
fieldNameMemberOf: {
|
||||||
|
type: "string",
|
||||||
|
defaultValue: "memberOf",
|
||||||
|
},
|
||||||
|
fieldNameEmail: {
|
||||||
|
type: "string",
|
||||||
|
defaultValue: "userPrincipalName",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
oauth: {
|
oauth: {
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { PrismaService } from "./prisma/prisma.service";
|
||||||
|
|
||||||
@Controller("/")
|
@Controller("/")
|
||||||
export class AppController {
|
export class AppController {
|
||||||
constructor(private prismaService: PrismaService) {}
|
constructor(private prismaService: PrismaService) { }
|
||||||
|
|
||||||
@Get("health")
|
@Get("health")
|
||||||
async health(@Res({ passthrough: true }) res: Response) {
|
async health(@Res({ passthrough: true }) res: Response) {
|
||||||
|
|
|
@ -29,7 +29,7 @@ export class AuthService {
|
||||||
private emailService: EmailService,
|
private emailService: EmailService,
|
||||||
private ldapService: LdapService,
|
private ldapService: LdapService,
|
||||||
private userService: UserSevice,
|
private userService: UserSevice,
|
||||||
) {}
|
) { }
|
||||||
private readonly logger = new Logger(AuthService.name);
|
private readonly logger = new Logger(AuthService.name);
|
||||||
|
|
||||||
async signUp(dto: AuthRegisterDTO, ip: string, isAdmin?: boolean) {
|
async signUp(dto: AuthRegisterDTO, ip: string, isAdmin?: boolean) {
|
||||||
|
@ -66,8 +66,9 @@ export class AuthService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async signIn(dto: AuthSignInDTO, ip: string) {
|
async signIn(dto: AuthSignInDTO, ip: string) {
|
||||||
if (!dto.email && !dto.username)
|
if (!dto.email && !dto.username) {
|
||||||
throw new BadRequestException("Email or username is required");
|
throw new BadRequestException("Email or username is required");
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.config.get("oauth.disablePassword")) {
|
if (!this.config.get("oauth.disablePassword")) {
|
||||||
const user = await this.prisma.user.findFirst({
|
const user = await this.prisma.user.findFirst({
|
||||||
|
@ -85,18 +86,25 @@ export class AuthService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.config.get("ldap.enabled")) {
|
if (this.config.get("ldap.enabled")) {
|
||||||
this.logger.debug(`Trying LDAP login for user ${dto.username}`);
|
/*
|
||||||
|
* E-mail-like user credentials are passed as the email property
|
||||||
|
* instead of the username. Since the username format does not matter
|
||||||
|
* when searching for users in LDAP, we simply use the username
|
||||||
|
* in whatever format it is provided.
|
||||||
|
*/
|
||||||
|
const ldapUsername = dto.username || dto.email;
|
||||||
|
this.logger.debug(`Trying LDAP login for user ${ldapUsername}`);
|
||||||
const ldapUser = await this.ldapService.authenticateUser(
|
const ldapUser = await this.ldapService.authenticateUser(
|
||||||
dto.username,
|
ldapUsername,
|
||||||
dto.password,
|
dto.password,
|
||||||
);
|
);
|
||||||
if (ldapUser) {
|
if (ldapUser) {
|
||||||
const user = await this.userService.findOrCreateFromLDAP(
|
const user = await this.userService.findOrCreateFromLDAP(
|
||||||
dto.username,
|
dto,
|
||||||
ldapUser,
|
ldapUser,
|
||||||
);
|
);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Successful LDAP login for user ${user.email} from IP ${ip}`,
|
`Successful LDAP login for user ${ldapUsername} (${user.id}) from IP ${ip}`,
|
||||||
);
|
);
|
||||||
return this.generateToken(user);
|
return this.generateToken(user);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,101 +1,7 @@
|
||||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
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 { inspect } from "node:util";
|
||||||
import { ConfigService } from "../config/config.service";
|
import { ConfigService } from "../config/config.service";
|
||||||
|
import { Client, Entry, InvalidCredentialsError } from "ldapts";
|
||||||
type LdapSearchEntry = {
|
|
||||||
objectName: string;
|
|
||||||
attributes: AttributeJson[];
|
|
||||||
};
|
|
||||||
|
|
||||||
async function ldapExecuteSearch(
|
|
||||||
client: ldap.Client,
|
|
||||||
base: string,
|
|
||||||
options: SearchOptions,
|
|
||||||
): Promise<LdapSearchEntry[]> {
|
|
||||||
const searchResponse = await new Promise<SearchCallbackResponse>(
|
|
||||||
(resolve, reject) => {
|
|
||||||
client.search(base, options, (err, res) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
} else {
|
|
||||||
resolve(res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return await new Promise<any[]>((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<void> {
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
client.bind(dn, password, (error) => {
|
|
||||||
if (error) {
|
|
||||||
reject(error);
|
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ldapCreateConnection(
|
|
||||||
logger: Logger,
|
|
||||||
url: string,
|
|
||||||
): Promise<ldap.Client> {
|
|
||||||
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<string, string[]>;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LdapService {
|
export class LdapService {
|
||||||
|
@ -103,42 +9,39 @@ export class LdapService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(ConfigService)
|
@Inject(ConfigService)
|
||||||
private readonly serviceConfig: ConfigService,
|
private readonly serviceConfig: ConfigService,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
private async createLdapConnection(): Promise<ldap.Client> {
|
private async createLdapConnection(): Promise<Client> {
|
||||||
const ldapUrl = this.serviceConfig.get("ldap.url");
|
const ldapUrl = this.serviceConfig.get("ldap.url");
|
||||||
if (!ldapUrl) {
|
if (!ldapUrl) {
|
||||||
throw new Error("LDAP server URL is not defined");
|
throw new Error("LDAP server URL is not defined");
|
||||||
}
|
}
|
||||||
|
|
||||||
const ldapClient = await ldapCreateConnection(this.logger, ldapUrl);
|
const ldapClient = new Client({
|
||||||
try {
|
url: ldapUrl,
|
||||||
const bindDn = this.serviceConfig.get("ldap.bindDn") || null;
|
timeout: 15_000,
|
||||||
if (bindDn) {
|
connectTimeout: 15_000,
|
||||||
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;
|
const bindDn = this.serviceConfig.get("ldap.bindDn") || null;
|
||||||
} catch (error) {
|
if (bindDn) {
|
||||||
ldapClient.destroy();
|
try {
|
||||||
throw error;
|
await ldapClient.bind(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;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async authenticateUser(
|
public async authenticateUser(
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<LdapAuthenticateResult | null> {
|
): Promise<Entry | null> {
|
||||||
if (!username.match(/^[a-zA-Z0-0]+$/)) {
|
if (!username.match(/^[a-zA-Z0-9-_.@]+$/)) {
|
||||||
|
this.logger.verbose(`Username ${username} does not match username pattern. Authentication failed.`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -149,45 +52,40 @@ export class LdapService {
|
||||||
|
|
||||||
const ldapClient = await this.createLdapConnection();
|
const ldapClient = await this.createLdapConnection();
|
||||||
try {
|
try {
|
||||||
const [result] = await ldapExecuteSearch(ldapClient, searchBase, {
|
const { searchEntries } = await ldapClient.search(searchBase, {
|
||||||
filter: searchQuery,
|
filter: searchQuery,
|
||||||
scope: "sub",
|
scope: "sub",
|
||||||
|
|
||||||
|
attributes: ["*"],
|
||||||
|
returnAttributeValues: true
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result) {
|
if (searchEntries.length > 1) {
|
||||||
|
/* too many users found */
|
||||||
|
this.logger.verbose(`Authentication for username ${username} failed. Too many users found with query ${searchQuery}`);
|
||||||
|
return null;
|
||||||
|
} else if (searchEntries.length == 0) {
|
||||||
/* user not found */
|
/* user not found */
|
||||||
|
this.logger.verbose(`Authentication for username ${username} failed. No user found with query ${searchQuery}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const targetEntity = searchEntries[0];
|
||||||
|
this.logger.verbose(`Trying to authenticate ${username} against LDAP user ${targetEntity.dn}`);
|
||||||
try {
|
try {
|
||||||
await ldapBindUser(ldapClient, result.objectName, password);
|
await ldapClient.bind(targetEntity.dn, password);
|
||||||
|
return targetEntity;
|
||||||
/*
|
|
||||||
* 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) {
|
} catch (error) {
|
||||||
if (error instanceof InvalidCredentialsError) {
|
if (error instanceof InvalidCredentialsError) {
|
||||||
|
this.logger.verbose(`Failed to authenticate ${username} against ${targetEntity.dn}. Invalid credentials.`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.warn(`LDAP user bind failure: ${inspect(error)}`);
|
this.logger.warn(`User bind failure: ${inspect(error)}`);
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
|
||||||
ldapClient.destroy();
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn(`LDAP connect error: ${inspect(error)}`);
|
this.logger.warn(`Connect error: ${inspect(error)}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { LogLevel } from "@nestjs/common";
|
||||||
|
|
||||||
export const DATA_DIRECTORY = process.env.DATA_DIRECTORY || "./data";
|
export const DATA_DIRECTORY = process.env.DATA_DIRECTORY || "./data";
|
||||||
export const SHARE_DIRECTORY = `${DATA_DIRECTORY}/uploads/shares`;
|
export const SHARE_DIRECTORY = `${DATA_DIRECTORY}/uploads/shares`;
|
||||||
export const DATABASE_URL =
|
export const DATABASE_URL =
|
||||||
|
@ -7,3 +9,7 @@ export const CLAMAV_HOST =
|
||||||
process.env.CLAMAV_HOST ||
|
process.env.CLAMAV_HOST ||
|
||||||
(process.env.NODE_ENV == "docker" ? "clamav" : "127.0.0.1");
|
(process.env.NODE_ENV == "docker" ? "clamav" : "127.0.0.1");
|
||||||
export const CLAMAV_PORT = parseInt(process.env.CLAMAV_PORT) || 3310;
|
export const CLAMAV_PORT = parseInt(process.env.CLAMAV_PORT) || 3310;
|
||||||
|
|
||||||
|
export const LOG_LEVEL_AVAILABLE: LogLevel[] = ['verbose', 'debug', 'log', 'warn', 'error', 'fatal'];
|
||||||
|
export const LOG_LEVEL_DEFAULT: LogLevel = process.env.NODE_ENV === 'development' ? "verbose" : "log";
|
||||||
|
export const LOG_LEVEL_ENV = `${process.env.PV_LOG_LEVEL || ""}`;
|
|
@ -1,6 +1,7 @@
|
||||||
import {
|
import {
|
||||||
ClassSerializerInterceptor,
|
ClassSerializerInterceptor,
|
||||||
Logger,
|
Logger,
|
||||||
|
LogLevel,
|
||||||
ValidationPipe,
|
ValidationPipe,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { NestFactory, Reflector } from "@nestjs/core";
|
import { NestFactory, Reflector } from "@nestjs/core";
|
||||||
|
@ -12,10 +13,30 @@ import { NextFunction, Request, Response } from "express";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import { AppModule } from "./app.module";
|
import { AppModule } from "./app.module";
|
||||||
import { ConfigService } from "./config/config.service";
|
import { ConfigService } from "./config/config.service";
|
||||||
import { DATA_DIRECTORY } from "./constants";
|
import { DATA_DIRECTORY, LOG_LEVEL_AVAILABLE, LOG_LEVEL_DEFAULT, LOG_LEVEL_ENV } from "./constants";
|
||||||
|
|
||||||
|
function generateNestJsLogLevels(): LogLevel[] {
|
||||||
|
if (LOG_LEVEL_ENV) {
|
||||||
|
const levelIndex = LOG_LEVEL_AVAILABLE.indexOf(LOG_LEVEL_ENV as any);
|
||||||
|
if (levelIndex === -1) {
|
||||||
|
throw new Error(`log level ${LOG_LEVEL_ENV} unknown`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return LOG_LEVEL_AVAILABLE.slice(levelIndex, LOG_LEVEL_AVAILABLE.length);
|
||||||
|
} else {
|
||||||
|
const levelIndex = LOG_LEVEL_AVAILABLE.indexOf(LOG_LEVEL_DEFAULT);
|
||||||
|
return LOG_LEVEL_AVAILABLE.slice(levelIndex, LOG_LEVEL_AVAILABLE.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
const logLevels = generateNestJsLogLevels();
|
||||||
|
Logger.log(`Showing ${logLevels.join(", ")} messages`);
|
||||||
|
|
||||||
|
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||||
|
logger: logLevels
|
||||||
|
});
|
||||||
|
|
||||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
|
||||||
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
|
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import { DATABASE_URL } from "../constants";
|
import { DATABASE_URL } from "../constants";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaService extends PrismaClient {
|
export class PrismaService extends PrismaClient {
|
||||||
|
private readonly logger = new Logger(PrismaService.name);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super({
|
super({
|
||||||
datasources: {
|
datasources: {
|
||||||
|
@ -12,6 +14,6 @@ export class PrismaService extends PrismaClient {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
super.$connect().then(() => console.info("Connected to the database"));
|
super.$connect().then(() => this.logger.log("Connected to the database"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||||
import * as argon from "argon2";
|
import * as argon from "argon2";
|
||||||
import * as crypto from "crypto";
|
import * as crypto from "crypto";
|
||||||
|
@ -8,16 +8,20 @@ import { FileService } from "../file/file.service";
|
||||||
import { CreateUserDTO } from "./dto/createUser.dto";
|
import { CreateUserDTO } from "./dto/createUser.dto";
|
||||||
import { UpdateUserDto } from "./dto/updateUser.dto";
|
import { UpdateUserDto } from "./dto/updateUser.dto";
|
||||||
import { ConfigService } from "../config/config.service";
|
import { ConfigService } from "../config/config.service";
|
||||||
import { LdapAuthenticateResult } from "../auth/ldap.service";
|
import { Entry } from "ldapts";
|
||||||
|
import { AuthSignInDTO } from "src/auth/dto/authSignIn.dto";
|
||||||
|
import { inspect } from "util";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserSevice {
|
export class UserSevice {
|
||||||
|
private readonly logger = new Logger(UserSevice.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private emailService: EmailService,
|
private emailService: EmailService,
|
||||||
private fileService: FileService,
|
private fileService: FileService,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
async list() {
|
async list() {
|
||||||
return await this.prisma.user.findMany();
|
return await this.prisma.user.findMany();
|
||||||
|
@ -92,33 +96,91 @@ export class UserSevice {
|
||||||
return await this.prisma.user.delete({ where: { id } });
|
return await this.prisma.user.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOrCreateFromLDAP(username: string, ldap: LdapAuthenticateResult) {
|
async findOrCreateFromLDAP(providedCredentials: AuthSignInDTO, ldapEntry: Entry) {
|
||||||
const passwordHash = await argon.hash(crypto.randomUUID());
|
const fieldNameMemberOf = this.configService.get("ldap.fieldNameMemberOf");
|
||||||
const userEmail =
|
const fieldNameEmail = this.configService.get("ldap.fieldNameEmail");
|
||||||
ldap.attributes["userPrincipalName"]?.at(0) ??
|
|
||||||
`${crypto.randomUUID()}@ldap.local`;
|
let isAdmin = false;
|
||||||
const adminGroup = this.configService.get("ldap.adminGroups");
|
if (fieldNameMemberOf in ldapEntry) {
|
||||||
const isAdmin = ldap.attributes["memberOf"]?.includes(adminGroup) ?? false;
|
const adminGroup = this.configService.get("ldap.adminGroups");
|
||||||
|
const entryGroups = Array.isArray(ldapEntry[fieldNameMemberOf]) ? ldapEntry[fieldNameMemberOf] : [ldapEntry[fieldNameMemberOf]];
|
||||||
|
isAdmin = entryGroups.includes(adminGroup) ?? false;
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`Trying to create/update a ldap user but the member field ${fieldNameMemberOf} is not present.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let userEmail: string | null = null;
|
||||||
|
if (fieldNameEmail in ldapEntry) {
|
||||||
|
const value = Array.isArray(ldapEntry[fieldNameEmail]) ? ldapEntry[fieldNameEmail][0] : ldapEntry[fieldNameEmail];
|
||||||
|
if (value) {
|
||||||
|
userEmail = value.toString();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`Trying to create/update a ldap user but the email field ${fieldNameEmail} is not present.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providedCredentials.email) {
|
||||||
|
/* if LDAP does not provides an users email address, take the user provided email address instead */
|
||||||
|
userEmail = providedCredentials.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
const randomId = crypto.randomUUID();
|
||||||
|
const placeholderUsername = `ldap_user_${randomId}`;
|
||||||
|
const placeholderEMail = `${randomId}@ldap.local`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.prisma.user.upsert({
|
const user = await this.prisma.user.upsert({
|
||||||
create: {
|
create: {
|
||||||
username,
|
username: providedCredentials.username ?? placeholderUsername,
|
||||||
email: userEmail,
|
email: userEmail ?? placeholderEMail,
|
||||||
password: passwordHash,
|
password: await argon.hash(crypto.randomUUID()),
|
||||||
isAdmin,
|
|
||||||
ldapDN: ldap.userDn,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
username,
|
|
||||||
email: userEmail,
|
|
||||||
|
|
||||||
isAdmin,
|
isAdmin,
|
||||||
ldapDN: ldap.userDn,
|
ldapDN: ldapEntry.dn,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
isAdmin,
|
||||||
|
ldapDN: ldapEntry.dn,
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
ldapDN: ldap.userDn,
|
ldapDN: ldapEntry.dn,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (user.username === placeholderUsername) {
|
||||||
|
/* Give the user a human readable name if the user has been created with a placeholder username */
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
username: `user_${user.id}`
|
||||||
|
}
|
||||||
|
}).then(newUser => {
|
||||||
|
user.username = newUser.username;
|
||||||
|
}).catch(error => {
|
||||||
|
this.logger.warn(`Failed to update users ${user.id} placeholder username: ${inspect(error)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userEmail && userEmail !== user.email) {
|
||||||
|
/* Sync users email if it has changed */
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
email: userEmail
|
||||||
|
}
|
||||||
|
}).then(newUser => {
|
||||||
|
this.logger.log(`Updated users ${user.id} email from ldap from ${user.email} to ${userEmail}.`);
|
||||||
|
user.email = newUser.email;
|
||||||
|
}).catch(error => {
|
||||||
|
this.logger.error(`Failed to update users ${user.id} email to ${userEmail}: ${inspect(error)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PrismaClientKnownRequestError) {
|
if (e instanceof PrismaClientKnownRequestError) {
|
||||||
if (e.code == "P2002") {
|
if (e.code == "P2002") {
|
||||||
|
|
|
@ -80,9 +80,7 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||||
useState(false);
|
useState(false);
|
||||||
|
|
||||||
const validationSchema = yup.object().shape({
|
const validationSchema = yup.object().shape({
|
||||||
emailOrUsername: config.get("ldap.enabled")
|
emailOrUsername: yup.string().required(t("common.error.field-required")),
|
||||||
? yup.string().matches(/^[^@]+$/, t("signIn.error.invalid-username"))
|
|
||||||
: yup.string().required(t("common.error.field-required")),
|
|
||||||
password: yup
|
password: yup
|
||||||
.string()
|
.string()
|
||||||
.min(8, t("common.error.too-short", { length: 8 }))
|
.min(8, t("common.error.too-short", { length: 8 }))
|
||||||
|
@ -174,16 +172,8 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<TextInput
|
<TextInput
|
||||||
label={
|
label={t("signin.input.email-or-username")}
|
||||||
config.get("ldap.enabled")
|
placeholder={t("signin.input.email-or-username.placeholder")}
|
||||||
? t("signup.input.username")
|
|
||||||
: t("signin.input.email-or-username")
|
|
||||||
}
|
|
||||||
placeholder={
|
|
||||||
config.get("ldap.enabled")
|
|
||||||
? t("signup.input.username.placeholder")
|
|
||||||
: t("signin.input.email-or-username.placeholder")
|
|
||||||
}
|
|
||||||
{...form.getInputProps("emailOrUsername")}
|
{...form.getInputProps("emailOrUsername")}
|
||||||
/>
|
/>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
|
|
|
@ -50,7 +50,6 @@ export default {
|
||||||
"signIn.oauth.microsoft": "Microsoft",
|
"signIn.oauth.microsoft": "Microsoft",
|
||||||
"signIn.oauth.discord": "Discord",
|
"signIn.oauth.discord": "Discord",
|
||||||
"signIn.oauth.oidc": "OpenID",
|
"signIn.oauth.oidc": "OpenID",
|
||||||
"signIn.error.invalid-username": "Invalid username",
|
|
||||||
|
|
||||||
// END /auth/signin
|
// END /auth/signin
|
||||||
|
|
||||||
|
@ -586,6 +585,10 @@ export default {
|
||||||
"admin.config.ldap.search-query.description": "The user query will be used to search the 'User base' for the LDAP user. %username% can be used as the placeholder for the user given input.",
|
"admin.config.ldap.search-query.description": "The user query will be used to search the 'User base' for the LDAP user. %username% can be used as the placeholder for the user given input.",
|
||||||
"admin.config.ldap.admin-groups": "Admin group",
|
"admin.config.ldap.admin-groups": "Admin group",
|
||||||
"admin.config.ldap.admin-groups.description": "Group required for administrative access.",
|
"admin.config.ldap.admin-groups.description": "Group required for administrative access.",
|
||||||
|
"admin.config.ldap.field-name-member-of": "User groups attribute name",
|
||||||
|
"admin.config.ldap.field-name-member-of.description": "LDAP attribute name for the groups, an user is a member of. This is used when checking for the admin group.",
|
||||||
|
"admin.config.ldap.field-name-email": "User email attribute name",
|
||||||
|
"admin.config.ldap.field-name-email.description": "LDAP attribute name for the email of an user.",
|
||||||
|
|
||||||
// 404
|
// 404
|
||||||
"404.description": "Oops this page doesn't exist.",
|
"404.description": "Oops this page doesn't exist.",
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import {
|
import {
|
||||||
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Center,
|
Center,
|
||||||
Container,
|
Container,
|
||||||
|
@ -142,6 +143,9 @@ const Account = () => {
|
||||||
<Paper withBorder p="xl">
|
<Paper withBorder p="xl">
|
||||||
<Title order={5} mb="xs">
|
<Title order={5} mb="xs">
|
||||||
<FormattedMessage id="account.card.info.title" />
|
<FormattedMessage id="account.card.info.title" />
|
||||||
|
{user?.isLdap ? (
|
||||||
|
<Badge style={{ marginLeft: "1em" }}>LDAP</Badge>
|
||||||
|
) : null}
|
||||||
</Title>
|
</Title>
|
||||||
<form
|
<form
|
||||||
onSubmit={accountForm.onSubmit((values) =>
|
onSubmit={accountForm.onSubmit((values) =>
|
||||||
|
@ -162,13 +166,16 @@ const Account = () => {
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label={t("account.card.info.email")}
|
label={t("account.card.info.email")}
|
||||||
|
disabled={user?.isLdap}
|
||||||
{...accountForm.getInputProps("email")}
|
{...accountForm.getInputProps("email")}
|
||||||
/>
|
/>
|
||||||
<Group position="right">
|
{!user?.isLdap && (
|
||||||
<Button type="submit">
|
<Group position="right">
|
||||||
<FormattedMessage id="common.button.save" />
|
<Button type="submit">
|
||||||
</Button>
|
<FormattedMessage id="common.button.save" />
|
||||||
</Group>
|
</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
Loading…
Add table
Reference in a new issue