feat: create backend service and router for totp functions

disable totp
This commit is contained in:
Nicolas Meienberger 2023-04-06 07:16:05 +02:00
parent 96bb7e9a3d
commit 1cc8d3f868
5 changed files with 612 additions and 19 deletions

View file

@ -0,0 +1,144 @@
import { PrismaClient } from '@prisma/client';
import { authRouter } from './auth.router';
import { getTestDbClient } from '../../../../tests/server/db-connection';
let db: PrismaClient;
const TEST_SUITE = 'authrouter';
beforeAll(async () => {
db = await getTestDbClient(TEST_SUITE);
jest.spyOn(console, 'log').mockImplementation(() => {});
});
beforeEach(async () => {
await db.user.deleteMany();
// Mute console.log
});
afterAll(async () => {
await db.user.deleteMany();
await db.$disconnect();
});
describe('Test: verifyTotp', () => {
it('should be accessible without an account', async () => {
// arrange
const caller = authRouter.createCaller({ session: null });
let error;
// act
try {
await caller.verifyTotp({ totpCode: '123456', totpSessionId: '123456' });
} catch (e) {
error = e as { code: string };
}
// assert
expect(error?.code).not.toBe('UNAUTHORIZED');
expect(error?.code).toBeDefined();
expect(error?.code).not.toBe(null);
});
});
describe('Test: getTotpUri', () => {
it('should not be accessible without an account', async () => {
// arrange
const caller = authRouter.createCaller({ session: null });
let error;
// act
try {
await caller.getTotpUri({ password: '123456' });
} catch (e) {
error = e as { code: string };
}
// assert
expect(error?.code).toBe('UNAUTHORIZED');
});
it('should be accessible with an account', async () => {
// arrange
const caller = authRouter.createCaller({ session: { userId: 123456 } });
let error;
// act
try {
await caller.getTotpUri({ password: '123456' });
} catch (e) {
error = e as { code: string };
}
// assert
expect(error?.code).not.toBe('UNAUTHORIZED');
});
});
describe('Test: setupTotp', () => {
it('should not be accessible without an account', async () => {
// arrange
const caller = authRouter.createCaller({ session: null });
let error;
// act
try {
await caller.setupTotp({ totpCode: '123456' });
} catch (e) {
error = e as { code: string };
}
// assert
expect(error?.code).toBe('UNAUTHORIZED');
});
it('should be accessible with an account', async () => {
// arrange
const caller = authRouter.createCaller({ session: { userId: 123456 } });
let error;
// act
try {
await caller.setupTotp({ totpCode: '123456' });
} catch (e) {
error = e as { code: string };
}
// assert
expect(error?.code).not.toBe('UNAUTHORIZED');
});
});
describe('Test: disableTotp', () => {
it('should not be accessible without an account', async () => {
// arrange
const caller = authRouter.createCaller({ session: null });
let error;
// act
try {
await caller.disableTotp({ password: '123456' });
} catch (e) {
error = e as { code: string };
}
// assert
expect(error?.code).toBe('UNAUTHORIZED');
});
it('should be accessible with an account', async () => {
// arrange
const caller = authRouter.createCaller({ session: { userId: 122 } });
let error;
// act
try {
await caller.disableTotp({ password: '112321' });
} catch (e) {
error = e as { code: string };
}
// assert
expect(error?.code).not.toBe('UNAUTHORIZED');
});
});

View file

