feat: create trpc router for auth service
This commit is contained in:
parent
7c9bd4fab3
commit
2e13666d80
9 changed files with 389 additions and 2 deletions
5
.env.test
Normal file
5
.env.test
Normal 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
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
|
|
12
packages/dashboard/src/server/routers/auth/auth.router.ts
Normal file
12
packages/dashboard/src/server/routers/auth/auth.router.ts
Normal 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)),
|
||||
});
|
|
@ -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),
|
||||
});
|
||||
|
|
228
packages/dashboard/src/server/services/auth/auth.service.test.ts
Normal file
228
packages/dashboard/src/server/services/auth/auth.service.test.ts
Normal 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');
|
||||
});
|
||||
});
|
120
packages/dashboard/src/server/services/auth/auth.service.ts
Normal file
120
packages/dashboard/src/server/services/auth/auth.service.ts
Normal 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;
|
15
packages/dashboard/src/server/tests/user.factory.ts
Normal file
15
packages/dashboard/src/server/tests/user.factory.ts
Normal 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 };
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue