Browse Source

feat: create backend service and router for totp functions

disable totp
Nicolas Meienberger 2 years ago
parent
commit
1cc8d3f868

+ 144 - 0
src/server/routers/auth/auth.router.test.ts

@@ -0,0 +1,144 @@
+import { PrismaClient } from '@prisma/client';
+import { authRouter } from './auth.router';
+import { getTestDbClient } from '../../../../tests/server/db-connection';
+
+let db: PrismaClient;
+const TEST_SUITE = 'authrouter';
+
+beforeAll(async () => {
+  db = await getTestDbClient(TEST_SUITE);
+  jest.spyOn(console, 'log').mockImplementation(() => {});
+});
+
+beforeEach(async () => {
+  await db.user.deleteMany();
+  // Mute console.log
+});
+
+afterAll(async () => {
+  await db.user.deleteMany();
+  await db.$disconnect();
+});
+
+describe('Test: verifyTotp', () => {
+  it('should be accessible without an account', async () => {
+    // arrange
+    const caller = authRouter.createCaller({ session: null });
+    let error;
+
+    // act
+    try {
+      await caller.verifyTotp({ totpCode: '123456', totpSessionId: '123456' });
+    } catch (e) {
+      error = e as { code: string };
+    }
+
+    // assert
+    expect(error?.code).not.toBe('UNAUTHORIZED');
+    expect(error?.code).toBeDefined();
+    expect(error?.code).not.toBe(null);
+  });
+});
+
+describe('Test: getTotpUri', () => {
+  it('should not be accessible without an account', async () => {
+    // arrange
+    const caller = authRouter.createCaller({ session: null });
+    let error;
+
+    // act
+    try {
+      await caller.getTotpUri({ password: '123456' });
+    } catch (e) {
+      error = e as { code: string };
+    }
+
+    // assert
+    expect(error?.code).toBe('UNAUTHORIZED');
+  });
+
+  it('should be accessible with an account', async () => {
+    // arrange
+    const caller = authRouter.createCaller({ session: { userId: 123456 } });
+    let error;
+
+    // act
+    try {
+      await caller.getTotpUri({ password: '123456' });
+    } catch (e) {
+      error = e as { code: string };
+    }
+
+    // assert
+    expect(error?.code).not.toBe('UNAUTHORIZED');
+  });
+});
+
+describe('Test: setupTotp', () => {
+  it('should not be accessible without an account', async () => {
+    // arrange
+    const caller = authRouter.createCaller({ session: null });
+    let error;
+
+    // act
+    try {
+      await caller.setupTotp({ totpCode: '123456' });
+    } catch (e) {
+      error = e as { code: string };
+    }
+
+    // assert
+    expect(error?.code).toBe('UNAUTHORIZED');
+  });
+
+  it('should be accessible with an account', async () => {
+    // arrange
+    const caller = authRouter.createCaller({ session: { userId: 123456 } });
+    let error;
+
+    // act
+    try {
+      await caller.setupTotp({ totpCode: '123456' });
+    } catch (e) {
+      error = e as { code: string };
+    }
+
+    // assert
+    expect(error?.code).not.toBe('UNAUTHORIZED');
+  });
+});
+
+describe('Test: disableTotp', () => {
+  it('should not be accessible without an account', async () => {
+    // arrange
+    const caller = authRouter.createCaller({ session: null });
+    let error;
+
+    // act
+    try {
+      await caller.disableTotp({ password: '123456' });
+    } catch (e) {
+      error = e as { code: string };
+    }
+
+    // assert
+    expect(error?.code).toBe('UNAUTHORIZED');
+  });
+
+  it('should be accessible with an account', async () => {
+    // arrange
+    const caller = authRouter.createCaller({ session: { userId: 122 } });
+    let error;
+
+    // act
+
+    try {
+      await caller.disableTotp({ password: '112321' });
+    } catch (e) {
+      error = e as { code: string };
+    }
+
+    // assert
+    expect(error?.code).not.toBe('UNAUTHORIZED');
+  });
+});

+ 7 - 1
src/server/routers/auth/auth.router.ts

