refactor(server): move auth and system services to class

This commit is contained in:
Nicolas Meienberger 2023-02-12 00:01:00 +01:00 committed by Nicolas Meienberger
parent c42b96ae53
commit 5ff7451267
8 changed files with 242 additions and 208 deletions

View file

@ -1,12 +1,15 @@
import { z } from 'zod';
import AuthService from '../../services/auth/auth.service';
import { AuthServiceClass } from '../../services/auth/auth.service';
import { router, publicProcedure, protectedProcedure } from '../../trpc';
import { prisma } from '../../db/client';
const AuthService = new AuthServiceClass(prisma);
export const authRouter = router({
login: publicProcedure.input(z.object({ username: z.string(), password: z.string() })).mutation(async ({ input }) => AuthService.login({ ...input })),
logout: protectedProcedure.mutation(async ({ ctx }) => AuthService.logout(ctx.session.id)),
logout: protectedProcedure.mutation(async ({ ctx }) => AuthServiceClass.logout(ctx.session.id)),
register: publicProcedure.input(z.object({ username: z.string(), password: z.string() })).mutation(async ({ input }) => AuthService.register({ ...input })),
refreshToken: protectedProcedure.mutation(async ({ ctx }) => AuthService.refreshToken(ctx.session.id)),
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()),
});

View file

