diff --git a/backend/package-lock.json b/backend/package-lock.json index ab7578b4..7862b98e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -32,6 +32,7 @@ "cookie-parser": "^1.4.6", "jmespath": "^0.16.0", "ldapjs": "^3.0.7", + "ldapts": "^7.2.0", "mime-types": "^2.1.35", "moment": "^2.30.1", "nanoid": "^3.3.7", @@ -1780,6 +1781,14 @@ "@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": { "version": "1.19.2", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", @@ -2758,7 +2767,6 @@ "version": "0.2.6", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", - "dev": true, "dependencies": { "safer-buffer": "~2.1.0" } @@ -3699,12 +3707,11 @@ } }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -5555,6 +5562,53 @@ "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": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5882,9 +5936,9 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/mute-stream": { "version": "0.0.8", @@ -6767,10 +6821,9 @@ "dev": true }, "node_modules/punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true, + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "engines": { "node": ">=6" } @@ -7248,11 +7301,6 @@ "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": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/serialised-error/-/serialised-error-1.1.3.tgz", @@ -7511,6 +7559,11 @@ "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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", diff --git a/backend/package.json b/backend/package.json index 55ad1190..a69af34e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,7 +25,6 @@ "@nestjs/throttler": "^6.2.1", "@prisma/client": "^5.19.1", "@types/jmespath": "^0.15.2", - "@types/ldapjs": "^3.0.6", "archiver": "^7.0.1", "argon2": "^0.41.1", "body-parser": "^1.20.3", @@ -36,7 +35,7 @@ "content-disposition": "^0.5.4", "cookie-parser": "^1.4.6", "jmespath": "^0.16.0", - "ldapjs": "^3.0.7", + "ldapts": "^7.2.0", "mime-types": "^2.1.35", "moment": "^2.30.1", "nanoid": "^3.3.7", @@ -84,4 +83,4 @@ "typescript": "^5.6.2", "wait-on": "^8.0.1" } -} +} \ No newline at end of file diff --git a/backend/prisma/seed/config.seed.ts b/backend/prisma/seed/config.seed.ts index 7bc9c8f7..161c7415 100644 --- a/backend/prisma/seed/config.seed.ts +++ b/backend/prisma/seed/config.seed.ts @@ -178,6 +178,15 @@ const configVariables: ConfigVariables = { adminGroups: { type: "string", defaultValue: "" + }, + + fieldNameMemberOf: { + type: "string", + defaultValue: "memberOf", + }, + fieldNameEmail: { + type: "string", + defaultValue: "userPrincipalName", } }, oauth: { diff --git a/backend/src/app.controller.ts b/backend/src/app.controller.ts index f7b3bc9c..27bf3427 100644 --- a/backend/src/app.controller.ts +++ b/backend/src/app.controller.ts @@ -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) { diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 438bf7db..5d5de704 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -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); } diff --git a/backend/src/auth/ldap.service.ts b/backend/src/auth/ldap.service.ts index cfcd90ed..866db188 100644 --- a/backend/src/auth/ldap.service.ts +++ b/backend/src/auth/ldap.service.ts @@ -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 { - 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; -}; +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 { + 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"); - } - } + 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 { - if (!username.match(/^[a-zA-Z0-0]+$/)) { + ): Promise { + 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; } } diff --git a/backend/src/constants.ts b/backend/src/constants.ts index e00299d5..34cab1a1 100644 --- a/backend/src/constants.ts +++ b/backend/src/constants.ts @@ -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 || ""}`; \ No newline at end of file diff --git a/backend/src/main.ts b/backend/src/main.ts index b77d01cd..dd3a71b0 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -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(AppModule); + const logLevels = generateNestJsLogLevels(); + Logger.log(`Showing ${logLevels.join(", ")} messages`); + + const app = await NestFactory.create(AppModule, { + logger: logLevels + }); + app.useGlobalPipes(new ValidationPipe({ whitelist: true })); app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))); diff --git a/backend/src/prisma/prisma.service.ts b/backend/src/prisma/prisma.service.ts index 7295e1ec..650958bb 100644 --- a/backend/src/prisma/prisma.service.ts +++ b/backend/src/prisma/prisma.service.ts @@ -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")); } } diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index 505a7e09..ad0cb63d 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -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") { diff --git a/frontend/src/components/auth/SignInForm.tsx b/frontend/src/components/auth/SignInForm.tsx index 09935b60..1dc16001 100644 --- a/frontend/src/components/auth/SignInForm.tsx +++ b/frontend/src/components/auth/SignInForm.tsx @@ -80,9 +80,7 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => { useState(false); const validationSchema = yup.object().shape({ - emailOrUsername: config.get("ldap.enabled") - ? yup.string().matches(/^[^@]+$/, t("signIn.error.invalid-username")) - : yup.string().required(t("common.error.field-required")), + emailOrUsername: yup.string().required(t("common.error.field-required")), password: yup .string() .min(8, t("common.error.too-short", { length: 8 })) @@ -174,16 +172,8 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => { })} > { <FormattedMessage id="account.card.info.title" /> + {user?.isLdap ? ( + <Badge style={{ marginLeft: "1em" }}>LDAP</Badge> + ) : null}
@@ -162,13 +166,16 @@ const Account = () => { /> - - - + {!user?.isLdap && ( + + + + )}