瀏覽代碼

feat(user): create routes and services for password reset

Nicolas Meienberger 2 年之前
父節點
當前提交
8f8b8487e6

+ 11 - 0
__mocks__/fs-extra.ts

@@ -102,6 +102,17 @@ class FsMock {
   };
   };
 
 
   getMockFiles = () => this.mockFiles;
   getMockFiles = () => this.mockFiles;
+
+  promises = {
+    unlink: (p: string) => {
+      if (this.mockFiles[p] instanceof Array) {
+        this.mockFiles[p].forEach((file: string) => {
+          delete this.mockFiles[path.join(p, file)];
+        });
+      }
+      delete this.mockFiles[p];
+    },
+  };
 }
 }
 
 
 export default FsMock.getInstance();
 export default FsMock.getInstance();

+ 2 - 0
src/server/common/fs.helpers.ts

@@ -35,3 +35,5 @@ export const getSeed = () => {
   const seed = readFile('/runtipi/state/seed');
   const seed = readFile('/runtipi/state/seed');
   return seed.toString();
   return seed.toString();
 };
 };
+
+export const unlinkFile = (path: string) => fs.promises.unlink(path);

+ 2 - 1
src/server/index.ts

@@ -13,6 +13,7 @@ import { prisma } from './db/client';
 let conf = {};
 let conf = {};
 let nextApp: NextServer;
 let nextApp: NextServer;
 
 
+const hostname = 'localhost';
 const port = parseInt(process.env.PORT || '3000', 10);
 const port = parseInt(process.env.PORT || '3000', 10);
 const dev = process.env.NODE_ENV !== 'production';
 const dev = process.env.NODE_ENV !== 'production';
 
 
@@ -23,7 +24,7 @@ if (!dev) {
   nextApp = new NextServer({ hostname: 'localhost', dev, port, customServer: true, conf });
   nextApp = new NextServer({ hostname: 'localhost', dev, port, customServer: true, conf });
 } else {
 } else {
   const next = require('next').default;
   const next = require('next').default;
-  nextApp = next({ dev });
+  nextApp = next({ dev, hostname, port });
 }
 }
 
 
 const handle = nextApp.getRequestHandler();
 const handle = nextApp.getRequestHandler();

+ 3 - 0
src/server/routers/auth/auth.router.ts

@@ -12,4 +12,7 @@ export const authRouter = router({
   refreshToken: protectedProcedure.mutation(async ({ ctx }) => AuthServiceClass.refreshToken(ctx.session.id)),
   refreshToken: protectedProcedure.mutation(async ({ ctx }) => AuthServiceClass.refreshToken(ctx.session.id)),
   me: publicProcedure.query(async ({ ctx }) => AuthService.me(ctx.session?.userId)),
   me: publicProcedure.query(async ({ ctx }) => AuthService.me(ctx.session?.userId)),
   isConfigured: publicProcedure.query(async () => AuthService.isConfigured()),
   isConfigured: publicProcedure.query(async () => AuthService.isConfigured()),
+  checkPasswordChangeRequest: publicProcedure.query(AuthServiceClass.checkPasswordChangeRequest),
+  resetPassword: publicProcedure.input(z.object({ newPassword: z.string() })).mutation(({ input }) => AuthService.changePassword({ newPassword: input.newPassword })),
+  cancelPasswordChangeRequest: publicProcedure.mutation(AuthServiceClass.cancelPasswordChangeRequest),
 });
 });

+ 99 - 5
src/server/services/auth/auth.service.test.ts

@@ -1,4 +1,5 @@
 import { PrismaClient } from '@prisma/client';
 import { PrismaClient } from '@prisma/client';
+import fs from 'fs-extra';
 import * as argon2 from 'argon2';
 import * as argon2 from 'argon2';
 import jwt from 'jsonwebtoken';
 import jwt from 'jsonwebtoken';
 import { faker } from '@faker-js/faker';
 import { faker } from '@faker-js/faker';
@@ -19,6 +20,7 @@ beforeAll(async () => {
 });
 });
 
 
 beforeEach(async () => {
 beforeEach(async () => {
+  jest.mock('fs-extra');
   jest.mock('redis');
   jest.mock('redis');
   await db.user.deleteMany();
   await db.user.deleteMany();
 });
 });