@@ -12,7 +12,13 @@ export const authRouter = router({
   refreshToken: protectedProcedure.mutation(async ({ ctx }) => AuthServiceClass.refreshToken(ctx.session.id)),
   me: publicProcedure.query(async ({ ctx }) => AuthService.me(ctx.session?.userId)),
   isConfigured: publicProcedure.query(async () => AuthService.isConfigured()),
+  // Password
   checkPasswordChangeRequest: publicProcedure.query(AuthServiceClass.checkPasswordChangeRequest),
-  resetPassword: publicProcedure.input(z.object({ newPassword: z.string() })).mutation(({ input }) => AuthService.changePassword({ newPassword: input.newPassword })),
+  resetPassword: publicProcedure.input(z.object({ newPassword: z.string() })).mutation(({ input }) => AuthService.changeOperatorPassword({ newPassword: input.newPassword })),
   cancelPasswordChangeRequest: publicProcedure.mutation(AuthServiceClass.cancelPasswordChangeRequest),
+  // Totp
+  verifyTotp: publicProcedure.input(z.object({ totpSessionId: z.string(), totpCode: z.string() })).mutation(({ input }) => AuthService.verifyTotp(input)),
+  getTotpUri: protectedProcedure.input(z.object({ password: z.string() })).mutation(({ input, ctx }) => AuthService.getTotpUri({ userId: Number(ctx.session.userId), password: input.password })),
+  setupTotp: protectedProcedure.input(z.object({ totpCode: z.string() })).mutation(({ input, ctx }) => AuthService.setupTotp({ userId: Number(ctx.session.userId), totpCode: input.totpCode })),
+  disableTotp: protectedProcedure.input(z.object({ password: z.string() })).mutation(({ input, ctx }) => AuthService.disableTotp({ userId: Number(ctx.session.userId), password: input.password })),
 });

+ 301 - 7
src/server/services/auth/auth.service.test.ts

@@ -3,6 +3,9 @@ import fs from 'fs-extra';
 import * as argon2 from 'argon2';
 import jwt from 'jsonwebtoken';
 import { faker } from '@faker-js/faker';
+import { v4 } from 'uuid';
+import { TotpAuthenticator } from '@/server/utils/totp';
+import { encrypt } from '../../utils/encryption';
 import { setConfig } from '../../core/TipiConfig';
 import { createUser } from '../../tests/user.factory';
 import { AuthServiceClass } from './auth.service';
