feat: create backend service and router for totp functions
disable totp
This commit is contained in:
parent
96bb7e9a3d
commit
1cc8d3f868
5 changed files with 612 additions and 19 deletions
144
src/server/routers/auth/auth.router.test.ts
Normal file
144
src/server/routers/auth/auth.router.test.ts
Normal 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');
|
||||
});
|
||||
});
|
|
@ -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 })),
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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`);
|
||||
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
Loading…
Add table
Reference in a new issue