feat(auth): LDAP login — authenticate imported users against the directory
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 47s
Tessera CI/CD / Build & Publish Images (push) Successful in 2m21s

LDAP-imported users have no local passwordHash, and validateUser only checked
the local password, so they could never log in. Now a passwordless user with
an ldapDn is authenticated by binding as their OWN DN with the entered
password against the tenant's active LDAP config (reusing the ldaps TLS-skip
option). Empty passwords are rejected before binding to avoid AD's
unauthenticated-bind bypass. Local-password users are unchanged.

LdapService.verifyUserCredentials added; LdapModule now exports
LdapConfigService; AuthModule imports LdapModule (no circular dep). 8 new
specs (bind success/fail, empty-password guard, login via bind, wrong pw, no
config, no ldapDn, inactive). API 226 green, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-22 14:51:23 +02:00
parent af9e968c6f
commit a1cf05404c
6 changed files with 220 additions and 3 deletions
+32 -2
View File
@@ -9,6 +9,8 @@ import { JwtService } from '@nestjs/jwt';
import * as argon2 from 'argon2';
import { randomUUID } from 'crypto';
import { Response } from 'express';
import { LdapConfigService } from '../ldap/ldap-config.service';
import { LdapService } from '../ldap/ldap.service';
import { MailService } from '../mail/mail.service';
import { PrismaService } from '../prisma/prisma.service';
@@ -21,6 +23,8 @@ export class AuthService {
private jwtService: JwtService,
private configService: ConfigService,
private mailService: MailService,
private ldapService: LdapService,
private ldapConfigService: LdapConfigService,
) {}
/**
@@ -40,9 +44,35 @@ export class AuthService {
return null;
}
// LDAP users without local password cannot log in via local auth
// LDAP users have no local password — authenticate them against the
// directory by binding as their OWN DN with the password they entered.
if (!user.passwordHash) {
return null;
if (!user.ldapDn) {
return null;
}
const config = await this.ldapConfigService.getConfig(user.tenantId);
if (!config || !config.isActive) {
return null;
}
const ok = await this.ldapService.verifyUserCredentials(
{
serverUrl: config.serverUrl,
tlsRejectUnauthorized: config.tlsRejectUnauthorized,
},
user.ldapDn,
password,
);
if (!ok) {
return null;
}
await this.prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
return user;
}
const isPasswordValid = await argon2.verify(user.passwordHash, password);