mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 21:00:09 +00:00
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:
@@ -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-%';
|
||||||
@@ -38,9 +38,11 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedUsername = username.trim().toLowerCase();
|
||||||
|
|
||||||
// Find user by local admin identifier
|
// Find user by local admin identifier
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { plexId: `local-${username}` },
|
where: { plexId: `local-${normalizedUsername}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|||||||
@@ -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 hashedPassword = await bcrypt.hash(admin.password, 10);
|
||||||
const encryptionService = getEncryptionService();
|
const encryptionService = getEncryptionService();
|
||||||
const encryptedPassword = encryptionService.encrypt(hashedPassword);
|
const encryptedPassword = encryptionService.encrypt(hashedPassword);
|
||||||
|
|
||||||
adminUser = await prisma.user.create({
|
adminUser = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
plexId: `local-${admin.username}`,
|
plexId: `local-${normalizedAdminUsername}`,
|
||||||
plexUsername: admin.username,
|
plexUsername: normalizedAdminUsername,
|
||||||
plexEmail: null,
|
plexEmail: null,
|
||||||
role: 'admin',
|
role: 'admin',
|
||||||
isSetupAdmin: true, // Mark as setup admin - role cannot be changed
|
isSetupAdmin: true, // Mark as setup admin - role cannot be changed
|
||||||
|
|||||||
@@ -54,10 +54,12 @@ export class LocalAuthProvider implements IAuthProvider {
|
|||||||
return { success: false, error: 'Username and password required' };
|
return { success: false, error: 'Username and password required' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedUsername = username.trim().toLowerCase();
|
||||||
|
|
||||||
// Find user (exclude soft-deleted users)
|
// Find user (exclude soft-deleted users)
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
plexUsername: username,
|
plexUsername: normalizedUsername,
|
||||||
authProvider: 'local',
|
authProvider: 'local',
|
||||||
deletedAt: null, // Exclude soft-deleted users
|
deletedAt: null, // Exclude soft-deleted users
|
||||||
},
|
},
|
||||||
@@ -144,9 +146,10 @@ export class LocalAuthProvider implements IAuthProvider {
|
|||||||
async register(params: RegisterParams): Promise<AuthResult> {
|
async register(params: RegisterParams): Promise<AuthResult> {
|
||||||
try {
|
try {
|
||||||
const { username, password } = params;
|
const { username, password } = params;
|
||||||
|
const normalizedUsername = username?.trim().toLowerCase();
|
||||||
|
|
||||||
// Validate
|
// Validate
|
||||||
if (!username || username.length < 3) {
|
if (!normalizedUsername || normalizedUsername.length < 3) {
|
||||||
return { success: false, error: 'Username must be at least 3 characters' };
|
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)
|
// Check username uniqueness (only among non-deleted users)
|
||||||
const existing = await prisma.user.findFirst({
|
const existing = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
plexUsername: username,
|
plexUsername: normalizedUsername,
|
||||||
authProvider: 'local',
|
authProvider: 'local',
|
||||||
deletedAt: null, // Allow reuse of usernames from deleted accounts
|
deletedAt: null, // Allow reuse of usernames from deleted accounts
|
||||||
},
|
},
|
||||||
@@ -194,8 +197,8 @@ export class LocalAuthProvider implements IAuthProvider {
|
|||||||
// Create user
|
// Create user
|
||||||
const user = await prisma.user.create({
|
const user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
plexId: `local-${username}`,
|
plexId: `local-${normalizedUsername}`,
|
||||||
plexUsername: username,
|
plexUsername: normalizedUsername,
|
||||||
authToken: encryptedHash,
|
authToken: encryptedHash,
|
||||||
authProvider: 'local',
|
authProvider: 'local',
|
||||||
role: isFirstUser ? 'admin' : 'user',
|
role: isFirstUser ? 'admin' : 'user',
|
||||||
|
|||||||
@@ -167,6 +167,31 @@ describe('LocalAuthProvider', () => {
|
|||||||
expect(result.error).toMatch(/invalid username or password/i);
|
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 () => {
|
it('blocks registration when disabled', async () => {
|
||||||
configMock.get.mockResolvedValueOnce('false');
|
configMock.get.mockResolvedValueOnce('false');
|
||||||
|
|
||||||
@@ -237,6 +262,51 @@ describe('LocalAuthProvider', () => {
|
|||||||
expect(result.error).toContain('Username already taken');
|
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 () => {
|
it('creates admin user on first registration', async () => {
|
||||||
configMock.get.mockResolvedValueOnce('true'); // registration enabled
|
configMock.get.mockResolvedValueOnce('true'); // registration enabled
|
||||||
configMock.get.mockResolvedValueOnce('false'); // no admin approval
|
configMock.get.mockResolvedValueOnce('false'); // no admin approval
|
||||||
|
|||||||
Reference in New Issue
Block a user