diff --git a/__mocks__/fs-extra.ts b/__mocks__/fs-extra.ts index 6daea7c0..4b1d6654 100644 --- a/__mocks__/fs-extra.ts +++ b/__mocks__/fs-extra.ts @@ -102,6 +102,17 @@ class FsMock { }; 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(); diff --git a/src/server/common/fs.helpers.ts b/src/server/common/fs.helpers.ts index 6c39295b..108e8c21 100644 --- a/src/server/common/fs.helpers.ts +++ b/src/server/common/fs.helpers.ts @@ -35,3 +35,5 @@ export const getSeed = () => { const seed = readFile('/runtipi/state/seed'); return seed.toString(); }; + +export const unlinkFile = (path: string) => fs.promises.unlink(path); diff --git a/src/server/index.ts b/src/server/index.ts index fa0136ab..d0071f8f 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -13,6 +13,7 @@ import { prisma } from './db/client'; let conf = {}; let nextApp: NextServer; +const hostname = 'localhost'; const port = parseInt(process.env.PORT || '3000', 10); const dev = process.env.NODE_ENV !== 'production'; @@ -23,7 +24,7 @@ if (!dev) { nextApp = new NextServer({ hostname: 'localhost', dev, port, customServer: true, conf }); } else { const next = require('next').default; - nextApp = next({ dev }); + nextApp = next({ dev, hostname, port }); } const handle = nextApp.getRequestHandler(); diff --git a/src/server/routers/auth/auth.router.ts b/src/server/routers/auth/auth.router.ts index 9dccc792..c91d5747 100644 --- a/src/server/routers/auth/auth.router.ts +++ b/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)), me: publicProcedure.query(async ({ ctx }) => AuthService.me(ctx.session?.userId)), 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), }); diff --git a/src/server/services/auth/auth.service.test.ts b/src/server/services/auth/auth.service.test.ts index 61808dd9..04323d77 100644 --- a/src/server/services/auth/auth.service.test.ts +++ b/src/server/services/auth/auth.service.test.ts @@ -1,4 +1,5 @@ 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'; @@ -19,6 +20,7 @@ beforeAll(async () => { }); beforeEach(async () => { + jest.mock('fs-extra'); jest.mock('redis'); await db.user.deleteMany(); }); @@ -32,7 +34,7 @@ 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 }, db); // Act const { token } = await AuthService.login({ username: email, password: 'password' }); @@ -55,7 +57,7 @@ describe('Login', () => { it('Should throw if password is incorrect', async () => { 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'); }); }); @@ -91,12 +93,21 @@ describe('Register', () => { 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 () => { // Arrange const email = faker.internet.email(); // 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'); }); @@ -219,7 +230,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 }, db); // Act const result = await AuthService.me(user.id); @@ -243,7 +254,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 }, db); // Act const result = await AuthService.isConfigured(); @@ -252,3 +263,86 @@ describe('Test: isConfigured', () => { 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); + }); +}); diff --git a/src/server/services/auth/auth.service.ts b/src/server/services/auth/auth.service.ts index 7dac98f6..edc0dac3 100644 --- a/src/server/services/auth/auth.service.ts +++ b/src/server/services/auth/auth.service.ts @@ -5,6 +5,7 @@ import jwt from 'jsonwebtoken'; import validator from 'validator'; import { getConfig } from '../../core/TipiConfig'; import TipiCache from '../../core/TipiCache'; +import { fileExists, unlinkFile } from '../../common/fs.helpers'; type UsernamePasswordInput = { 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 */ 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 email = username.trim().toLowerCase(); @@ -77,7 +84,7 @@ export class AuthServiceClass { } 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 token = jwt.sign({ id: newUser.id, session }, getConfig().jwtSecret, { expiresIn: '1d' }); @@ -145,8 +152,65 @@ export class AuthServiceClass { * @returns {Promise} - A boolean indicating if the system is configured or not */ public isConfigured = async (): Promise => { - const count = await this.prisma.user.count(); + const count = await this.prisma.user.count({ where: { operator: true } }); 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} - 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; + }; } diff --git a/src/server/tests/user.factory.ts b/src/server/tests/user.factory.ts index 24d8c470..5ddcdd46 100644 --- a/src/server/tests/user.factory.ts +++ b/src/server/tests/user.factory.ts @@ -3,11 +3,17 @@ import { faker } from '@faker-js/faker'; import * as argon2 from 'argon2'; 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 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; };