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