@ -1,12 +1,13 @@
import { inferRouterOutputs } from '@trpc/server';
import { router, protectedProcedure, publicProcedure } from '../../trpc';
import { SystemService } from '../../services/system/system.service';
import { SystemServiceClass } from '../../services/system';
export type SystemRouterOutput = inferRouterOutputs<typeof systemRouter>;
const SystemService = new SystemServiceClass();
export const systemRouter = router({
status: publicProcedure.query(SystemService.status),
systemInfo: protectedProcedure.query(SystemService.systemInfo),
status: publicProcedure.query(SystemServiceClass.status),
systemInfo: protectedProcedure.query(SystemServiceClass.systemInfo),
getVersion: publicProcedure.query(SystemService.getVersion),
restart: protectedProcedure.mutation(SystemService.restart),
update: protectedProcedure.mutation(SystemService.update),

View file

@ -1,32 +1,38 @@
import { PrismaClient } from '@prisma/client';
import * as argon2 from 'argon2';
import jwt from 'jsonwebtoken';
import { faker } from '@faker-js/faker';
import { setConfig } from '../../core/TipiConfig';
import { createUser } from '../../tests/user.factory';
import AuthService from './auth.service';
import { prisma } from '../../db/client';
import { AuthServiceClass } from './auth.service';
import TipiCache from '../../core/TipiCache';
import { getTestDbClient } from '../../../../tests/server/db-connection';
jest.mock('redis');
let db: PrismaClient;
let AuthService: AuthServiceClass;
const TEST_SUITE = 'authservice';
beforeAll(async () => {
setConfig('jwtSecret', 'test');
db = await getTestDbClient(TEST_SUITE);
AuthService = new AuthServiceClass(db);
});
beforeEach(async () => {
await prisma.user.deleteMany();
jest.mock('redis');
await db.user.deleteMany();
});
afterAll(async () => {
await prisma.user.deleteMany();
await prisma.$disconnect();
await db.user.deleteMany();
await db.$disconnect();
});
describe('Login', () => {
it('Should return a valid jsonwebtoken containing a user id', async () => {
// Arrange
const email = faker.internet.email();
const user = await createUser(email);
const user = await createUser(email, db);
// Act
const { token } = await AuthService.login({ username: email, password: 'password' });
@ -49,7 +55,7 @@ describe('Login', () => {
it('Should throw if password is incorrect', async () => {
const email = faker.internet.email();
await createUser(email);
await createUser(email, db);
await expect(AuthService.login({ username: email, password: 'wrong' })).rejects.toThrowError('Wrong password');
});
});
@ -78,7 +84,7 @@ describe('Register', () => {
// Act
await AuthService.register({ username: email, password: 'test' });
const user = await prisma.user.findFirst({ where: { username: email.toLowerCase().trim() } });
const user = await db.user.findFirst({ where: { username: email.toLowerCase().trim() } });
// Assert
expect(user).toBeDefined();
@ -90,7 +96,7 @@ describe('Register', () => {
const email = faker.internet.email();
// Act & Assert
await createUser(email);
await createUser(email, db);
await expect(AuthService.register({ username: email, password: 'test' })).rejects.toThrowError('User already exists');
});
@ -108,7 +114,7 @@ describe('Register', () => {
// Act
await AuthService.register({ username: email, password: 'test' });
const user = await prisma.user.findUnique({ where: { username: email } });
const user = await db.user.findUnique({ where: { username: email } });
const isPasswordValid = await argon2.verify(user?.password || '', 'test');
// Assert
@ -123,7 +129,7 @@ describe('Register', () => {
describe('Test: logout', () => {
it('Should return true if there is no session to delete', async () => {
// Act
const result = await AuthService.logout();
const result = await AuthServiceClass.logout();
// Assert
expect(result).toBe(true);
@ -136,7 +142,7 @@ describe('Test: logout', () => {
expect(await TipiCache.get(session)).toBe('test');
// Act
const result = await AuthService.logout(session);
const result = await AuthServiceClass.logout(session);
// Assert
expect(result).toBe(true);
@ -147,7 +153,7 @@ describe('Test: logout', () => {
describe('Test: refreshToken', () => {
it('Should return null if session is not provided', async () => {
// Act
const result = await AuthService.refreshToken();
const result = await AuthServiceClass.refreshToken();
// Assert
expect(result).toBeNull();
@ -155,7 +161,7 @@ describe('Test: refreshToken', () => {
it('Should return null if session is not found in cache', async () => {
// Act
const result = await AuthService.refreshToken('test');
const result = await AuthServiceClass.refreshToken('test');
// Assert
expect(result).toBeNull();
@ -167,7 +173,7 @@ describe('Test: refreshToken', () => {
await TipiCache.set(session, 'test');
// Act
const result = await AuthService.refreshToken(session);
const result = await AuthServiceClass.refreshToken(session);
// Assert
expect(result).not.toBeNull();
@ -181,7 +187,7 @@ describe('Test: refreshToken', () => {
await TipiCache.set(session, '1');
// Act
const result = await AuthService.refreshToken(session);
const result = await AuthServiceClass.refreshToken(session);
const expiration = await TipiCache.ttl(session);
// Assert
@ -213,7 +219,7 @@ describe('Test: me', () => {
it('Should return user if user exists', async () => {
// Arrange
const email = faker.internet.email();
const user = await createUser(email);
const user = await createUser(email, db);
// Act
const result = await AuthService.me(user.id);
@ -224,3 +230,25 @@ describe('Test: me', () => {
expect(result).toHaveProperty('username');
});
});
describe('Test: isConfigured', () => {
it('Should return false if no user exists', async () => {
// Act
const result = await AuthService.isConfigured();
// Assert
expect(result).toBe(false);
});
it('Should return true if user exists', async () => {
// Arrange
const email = faker.internet.email();
await createUser(email, db);
// Act
const result = await AuthService.isConfigured();
// Assert
expect(result).toBe(true);
});
});

View file

@ -1,10 +1,10 @@
import { PrismaClient } from '@prisma/client';
import * as argon2 from 'argon2';
import { v4 } from 'uuid';
import jwt from 'jsonwebtoken';
import validator from 'validator';
import { getConfig } from '../../core/TipiConfig';
import TipiCache from '../../core/TipiCache';
import { prisma } from '../../db/client';
type UsernamePasswordInput = {
username: string;
@ -15,141 +15,138 @@ type TokenResponse = {
token: string;
};
/**
* Authenticate user with given username and password
*
* @param {UsernamePasswordInput} input - An object containing the user's username and password
* @return {Promise<{token:string}>} - A promise that resolves to an object containing the JWT token
*/
const login = async (input: UsernamePasswordInput) => {
const { password, username } = input;
export class AuthServiceClass {
private prisma;
const user = await prisma.user.findUnique({ where: { username: username.trim().toLowerCase() } });
if (!user) {
throw new Error('User not found');
constructor(p: PrismaClient) {
this.prisma = p;
}
const isPasswordValid = await argon2.verify(user.password, password);
/**
* Authenticate user with given username and password
*
* @param {UsernamePasswordInput} input - An object containing the user's username and password
* @returns {Promise<{token:string}>} - A promise that resolves to an object containing the JWT token
*/
public login = async (input: UsernamePasswordInput) => {
const { password, username } = input;
if (!isPasswordValid) {
throw new Error('Wrong password');
}
const user = await this.prisma.user.findUnique({ where: { username: username.trim().toLowerCase() } });
const session = v4();
const token = jwt.sign({ id: user.id, session }, getConfig().jwtSecret, { expiresIn: '7d' });
if (!user) {
throw new Error('User not found');
}
await TipiCache.set(session, user.id.toString());
const isPasswordValid = await argon2.verify(user.password, password);
return { token };
};
if (!isPasswordValid) {
throw new Error('Wrong password');
}
/**
* Creates a new user with the provided email and password and returns a session token
*
* @param {UsernamePasswordInput} input - An object containing the email and password fields
* @returns {Promise<{token: string}>} - An object containing the session token
* @throws {Error} - If the email or password is missing, the email is invalid or the user already exists
*/
const register = async (input: UsernamePasswordInput) => {
const { password, username } = input;
const email = username.trim().toLowerCase();
const session = v4();
const token = jwt.sign({ id: user.id, session }, getConfig().jwtSecret, { expiresIn: '7d' });
if (!username || !password) {
throw new Error('Missing email or password');
}
await TipiCache.set(session, user.id.toString());
if (username.length < 3 || !validator.isEmail(email)) {
throw new Error('Invalid username');
}
return { token };
};
const user = await prisma.user.findUnique({ where: { username: email } });
/**
* Creates a new user with the provided email and password and returns a session token
*
* @param {UsernamePasswordInput} input - An object containing the email and password fields
* @returns {Promise<{token: string}>} - An object containing the session token
* @throws {Error} - If the email or password is missing, the email is invalid or the user already exists
*/
public register = async (input: UsernamePasswordInput) => {
const { password, username } = input;
const email = username.trim().toLowerCase();
if (user) {
throw new Error('User already exists');
}
if (!username || !password) {
throw new Error('Missing email or password');
}
const hash = await argon2.hash(password);
const newUser = await prisma.user.create({ data: { username: email, password: hash } });
if (username.length < 3 || !validator.isEmail(email)) {
throw new Error('Invalid username');
}
const session = v4();
const token = jwt.sign({ id: newUser.id, session }, getConfig().jwtSecret, { expiresIn: '1d' });
const user = await this.prisma.user.findUnique({ where: { username: email } });
await TipiCache.set(session, newUser.id.toString());
if (user) {
throw new Error('User already exists');
}
return { token };
};
const hash = await argon2.hash(password);
const newUser = await this.prisma.user.create({ data: { username: email, password: hash } });
/**
* Retrieves the user with the provided ID
*
* @param {number|undefined} userId - The user ID to retrieve
* @returns {Promise<{id: number, username: string} | null>} - An object containing the user's id and email, or null if the user is not found
*/
const me = async (userId: number | undefined) => {
if (!userId) return null;
const session = v4();
const token = jwt.sign({ id: newUser.id, session }, getConfig().jwtSecret, { expiresIn: '1d' });
const user = await prisma.user.findUnique({ where: { id: Number(userId) }, select: { id: true, username: true } });
await TipiCache.set(session, newUser.id.toString());
if (!user) return null;
return { token };
};
return user;
};
/**
* Retrieves the user with the provided ID
*
* @param {number|undefined} userId - The user ID to retrieve
* @returns {Promise<{id: number, username: string} | null>} - An object containing the user's id and email, or null if the user is not found
*/
public me = async (userId: number | undefined) => {
if (!userId) return null;
/**
* Logs out the current user by removing the session token
*
* @param {string} [session] - The session token to log out
* @returns {Promise<boolean>} - Returns true if the session token is removed successfully
*/
const logout = async (session?: string): Promise<boolean> => {
if (session) {
await TipiCache.del(session);
}
const user = await this.prisma.user.findUnique({ where: { id: Number(userId) }, select: { id: true, username: true } });
return true;
};
if (!user) return null;
/**
* Refreshes a user's session token
*
* @param {string} [session] - The current session token
* @returns {Promise<{token: string} | null>} - An object containing the new session token, or null if the session is invalid
*/
const refreshToken = async (session?: string): Promise<TokenResponse | null> => {
if (!session) return null;
return user;
};
const userId = await TipiCache.get(session);
if (!userId) return null;
/**
* Logs out the current user by removing the session token
*
* @param {string} [session] - The session token to log out
* @returns {Promise<boolean>} - Returns true if the session token is removed successfully
*/
public static logout = async (session?: string): Promise<boolean> => {
if (session) {
await TipiCache.del(session);
}
// Expire token in 6 seconds
await TipiCache.set(session, userId, 6);
return true;
};
const newSession = v4();
const token = jwt.sign({ id: userId, session: newSession }, getConfig().jwtSecret, { expiresIn: '1d' });
await TipiCache.set(newSession, userId);
/**
* Refreshes a user's session token
*
* @param {string} [session] - The current session token
* @returns {Promise<{token: string} | null>} - An object containing the new session token, or null if the session is invalid
*/
public static refreshToken = async (session?: string): Promise<TokenResponse | null> => {
if (!session) return null;
return { token };
};
const userId = await TipiCache.get(session);
if (!userId) return null;
/**
* Check if the system is configured and has at least one user
*
* @returns {Promise<boolean>} - A boolean indicating if the system is configured or not
*/
const isConfigured = async (): Promise<boolean> => {
const count = await prisma.user.count();
// Expire token in 6 seconds
await TipiCache.set(session, userId, 6);
return count > 0;
};
const newSession = v4();
const token = jwt.sign({ id: userId, session: newSession }, getConfig().jwtSecret, { expiresIn: '1d' });
await TipiCache.set(newSession, userId);
const AuthService = {
login,
register,
me,
logout,
refreshToken,
isConfigured,
};
return { token };
};
export default AuthService;
/**
* Check if the system is configured and has at least one user
*
* @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();
return count > 0;
};
}

View file

@ -1 +1 @@
export { SystemService } from './system.service';
export { SystemServiceClass } from './system.service';

View file

@ -1,14 +1,16 @@
import fs from 'fs-extra';
import semver from 'semver';
import { faker } from '@faker-js/faker';
import { SystemService } from '.';
import { EventDispatcher } from '../../core/EventDispatcher';
import { setConfig } from '../../core/TipiConfig';
import TipiCache from '../../core/TipiCache';
import { SystemServiceClass } from '.';
jest.mock('axios');
jest.mock('redis');
const SystemService = new SystemServiceClass();
beforeEach(async () => {
jest.mock('fs-extra');
jest.resetModules();
@ -18,7 +20,7 @@ beforeEach(async () => {
describe('Test: systemInfo', () => {
it('Should throw if system-info.json does not exist', () => {
try {
SystemService.systemInfo();
SystemServiceClass.systemInfo();
} catch (e: unknown) {
if (e instanceof Error) {
expect(e).toBeDefined();
@ -45,7 +47,7 @@ describe('Test: systemInfo', () => {
fs.__createMockFiles(MockFiles);
// Act
const systemInfo = SystemService.systemInfo();
const systemInfo = SystemServiceClass.systemInfo();
// Assert
expect(systemInfo).toBeDefined();

View file

@ -23,88 +23,91 @@ const systemInfoSchema = z.object({
}),
});
const status = async (): Promise<{ status: SystemStatus }> => ({
status: getConfig().status as SystemStatus,
});
export class SystemServiceClass {
private cache;
/**
* Get the current and latest version of Tipi
* @returns {Promise<{ current: string; latest: string }>}
*/
const getVersion = async (): Promise<{ current: string; latest?: string }> => {
try {
let version = await TipiCache.get('latestVersion');
private dispatcher;
if (!version) {
const data = await fetch('https://api.github.com/repos/meienberger/runtipi/releases/latest');
const release = await data.json();
constructor() {
this.cache = TipiCache;
this.dispatcher = EventDispatcher;
}
version = release.name.replace('v', '');
await TipiCache.set('latestVersion', version?.replace('v', '') || '', 60 * 60);
/**
* Get the current and latest version of Tipi
* @returns {Promise<{ current: string; latest: string }>}
*/
public getVersion = async (): Promise<{ current: string; latest?: string }> => {
try {
let version = await this.cache.get('latestVersion');
if (!version) {
const data = await fetch('https://api.github.com/repos/meienberger/runtipi/releases/latest');
const release = await data.json();
version = release.name.replace('v', '');
await this.cache.set('latestVersion', version?.replace('v', '') || '', 60 * 60);
}
return { current: getConfig().version, latest: version?.replace('v', '') };
} catch (e) {
Logger.error(e);
return { current: getConfig().version, latest: undefined };
}
};
public static systemInfo = (): z.infer<typeof systemInfoSchema> => {
const info = systemInfoSchema.safeParse(readJsonFile('/runtipi/state/system-info.json'));
if (!info.success) {
throw new Error('Error parsing system info');
} else {
return info.data;
}
};
public update = async (): Promise<boolean> => {
const { current, latest } = await this.getVersion();
if (getConfig().NODE_ENV === 'development') {
throw new Error('Cannot update in development mode');
}
return { current: getConfig().version, latest: version?.replace('v', '') };
} catch (e) {
Logger.error(e);
return { current: getConfig().version, latest: undefined };
}
};
if (!latest) {
throw new Error('Could not get latest version');
}
const systemInfo = (): z.infer<typeof systemInfoSchema> => {
const info = systemInfoSchema.safeParse(readJsonFile('/runtipi/state/system-info.json'));
if (semver.gt(current, latest)) {
throw new Error('Current version is newer than latest version');
}
if (!info.success) {
throw new Error('Error parsing system info');
} else {
return info.data;
}
};
if (semver.eq(current, latest)) {
throw new Error('Current version is already up to date');
}
const restart = async (): Promise<boolean> => {
if (getConfig().NODE_ENV === 'development') {
throw new Error('Cannot restart in development mode');
}
if (semver.major(current) !== semver.major(latest)) {
throw new Error('The major version has changed. Please update manually (instructions on GitHub)');
}
setConfig('status', 'RESTARTING');
EventDispatcher.dispatchEventAsync('restart');
setConfig('status', 'UPDATING');
return true;
};
this.dispatcher.dispatchEventAsync('update');
const update = async (): Promise<boolean> => {
const { current, latest } = await getVersion();
return true;
};
if (getConfig().NODE_ENV === 'development') {
throw new Error('Cannot update in development mode');
}
public restart = async (): Promise<boolean> => {
if (getConfig().NODE_ENV === 'development') {
throw new Error('Cannot restart in development mode');
}
if (!latest) {
throw new Error('Could not get latest version');
}
setConfig('status', 'RESTARTING');
this.dispatcher.dispatchEventAsync('restart');
if (semver.gt(current, latest)) {
throw new Error('Current version is newer than latest version');
}
return true;
};
if (semver.eq(current, latest)) {
throw new Error('Current version is already up to date');
}
if (semver.major(current) !== semver.major(latest)) {
throw new Error('The major version has changed. Please update manually (instructions on GitHub)');
}
setConfig('status', 'UPDATING');
EventDispatcher.dispatchEventAsync('update');
return true;
};
export const SystemService = {
getVersion,
systemInfo,
restart,
update,
status,
};
public static status = async (): Promise<{ status: SystemStatus }> => ({
status: getConfig().status as SystemStatus,
});
}

View file

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