@@ -38,7 +41,7 @@ describe('Login', () => {
 
     // Act
     const { token } = await AuthService.login({ username: email, password: 'password' });
-    const decoded = jwt.verify(token, 'test') as jwt.JwtPayload;
+    const decoded = jwt.verify(token as string, 'test') as jwt.JwtPayload;
 
     // Assert
     expect(decoded).toBeDefined();
@@ -60,6 +63,278 @@ describe('Login', () => {
     await createUser({ email }, db);
     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 () => {
+    // arrange
+    const email = faker.internet.email();
+    const totpSecret = TotpAuthenticator.generateSecret();
+    await createUser({ email, totp_enabled: true, totp_secret: totpSecret }, db);
+
+    // act
+    const { totpSessionId, token } = await AuthService.login({ username: email, password: 'password' });
+
+    // assert
+    expect(totpSessionId).toBeDefined();
+    expect(totpSessionId).not.toBeNull();
+    expect(token).toBeUndefined();
+  });
+});
+
+describe('Test: verifyTotp', () => {
+  it('should return a valid jsonwebtoken if the totp is correct', 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: true, totp_secret: encryptedTotpSecret, salt }, db);
+    const totpSessionId = v4();
+    const otp = TotpAuthenticator.generate(totpSecret);
+
+    await TipiCache.set(totpSessionId, user.id.toString());
+
+    // act
+    const { token } = await AuthService.verifyTotp({ totpSessionId, totpCode: otp });
+
+    // assert
+    expect(token).toBeDefined();
+    expect(token).not.toBeNull();
+  });
+
+  it('should throw if the totp is incorrect', 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: true, totp_secret: encryptedTotpSecret, salt }, db);
+    const totpSessionId = v4();
+    await TipiCache.set(totpSessionId, user.id.toString());
+
+    // act & assert
+    await expect(AuthService.verifyTotp({ totpSessionId, totpCode: 'wrong' })).rejects.toThrowError('Invalid TOTP');
+  });
+
+  it('should throw if the totpSessionId is invalid', 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: true, totp_secret: encryptedTotpSecret, salt }, db);
+    const totpSessionId = v4();
+    const otp = TotpAuthenticator.generate(totpSecret);
+
+    await TipiCache.set(totpSessionId, user.id.toString());
+
+    // act & assert
+    await expect(AuthService.verifyTotp({ totpSessionId: 'wrong', totpCode: otp })).rejects.toThrowError('TOTP session not found');
+  });
+
+  it('should throw if the user does not exist', async () => {
+    // arrange
+    const totpSessionId = v4();
+    await TipiCache.set(totpSessionId, '1234');
+
+    // act & assert
+    await expect(AuthService.verifyTotp({ totpSessionId, totpCode: '1234' })).rejects.toThrowError('User not found');
+  });
+
+  it('should throw if the user totp_enabled 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 totpSessionId = v4();
+    const otp = TotpAuthenticator.generate(totpSecret);
+
+    await TipiCache.set(totpSessionId, user.id.toString());
+
+    // act & assert
+    await expect(AuthService.verifyTotp({ totpSessionId, totpCode: otp })).rejects.toThrowError('TOTP is not enabled for this user');
+  });
+});
+
+describe('Test: getTotpUri', () => {
+  it('should return a valid totp uri', async () => {
+    // arrange
+    const email = faker.internet.email();
+    const user = await createUser({ email }, db);
+
+    // act
+    const { uri, key } = await AuthService.getTotpUri({ userId: user.id, password: 'password' });
+
+    // assert
+    expect(uri).toBeDefined();
+    expect(uri).not.toBeNull();
+    expect(key).toBeDefined();
+    expect(key).not.toBeNull();
+    expect(uri).toContain(key);
+  });
+
+  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);
+
+    // act
+    await AuthService.getTotpUri({ userId: user.id, password: 'password' });
+    const userFromDb = await db.user.findUnique({ where: { id: user.id } });
+
+    // assert
+    expect(userFromDb).toBeDefined();
+    expect(userFromDb).not.toBeNull();
+    expect(userFromDb).toHaveProperty('totp_secret');
+    expect(userFromDb).toHaveProperty('salt');
+  });
+
+  it('should regenerate a new totp secret if the user already has one', 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_secret: encryptedTotpSecret, salt }, db);
+
+    // act
+    await AuthService.getTotpUri({ userId: user.id, password: 'password' });
+    const userFromDb = await db.user.findUnique({ where: { id: user.id } });
+
+    // assert
+    expect(userFromDb).toBeDefined();
+    expect(userFromDb).not.toBeNull();
+    expect(userFromDb).toHaveProperty('totp_secret');
+    expect(userFromDb).toHaveProperty('salt');
+    expect(userFromDb?.totp_secret).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);
+
+    // act & assert
+    await expect(AuthService.getTotpUri({ userId: user.id, password: 'password' })).rejects.toThrowError('TOTP is already enabled for this user');
+  });
+
+  it('should throw an error if the user password is incorrect', async () => {
+    // arrange
+    const email = faker.internet.email();
+    const user = await createUser({ email }, db);
+
+    // act & assert
+    await expect(AuthService.getTotpUri({ userId: user.id, password: 'wrong' })).rejects.toThrowError('Invalid password');
+  });
+
+  it('should throw an error if the user does not exist', async () => {
+    // arrange
+    const userId = 11;
+
+    // act & assert
+    await expect(AuthService.getTotpUri({ userId, password: 'password' })).rejects.toThrowError('User not found');
+  });
+});
+
+describe('Test: setupTotp', () => {
+  it('should enable totp for the user', async () => {
+    // arrange
+    const email = faker.internet.email();
+    const totpSecret = TotpAuthenticator.generateSecret();
+    const salt = faker.random.word();
+    const encryptedTotpSecret = encrypt(totpSecret, salt);
+
+    const user = await createUser({ email, totp_secret: encryptedTotpSecret, salt }, db);
+    const otp = TotpAuthenticator.generate(totpSecret);
+
+    // act
+    await AuthService.setupTotp({ userId: user.id, totpCode: otp });
+    const userFromDb = await db.user.findUnique({ where: { id: user.id } });
+
+    // assert
+    expect(userFromDb).toBeDefined();
+    expect(userFromDb).not.toBeNull();
+    expect(userFromDb).toHaveProperty('totp_enabled');
+    expect(userFromDb?.totp_enabled).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);
+
+    // act & assert
+    await expect(AuthService.setupTotp({ userId: user.id, totpCode: '1234' })).rejects.toThrowError('TOTP is already enabled for this user');
+  });
+
+  it('should throw if the user does not exist', async () => {
+    // arrange
+    const userId = 11;
+
+    // act & assert
+    await expect(AuthService.setupTotp({ userId, totpCode: '1234' })).rejects.toThrowError('User not found');
+  });
+
+  it('should throw if the otp is invalid', async () => {
+    // arrange
+    const email = faker.internet.email();
+    const totpSecret = TotpAuthenticator.generateSecret();
+    const salt = faker.random.word();
+    const encryptedTotpSecret = encrypt(totpSecret, salt);
+
+    const user = await createUser({ email, totp_secret: encryptedTotpSecret, salt }, db);
+
+    // act & assert
+    await expect(AuthService.setupTotp({ userId: user.id, totpCode: '1234' })).rejects.toThrowError('Invalid TOTP code');
+  });
+});
+
+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);
+
+    // act
+    await AuthService.disableTotp({ userId: user.id, password: 'password' });
+    const userFromDb = await db.user.findUnique({ where: { id: user.id } });
+
+    // assert
+    expect(userFromDb).toBeDefined();
+    expect(userFromDb).not.toBeNull();
+    expect(userFromDb).toHaveProperty('totp_enabled');
+    expect(userFromDb?.totp_enabled).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);
+
+    // act & assert
+    await expect(AuthService.disableTotp({ userId: user.id, password: 'password' })).rejects.toThrowError('TOTP is not enabled for this user');
+  });
+
+  it('should throw if the user does not exist', async () => {
+    // arrange
+    const userId = 11;
+
+    // act & assert
+    await expect(AuthService.disableTotp({ userId, password: 'password' })).rejects.toThrowError('User not found');
+  });
+
+  it('should throw if the password is invalid', async () => {
+    // arrange
+    const email = faker.internet.email();
+    const user = await createUser({ email, totp_enabled: true }, db);
+
+    // act & assert
+    await expect(AuthService.disableTotp({ userId: user.id, password: 'wrong' })).rejects.toThrowError('Invalid password');
+  });
 });
 
 describe('Register', () => {
@@ -264,7 +539,7 @@ describe('Test: isConfigured', () => {
   });
 });
 
