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); }