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:
@@ -4,7 +4,7 @@ import { PrismaService } from "./prisma/prisma.service";
|
||||
|
||||
@Controller("/")
|
||||
export class AppController {
|
||||
constructor(private prismaService: PrismaService) {}
|
||||
constructor(private prismaService: PrismaService) { }
|
||||
|
||||
@Get("health")
|
||||
async health(@Res({ passthrough: true }) res: Response) {
|
||||
|
||||
@@ -29,7 +29,7 @@ export class AuthService {
|
||||
private emailService: EmailService,
|
||||
private ldapService: LdapService,
|
||||
private userService: UserSevice,
|
||||
) {}
|
||||
) { }
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
|
||||
async signUp(dto: AuthRegisterDTO, ip: string, isAdmin?: boolean) {
|
||||
@@ -66,8 +66,9 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async signIn(dto: AuthSignInDTO, ip: string) {
|
||||
if (!dto.email && !dto.username)
|
||||
if (!dto.email && !dto.username) {
|
||||
throw new BadRequestException("Email or username is required");
|
||||
}
|
||||
|
||||
if (!this.config.get("oauth.disablePassword")) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
@@ -85,18 +86,25 @@ export class AuthService {
|
||||
}
|
||||
|
||||
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(
|
||||
dto.username,
|
||||
ldapUsername,
|
||||
dto.password,
|
||||
);
|
||||
if (ldapUser) {
|
||||
const user = await this.userService.findOrCreateFromLDAP(
|
||||
dto.username,
|
||||
dto,
|
||||
ldapUser,
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,101 +1,7 @@
|
||||
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<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[]>;
|
||||
};
|
||||
import { Client, Entry, InvalidCredentialsError } from "ldapts";
|
||||
|
||||
@Injectable()
|
||||
export class LdapService {
|
||||
@@ -103,42 +9,39 @@ export class LdapService {
|
||||
constructor(
|
||||
@Inject(ConfigService)
|
||||
private readonly serviceConfig: ConfigService,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
private async createLdapConnection(): Promise<ldap.Client> {
|
||||
private async createLdapConnection(): Promise<Client> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
const ldapClient = new Client({
|
||||
url: ldapUrl,
|
||||
timeout: 15_000,
|
||||
connectTimeout: 15_000,
|
||||
});
|
||||
|
||||
return ldapClient;
|
||||
} catch (error) {
|
||||
ldapClient.destroy();
|
||||
throw error;
|
||||
const bindDn = this.serviceConfig.get("ldap.bindDn") || null;
|
||||
if (bindDn) {
|
||||
try {
|
||||
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(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<LdapAuthenticateResult | null> {
|
||||
if (!username.match(/^[a-zA-Z0-0]+$/)) {
|
||||
): Promise<Entry | null> {
|
||||
if (!username.match(/^[a-zA-Z0-9-_.@]+$/)) {
|
||||
this.logger.verbose(`Username ${username} does not match username pattern. Authentication failed.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -149,45 +52,40 @@ export class LdapService {
|
||||
|
||||
const ldapClient = await this.createLdapConnection();
|
||||
try {
|
||||
const [result] = await ldapExecuteSearch(ldapClient, searchBase, {
|
||||
const { searchEntries } = await ldapClient.search(searchBase, {
|
||||
filter: searchQuery,
|
||||
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 */
|
||||
this.logger.verbose(`Authentication for username ${username} failed. No user found with query ${searchQuery}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetEntity = searchEntries[0];
|
||||
this.logger.verbose(`Trying to authenticate ${username} against LDAP user ${targetEntity.dn}`);
|
||||
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,
|
||||
]),
|
||||
),
|
||||
};
|
||||
await ldapClient.bind(targetEntity.dn, password);
|
||||
return targetEntity;
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidCredentialsError) {
|
||||
this.logger.verbose(`Failed to authenticate ${username} against ${targetEntity.dn}. Invalid credentials.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.warn(`LDAP user bind failure: ${inspect(error)}`);
|
||||
this.logger.warn(`User bind failure: ${inspect(error)}`);
|
||||
return null;
|
||||
} finally {
|
||||
ldapClient.destroy();
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(`LDAP connect error: ${inspect(error)}`);
|
||||
this.logger.warn(`Connect error: ${inspect(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { LogLevel } from "@nestjs/common";
|
||||
|
||||
export const DATA_DIRECTORY = process.env.DATA_DIRECTORY || "./data";
|
||||
export const SHARE_DIRECTORY = `${DATA_DIRECTORY}/uploads/shares`;
|
||||
export const DATABASE_URL =
|
||||
@@ -7,3 +9,7 @@ export const CLAMAV_HOST =
|
||||
process.env.CLAMAV_HOST ||
|
||||
(process.env.NODE_ENV == "docker" ? "clamav" : "127.0.0.1");
|
||||
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 {
|
||||
ClassSerializerInterceptor,
|
||||
Logger,
|
||||
LogLevel,
|
||||
ValidationPipe,
|
||||
} from "@nestjs/common";
|
||||
import { NestFactory, Reflector } from "@nestjs/core";
|
||||
@@ -12,10 +13,30 @@ import { NextFunction, Request, Response } from "express";
|
||||
import * as fs from "fs";
|
||||
import { AppModule } from "./app.module";
|
||||
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() {
|
||||
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.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 { DATABASE_URL } from "../constants";
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient {
|
||||
private readonly logger = new Logger(PrismaService.name);
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
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 * as argon from "argon2";
|
||||
import * as crypto from "crypto";
|
||||
@@ -8,16 +8,20 @@ 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";
|
||||
import { Entry } from "ldapts";
|
||||
import { AuthSignInDTO } from "src/auth/dto/authSignIn.dto";
|
||||
import { inspect } from "util";
|
||||
|
||||
@Injectable()
|
||||
export class UserSevice {
|
||||
private readonly logger = new Logger(UserSevice.name);
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private emailService: EmailService,
|
||||
private fileService: FileService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
async list() {
|
||||
return await this.prisma.user.findMany();
|
||||
@@ -92,33 +96,91 @@ 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;
|
||||
async findOrCreateFromLDAP(providedCredentials: AuthSignInDTO, ldapEntry: Entry) {
|
||||
const fieldNameMemberOf = this.configService.get("ldap.fieldNameMemberOf");
|
||||
const fieldNameEmail = this.configService.get("ldap.fieldNameEmail");
|
||||
|
||||
let isAdmin = false;
|
||||
if (fieldNameMemberOf in ldapEntry) {
|
||||
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 {
|
||||
return await this.prisma.user.upsert({
|
||||
const user = await this.prisma.user.upsert({
|
||||
create: {
|
||||
username,
|
||||
email: userEmail,
|
||||
password: passwordHash,
|
||||
isAdmin,
|
||||
ldapDN: ldap.userDn,
|
||||
},
|
||||
update: {
|
||||
username,
|
||||
email: userEmail,
|
||||
username: providedCredentials.username ?? placeholderUsername,
|
||||
email: userEmail ?? placeholderEMail,
|
||||
password: await argon.hash(crypto.randomUUID()),
|
||||
|
||||
isAdmin,
|
||||
ldapDN: ldap.userDn,
|
||||
ldapDN: ldapEntry.dn,
|
||||
},
|
||||
update: {
|
||||
isAdmin,
|
||||
ldapDN: ldapEntry.dn,
|
||||
},
|
||||
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) {
|
||||
if (e instanceof PrismaClientKnownRequestError) {
|
||||
if (e.code == "P2002") {
|
||||
|
||||
Reference in New Issue
Block a user