-describe('Test: changePassword', () => {
+describe('Test: changeOperatorPassword', () => {
   it('should change the password of the operator user', async () => {
     // Arrange
     const email = faker.internet.email();
@@ -274,7 +549,7 @@ describe('Test: changePassword', () => {
     fs.__createMockFiles({ '/runtipi/state/password-change-request': '' });
 
     // Act
-    const result = await AuthService.changePassword({ newPassword });
+    const result = await AuthService.changeOperatorPassword({ newPassword });
 
     // Assert
     expect(result.email).toBe(email.toLowerCase());
@@ -291,7 +566,7 @@ describe('Test: changePassword', () => {
     fs.__createMockFiles({});
 
     // Act & Assert
-    await expect(AuthService.changePassword({ newPassword })).rejects.toThrowError('No password change request found');
+    await expect(AuthService.changeOperatorPassword({ newPassword })).rejects.toThrowError('No password change request found');
   });
 
   it('should throw if there is no operator user', async () => {
@@ -303,7 +578,26 @@ describe('Test: changePassword', () => {
     fs.__createMockFiles({ '/runtipi/state/password-change-request': '' });
 
     // Act & Assert
-    await expect(AuthService.changePassword({ newPassword })).rejects.toThrowError('Operator user not found');
+    await expect(AuthService.changeOperatorPassword({ newPassword })).rejects.toThrowError('Operator user not found');
+  });
+
+  it('should reset totp_secret and totp_enabled if totp is enabled', async () => {
+    // Arrange
+    const email = faker.internet.email();
+    const user = await createUser({ email, totp_enabled: true }, db);
+    const newPassword = faker.internet.password();
+    // @ts-expect-error - mocking fs
+    fs.__createMockFiles({ '/runtipi/state/password-change-request': '' });
+
+    // Act
+    const result = await AuthService.changeOperatorPassword({ newPassword });
+
+    // Assert
+    expect(result.email).toBe(email.toLowerCase());
+    const updatedUser = await db.user.findUnique({ where: { id: user.id } });
+    expect(updatedUser?.password).not.toBe(user.password);
+    expect(updatedUser?.totp_enabled).toBe(false);
+    expect(updatedUser?.totp_secret).toBeNull();
   });
 });
 
@@ -314,7 +608,7 @@ describe('Test: checkPasswordChangeRequest', () => {
     fs.__createMockFiles({ '/runtipi/state/password-change-request': '' });
 
     // Act
-    const result = await AuthServiceClass.checkPasswordChangeRequest();
+    const result = AuthServiceClass.checkPasswordChangeRequest();
 
     // Assert
     expect(result).toBe(true);
@@ -326,7 +620,7 @@ describe('Test: checkPasswordChangeRequest', () => {
     fs.__createMockFiles({});
 
     // Act
-    const result = await AuthServiceClass.checkPasswordChangeRequest();
+    const result = AuthServiceClass.checkPasswordChangeRequest();
 
     // Assert
     expect(result).toBe(false);

+ 156 - 3
src/server/services/auth/auth.service.ts

@@ -3,9 +3,11 @@ import * as argon2 from 'argon2';
 import { v4 } from 'uuid';
 import jwt from 'jsonwebtoken';
 import validator from 'validator';
+import { TotpAuthenticator } from '@/server/utils/totp';
 import { getConfig } from '../../core/TipiConfig';
 import TipiCache from '../../core/TipiCache';
 import { fileExists, unlinkFile } from '../../common/fs.helpers';
+import { decrypt, encrypt } from '../../utils/encryption';
 
 type UsernamePasswordInput = {
   username: string;
@@ -45,6 +47,13 @@ export class AuthServiceClass {
     }
 
     const session = v4();
+
+    if (user.totp_enabled) {
+      const totpSessionId = v4();
+      await TipiCache.set(totpSessionId, user.id.toString());
+      return { totpSessionId };
+    }
+
     const token = jwt.sign({ id: user.id, session }, getConfig().jwtSecret, { expiresIn: '7d' });
 
     await TipiCache.set(session, user.id.toString());
@@ -52,6 +61,150 @@ export class AuthServiceClass {
     return { token };
   };
 
+  /**
+   * Verify TOTP code and return a JWT token
+   *
+   * @param {object} params - An object containing the TOTP session ID and the TOTP code
+   * @param {string} params.totpSessionId - The TOTP session ID
+   * @param {string} params.totpCode - The TOTP code
+   * @returns {Promise<{token:string}>} - A promise that resolves to an object containing the JWT token
+   */
+  public verifyTotp = async (params: { totpSessionId: string; totpCode: string }) => {
+    const { totpSessionId, totpCode } = params;
+    const userId = await TipiCache.get(totpSessionId);
+
+    if (!userId) {
+      throw new Error('TOTP session not found');
+    }
+
+    const user = await this.prisma.user.findUnique({ where: { id: Number(userId) } });
+
+    if (!user) {
+      throw new Error('User not found');
+    }
+
+    if (!user.totp_enabled || !user.totp_secret || !user.salt) {
+      throw new Error('TOTP is not enabled for this user');
+    }
+
+    const totpSecret = decrypt(user.totp_secret, user.salt);
+    const isValid = TotpAuthenticator.check(totpCode, totpSecret);
+
+    if (!isValid) {
+      throw new Error('Invalid TOTP code');
+    }
+
+    const session = v4();
+    const token = jwt.sign({ id: user.id, session }, getConfig().jwtSecret, { expiresIn: '7d' });
+
+    await TipiCache.set(session, user.id.toString());
+
+    return { token };
+  };
+
+  /**
+   * Given a userId returns the TOTP URI and the secret key
+   *
+   * @param {object} params - An object containing the userId and the user's password
+   * @param {number} params.userId - The user's ID
+   * @param {string} params.password - The user's password
+   * @returns {Promise<{uri: string, key: string}>} - A promise that resolves to an object containing the TOTP URI and the secret key
+   */
+  public getTotpUri = async (params: { userId: number; password: string }) => {
+    const { userId, password } = params;
+    const user = await this.prisma.user.findUnique({ where: { id: userId } });
+
+    if (!user) {
+      throw new Error('User not found');
+    }
+
+    const isPasswordValid = await argon2.verify(user.password, password);
+    if (!isPasswordValid) {
+      throw new Error('Invalid password');
+    }
+
+    if (user.totp_enabled) {
+      throw new Error('TOTP is already enabled for this user');
+    }
+
+    let { salt } = user;
+    const newTotpSecret = TotpAuthenticator.generateSecret();
+
+    if (!salt) {
+      salt = v4();
+    }
+
+    const encryptedTotpSecret = encrypt(newTotpSecret, salt);
+
+    await this.prisma.user.update({
+      where: { id: userId },
+      data: {
+        totp_secret: encryptedTotpSecret,
+        salt,
+      },
+    });
+
+    const uri = TotpAuthenticator.keyuri(user.username, 'Runtipi', newTotpSecret);
+
+    return { uri, key: newTotpSecret };
+  };
+
+  public setupTotp = async (params: { userId: number; totpCode: string }) => {
+    const { userId, totpCode } = params;
+    const user = await this.prisma.user.findUnique({ where: { id: userId } });
+
+    if (!user) {
+      throw new Error('User not found');
+    }
+
+    if (user.totp_enabled || !user.totp_secret || !user.salt) {
+      throw new Error('TOTP is already enabled for this user');
+    }
+
+    const totpSecret = decrypt(user.totp_secret, 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,
+      },
+    });
+  };
+
+  public disableTotp = async (params: { userId: number; password: string }) => {
+    const { userId, password } = params;
+
+    const user = await this.prisma.user.findUnique({ where: { id: userId } });
+
+    if (!user) {
+      throw new Error('User not found');
+    }
+
+    if (!user.totp_enabled) {
+      throw new Error('TOTP is not enabled for this user');
+    }
+
+    const isPasswordValid = await argon2.verify(user.password, password);
+    if (!isPasswordValid) {
+      throw new Error('Invalid password');
+    }
+
+    await this.prisma.user.update({
+      where: { id: userId },
+      data: {
+        totp_enabled: false,
+        totp_secret: null,
+      },
+    });
+
+    return true;
+  };
+
   /**
    * Creates a new user with the provided email and password and returns a session token
    *
@@ -103,7 +256,7 @@ 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 } });
+    const user = await this.prisma.user.findUnique({ where: { id: Number(userId) }, select: { id: true, username: true, totp_enabled: true } });
 
     if (!user) return null;
 
@@ -165,7 +318,7 @@ export class AuthServiceClass {
    * @returns {Promise<string>} - The username of the operator user
    * @throws {Error} - If the operator user is not found or if there is no password change request
    */
-  public changePassword = async (params: { newPassword: string }) => {
+  public changeOperatorPassword = async (params: { newPassword: string }) => {
     if (!AuthServiceClass.checkPasswordChangeRequest()) {
       throw new Error('No password change request found');
     }
@@ -178,7 +331,7 @@ export class AuthServiceClass {
     }
 
     const hash = await argon2.hash(newPassword);
-    await this.prisma.user.update({ where: { id: user.id }, data: { password: hash } });
+    await this.prisma.user.update({ where: { id: user.id }, data: { password: hash, totp_enabled: false, totp_secret: null } });
 
     await unlinkFile(`/runtipi/state/password-change-request`);
 

+ 4 - 8
src/server/tests/user.factory.ts

@@ -1,19 +1,15 @@
 // 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';
 
-type CreateUserParams = {
-  email?: string;
-  operator?: boolean;
-};
-
-const createUser = async (params: CreateUserParams, db = prisma) => {
-  const { email, operator = true } = params;
+const createUser = async (params: Partial<User & { email?: string }>, db = prisma) => {
+  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 } });
+  const user = await db.user.create({ data: { username, password: hash, operator, ...rest } });
 
   return user;
 };