feat: create trpc router for auth service

This commit is contained in:
Nicolas Meienberger 2022-12-26 22:07:12 +01:00 committed by Nicolas Meienberger
parent 7c9bd4fab3
commit 2e13666d80
9 changed files with 389 additions and 2 deletions

5
.env.test Normal file
View file

@ -0,0 +1,5 @@
ROOT_FOLDER=/test
ROOT_FOLDER_HOST=/tipi
JWT_SECRET=secret
STORAGE_PATH=/test/storage
APPS_REPO_URL=http://localhost:8080

View file

@ -48,6 +48,7 @@
"sharp": "0.30.7",
"superjson": "^1.12.0",
"tslib": "^2.4.0",
"uuid": "^9.0.0",
"validator": "^13.7.0",
"winston": "^3.7.2",
"zod": "^3.19.1",
@ -73,6 +74,7 @@
"@types/react-dom": "18.0.3",
"@types/semver": "^7.3.12",
"@types/testing-library__jest-dom": "^5.14.5",
"@types/uuid": "^8.3.4",
"@types/validator": "^13.7.2",
"@typescript-eslint/eslint-plugin": "^5.47.1",
"@typescript-eslint/parser": "^5.47.1",

View file

@ -1,9 +1,11 @@
import { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
import { router } from '../trpc';
import { authRouter } from './auth/auth.router';
import { systemRouter } from './system/system.router';
export const appRouter = router({
system: systemRouter,
auth: authRouter,
});
// export type definition of API
export type AppRouter = typeof appRouter;

View file

@ -0,0 +1,12 @@
import { z } from 'zod';
import AuthService from '../../services/auth/auth.service';
import { router, publicProcedure, protectedProcedure } from '../../trpc';
export const authRouter = router({
login: publicProcedure.input(z.object({ username: z.string(), password: z.string() })).mutation(async ({ ctx, input }) => AuthService.login({ ...input }, ctx)),
logout: protectedProcedure.mutation(async ({ ctx }) => AuthService.logout(ctx.session.id)),
register: publicProcedure.input(z.object({ username: z.string(), password: z.string() })).mutation(async ({ ctx, input }) => AuthService.register({ ...input }, ctx)),
refreshToken: protectedProcedure.mutation(async ({ ctx }) => AuthService.refreshToken(ctx.session.id)),
me: publicProcedure.query(async ({ ctx }) => AuthService.me(ctx)),
isConfigured: publicProcedure.query(async ({ ctx }) => AuthService.isConfigured(ctx)),
});

View file

@ -7,7 +7,7 @@ export type SystemRouterOutput = inferRouterOutputs<typeof systemRouter>;
export const systemRouter = router({
status: publicProcedure.query(SystemService.status),
systemInfo: protectedProcedure.query(SystemService.systemInfo),
getVersion: protectedProcedure.query(SystemService.getVersion),
getVersion: publicProcedure.query(SystemService.getVersion),
restart: protectedProcedure.mutation(SystemService.restart),
update: protectedProcedure.mutation(SystemService.update),
});

View file

@ -0,0 +1,228 @@
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 { Context } from '../../context';
import TipiCache from '../../core/TipiCache';
jest.mock('redis');
beforeAll(async () => {
setConfig('jwtSecret', 'test');
});
beforeEach(async () => {
await prisma.user.deleteMany();
});
afterAll(async () => {
await prisma.user.deleteMany();
await prisma.$disconnect();
});
const ctx = { prisma } as Context;
describe('Login', () => {
it('Should return a valid jsonwebtoken containing a user id', async () => {
// Arrange
const email = faker.internet.email();
const user = await createUser(email);
// Act
const { token } = await AuthService.login({ username: email, password: 'password' }, ctx);
const decoded = jwt.verify(token, 'test') as jwt.JwtPayload;
// Assert
expect(decoded).toBeDefined();
expect(decoded).toBeDefined();
expect(decoded).not.toBeNull();
expect(decoded).toHaveProperty('id');
expect(decoded.id).toBe(user.id);
expect(decoded).toHaveProperty('iat');
expect(decoded).toHaveProperty('exp');
expect(decoded).toHaveProperty('session');
});
it('Should throw if user does not exist', async () => {
await expect(AuthService.login({ username: 'test', password: 'test' }, ctx)).rejects.toThrowError('User not found');
});
it('Should throw if password is incorrect', async () => {
const email = faker.internet.email();
await createUser(email);
await expect(AuthService.login({ username: email, password: 'wrong' }, ctx)).rejects.toThrowError('Wrong password');
});
});
describe('Register', () => {
it('Should return valid jsonwebtoken after register', async () => {
// Arrange
const email = faker.internet.email();
// Act
const { token } = await AuthService.register({ username: email, password: 'password' }, ctx);
const decoded = jwt.verify(token, 'test') as jwt.JwtPayload;
// Assert
expect(decoded).toBeDefined();
expect(decoded).not.toBeNull();
expect(decoded).toHaveProperty('id');
expect(decoded).toHaveProperty('iat');
expect(decoded).toHaveProperty('exp');
expect(decoded).toHaveProperty('session');
});
it('Should correctly trim and lowercase email', async () => {
// Arrange
const email = faker.internet.email();
// Act
await AuthService.register({ username: email, password: 'test' }, ctx);
const user = await prisma.user.findFirst({ where: { username: email.toLowerCase().trim() } });
// Assert
expect(user).toBeDefined();
expect(user?.username).toBe(email.toLowerCase().trim());
});
it('Should throw if user already exists', async () => {
// Arrange
const email = faker.internet.email();
// Act & Assert
await createUser(email);
await expect(AuthService.register({ username: email, password: 'test' }, ctx)).rejects.toThrowError('User already exists');
});
it('Should throw if email is not provided', async () => {
await expect(AuthService.register({ username: '', password: 'test' }, ctx)).rejects.toThrowError('Missing email or password');
});
it('Should throw if password is not provided', async () => {
await expect(AuthService.register({ username: faker.internet.email(), password: '' }, ctx)).rejects.toThrowError('Missing email or password');
});
it('Password is correctly hashed', async () => {
// Arrange
const email = faker.internet.email().toLowerCase().trim();
// Act
await AuthService.register({ username: email, password: 'test' }, ctx);
const user = await prisma.user.findUnique({ where: { username: email } });
const isPasswordValid = await argon2.verify(user?.password || '', 'test');
// Assert
expect(isPasswordValid).toBe(true);
});
it('Should throw if email is invalid', async () => {
await expect(AuthService.register({ username: 'test', password: 'test' }, ctx)).rejects.toThrowError('Invalid username');
});
});
describe('Test: logout', () => {
it('Should return true if there is no session to delete', async () => {
// Act
const result = await AuthService.logout();
// Assert
expect(result).toBe(true);
});
it('Should delete session from cache', async () => {
// Arrange
const session = faker.random.alphaNumeric(32);
await TipiCache.set(session, 'test');
expect(await TipiCache.get(session)).toBe('test');
// Act
const result = await AuthService.logout(session);
// Assert
expect(result).toBe(true);
expect(await TipiCache.get('session')).toBeUndefined();
});
});
describe('Test: refreshToken', () => {
it('Should return null if session is not provided', async () => {
// Act
const result = await AuthService.refreshToken();
// Assert
expect(result).toBeNull();
});
it('Should return null if session is not found in cache', async () => {
// Act
const result = await AuthService.refreshToken('test');
// Assert
expect(result).toBeNull();
});
it('Should return a new token if session is found in cache', async () => {
// Arrange
const session = faker.random.alphaNumeric(32);
await TipiCache.set(session, 'test');
// Act
const result = await AuthService.refreshToken(session);
// Assert
expect(result).not.toBeNull();
expect(result).toHaveProperty('token');
expect(result?.token).not.toBe(session);
});
it('Should put expiration in 6 seconds for old session', async () => {
// Arrange
const session = faker.random.alphaNumeric(32);
await TipiCache.set(session, '1');
// Act
const result = await AuthService.refreshToken(session);
const expiration = await TipiCache.ttl(session);
// Assert
expect(result).not.toBeNull();
expect(result).toHaveProperty('token');
expect(result?.token).not.toBe(session);
expect(expiration).toMatchObject({ EX: 6 });
});
});
describe('Test: me', () => {
it('Should return null if userId is not provided', async () => {
// Act
const result = await AuthService.me(ctx);
// Assert
expect(result).toBeNull();
});
it('Should return null if user does not exist', async () => {
// Act
const result = await AuthService.me({ ...ctx, session: { userId: 1 } });
// Assert
expect(result).toBeNull();
});
it('Should return user if user exists', async () => {
// Arrange
const email = faker.internet.email();
const user = await createUser(email);
// Act
const result = await AuthService.me({ ...ctx, session: { userId: user.id } });
// Assert
expect(result).not.toBeNull();
expect(result).toHaveProperty('id');
expect(result).toHaveProperty('username');
});
});

View file

@ -0,0 +1,120 @@
import * as argon2 from 'argon2';
import { v4 } from 'uuid';
import jwt from 'jsonwebtoken';
import validator from 'validator';
import { User } from '@prisma/client';
import { getConfig } from '../../core/TipiConfig';
import TipiCache from '../../core/TipiCache';
import { Context } from '../../context';
type UsernamePasswordInput = {
username: string;
password: string;
};
type TokenResponse = {
token: string;
};
const login = async (input: UsernamePasswordInput, ctx: Context): Promise<TokenResponse> => {
const { password, username } = input;
const user = await ctx.prisma.user.findUnique({ where: { username: username.trim().toLowerCase() } });
if (!user) {
throw new Error('User not found');
}
const isPasswordValid = await argon2.verify(user.password, password);
if (!isPasswordValid) {
throw new Error('Wrong password');
}
const session = v4();
const token = jwt.sign({ id: user.id, session }, getConfig().jwtSecret, { expiresIn: '7d' });
await TipiCache.set(session, user.id.toString());
return { token };
};
const register = async (input: UsernamePasswordInput, ctx: Context): Promise<TokenResponse> => {
const { password, username } = input;
const email = username.trim().toLowerCase();
if (!username || !password) {
throw new Error('Missing email or password');
}
if (username.length < 3 || !validator.isEmail(email)) {
throw new Error('Invalid username');
}
const user = await ctx.prisma.user.findUnique({ where: { username: email } });
if (user) {
throw new Error('User already exists');
}
const hash = await argon2.hash(password);
const newUser = await ctx.prisma.user.create({ data: { username: email, password: hash } });
const session = v4();
const token = jwt.sign({ id: newUser.id, session }, getConfig().jwtSecret, { expiresIn: '1d' });
await TipiCache.set(session, newUser.id.toString());
return { token };
};
const me = async (ctx: Context): Promise<Pick<User, 'id' | 'username'> | null> => {
if (!ctx.session?.userId) return null;
const user = await ctx.prisma.user.findUnique({ where: { id: Number(ctx.session?.userId) }, select: { id: true, username: true } });
if (!user) return null;
return user;
};
const logout = async (session?: string): Promise<boolean> => {
if (session) {
await TipiCache.del(session);
}
return true;
};
const refreshToken = async (session?: string): Promise<TokenResponse | null> => {
if (!session) return null;
const userId = await TipiCache.get(session);
if (!userId) return null;
// Expire token in 6 seconds
await TipiCache.set(session, userId, 6);
const newSession = v4();
const token = jwt.sign({ id: userId, session: newSession }, getConfig().jwtSecret, { expiresIn: '1d' });
await TipiCache.set(newSession, userId);
return { token };
};
const isConfigured = async (ctx: Context): Promise<boolean> => {
const count = await ctx.prisma.user.count();
return count > 0;
};
const AuthService = {
login,
register,
me,
logout,
refreshToken,
isConfigured,
};
export default AuthService;

View file

@ -0,0 +1,15 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import { faker } from '@faker-js/faker';
import * as argon2 from 'argon2';
import { prisma } from '../db/client';
const createUser = async (email?: string) => {
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 } });
return user;
};
export { createUser };