@ -12,7 +12,13 @@ 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()),
// Password
checkPasswordChangeRequest: publicProcedure.query(AuthServiceClass.checkPasswordChangeRequest),
resetPassword: publicProcedure.input(z.object({ newPassword: z.string() })).mutation(({ input }) => AuthService.changePassword({ newPassword: input.newPassword })),
resetPassword: publicProcedure.input(z.object({ newPassword: z.string() })).mutation(({ input }) => AuthService.changeOperatorPassword({ newPassword: input.newPassword })),
cancelPasswordChangeRequest: publicProcedure.mutation(AuthServiceClass.cancelPasswordChangeRequest),
// Totp
verifyTotp: publicProcedure.input(z.object({ totpSessionId: z.string(), totpCode: z.string() })).mutation(({ input }) => AuthService.verifyTotp(input)),
getTotpUri: protectedProcedure.input(z.object({ password: z.string() })).mutation(({ input, ctx }) => AuthService.getTotpUri({ userId: Number(ctx.session.userId), password: input.password })),
setupTotp: protectedProcedure.input(z.object({ totpCode: z.string() })).mutation(({ input, ctx }) => AuthService.setupTotp({ userId: Number(ctx.session.userId), totpCode: input.totpCode })),
disableTotp: protectedProcedure.input(z.object({ password: z.string() })).mutation(({ input, ctx }) => AuthService.disableTotp({ userId: Number(ctx.session.userId), password: input.password })),
});

View file

