feat(ldap): Adding support for LDAP authentication (#554)

This commit is contained in:
WolverinDEV
2024-08-24 16:15:33 +02:00
committed by GitHub
parent 4924f76394
commit 4186a768b3
17 changed files with 573 additions and 128 deletions

View File

@@ -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 { }

View File

@@ -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(
`Failed login attempt for user ${dto.email} from IP ${ip}`,
);
throw new UnauthorizedException("Wrong email or password");
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);
}
}
this.logger.log(`Successful login for user ${user.email} from IP ${ip}`);
return this.generateToken(user);
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(
`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) {

View File

@@ -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<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()
export class LdapService {
private readonly logger = new Logger(LdapService.name);
constructor(
@Inject(ConfigService)
private readonly serviceConfig: ConfigService,
) { }
private async createLdapConnection(): Promise<ldap.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");
}
}
return ldapClient;
} catch (error) {
ldapClient.destroy();
throw error;
}
}
public async authenticateUser(username: string, password: string): Promise<LdapAuthenticateResult | null> {
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;
}
}
}