fix(auth): make usernames case-insensitive
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 42s
Tessera CI/CD / Build & Publish Images (push) Successful in 24s

Username lookups (login, admin seed, LDAP sync) compared case-sensitively
against a stored value with whatever casing it was created with, so
"Admin" and "admin" were treated as different accounts.

Normalizes at every write and read path: UserService.create/update
lowercase the username before persisting, findByUsername lowercases
the lookup input, AuthService.validateUser lowercases before the login
query, AdminSeedService lowercases the configured admin username, and
the LDAP sync loop lowercases the mapped sAMAccountName before using it
for lookup/create/update -- so AD casing differences don't create
duplicate accounts either.

Added a data migration to lowercase any existing mixed-case usernames.
It relies on the User.username unique constraint to fail loudly if two
existing accounts would collide after normalizing, rather than silently
merging them.

Verified locally: logged in with "ADMIN" (uppercase) against the
existing lowercase "admin" account after rebuilding the API image.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 15:20:49 +02:00
parent 010aceb1ac
commit baff7ce4db
5 changed files with 27 additions and 8 deletions
@@ -0,0 +1,7 @@
-- DataMigration
-- Normalize existing usernames to lowercase so login becomes case-insensitive
-- (application code now always stores/looks up usernames lowercased). If two
-- users would collide after lowercasing, this UPDATE fails on the unique
-- constraint -- that's intentional: it surfaces a real conflict that needs
-- manual resolution rather than silently merging/dropping an account.
UPDATE "User" SET username = LOWER(username) WHERE username <> LOWER(username);
+2 -1
View File
@@ -31,8 +31,9 @@ export class AuthService {
* Pitfall 6: Checks isActive to prevent deactivated users from logging in. * Pitfall 6: Checks isActive to prevent deactivated users from logging in.
*/ */
async validateUser(username: string, password: string): Promise<any> { async validateUser(username: string, password: string): Promise<any> {
// Usernames are stored lowercase (case-insensitive login).
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
where: { username }, where: { username: username.toLowerCase() },
}); });
if (!user || !user.isActive) { if (!user || !user.isActive) {
+4 -5
View File
@@ -223,8 +223,9 @@ export class LdapService {
} }
} }
// Require at minimum a username // Require at minimum a username. Normalize to lowercase so
const username = mappedData['username']; // logins stay case-insensitive regardless of AD casing.
const username = mappedData['username']?.toLowerCase();
if (!username) { if (!username) {
result.errors.push( result.errors.push(
`Entry ${dn}: no username mapped (check sAMAccountName mapping)`, `Entry ${dn}: no username mapped (check sAMAccountName mapping)`,
@@ -254,9 +255,7 @@ export class LdapService {
displayName: mappedData['displayName'], displayName: mappedData['displayName'],
}), }),
...(mappedData['email'] && { email: mappedData['email'] }), ...(mappedData['email'] && { email: mappedData['email'] }),
...(mappedData['username'] && { ...(mappedData['username'] && { username }),
username: mappedData['username'],
}),
ldapDn: dn, ldapDn: dn,
isActive: true, isActive: true,
}, },
+3 -1
View File
@@ -17,7 +17,9 @@ export class AdminSeedService implements OnApplicationBootstrap {
) {} ) {}
async onApplicationBootstrap() { async onApplicationBootstrap() {
const username = this.configService.get<string>('TESSERA_ADMIN_USER'); const username = this.configService
.get<string>('TESSERA_ADMIN_USER')
?.toLowerCase();
const email = this.configService.get<string>('TESSERA_ADMIN_EMAIL'); const email = this.configService.get<string>('TESSERA_ADMIN_EMAIL');
const password = this.configService.get<string>('TESSERA_ADMIN_PASSWORD'); const password = this.configService.get<string>('TESSERA_ADMIN_PASSWORD');
const forceChange = const forceChange =
+11 -1
View File
@@ -9,9 +9,13 @@ export class UserService {
/** /**
* Find user by username. Uses UNSCOPED Prisma (not tenant-scoped) * Find user by username. Uses UNSCOPED Prisma (not tenant-scoped)
* because login must work across all tenants. * because login must work across all tenants.
* Usernames are stored lowercase (case-insensitive login) -- normalize
* the lookup input to match regardless of how it was typed.
*/ */
async findByUsername(username: string) { async findByUsername(username: string) {
return this.prisma.user.findUnique({ where: { username } }); return this.prisma.user.findUnique({
where: { username: username.toLowerCase() },
});
} }
/** /**
@@ -23,6 +27,7 @@ export class UserService {
/** /**
* Create a new user with hashed password. * Create a new user with hashed password.
* Username is normalized to lowercase so login is case-insensitive.
*/ */
async create(data: { async create(data: {
username: string; username: string;
@@ -38,6 +43,7 @@ export class UserService {
return this.prisma.user.create({ return this.prisma.user.create({
data: { data: {
...rest, ...rest,
username: rest.username.toLowerCase(),
passwordHash: password ? await argon2.hash(password) : null, passwordHash: password ? await argon2.hash(password) : null,
}, },
}); });
@@ -61,6 +67,10 @@ export class UserService {
const { password, ...rest } = data; const { password, ...rest } = data;
const updateData: any = { ...rest }; const updateData: any = { ...rest };
if (updateData.username) {
updateData.username = updateData.username.toLowerCase();
}
if (password) { if (password) {
updateData.passwordHash = await argon2.hash(password); updateData.passwordHash = await argon2.hash(password);
} }