@ -3,6 +3,9 @@ import fs from 'fs-extra';
import * as argon2 from 'argon2';
import jwt from 'jsonwebtoken';
import { faker } from '@faker-js/faker';
import { v4 } from 'uuid';
import { TotpAuthenticator } from '@/server/utils/totp';
import { encrypt } from '../../utils/encryption';
import { setConfig } from '../../core/TipiConfig';
import { createUser } from '../../tests/user.factory';
import { AuthServiceClass } from './auth.service';
@ -38,7 +41,7 @@ describe('Login', () => {
// Act
const { token } = await AuthService.login({ username: email, password: 'password' });
const decoded = jwt.verify(token, 'test') as jwt.JwtPayload;
const decoded = jwt.verify(token as string, 'test') as jwt.JwtPayload;
// Assert
expect(decoded).toBeDefined();
@ -60,6 +63,278 @@ describe('Login', () => {
await createUser({ email }, db);
await expect(AuthService.login({ username: email, password: 'wrong' })).rejects.toThrowError('Wrong password');
});
// TOTP
it('should return a totp session id the user totp_enabled is true', async () => {
// arrange
const email = faker.internet.email();
const totpSecret = TotpAuthenticator.generateSecret();
await createUser({ email, totp_enabled: true, totp_secret: totpSecret }, db);
// act
const { totpSessionId, token } = await AuthService.login({ username: email, password: 'password' });
// assert
expect(totpSessionId).toBeDefined();
expect(totpSessionId).not.toBeNull();
expect(token).toBeUndefined();
});
});
describe('Test: verifyTotp', () => {
it('should return a valid jsonwebtoken if the totp is correct', async () => {
// arrange
const email = faker.internet.email();
const salt = faker.random.word();
const totpSecret = TotpAuthenticator.generateSecret();
const encryptedTotpSecret = encrypt(totpSecret, salt);
const user = await createUser({ email, totp_enabled: true, totp_secret: encryptedTotpSecret, salt }, db);
const totpSessionId = v4();
const otp = TotpAuthenticator.generate(totpSecret);
await TipiCache.set(totpSessionId, user.id.toString());
// act
const { token } = await AuthService.verifyTotp({ totpSessionId, totpCode: otp });
// assert
expect(token).toBeDefined();
expect(token).not.toBeNull();
});
it('should throw if the totp is incorrect', async () => {
// arrange
const email = faker.internet.email();
const salt = faker.random.word();
const totpSecret = TotpAuthenticator.generateSecret();
const encryptedTotpSecret = encrypt(totpSecret, salt);
const user = await createUser({ email, totp_enabled: true, totp_secret: encryptedTotpSecret, salt }, db);
const totpSessionId = v4();
await TipiCache.set(totpSessionId, user.id.toString());
// act & assert
await expect(AuthService.verifyTotp({ totpSessionId, totpCode: 'wrong' })).rejects.toThrowError('Invalid TOTP');
});
it('should throw if the totpSessionId is invalid', async () => {
// arrange
const email = faker.internet.email();
const salt = faker.random.word();
const totpSecret = TotpAuthenticator.generateSecret();
const encryptedTotpSecret = encrypt(totpSecret, salt);
const user = await createUser({ email, totp_enabled: true, totp_secret: encryptedTotpSecret, salt }, db);
const totpSessionId = v4();
const otp = TotpAuthenticator.generate(totpSecret);
await TipiCache.set(totpSessionId, user.id.toString());
// act & assert
await expect(AuthService.verifyTotp({ totpSessionId: 'wrong', totpCode: otp })).rejects.toThrowError('TOTP session not found');
});
it('should throw if the user does not exist', async () => {
// arrange
const totpSessionId = v4();
await TipiCache.set(totpSessionId, '1234');
// act & assert
await expect(AuthService.verifyTotp({ totpSessionId, totpCode: '1234' })).rejects.toThrowError('User not found');
});
it('should throw if the user totp_enabled is false', async () => {
// arrange
const email = faker.internet.email();
const salt = faker.random.word();
const totpSecret = TotpAuthenticator.generateSecret();
const encryptedTotpSecret = encrypt(totpSecret, salt);
const user = await createUser({ email, totp_enabled: false, totp_secret: encryptedTotpSecret, salt }, db);
const totpSessionId = v4();
const otp = TotpAuthenticator.generate(totpSecret);
await TipiCache.set(totpSessionId, user.id.toString());
// act & assert
await expect(AuthService.verifyTotp({ totpSessionId, totpCode: otp })).rejects.toThrowError('TOTP is not enabled for this user');
});
});
describe('Test: getTotpUri', () => {
it('should return a valid totp uri', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email }, db);
// act
const { uri, key } = await AuthService.getTotpUri({ userId: user.id, password: 'password' });
// assert
expect(uri).toBeDefined();
expect(uri).not.toBeNull();
expect(key).toBeDefined();
expect(key).not.toBeNull();
expect(uri).toContain(key);
});
it('should create a new totp secret if the user does not have one', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email }, db);
// act
await AuthService.getTotpUri({ userId: user.id, password: 'password' });
const userFromDb = await db.user.findUnique({ where: { id: user.id } });
// assert
expect(userFromDb).toBeDefined();
expect(userFromDb).not.toBeNull();
expect(userFromDb).toHaveProperty('totp_secret');
expect(userFromDb).toHaveProperty('salt');
});
it('should regenerate a new totp secret if the user already has one', async () => {
// arrange
const email = faker.internet.email();
const salt = faker.random.word();
const totpSecret = TotpAuthenticator.generateSecret();
const encryptedTotpSecret = encrypt(totpSecret, salt);
const user = await createUser({ email, totp_secret: encryptedTotpSecret, salt }, db);
// act
await AuthService.getTotpUri({ userId: user.id, password: 'password' });
const userFromDb = await db.user.findUnique({ where: { id: user.id } });
// assert
expect(userFromDb).toBeDefined();
expect(userFromDb).not.toBeNull();
expect(userFromDb).toHaveProperty('totp_secret');
expect(userFromDb).toHaveProperty('salt');
expect(userFromDb?.totp_secret).not.toEqual(encryptedTotpSecret);
expect(userFromDb?.salt).toEqual(salt);
});
it('should thorw an error if user has already configured totp', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email, totp_enabled: true }, db);
// act & assert
await expect(AuthService.getTotpUri({ userId: user.id, password: 'password' })).rejects.toThrowError('TOTP is already enabled for this user');
});
it('should throw an error if the user password is incorrect', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email }, db);
// act & assert
await expect(AuthService.getTotpUri({ userId: user.id, password: 'wrong' })).rejects.toThrowError('Invalid password');
});
it('should throw an error if the user does not exist', async () => {
// arrange
const userId = 11;
// act & assert
await expect(AuthService.getTotpUri({ userId, password: 'password' })).rejects.toThrowError('User not found');
});
});
describe('Test: setupTotp', () => {
it('should enable totp for the user', async () => {
// arrange
const email = faker.internet.email();
const totpSecret = TotpAuthenticator.generateSecret();
const salt = faker.random.word();
const encryptedTotpSecret = encrypt(totpSecret, salt);
const user = await createUser({ email, totp_secret: encryptedTotpSecret, salt }, db);
const otp = TotpAuthenticator.generate(totpSecret);
// act
await AuthService.setupTotp({ userId: user.id, totpCode: otp });
const userFromDb = await db.user.findUnique({ where: { id: user.id } });
// assert
expect(userFromDb).toBeDefined();
expect(userFromDb).not.toBeNull();
expect(userFromDb).toHaveProperty('totp_enabled');
expect(userFromDb?.totp_enabled).toBeTruthy();
});
it('should throw if the user has already enabled totp', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email, totp_enabled: true }, db);
// act & assert
await expect(AuthService.setupTotp({ userId: user.id, totpCode: '1234' })).rejects.toThrowError('TOTP is already enabled for this user');
});
it('should throw if the user does not exist', async () => {
// arrange
const userId = 11;
// act & assert
await expect(AuthService.setupTotp({ userId, totpCode: '1234' })).rejects.toThrowError('User not found');
});
it('should throw if the otp is invalid', async () => {
// arrange
const email = faker.internet.email();
const totpSecret = TotpAuthenticator.generateSecret();
const salt = faker.random.word();
const encryptedTotpSecret = encrypt(totpSecret, salt);
const user = await createUser({ email, totp_secret: encryptedTotpSecret, salt }, db);
// act & assert
await expect(AuthService.setupTotp({ userId: user.id, totpCode: '1234' })).rejects.toThrowError('Invalid TOTP code');
});
});
describe('Test: disableTotp', () => {
it('should disable totp for the user', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email, totp_enabled: true }, db);
// act
await AuthService.disableTotp({ userId: user.id, password: 'password' });
const userFromDb = await db.user.findUnique({ where: { id: user.id } });
// assert
expect(userFromDb).toBeDefined();
expect(userFromDb).not.toBeNull();
expect(userFromDb).toHaveProperty('totp_enabled');
expect(userFromDb?.totp_enabled).toBeFalsy();
});
it('should throw if the user has already disabled totp', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email, totp_enabled: false }, db);
// act & assert
await expect(AuthService.disableTotp({ userId: user.id, password: 'password' })).rejects.toThrowError('TOTP is not enabled for this user');
});
it('should throw if the user does not exist', async () => {
// arrange
const userId = 11;
// act & assert
await expect(AuthService.disableTotp({ userId, password: 'password' })).rejects.toThrowError('User not found');
});
it('should throw if the password is invalid', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email, totp_enabled: true }, db);
// act & assert
await expect(AuthService.disableTotp({ userId: user.id, password: 'wrong' })).rejects.toThrowError('Invalid password');
});
});
describe('Register', () => {
@ -264,7 +539,7 @@ describe('Test: isConfigured', () => {
});
});
describe('Test: changePassword', () => {
describe('Test: changeOperatorPassword', () => {
it('should change the password of the operator user', async () => {
// Arrange
const email = faker.internet.email();
@ -274,7 +549,7 @@ describe('Test: changePassword', () => {
fs.__createMockFiles({ '/runtipi/state/password-change-request': '' });
// Act
const result = await AuthService.changePassword({ newPassword });
const result = await AuthService.changeOperatorPassword({ newPassword });
// Assert
expect(result.email).toBe(email.toLowerCase());
@ -291,7 +566,7 @@ describe('Test: changePassword', () => {
fs.__createMockFiles({});
// Act & Assert
await expect(AuthService.changePassword({ newPassword })).rejects.toThrowError('No password change request found');
await expect(AuthService.changeOperatorPassword({ newPassword })).rejects.toThrowError('No password change request found');
});
it('should throw if there is no operator user', async () => {
@ -303,7 +578,26 @@ describe('Test: changePassword', () => {
fs.__createMockFiles({ '/runtipi/state/password-change-request': '' });
// Act & Assert
await expect(AuthService.changePassword({ newPassword })).rejects.toThrowError('Operator user not found');
await expect(AuthService.changeOperatorPassword({ newPassword })).rejects.toThrowError('Operator user not found');
});
it('should reset totp_secret and totp_enabled if totp is enabled', async () => {
// Arrange
const email = faker.internet.email();
const user = await createUser({ email, totp_enabled: true }, db);
const newPassword = faker.internet.password();
// @ts-expect-error - mocking fs
fs.__createMockFiles({ '/runtipi/state/password-change-request': '' });
// Act
const result = await AuthService.changeOperatorPassword({ 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);
expect(updatedUser?.totp_enabled).toBe(false);
expect(updatedUser?.totp_secret).toBeNull();
});
});
@ -314,7 +608,7 @@ describe('Test: checkPasswordChangeRequest', () => {
fs.__createMockFiles({ '/runtipi/state/password-change-request': '' });
// Act
const result = await AuthServiceClass.checkPasswordChangeRequest();
const result = AuthServiceClass.checkPasswordChangeRequest();
// Assert
expect(result).toBe(true);
@ -326,7 +620,7 @@ describe('Test: checkPasswordChangeRequest', () => {
fs.__createMockFiles({});
// Act
const result = await AuthServiceClass.checkPasswordChangeRequest();
const result = AuthServiceClass.checkPasswordChangeRequest();
// Assert
expect(result).toBe(false);

View file

@ -3,9 +3,11 @@ import * as argon2 from 'argon2';
import { v4 } from 'uuid';
import jwt from 'jsonwebtoken';
import validator from 'validator';
import { TotpAuthenticator } from '@/server/utils/totp';
import { getConfig } from '../../core/TipiConfig';
import TipiCache from '../../core/TipiCache';
import { fileExists, unlinkFile } from '../../common/fs.helpers';
import { decrypt, encrypt } from '../../utils/encryption';
type UsernamePasswordInput = {
username: string;
@ -45,6 +47,13 @@ export class AuthServiceClass {
}
const session = v4();
if (user.totp_enabled) {
const totpSessionId = v4();
await TipiCache.set(totpSessionId, user.id.toString());
return { totpSessionId };
}
const token = jwt.sign({ id: user.id, session }, getConfig().jwtSecret, { expiresIn: '7d' });
await TipiCache.set(session, user.id.toString());
@ -52,6 +61,150 @@ export class AuthServiceClass {
return { token };
};
/**
* Verify TOTP code and return a JWT token
*
* @param {object} params - An object containing the TOTP session ID and the TOTP code
* @param {string} params.totpSessionId - The TOTP session ID
* @param {string} params.totpCode - The TOTP code
* @returns {Promise<{token:string}>} - A promise that resolves to an object containing the JWT token
*/
public verifyTotp = async (params: { totpSessionId: string; totpCode: string }) => {
const { totpSessionId, totpCode } = params;
const userId = await TipiCache.get(totpSessionId);
if (!userId) {
throw new Error('TOTP session not found');
}
const user = await this.prisma.user.findUnique({ where: { id: Number(userId) } });
if (!user) {
throw new Error('User not found');
}
if (!user.totp_enabled || !user.totp_secret || !user.salt) {
throw new Error('TOTP is not enabled for this user');
}
const totpSecret = decrypt(user.totp_secret, user.salt);
const isValid = TotpAuthenticator.check(totpCode, totpSecret);
if (!isValid) {
throw new Error('Invalid TOTP code');
}
const session = v4();
const token = jwt.sign({ id: user.id, session }, getConfig().jwtSecret, { expiresIn: '7d' });
await TipiCache.set(session, user.id.toString());
return { token };
};
/**
* Given a userId returns the TOTP URI and the secret key
*
* @param {object} params - An object containing the userId and the user's password
* @param {number} params.userId - The user's ID
* @param {string} params.password - The user's password
* @returns {Promise<{uri: string, key: string}>} - A promise that resolves to an object containing the TOTP URI and the secret key
*/
public getTotpUri = async (params: { userId: number; password: string }) => {
const { userId, password } = params;
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new Error('User not found');
}
const isPasswordValid = await argon2.verify(user.password, password);
if (!isPasswordValid) {
throw new Error('Invalid password');
}
if (user.totp_enabled) {
throw new Error('TOTP is already enabled for this user');
}
let { salt } = user;
const newTotpSecret = TotpAuthenticator.generateSecret();
if (!salt) {
salt = v4();
}
const encryptedTotpSecret = encrypt(newTotpSecret, salt);
await this.prisma.user.update({
where: { id: userId },
data: {
totp_secret: encryptedTotpSecret,
salt,
},
});
const uri = TotpAuthenticator.keyuri(user.username, 'Runtipi', newTotpSecret);
return { uri, key: newTotpSecret };
};
public setupTotp = async (params: { userId: number; totpCode: string }) => {
const { userId, totpCode } = params;
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new Error('User not found');
}
if (user.totp_enabled || !user.totp_secret || !user.salt) {
throw new Error('TOTP is already enabled for this user');
}
const totpSecret = decrypt(user.totp_secret, user.salt);
const isValid = TotpAuthenticator.check(totpCode, totpSecret);
if (!isValid) {
throw new Error('Invalid TOTP code');
}
await this.prisma.user.update({
where: { id: userId },
data: {
totp_enabled: true,
},
});
};
public disableTotp = async (params: { userId: number; password: string }) => {
const { userId, password } = params;
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new Error('User not found');
}
if (!user.totp_enabled) {
throw new Error('TOTP is not enabled for this user');
}
const isPasswordValid = await argon2.verify(user.password, password);
if (!isPasswordValid) {
throw new Error('Invalid password');
}
await this.prisma.user.update({
where: { id: userId },
data: {
totp_enabled: false,
totp_secret: null,
},
});
return true;
};
/**
* Creates a new user with the provided email and password and returns a session token
*
@ -103,7 +256,7 @@ export class AuthServiceClass {
public me = async (userId: number | undefined) => {
if (!userId) return null;
const user = await this.prisma.user.findUnique({ where: { id: Number(userId) }, select: { id: true, username: true } });
const user = await this.prisma.user.findUnique({ where: { id: Number(userId) }, select: { id: true, username: true, totp_enabled: true } });
if (!user) return null;
@ -165,7 +318,7 @@ export class AuthServiceClass {
* @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 }) => {
public changeOperatorPassword = async (params: { newPassword: string }) => {
if (!AuthServiceClass.checkPasswordChangeRequest()) {
throw new Error('No password change request found');
}
@ -178,7 +331,7 @@ export class AuthServiceClass {
}
const hash = await argon2.hash(newPassword);
await this.prisma.user.update({ where: { id: user.id }, data: { password: hash } });
await this.prisma.user.update({ where: { id: user.id }, data: { password: hash, totp_enabled: false, totp_secret: null } });
await unlinkFile(`/runtipi/state/password-change-request`);

View file

@ -1,19 +1,15 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import { faker } from '@faker-js/faker';
import * as argon2 from 'argon2';
import { User } from '@prisma/client';
import { prisma } from '../db/client';
type CreateUserParams = {
email?: string;
operator?: boolean;
};
const createUser = async (params: CreateUserParams, db = prisma) => {
const { email, operator = true } = params;
const createUser = async (params: Partial<User & { email?: string }>, db = prisma) => {
const { email, operator = true, ...rest } = 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, operator } });
const user = await db.user.create({ data: { username, password: hash, operator, ...rest } });
return user;
};