feat(user): create routes and services for password reset
This commit is contained in:
parent
51c2dfa816
commit
8f8b8487e6
7 changed files with 191 additions and 10 deletions
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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),
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
Loading…
Add table
Reference in a new issue