feat: migrate user.service to use drizzle

This commit is contained in:
Nicolas Meienberger 2023-04-15 15:51:59 +02:00 committed by Nicolas Meienberger
parent bf89c24702
commit edec96bc90
11 changed files with 234 additions and 128 deletions

View file

@ -24,6 +24,7 @@ module.exports = {
},
rules: {
'no-restricted-exports': 0,
'no-redeclare': 0, // already handled by @typescript-eslint/no-redeclare
'react/display-name': 0,
'react/prop-types': 0,
'react/function-component-definition': 0,

View file

@ -61,7 +61,7 @@ export const handlers = [
path: ['auth', 'me'],
type: 'query',
response: {
totp_enabled: false,
totpEnabled: false,
id: faker.datatype.number(),
username: faker.internet.userName(),
},

View file

@ -28,7 +28,7 @@ describe('<OtpForm />', () => {
it('should prompt for password when disabling 2FA', async () => {
// arrange
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: true, id: 12, username: 'test' } }));
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totpEnabled: true, id: 12, username: 'test' } }));
render(<OtpForm />);
const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
await waitFor(() => {
@ -46,7 +46,7 @@ describe('<OtpForm />', () => {
it('should show show error toast if password is incorrect while enabling 2FA', async () => {
// arrange
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: false, id: 12, username: 'test' } }));
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totpEnabled: false, id: 12, username: 'test' } }));
server.use(getTRPCMockError({ path: ['auth', 'getTotpUri'], type: 'mutation', message: 'Invalid password' }));
render(<OtpForm />);
const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
@ -74,7 +74,7 @@ describe('<OtpForm />', () => {
it('should show show error toast if password is incorrect while disabling 2FA', async () => {
// arrange
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: true, id: 12, username: 'test' } }));
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totpEnabled: true, id: 12, username: 'test' } }));
server.use(getTRPCMockError({ path: ['auth', 'disableTotp'], type: 'mutation', message: 'Invalid password' }));
render(<OtpForm />);
@ -103,7 +103,7 @@ describe('<OtpForm />', () => {
it('should show success toast if password is correct while disabling 2FA', async () => {
// arrange
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: true, id: 12, username: 'test' } }));
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totpEnabled: true, id: 12, username: 'test' } }));
server.use(getTRPCMock({ path: ['auth', 'disableTotp'], type: 'mutation', response: true }));
render(<OtpForm />);
@ -262,7 +262,7 @@ describe('<OtpForm />', () => {
it('can close the disable modal by clicking on the esc key', async () => {
// arrange
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: true, username: '', id: 1 } }));
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totpEnabled: true, username: '', id: 1 } }));
render(<OtpForm />);
const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
await waitFor(() => {

View file

@ -66,7 +66,7 @@ export const OtpForm = () => {
});
const renderSetupQr = () => {
if (!uri || me.data?.totp_enabled) return null;
if (!uri || me.data?.totpEnabled) return null;
return (
<div className="mt-4">
@ -99,7 +99,7 @@ export const OtpForm = () => {
return (
<>
{!key && <Switch disabled={!me.isSuccess} onCheckedChange={handleTotp} checked={me.data?.totp_enabled} label="Enable two-factor authentication" />}
{!key && <Switch disabled={!me.isSuccess} onCheckedChange={handleTotp} checked={me.data?.totpEnabled} label="Enable two-factor authentication" />}
{getTotpUri.isLoading && (
<div className="progress w-50">
<div className="progress-bar progress-bar-indeterminate bg-green" />

View file

@ -1,9 +1,9 @@
import { z } from 'zod';
import { AuthServiceClass } from '../../services/auth/auth.service';
import { router, publicProcedure, protectedProcedure } from '../../trpc';
import { prisma } from '../../db/client';
import { db } from '../../db';
const AuthService = new AuthServiceClass(prisma);
const AuthService = new AuthServiceClass(db);
export const authRouter = router({
login: publicProcedure.input(z.object({ username: z.string(), password: z.string() })).mutation(async ({ input }) => AuthService.login({ ...input })),

View file

@ -1,44 +1,44 @@
import { PrismaClient } from '@prisma/client';
import fs from 'fs-extra';
import * as argon2 from 'argon2';
import jwt from 'jsonwebtoken';
import { faker } from '@faker-js/faker';
import { TotpAuthenticator } from '@/server/utils/totp';
import { generateSessionId } from '@/server/common/get-server-auth-session';
import { fromAny } from '@total-typescript/shoehorn';
import { mockInsert, mockSelect } from '@/server/tests/drizzle-helpers';
import { createDatabase, clearDatabase, closeDatabase, TestDatabase } from '@/server/tests/test-utils';
import { encrypt } from '../../utils/encryption';
import { setConfig } from '../../core/TipiConfig';
import { createUser } from '../../tests/user.factory';
import { createUser, getUserByEmail, getUserById } from '../../tests/user.factory';
import { AuthServiceClass } from './auth.service';
import TipiCache from '../../core/TipiCache';
import { getTestDbClient } from '../../../../tests/server/db-connection';
let db: PrismaClient;
let AuthService: AuthServiceClass;
let database: TestDatabase;
const TEST_SUITE = 'authservice';
beforeAll(async () => {
setConfig('jwtSecret', 'test');
db = await getTestDbClient(TEST_SUITE);
AuthService = new AuthServiceClass(db);
database = await createDatabase(TEST_SUITE);
AuthService = new AuthServiceClass(database.db);
});
beforeEach(async () => {
jest.mock('fs-extra');
jest.mock('redis');
await setConfig('demoMode', false);
await db.user.deleteMany();
await clearDatabase(database);
});
afterAll(async () => {
await db.user.deleteMany();
await db.$disconnect();
await closeDatabase(database);
});
describe('Login', () => {
it('Should return a valid jsonwebtoken containing a user id', async () => {
// Arrange
const email = faker.internet.email();
const user = await createUser({ email }, db);
const user = await createUser({ email }, database);
// Act
const { token } = await AuthService.login({ username: email, password: 'password' });
@ -61,16 +61,16 @@ describe('Login', () => {
it('Should throw if password is incorrect', async () => {
const email = faker.internet.email();
await createUser({ email }, db);
await createUser({ email }, database);
await expect(AuthService.login({ username: email, password: 'wrong' })).rejects.toThrowError('Wrong password');
});
// TOTP
it('should return a totp session id the user totp_enabled is true', async () => {
it('should return a totp session id the user totpEnabled is true', async () => {
// arrange
const email = faker.internet.email();
const totpSecret = TotpAuthenticator.generateSecret();
await createUser({ email, totp_enabled: true, totp_secret: totpSecret }, db);
await createUser({ email, totpEnabled: true, totpSecret }, database);
// act
const { totpSessionId, token } = await AuthService.login({ username: email, password: 'password' });
@ -90,7 +90,7 @@ describe('Test: verifyTotp', () => {
const totpSecret = TotpAuthenticator.generateSecret();
const encryptedTotpSecret = encrypt(totpSecret, salt);
const user = await createUser({ email, totp_enabled: true, totp_secret: encryptedTotpSecret, salt }, db);
const user = await createUser({ email, totpEnabled: true, totpSecret: encryptedTotpSecret, salt }, database);
const totpSessionId = generateSessionId('otp');
const otp = TotpAuthenticator.generate(totpSecret);
@ -110,7 +110,7 @@ describe('Test: verifyTotp', () => {
const salt = faker.random.word();
const totpSecret = TotpAuthenticator.generateSecret();
const encryptedTotpSecret = encrypt(totpSecret, salt);
const user = await createUser({ email, totp_enabled: true, totp_secret: encryptedTotpSecret, salt }, db);
const user = await createUser({ email, totpEnabled: true, totpSecret: encryptedTotpSecret, salt }, database);
const totpSessionId = generateSessionId('otp');
await TipiCache.set(totpSessionId, user.id.toString());
@ -124,7 +124,7 @@ describe('Test: verifyTotp', () => {
const salt = faker.random.word();
const totpSecret = TotpAuthenticator.generateSecret();
const encryptedTotpSecret = encrypt(totpSecret, salt);
const user = await createUser({ email, totp_enabled: true, totp_secret: encryptedTotpSecret, salt }, db);
const user = await createUser({ email, totpEnabled: true, totpSecret: encryptedTotpSecret, salt }, database);
const totpSessionId = generateSessionId('otp');
const otp = TotpAuthenticator.generate(totpSecret);
@ -143,13 +143,13 @@ describe('Test: verifyTotp', () => {
await expect(AuthService.verifyTotp({ totpSessionId, totpCode: '1234' })).rejects.toThrowError('User not found');
});
it('should throw if the user totp_enabled is false', async () => {
it('should throw if the user totpEnabled is false', async () => {
// arrange
const email = faker.internet.email();
const salt = faker.random.word();
const totpSecret = TotpAuthenticator.generateSecret();
const encryptedTotpSecret = encrypt(totpSecret, salt);
const user = await createUser({ email, totp_enabled: false, totp_secret: encryptedTotpSecret, salt }, db);
const user = await createUser({ email, totpEnabled: false, totpSecret: encryptedTotpSecret, salt }, database);
const totpSessionId = generateSessionId('otp');
const otp = TotpAuthenticator.generate(totpSecret);
@ -164,7 +164,7 @@ describe('Test: getTotpUri', () => {
it('should return a valid totp uri', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email }, db);
const user = await createUser({ email }, database);
// act
const { uri, key } = await AuthService.getTotpUri({ userId: user.id, password: 'password' });
@ -180,16 +180,16 @@ describe('Test: getTotpUri', () => {
it('should create a new totp secret if the user does not have one', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email }, db);
const user = await createUser({ email }, database);
// act
await AuthService.getTotpUri({ userId: user.id, password: 'password' });
const userFromDb = await db.user.findUnique({ where: { id: user.id } });
const userFromDb = await getUserById(user.id, database);
// assert
expect(userFromDb).toBeDefined();
expect(userFromDb).not.toBeNull();
expect(userFromDb).toHaveProperty('totp_secret');
expect(userFromDb).toHaveProperty('totpSecret');
expect(userFromDb).toHaveProperty('salt');
});
@ -199,25 +199,25 @@ describe('Test: getTotpUri', () => {
const salt = faker.random.word();
const totpSecret = TotpAuthenticator.generateSecret();
const encryptedTotpSecret = encrypt(totpSecret, salt);
const user = await createUser({ email, totp_secret: encryptedTotpSecret, salt }, db);
const user = await createUser({ email, totpSecret: encryptedTotpSecret, salt }, database);
// act
await AuthService.getTotpUri({ userId: user.id, password: 'password' });
const userFromDb = await db.user.findUnique({ where: { id: user.id } });
const userFromDb = await getUserById(user.id, database);
// assert
expect(userFromDb).toBeDefined();
expect(userFromDb).not.toBeNull();
expect(userFromDb).toHaveProperty('totp_secret');
expect(userFromDb).toHaveProperty('totpSecret');
expect(userFromDb).toHaveProperty('salt');
expect(userFromDb?.totp_secret).not.toEqual(encryptedTotpSecret);
expect(userFromDb?.totpSecret).not.toEqual(encryptedTotpSecret);
expect(userFromDb?.salt).toEqual(salt);
});
it('should thorw an error if user has already configured totp', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email, totp_enabled: true }, db);
const user = await createUser({ email, totpEnabled: true }, database);
// act & assert
await expect(AuthService.getTotpUri({ userId: user.id, password: 'password' })).rejects.toThrowError('TOTP is already enabled for this user');
@ -226,7 +226,7 @@ describe('Test: getTotpUri', () => {
it('should throw an error if the user password is incorrect', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email }, db);
const user = await createUser({ email }, database);
// act & assert
await expect(AuthService.getTotpUri({ userId: user.id, password: 'wrong' })).rejects.toThrowError('Invalid password');
@ -244,7 +244,7 @@ describe('Test: getTotpUri', () => {
// arrange
await setConfig('demoMode', true);
const email = faker.internet.email();
const user = await createUser({ email }, db);
const user = await createUser({ email }, database);
// act & assert
await expect(AuthService.getTotpUri({ userId: user.id, password: 'password' })).rejects.toThrowError('2FA is not available in demo mode');
@ -259,24 +259,24 @@ describe('Test: setupTotp', () => {
const salt = faker.random.word();
const encryptedTotpSecret = encrypt(totpSecret, salt);
const user = await createUser({ email, totp_secret: encryptedTotpSecret, salt }, db);
const user = await createUser({ email, totpSecret: encryptedTotpSecret, salt }, database);
const otp = TotpAuthenticator.generate(totpSecret);
// act
await AuthService.setupTotp({ userId: user.id, totpCode: otp });
const userFromDb = await db.user.findUnique({ where: { id: user.id } });
const userFromDb = await getUserById(user.id, database);
// assert
expect(userFromDb).toBeDefined();
expect(userFromDb).not.toBeNull();
expect(userFromDb).toHaveProperty('totp_enabled');
expect(userFromDb?.totp_enabled).toBeTruthy();
expect(userFromDb).toHaveProperty('totpEnabled');
expect(userFromDb?.totpEnabled).toBeTruthy();
});
it('should throw if the user has already enabled totp', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email, totp_enabled: true }, db);
const user = await createUser({ email, totpEnabled: true }, database);
// act & assert
await expect(AuthService.setupTotp({ userId: user.id, totpCode: '1234' })).rejects.toThrowError('TOTP is already enabled for this user');
@ -297,7 +297,7 @@ describe('Test: setupTotp', () => {
const salt = faker.random.word();
const encryptedTotpSecret = encrypt(totpSecret, salt);
const user = await createUser({ email, totp_secret: encryptedTotpSecret, salt }, db);
const user = await createUser({ email, totpSecret: encryptedTotpSecret, salt }, database);
// act & assert
await expect(AuthService.setupTotp({ userId: user.id, totpCode: '1234' })).rejects.toThrowError('Invalid TOTP code');
@ -307,7 +307,7 @@ describe('Test: setupTotp', () => {
// arrange
await setConfig('demoMode', true);
const email = faker.internet.email();
const user = await createUser({ email }, db);
const user = await createUser({ email }, database);
// act & assert
await expect(AuthService.setupTotp({ userId: user.id, totpCode: '1234' })).rejects.toThrowError('2FA is not available in demo mode');
@ -318,23 +318,23 @@ describe('Test: disableTotp', () => {
it('should disable totp for the user', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email, totp_enabled: true }, db);
const user = await createUser({ email, totpEnabled: true }, database);
// act
await AuthService.disableTotp({ userId: user.id, password: 'password' });
const userFromDb = await db.user.findUnique({ where: { id: user.id } });
const userFromDb = await getUserById(user.id, database);
// assert
expect(userFromDb).toBeDefined();
expect(userFromDb).not.toBeNull();
expect(userFromDb).toHaveProperty('totp_enabled');
expect(userFromDb?.totp_enabled).toBeFalsy();
expect(userFromDb).toHaveProperty('totpEnabled');
expect(userFromDb?.totpEnabled).toBeFalsy();
});
it('should throw if the user has already disabled totp', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email, totp_enabled: false }, db);
const user = await createUser({ email, totpEnabled: false }, database);
// act & assert
await expect(AuthService.disableTotp({ userId: user.id, password: 'password' })).rejects.toThrowError('TOTP is not enabled for this user');
@ -351,7 +351,7 @@ describe('Test: disableTotp', () => {
it('should throw if the password is invalid', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email, totp_enabled: true }, db);
const user = await createUser({ email, totpEnabled: true }, database);
// act & assert
await expect(AuthService.disableTotp({ userId: user.id, password: 'wrong' })).rejects.toThrowError('Invalid password');
@ -382,7 +382,7 @@ describe('Register', () => {
// Act
await AuthService.register({ username: email, password: 'test' });
const user = await db.user.findFirst({ where: { username: email.toLowerCase().trim() } });
const user = await getUserByEmail(email.toLowerCase().trim(), database);
// Assert
expect(user).toBeDefined();
@ -394,7 +394,7 @@ describe('Register', () => {
const email = faker.internet.email();
// Act & Assert
await createUser({ email, operator: true }, db);
await createUser({ email, operator: true }, database);
await expect(AuthService.register({ username: email, password: 'test' })).rejects.toThrowError('There is already an admin user. Please login to create a new user from the admin panel.');
});
@ -403,7 +403,7 @@ describe('Register', () => {
const email = faker.internet.email();
// Act & Assert
await createUser({ email, operator: false }, db);
await createUser({ email, operator: false }, database);
await expect(AuthService.register({ username: email, password: 'test' })).rejects.toThrowError('User already exists');
});
@ -421,7 +421,7 @@ describe('Register', () => {
// Act
await AuthService.register({ username: email, password: 'test' });
const user = await db.user.findUnique({ where: { username: email } });
const user = await getUserByEmail(email, database);
const isPasswordValid = await argon2.verify(user?.password || '', 'test');
// Assert
@ -431,6 +431,16 @@ describe('Register', () => {
it('Should throw if email is invalid', async () => {
await expect(AuthService.register({ username: 'test', password: 'test' })).rejects.toThrowError('Invalid username');
});
it('should throw if db fails to insert user', async () => {
// Arrange
const email = faker.internet.email();
const mockDatabase = { select: mockSelect([]), insert: mockInsert([]) };
const newAuthService = new AuthServiceClass(fromAny(mockDatabase));
// Act & Assert
await expect(newAuthService.register({ username: email, password: 'test' })).rejects.toThrowError('Error creating user');
});
});
describe('Test: logout', () => {
@ -526,7 +536,7 @@ describe('Test: me', () => {
it('Should return user if user exists', async () => {
// Arrange
const email = faker.internet.email();
const user = await createUser({ email }, db);
const user = await createUser({ email }, database);
// Act
const result = await AuthService.me(user.id);
@ -550,7 +560,7 @@ describe('Test: isConfigured', () => {
it('Should return true if user exists', async () => {
// Arrange
const email = faker.internet.email();
await createUser({ email }, db);
await createUser({ email }, database);
// Act
const result = await AuthService.isConfigured();
@ -564,7 +574,7 @@ describe('Test: changeOperatorPassword', () => {
it('should change the password of the operator user', async () => {
// Arrange
const email = faker.internet.email();
const user = await createUser({ email }, db);
const user = await createUser({ email }, database);
const newPassword = faker.internet.password();
// @ts-expect-error - mocking fs
fs.__createMockFiles({ '/runtipi/state/password-change-request': '' });
@ -574,14 +584,14 @@ describe('Test: changeOperatorPassword', () => {
// Assert
expect(result.email).toBe(email.toLowerCase());
const updatedUser = await db.user.findUnique({ where: { id: user.id } });
const updatedUser = await getUserById(user.id, database);
expect(updatedUser?.password).not.toBe(user.password);
});
it('should throw if the password change request file does not exist', async () => {
// Arrange
const email = faker.internet.email();
await createUser({ email }, db);
await createUser({ email }, database);
const newPassword = faker.internet.password();
// @ts-expect-error - mocking fs
fs.__createMockFiles({});
@ -593,7 +603,7 @@ describe('Test: changeOperatorPassword', () => {
it('should throw if there is no operator user', async () => {
// Arrange
const email = faker.internet.email();
await createUser({ email, operator: false }, db);
await createUser({ email, operator: false }, database);
const newPassword = faker.internet.password();
// @ts-expect-error - mocking fs
fs.__createMockFiles({ '/runtipi/state/password-change-request': '' });
@ -602,10 +612,10 @@ describe('Test: changeOperatorPassword', () => {
await expect(AuthService.changeOperatorPassword({ newPassword })).rejects.toThrowError('Operator user not found');
});
it('should reset totp_secret and totp_enabled if totp is enabled', async () => {
it('should reset totpSecret and totpEnabled if totp is enabled', async () => {
// Arrange
const email = faker.internet.email();
const user = await createUser({ email, totp_enabled: true }, db);
const user = await createUser({ email, totpEnabled: true }, database);
const newPassword = faker.internet.password();
// @ts-expect-error - mocking fs
fs.__createMockFiles({ '/runtipi/state/password-change-request': '' });
@ -615,10 +625,10 @@ describe('Test: changeOperatorPassword', () => {
// Assert
expect(result.email).toBe(email.toLowerCase());
const updatedUser = await db.user.findUnique({ where: { id: user.id } });
const updatedUser = await getUserById(user.id, database);
expect(updatedUser?.password).not.toBe(user.password);
expect(updatedUser?.totp_enabled).toBe(false);
expect(updatedUser?.totp_secret).toBeNull();
expect(updatedUser?.totpEnabled).toBe(false);
expect(updatedUser?.totpSecret).toBeNull();
});
});
@ -666,14 +676,14 @@ describe('Test: changePassword', () => {
it('should change the password of the user', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email }, db);
const user = await createUser({ email }, database);
const newPassword = faker.internet.password();
// act
await AuthService.changePassword({ userId: user.id, newPassword, currentPassword: 'password' });
// assert
const updatedUser = await db.user.findUnique({ where: { id: user.id } });
const updatedUser = await getUserById(user.id, database);
expect(updatedUser?.password).not.toBe(user.password);
});
@ -688,7 +698,7 @@ describe('Test: changePassword', () => {
it('should throw if the password is incorrect', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email }, db);
const user = await createUser({ email }, database);
const newPassword = faker.internet.password();
// act & assert
@ -698,7 +708,7 @@ describe('Test: changePassword', () => {
it('should throw if password is less than 8 characters', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email }, db);
const user = await createUser({ email }, database);
const newPassword = faker.internet.password(7);
// act & assert
@ -709,7 +719,7 @@ describe('Test: changePassword', () => {
// arrange
await setConfig('demoMode', true);
const email = faker.internet.email();
const user = await createUser({ email }, db);
const user = await createUser({ email }, database);
const newPassword = faker.internet.password();
// act & assert

View file

@ -1,9 +1,11 @@
import { PrismaClient } from '@prisma/client';
import * as argon2 from 'argon2';
import jwt from 'jsonwebtoken';
import validator from 'validator';
import { TotpAuthenticator } from '@/server/utils/totp';
import { generateSessionId } from '@/server/common/get-server-auth-session';
import { eq } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { userTable } from '@/server/db/schema';
import { getConfig } from '../../core/TipiConfig';
import TipiCache from '../../core/TipiCache';
import { fileExists, unlinkFile } from '../../common/fs.helpers';
@ -19,10 +21,10 @@ type TokenResponse = {
};
export class AuthServiceClass {
private prisma;
private db;
constructor(p: PrismaClient) {
this.prisma = p;
constructor(p: NodePgDatabase) {
this.db = p;
}
/**
@ -34,7 +36,8 @@ export class AuthServiceClass {
public login = async (input: UsernamePasswordInput) => {
const { password, username } = input;
const user = await this.prisma.user.findUnique({ where: { username: username.trim().toLowerCase() } });
const users = await this.db.select().from(userTable).where(eq(userTable.username, username.trim().toLowerCase()));
const user = users[0];
if (!user) {
throw new Error('User not found');
@ -48,7 +51,7 @@ export class AuthServiceClass {
const session = generateSessionId('auth');
if (user.totp_enabled) {
if (user.totpEnabled) {
const totpSessionId = generateSessionId('otp');
await TipiCache.set(totpSessionId, user.id.toString());
return { totpSessionId };
@ -77,17 +80,21 @@ export class AuthServiceClass {
throw new Error('TOTP session not found');
}
const user = await this.prisma.user.findUnique({ where: { id: Number(userId) } });
const users = await this.db
.select()
.from(userTable)
.where(eq(userTable.id, Number(userId)));
const user = users[0];
if (!user) {
throw new Error('User not found');
}
if (!user.totp_enabled || !user.totp_secret || !user.salt) {
if (!user.totpEnabled || !user.totpSecret || !user.salt) {
throw new Error('TOTP is not enabled for this user');
}
const totpSecret = decrypt(user.totp_secret, user.salt);
const totpSecret = decrypt(user.totpSecret, user.salt);
const isValid = TotpAuthenticator.check(totpCode, totpSecret);
if (!isValid) {
@ -116,7 +123,9 @@ export class AuthServiceClass {
}
const { userId, password } = params;
const user = await this.prisma.user.findUnique({ where: { id: userId } });
const users = await this.db.select().from(userTable).where(eq(userTable.id, userId));
const user = users[0];
if (!user) {
throw new Error('User not found');
@ -127,7 +136,7 @@ export class AuthServiceClass {
throw new Error('Invalid password');
}
if (user.totp_enabled) {
if (user.totpEnabled) {
throw new Error('TOTP is already enabled for this user');
}
@ -140,13 +149,7 @@ export class AuthServiceClass {
const encryptedTotpSecret = encrypt(newTotpSecret, salt);
await this.prisma.user.update({
where: { id: userId },
data: {
totp_secret: encryptedTotpSecret,
salt,
},
});
await this.db.update(userTable).set({ totpSecret: encryptedTotpSecret, salt }).where(eq(userTable.id, userId));
const uri = TotpAuthenticator.keyuri(user.username, 'Runtipi', newTotpSecret);
@ -159,29 +162,25 @@ export class AuthServiceClass {
}
const { userId, totpCode } = params;
const user = await this.prisma.user.findUnique({ where: { id: userId } });
const users = await this.db.select().from(userTable).where(eq(userTable.id, userId));
const user = users[0];
if (!user) {
throw new Error('User not found');
}
if (user.totp_enabled || !user.totp_secret || !user.salt) {
if (user.totpEnabled || !user.totpSecret || !user.salt) {
throw new Error('TOTP is already enabled for this user');
}
const totpSecret = decrypt(user.totp_secret, user.salt);
const totpSecret = decrypt(user.totpSecret, user.salt);
const isValid = TotpAuthenticator.check(totpCode, totpSecret);
if (!isValid) {
throw new Error('Invalid TOTP code');
}
await this.prisma.user.update({
where: { id: userId },
data: {
totp_enabled: true,
},
});
await this.db.update(userTable).set({ totpEnabled: true }).where(eq(userTable.id, userId));
return true;
};
@ -189,13 +188,14 @@ export class AuthServiceClass {
public disableTotp = async (params: { userId: number; password: string }) => {
const { userId, password } = params;
const user = await this.prisma.user.findUnique({ where: { id: userId } });
const users = await this.db.select().from(userTable).where(eq(userTable.id, userId));
const user = users[0];
if (!user) {
throw new Error('User not found');
}
if (!user.totp_enabled) {
if (!user.totpEnabled) {
throw new Error('TOTP is not enabled for this user');
}
@ -204,13 +204,7 @@ export class AuthServiceClass {
throw new Error('Invalid password');
}
await this.prisma.user.update({
where: { id: userId },
data: {
totp_enabled: false,
totp_secret: null,
},
});
await this.db.update(userTable).set({ totpEnabled: false, totpSecret: null }).where(eq(userTable.id, userId));
return true;
};
@ -223,9 +217,9 @@ export class AuthServiceClass {
* @throws {Error} - If the email or password is missing, the email is invalid or the user already exists
*/
public register = async (input: UsernamePasswordInput) => {
const registeredUser = await this.prisma.user.findFirst({ where: { operator: true } });
const operator = await this.db.select().from(userTable).where(eq(userTable.operator, true));
if (registeredUser) {
if (operator.length > 0) {
throw new Error('There is already an admin user. Please login to create a new user from the admin panel.');
}
@ -240,14 +234,21 @@ export class AuthServiceClass {
throw new Error('Invalid username');
}
const user = await this.prisma.user.findUnique({ where: { username: email } });
const users = await this.db.select().from(userTable).where(eq(userTable.username, email));
const user = users[0];
if (user) {
throw new Error('User already exists');
}
const hash = await argon2.hash(password);
const newUser = await this.prisma.user.create({ data: { username: email, password: hash, operator: true } });
const newUsers = await this.db.insert(userTable).values({ username: email, password: hash, operator: true }).returning();
const newUser = newUsers[0];
if (!newUser) {
throw new Error('Error creating user');
}
const session = generateSessionId('auth');
const token = jwt.sign({ id: newUser.id, session }, getConfig().jwtSecret, { expiresIn: '1d' });
@ -266,7 +267,8 @@ export class AuthServiceClass {
public me = async (userId: number | undefined) => {
if (!userId) return null;
const user = await this.prisma.user.findUnique({ where: { id: Number(userId) }, select: { id: true, username: true, totp_enabled: true } });
const users = await this.db.select({ id: userTable.id, username: userTable.username, totpEnabled: userTable.totpEnabled }).from(userTable).where(eq(userTable.id, userId));
const user = users[0];
if (!user) return null;
@ -315,9 +317,9 @@ export class AuthServiceClass {
* @returns {Promise<boolean>} - A boolean indicating if the system is configured or not
*/
public isConfigured = async (): Promise<boolean> => {
const count = await this.prisma.user.count({ where: { operator: true } });
const operators = await this.db.select().from(userTable).where(eq(userTable.operator, true));
return count > 0;
return operators.length > 0;
};
/**
@ -334,14 +336,16 @@ export class AuthServiceClass {
}
const { newPassword } = params;
const user = await this.prisma.user.findFirst({ where: { operator: true } });
const users = await this.db.select().from(userTable).where(eq(userTable.operator, true));
const user = users[0];
if (!user) {
throw new Error('Operator user not found');
}
const hash = await argon2.hash(newPassword);
await this.prisma.user.update({ where: { id: user.id }, data: { password: hash, totp_enabled: false, totp_secret: null } });
await this.db.update(userTable).set({ password: hash, totpEnabled: false, totpSecret: null }).where(eq(userTable.id, user.id));
await unlinkFile(`/runtipi/state/password-change-request`);
@ -384,7 +388,8 @@ export class AuthServiceClass {
const { currentPassword, newPassword, userId } = params;
const user = await this.prisma.user.findUnique({ where: { id: userId } });
const users = await this.db.select().from(userTable).where(eq(userTable.id, userId));
const user = users[0];
if (!user) {
throw new Error('User not found');
@ -401,7 +406,7 @@ export class AuthServiceClass {
}
const hash = await argon2.hash(newPassword);
await this.prisma.user.update({ where: { id: user.id }, data: { password: hash } });
await this.db.update(userTable).set({ password: hash }).where(eq(userTable.id, user.id));
await TipiCache.delByValue(userId.toString(), 'auth');

View file

@ -0,0 +1,3 @@
export const mockSelect = <T>(returnValue: T) => jest.fn(() => ({ from: jest.fn(() => ({ where: jest.fn(() => returnValue) })) }));
export const mockInsert = <T>(returnValue: T) => jest.fn(() => ({ values: jest.fn(() => ({ returning: jest.fn(() => returnValue) })) }));

View file

@ -0,0 +1,56 @@
import pg, { Pool } from 'pg';
import { NodePgDatabase, drizzle } from 'drizzle-orm/node-postgres';
import { runPostgresMigrations } from '../run-migration';
import { getConfig } from '../core/TipiConfig';
import { appTable, userTable } from '../db/schema';
export type TestDatabase = {
client: Pool;
db: NodePgDatabase;
};
/**
* Given a test suite name, create a new database and return a client to it.
*
* @param {string} testsuite - name of the test suite
*/
const createDatabase = async (testsuite: string): Promise<TestDatabase> => {
const pgClient = new pg.Client({
user: getConfig().postgresUsername,
host: getConfig().postgresHost,
database: getConfig().postgresDatabase,
password: getConfig().postgresPassword,
port: getConfig().postgresPort,
});
await pgClient.connect();
await pgClient.query(`DROP DATABASE IF EXISTS ${testsuite}`);
await pgClient.query(`CREATE DATABASE ${testsuite}`);
await pgClient.end();
await runPostgresMigrations(testsuite);
const client = new Pool({
connectionString: `postgresql://${getConfig().postgresUsername}:${getConfig().postgresPassword}@${getConfig().postgresHost}:${getConfig().postgresPort}/${testsuite}?connect_timeout=300`,
});
return { client, db: drizzle(client) };
};
/**
* Clear the database and close the connection.
*
* @param {TestDatabase} database - database to clear
*/
const clearDatabase = async (database: TestDatabase) => {
await database.db.delete(userTable);
await database.db.delete(appTable);
};
const closeDatabase = async (database: TestDatabase) => {
await clearDatabase(database);
await database.client.end();
};
export { createDatabase, clearDatabase, closeDatabase };

View file

@ -1,17 +1,41 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import { faker } from '@faker-js/faker';
import * as argon2 from 'argon2';
import { User } from '@prisma/client';
import { prisma } from '../db/client';
import { eq } from 'drizzle-orm';
import { userTable, User } from '../db/schema';
import { TestDatabase } from './test-utils';
const createUser = async (params: Partial<User & { email?: string }>, db = prisma) => {
/**
*
* @param {User} params - user params
* @param {TestDatabase} database - database client
*/
async function createUser(params: Partial<User & { email?: string }>, database: TestDatabase) {
const { email, operator = true, ...rest } = params;
const hash = await argon2.hash('password');
const username = email?.toLowerCase().trim() || faker.internet.email().toLowerCase().trim();
const user = await db.user.create({ data: { username, password: hash, operator, ...rest } });
return user;
const users = await database.db
.insert(userTable)
.values({ username, password: hash, operator, ...rest })
.returning();
const user = users[0];
return user as User;
}
const getUserById = async (id: number, database: TestDatabase) => {
const usersFromDb = await database.db.select().from(userTable).where(eq(userTable.id, id));
const userFromDb = usersFromDb[0];
return userFromDb as User;
};
export { createUser };
const getUserByEmail = async (email: string, database: TestDatabase) => {
const usersFromDb = await database.db.select().from(userTable).where(eq(userTable.username, email));
const userFromDb = usersFromDb[0];
return userFromDb as User;
};
export { createUser, getUserById, getUserByEmail };

View file

@ -3,7 +3,12 @@ import pg from 'pg';
import { runPostgresMigrations } from '../../src/server/run-migration';
import { getConfig } from '../../src/server/core/TipiConfig';
export const getTestDbClient = async (testsuite: string) => {
/**
* Given a test suite name, create a new database and return a client to it.
*
* @param {string} testsuite - name of the test suite
*/
async function getTestDbClient(testsuite: string) {
const pgClient = new pg.Client({
user: getConfig().postgresUsername,
host: getConfig().postgresHost,
@ -27,4 +32,6 @@ export const getTestDbClient = async (testsuite: string) => {
},
},
});
};
}
export { getTestDbClient };