Normalize local usernames to lowercase

Normalize local account usernames by trimming and lowercasing across the stack. Added a Prisma migration to lowercase existing plex_username and rewrite local plex_id values for non-deleted accounts. Updated LocalAuthProvider, admin login route, and setup completion to use normalized usernames when looking up, creating, and storing users (including plexId `local-{username}`). Added/updated tests to assert case-insensitive lookups, storage of lowercased usernames/plexIds, and duplicate username rejection.
This commit is contained in:
kikootwo
2026-03-04 12:47:09 -05:00
parent d0ce485bdc
commit 441724c378
5 changed files with 87 additions and 8 deletions
@@ -0,0 +1,3 @@
-- Normalize existing local usernames to lowercase
UPDATE users SET plex_username = LOWER(plex_username) WHERE auth_provider = 'local' AND deleted_at IS NULL;
UPDATE users SET plex_id = 'local-' || LOWER(SUBSTRING(plex_id FROM 7)) WHERE plex_id LIKE 'local-%' AND plex_id NOT LIKE 'local-%-deleted-%';
+3 -1
View File
@@ -38,9 +38,11 @@ export async function POST(request: NextRequest) {
);
}
const normalizedUsername = username.trim().toLowerCase();
// Find user by local admin identifier
const user = await prisma.user.findUnique({
where: { plexId: `local-${username}` },
where: { plexId: `local-${normalizedUsername}` },
});
if (!user) {
+3 -2
View File
@@ -140,14 +140,15 @@ export async function POST(request: NextRequest) {
);
}
const normalizedAdminUsername = admin.username.trim().toLowerCase();
const hashedPassword = await bcrypt.hash(admin.password, 10);
const encryptionService = getEncryptionService();
const encryptedPassword = encryptionService.encrypt(hashedPassword);
adminUser = await prisma.user.create({
data: {
plexId: `local-${admin.username}`,
plexUsername: admin.username,
plexId: `local-${normalizedAdminUsername}`,
plexUsername: normalizedAdminUsername,
plexEmail: null,
role: 'admin',
isSetupAdmin: true, // Mark as setup admin - role cannot be changed
+8 -5
View File
@@ -54,10 +54,12 @@ export class LocalAuthProvider implements IAuthProvider {
return { success: false, error: 'Username and password required' };
}
const normalizedUsername = username.trim().toLowerCase();
// Find user (exclude soft-deleted users)
const user = await prisma.user.findFirst({
where: {
plexUsername: username,
plexUsername: normalizedUsername,
authProvider: 'local',
deletedAt: null, // Exclude soft-deleted users
},
@@ -144,9 +146,10 @@ export class LocalAuthProvider implements IAuthProvider {
async register(params: RegisterParams): Promise<AuthResult> {
try {
const { username, password } = params;
const normalizedUsername = username?.trim().toLowerCase();
// Validate
if (!username || username.length < 3) {
if (!normalizedUsername || normalizedUsername.length < 3) {
return { success: false, error: 'Username must be at least 3 characters' };
}
@@ -167,7 +170,7 @@ export class LocalAuthProvider implements IAuthProvider {
// Check username uniqueness (only among non-deleted users)
const existing = await prisma.user.findFirst({
where: {
plexUsername: username,
plexUsername: normalizedUsername,
authProvider: 'local',
deletedAt: null, // Allow reuse of usernames from deleted accounts
},
@@ -194,8 +197,8 @@ export class LocalAuthProvider implements IAuthProvider {
// Create user
const user = await prisma.user.create({
data: {
plexId: `local-${username}`,
plexUsername: username,
plexId: `local-${normalizedUsername}`,
plexUsername: normalizedUsername,
authToken: encryptedHash,
authProvider: 'local',
role: isFirstUser ? 'admin' : 'user',
@@ -167,6 +167,31 @@ describe('LocalAuthProvider', () => {
expect(result.error).toMatch(/invalid username or password/i);
});
it('normalizes username to lowercase on login', async () => {
prismaMock.user.findFirst.mockResolvedValue({
id: 'user-ci',
plexId: 'local-admin',
plexUsername: 'admin',
role: 'admin',
authProvider: 'local',
authToken: 'enc:hash',
registrationStatus: 'approved',
deletedAt: null,
});
prismaMock.user.update.mockResolvedValue({});
bcryptCompare.mockResolvedValue(true);
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
const provider = new LocalAuthProvider();
await provider.handleCallback({ username: 'Admin', password: 'pass' });
expect(prismaMock.user.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ plexUsername: 'admin' }),
})
);
});
it('blocks registration when disabled', async () => {
configMock.get.mockResolvedValueOnce('false');
@@ -237,6 +262,51 @@ describe('LocalAuthProvider', () => {
expect(result.error).toContain('Username already taken');
});
it('stores lowercase username and plexId on registration', async () => {
configMock.get.mockResolvedValueOnce('true'); // registration enabled
configMock.get.mockResolvedValueOnce('false'); // no admin approval
prismaMock.user.findFirst.mockResolvedValue(null);
prismaMock.user.count.mockResolvedValue(1);
prismaMock.user.create.mockResolvedValue({
id: 'user-ci2',
plexId: 'local-myuser',
plexUsername: 'myuser',
role: 'user',
});
bcryptHash.mockResolvedValue('hash');
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
const provider = new LocalAuthProvider();
await provider.register({ username: 'MyUser', password: 'password123' });
expect(prismaMock.user.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
plexId: 'local-myuser',
plexUsername: 'myuser',
}),
})
);
});
it('rejects duplicate username case-insensitively on registration', async () => {
configMock.get.mockResolvedValueOnce('true'); // registration enabled
prismaMock.user.findFirst.mockResolvedValue({ id: 'user-existing' });
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
const provider = new LocalAuthProvider();
const result = await provider.register({ username: 'User', password: 'password123' });
expect(result.success).toBe(false);
expect(result.error).toContain('Username already taken');
// The lookup should use the lowercased username
expect(prismaMock.user.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ plexUsername: 'user' }),
})
);
});
it('creates admin user on first registration', async () => {
configMock.get.mockResolvedValueOnce('true'); // registration enabled
configMock.get.mockResolvedValueOnce('false'); // no admin approval