@@ -32,7 +34,7 @@ describe('Login', () => {
   it('Should return a valid jsonwebtoken containing a user id', async () => {
   it('Should return a valid jsonwebtoken containing a user id', async () => {
     // Arrange
     // Arrange
     const email = faker.internet.email();
     const email = faker.internet.email();
-    const user = await createUser(email, db);
+    const user = await createUser({ email }, db);
 
 
     // Act
     // Act
     const { token } = await AuthService.login({ username: email, password: 'password' });
     const { token } = await AuthService.login({ username: email, password: 'password' });
@@ -55,7 +57,7 @@ describe('Login', () => {
 
 
   it('Should throw if password is incorrect', async () => {
   it('Should throw if password is incorrect', async () => {
     const email = faker.internet.email();
     const email = faker.internet.email();
-    await createUser(email, db);
+    await createUser({ email }, db);
     await expect(AuthService.login({ username: email, password: 'wrong' })).rejects.toThrowError('Wrong password');
     await expect(AuthService.login({ username: email, password: 'wrong' })).rejects.toThrowError('Wrong password');
   });
   });
 });
 });
@@ -91,12 +93,21 @@ describe('Register', () => {
     expect(user?.username).toBe(email.toLowerCase().trim());
     expect(user?.username).toBe(email.toLowerCase().trim());
   });
   });
 
 
+  it('should throw if there is already an operator', async () => {
+    // Arrange
+    const email = faker.internet.email();
+
+    // Act & Assert
+    await createUser({ email, operator: true }, db);
+    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.');
+  });
+
   it('Should throw if user already exists', async () => {
   it('Should throw if user already exists', async () => {
     // Arrange
     // Arrange
     const email = faker.internet.email();
     const email = faker.internet.email();
 
 
     // Act & Assert
     // Act & Assert
-    await createUser(email, db);
+    await createUser({ email, operator: false }, db);
     await expect(AuthService.register({ username: email, password: 'test' })).rejects.toThrowError('User already exists');
     await expect(AuthService.register({ username: email, password: 'test' })).rejects.toThrowError('User already exists');
   });
   });
 
 
@@ -219,7 +230,7 @@ describe('Test: me', () => {
   it('Should return user if user exists', async () => {
   it('Should return user if user exists', async () => {
     // Arrange
     // Arrange
     const email = faker.internet.email();
     const email = faker.internet.email();
-    const user = await createUser(email, db);
+    const user = await createUser({ email }, db);
 
 
     // Act
     // Act
     const result = await AuthService.me(user.id);
     const result = await AuthService.me(user.id);
@@ -243,7 +254,7 @@ describe('Test: isConfigured', () => {
   it('Should return true if user exists', async () => {
   it('Should return true if user exists', async () => {
     // Arrange
     // Arrange
     const email = faker.internet.email();
     const email = faker.internet.email();
-    await createUser(email, db);
+    await createUser({ email }, db);
 
 
     // Act
     // Act
     const result = await AuthService.isConfigured();
     const result = await AuthService.isConfigured();
@@ -252,3 +263,86 @@ describe('Test: isConfigured', () => {
     expect(result).toBe(true);
     expect(result).toBe(true);
   });
   });
 });
 });
+
+describe('Test: changePassword', () => {
+  it('should change the password of the operator user', async () => {
+    // Arrange
+    const email = faker.internet.email();
+    const user = await createUser({ email }, db);
+    const newPassword = faker.internet.password();
+    // @ts-expect-error - mocking fs
+    fs.__createMockFiles({ '/runtipi/state/password-change-request': '' });
+
+    // Act
+    const result = await AuthService.changePassword({ 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);
+  });
+
+  it('should throw if the password change request file does not exist', async () => {
+    // Arrange
+    const email = faker.internet.email();
+    await createUser({ email }, db);
+    const newPassword = faker.internet.password();
+    // @ts-expect-error - mocking fs
+    fs.__createMockFiles({});
+
+    // Act & Assert
+    await expect(AuthService.changePassword({ newPassword })).rejects.toThrowError('No password change request found');
+  });
+
+  it('should throw if there is no operator user', async () => {
+    // Arrange
+    const email = faker.internet.email();
+    await createUser({ email, operator: false }, db);
+    const newPassword = faker.internet.password();
+    // @ts-expect-error - mocking fs
+    fs.__createMockFiles({ '/runtipi/state/password-change-request': '' });
+
+    // Act & Assert
+    await expect(AuthService.changePassword({ newPassword })).rejects.toThrowError('Operator user not found');
+  });
+});
+
+describe('Test: checkPasswordChangeRequest', () => {
+  it('should return true if the password change request file exists', async () => {
+    // Arrange
+    // @ts-expect-error - mocking fs
+    fs.__createMockFiles({ '/runtipi/state/password-change-request': '' });
+
+    // Act
+    const result = await AuthServiceClass.checkPasswordChangeRequest();
+
+    // Assert
+    expect(result).toBe(true);
+  });
+
+  it('should return false if the password change request file does not exist', async () => {
+    // Arrange
+    // @ts-expect-error - mocking fs
+    fs.__createMockFiles({});
+
+    // Act
+    const result = await AuthServiceClass.checkPasswordChangeRequest();
+
+    // Assert
+    expect(result).toBe(false);
+  });
+});
+
+describe('Test: cancelPasswordChangeRequest', () => {
+  it('should delete the password change request file', async () => {
+    // Arrange
+    // @ts-expect-error - mocking fs
+    fs.__createMockFiles({ '/runtipi/state/password-change-request': '' });
+
+    // Act
+    await AuthServiceClass.cancelPasswordChangeRequest();
+
+    // Assert
+    expect(fs.existsSync('/runtipi/state/password-change-request')).toBe(false);
+  });
+});

+ 66 - 2
src/server/services/auth/auth.service.ts

@@ -5,6 +5,7 @@ import jwt from 'jsonwebtoken';
 import validator from 'validator';
 import validator from 'validator';
 import { getConfig } from '../../core/TipiConfig';
 import { getConfig } from '../../core/TipiConfig';
 import TipiCache from '../../core/TipiCache';
 import TipiCache from '../../core/TipiCache';
+import { fileExists, unlinkFile } from '../../common/fs.helpers';
 
 
 type UsernamePasswordInput = {
 type UsernamePasswordInput = {
   username: string;
   username: string;
@@ -59,6 +60,12 @@ export class AuthServiceClass {
    * @throws {Error} - If the email or password is missing, the email is invalid or the user already exists
    * @throws {Error} - If the email or password is missing, the email is invalid or the user already exists
    */
    */
   public register = async (input: UsernamePasswordInput) => {
   public register = async (input: UsernamePasswordInput) => {
+    const registeredUser = await this.prisma.user.findFirst({ where: { operator: true } });
+
+    if (registeredUser) {
+      throw new Error('There is already an admin user. Please login to create a new user from the admin panel.');
+    }
+
     const { password, username } = input;
     const { password, username } = input;
     const email = username.trim().toLowerCase();
     const email = username.trim().toLowerCase();
 
 
@@ -77,7 +84,7 @@ export class AuthServiceClass {
     }
     }
 
 
     const hash = await argon2.hash(password);
     const hash = await argon2.hash(password);
-    const newUser = await this.prisma.user.create({ data: { username: email, password: hash } });
+    const newUser = await this.prisma.user.create({ data: { username: email, password: hash, operator: true } });
 
 
     const session = v4();
     const session = v4();
     const token = jwt.sign({ id: newUser.id, session }, getConfig().jwtSecret, { expiresIn: '1d' });
     const token = jwt.sign({ id: newUser.id, session }, getConfig().jwtSecret, { expiresIn: '1d' });
@@ -145,8 +152,65 @@ export class AuthServiceClass {
    * @returns {Promise<boolean>} - A boolean indicating if the system is configured or not
    * @returns {Promise<boolean>} - A boolean indicating if the system is configured or not
    */
    */
   public isConfigured = async (): Promise<boolean> => {
   public isConfigured = async (): Promise<boolean> => {
-    const count = await this.prisma.user.count();
+    const count = await this.prisma.user.count({ where: { operator: true } });
 
 
     return count > 0;
     return count > 0;
   };
   };
+
+  /**
+   * Change the password of the operator user
+   *
+   * @param {object} params - An object containing the new password
+   * @param {string} params.newPassword - The new password
+   * @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 }) => {
+    if (!AuthServiceClass.checkPasswordChangeRequest()) {
+      throw new Error('No password change request found');
+    }
+
+    const { newPassword } = params;
+    const user = await this.prisma.user.findFirst({ where: { operator: true } });
+
+    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 } });
+
+    await unlinkFile(`/runtipi/state/password-change-request`);
+
+    return { email: user.username };
+  };
+
+  /*
+   * Check if there is a pending password change request for the given email
+   * Returns true if there is a file in the password change requests folder with the given email
+   *
+   * @returns {boolean} - A boolean indicating if there is a password change request or not
+   */
+  public static checkPasswordChangeRequest = () => {
+    if (fileExists(`/runtipi/state/password-change-request`)) {
+      return true;
+    }
+
+    return false;
+  };
+
+  /*
+   * If there is a pending password change request, remove it
+   * Returns true if the file is removed successfully
+   *
+   * @returns {boolean} - A boolean indicating if the file is removed successfully or not
+   * @throws {Error} - If the file cannot be removed
+   */
+  public static cancelPasswordChangeRequest = async () => {
+    if (fileExists(`/runtipi/state/password-change-request`)) {
+      await unlinkFile(`/runtipi/state/password-change-request`);
+    }
+
+    return true;
+  };
 }
 }

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

@@ -3,11 +3,17 @@ import { faker } from '@faker-js/faker';
 import * as argon2 from 'argon2';
 import * as argon2 from 'argon2';
 import { prisma } from '../db/client';
 import { prisma } from '../db/client';
 
 
-const createUser = async (email?: string, db = prisma) => {
+type CreateUserParams = {
+  email?: string;
+  operator?: boolean;
+};
+
+const createUser = async (params: CreateUserParams, db = prisma) => {
+  const { email, operator = true } = params;
   const hash = await argon2.hash('password');
   const hash = await argon2.hash('password');
 
 
   const username = email?.toLowerCase().trim() || faker.internet.email().toLowerCase().trim();
   const username = email?.toLowerCase().trim() || faker.internet.email().toLowerCase().trim();
-  const user = await db.user.create({ data: { username, password: hash } });
+  const user = await db.user.create({ data: { username, password: hash, operator } });
 
 
   return user;
   return user;
 };
 };