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

This commit is contained in:
Nicolas Meienberger 2023-02-21 22:57:17 +01:00 committed by Nicolas Meienberger
parent 26fe881aa7
commit 57f05a80bd
7 changed files with 191 additions and 10 deletions

View file

@ -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();

View file

@ -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);

View file

@ -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();

View file

@ -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),
});

View file

@ -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);
});
});

View file

@ -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<boolean>} - A boolean indicating if the system is configured or not
*/
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;
};
/**
* 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;
};
}

View file

@ -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;
};