From baff7ce4db1cf4a5ebdab7d2f3993491da8cd64f Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 9 Jul 2026 15:20:49 +0200 Subject: [PATCH] fix(auth): make usernames case-insensitive 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 --- .../20260709000000_lowercase_usernames/migration.sql | 7 +++++++ apps/api/src/auth/auth.service.ts | 3 ++- apps/api/src/ldap/ldap.service.ts | 9 ++++----- apps/api/src/user/admin-seed.service.ts | 4 +++- apps/api/src/user/user.service.ts | 12 +++++++++++- 5 files changed, 27 insertions(+), 8 deletions(-) create mode 100644 apps/api/prisma/migrations/20260709000000_lowercase_usernames/migration.sql diff --git a/apps/api/prisma/migrations/20260709000000_lowercase_usernames/migration.sql b/apps/api/prisma/migrations/20260709000000_lowercase_usernames/migration.sql new file mode 100644 index 0000000..16f6524 --- /dev/null +++ b/apps/api/prisma/migrations/20260709000000_lowercase_usernames/migration.sql @@ -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); diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 3e80c61..c819b28 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -31,8 +31,9 @@ export class AuthService { * Pitfall 6: Checks isActive to prevent deactivated users from logging in. */ async validateUser(username: string, password: string): Promise { + // Usernames are stored lowercase (case-insensitive login). const user = await this.prisma.user.findUnique({ - where: { username }, + where: { username: username.toLowerCase() }, }); if (!user || !user.isActive) { diff --git a/apps/api/src/ldap/ldap.service.ts b/apps/api/src/ldap/ldap.service.ts index 9a122b5..f9ee53f 100644 --- a/apps/api/src/ldap/ldap.service.ts +++ b/apps/api/src/ldap/ldap.service.ts @@ -223,8 +223,9 @@ export class LdapService { } } - // Require at minimum a username - const username = mappedData['username']; + // Require at minimum a username. Normalize to lowercase so + // logins stay case-insensitive regardless of AD casing. + const username = mappedData['username']?.toLowerCase(); if (!username) { result.errors.push( `Entry ${dn}: no username mapped (check sAMAccountName mapping)`, @@ -254,9 +255,7 @@ export class LdapService { displayName: mappedData['displayName'], }), ...(mappedData['email'] && { email: mappedData['email'] }), - ...(mappedData['username'] && { - username: mappedData['username'], - }), + ...(mappedData['username'] && { username }), ldapDn: dn, isActive: true, }, diff --git a/apps/api/src/user/admin-seed.service.ts b/apps/api/src/user/admin-seed.service.ts index 4281919..0b1242e 100644 --- a/apps/api/src/user/admin-seed.service.ts +++ b/apps/api/src/user/admin-seed.service.ts @@ -17,7 +17,9 @@ export class AdminSeedService implements OnApplicationBootstrap { ) {} async onApplicationBootstrap() { - const username = this.configService.get('TESSERA_ADMIN_USER'); + const username = this.configService + .get('TESSERA_ADMIN_USER') + ?.toLowerCase(); const email = this.configService.get('TESSERA_ADMIN_EMAIL'); const password = this.configService.get('TESSERA_ADMIN_PASSWORD'); const forceChange = diff --git a/apps/api/src/user/user.service.ts b/apps/api/src/user/user.service.ts index 8d5700e..8a11a51 100644 --- a/apps/api/src/user/user.service.ts +++ b/apps/api/src/user/user.service.ts @@ -9,9 +9,13 @@ export class UserService { /** * Find user by username. Uses UNSCOPED Prisma (not tenant-scoped) * 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) { - 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. + * Username is normalized to lowercase so login is case-insensitive. */ async create(data: { username: string; @@ -38,6 +43,7 @@ export class UserService { return this.prisma.user.create({ data: { ...rest, + username: rest.username.toLowerCase(), passwordHash: password ? await argon2.hash(password) : null, }, }); @@ -61,6 +67,10 @@ export class UserService { const { password, ...rest } = data; const updateData: any = { ...rest }; + if (updateData.username) { + updateData.username = updateData.username.toLowerCase(); + } + if (password) { updateData.passwordHash = await argon2.hash(password); }