View file

@ -47,6 +47,7 @@ importers:
'@types/react-dom': 18.0.3
'@types/semver': ^7.3.12
'@types/testing-library__jest-dom': ^5.14.5
'@types/uuid': ^8.3.4
'@types/validator': ^13.7.2
'@typescript-eslint/eslint-plugin': ^5.47.1
'@typescript-eslint/parser': ^5.47.1
@ -128,6 +129,7 @@ importers:
sharp: 0.30.7
superjson: 1.12.0
tslib: 2.4.0
uuid: 9.0.0
validator: 13.7.0
winston: 3.7.2
zod: 3.19.1
@ -152,6 +154,7 @@ importers:
'@types/react-dom': 18.0.3
'@types/semver': 7.3.12
'@types/testing-library__jest-dom': 5.14.5
'@types/uuid': 8.3.4
'@types/validator': 13.7.2
'@typescript-eslint/eslint-plugin': 5.47.1_txmweb6yn7coi7nfrp22gpyqmy
'@typescript-eslint/parser': 5.47.1_lzzuuodtsqwxnvqeq4g4likcqa
@ -2817,7 +2820,7 @@ packages:
nopt: 5.0.0
npmlog: 5.0.1
rimraf: 3.0.2
semver: 7.3.7
semver: 7.3.8
tar: 6.1.11
transitivePeerDependencies:
- encoding