mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +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
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { plexId: `local-${username}` },
|
||||
where: { plexId: `local-${normalizedUsername}` },
|
||||
});
|
||||
|
||||
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 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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user