diff --git a/.env.template b/.env.template index 38d306016..193c0fc3f 100644 --- a/.env.template +++ b/.env.template @@ -1,5 +1,6 @@ CRYPTO_SECRET=6KYQBP847D4ATSFA CRYPTO_SECRET2=8Q8VMUE3BJZV87GT +OPAQUE_SECRET=awirun08Dxx3yBpGdd0W2-j4Tl5ip02M5Uu7EVRhtqUzEEdW5EhlP1QC1z3UX8hB7cavoCyem4Kl0iCymdTsbk_tbiJu8-zzrWF3S1nQ2cGY5TkDXIatNKh5riaw7xINwkTOycgxvsIENsPn2W19OgAw2_Zih_1f4Px6ncj7-iw GATEWAY_SECRET=LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCT3dJQkFBSkJBTDlDTVRlZGEramdIcGJuTmtlSm51TlpnYzg5TGFvMGNQNkl6dlJrYTJ0MUVKbnh5ZTA1CndSWGZLMXFpbTFOMGU3cGhkd0RkRWYvNGJ1eFc5V2g1UWxzQ0F3RUFBUUpCQUpnRXljLzF2VDdGWFNyK3JpTWcKWFAxQ09LNTdaeCtCUFVyamZQTytHYSszWk1MRHhqaG44dGZmV1E4VUpKemJ5VkQ0Q0JqTmNra2xRN3phQ29BNwo1WWtDSVFEd0h2MXhVRkFVUkI2b3QwL0JMMWNxek5SNU80dFBMT0NjL2gyK0o4Y09WUUloQU12b0FrMm5IQWhSClpRNmhNZGFTdWtPVTE3MTYvRGxnNWNiSXNWYXh0bDN2QWlBTUdTT2YzL0lJODEyd0ZueFlPWEJrNGFrYTZwc2MKUkNDVkNHQ3JRZ25QZVFJZ2NTU2E2cFc0YzFFZTN5Qkl0RVNVZ0YxOTNKRDZsYWdUdDlxeXRHVkZ5UmNDSVFDYgp6dE85ampXcERmYTlnWTV2dVB4MFgyUkcxbjJQb0ZYVjVXT29RanNqbnc9PQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo= DRIVE_GATEWAY_PUBLIC_SECRET=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUE0bmVlL0Y5OC8wTjJhbFNKQ3JnMAp5bzJRRysydzR5SGk3ZXVDT3JYYUhENzFmN0NrWExWdHlxWTFRRWNFVnFuQm5QUnVpR1EvSklzWkJKVXhoQTlOCmdwdUpIbVY0aytnMEorRGcxeS9wd3k4L0lNM0FhTnNtWWp4c0NZQUZBSWhqUDZNZlBsVTNTYWdyekVRNklFU3MKeHBzT1JhRXd3WUZIWm42TU50b0FGbktMb3VlMVprUVpSSlVwNGZSSlMvcGNrdVNSNjZFcjFLMjhYckpieGE5egpCNG9SbFJMb0ExQ2cvTFN6ZEFQc2lVMzlSOWtlamxBKzFOMkxLVWFrNVJ3OWN5TkM0N3lHS3ZicSt2TTNYaUFmCk43Wk1teTdkY09aeXcyZW9idFFUVzVtTmR1WElWOHhWeWUzMkpyY3BuYWVqbDlUMG5TWGJEWE9OV2d6N0lWcmoKNFFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0t HOST_DRIVE_WEB=http://localhost:3000 diff --git a/.env.test b/.env.test index af51fa44e..7d08c8997 100644 --- a/.env.test +++ b/.env.test @@ -10,6 +10,7 @@ DRIVE_GATEWAY_PUBLIC_SECRET=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2tx CRYPTO_SECRET=6KYQBP847D4ATSFA CRYPTO_SECRET2=8Q8VMUE3BJZV87GT +OPAQUE_SECRET=awirun08Dxx3yBpGdd0W2-j4Tl5ip02M5Uu7EVRhtqUzEEdW5EhlP1QC1z3UX8hB7cavoCyem4Kl0iCymdTsbk_tbiJu8-zzrWF3S1nQ2cGY5TkDXIatNKh5riaw7xINwkTOycgxvsIENsPn2W19OgAw2_Zih_1f4Px6ncj7-iw NOTIFICATIONS_URL=http://localhost:3000 NOTIFICATIONS_API_KEY=secret diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 000000000..601308a05 --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,24 @@ +import type { JestConfigWithTsJest } from 'ts-jest'; + +const config: JestConfigWithTsJest = { + preset: 'ts-jest', + testEnvironment: 'node', + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + }, + ], + }, + roots: ['src', 'test'], + collectCoverageFrom: ['**/*.(t|j)s'], + testRegex: String.raw`.*\.spec\.ts$`, + transformIgnorePatterns: ['node_modules/(?!@serenity-kit/opaque)'], + setupFilesAfterEnv: ['/jest.setup.js'], +}; + +export default config; diff --git a/jest.setup.js b/jest.setup.js index 69357ad19..de508b4de 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -12,3 +12,10 @@ if (typeof globalThis.BigInt === 'function') { const { TextEncoder, TextDecoder } = require('util'); global.TextEncoder = global.TextEncoder || TextEncoder; global.TextDecoder = global.TextDecoder || TextDecoder; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const opaque = require('@serenity-kit/opaque'); + +beforeAll(async () => { + await opaque.ready; +}); diff --git a/migrations/20251216131653-add-registration-record.js b/migrations/20251216131653-add-registration-record.js new file mode 100644 index 000000000..11d2ebbdc --- /dev/null +++ b/migrations/20251216131653-add-registration-record.js @@ -0,0 +1,19 @@ +'use strict'; + +const tableName = 'users'; +const newColumn = 'registration_record'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn(tableName, newColumn, { + type: Sequelize.STRING(300), + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeColumn(tableName, newColumn, { + type: Sequelize.STRING(300), + }); + }, +}; diff --git a/package.json b/package.json index b0f2c9268..8cd41198b 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@nestjs/swagger": "^11.2.3", "@nestjs/throttler": "^6.4.0", "@sendgrid/mail": "^8.1.6", + "@serenity-kit/opaque": "^1.0.0", "@types/passport-jwt": "^3.0.13", "agentkeepalive": "^4.5.0", "axios": "^1.12.0", @@ -131,28 +132,6 @@ "tsconfig-paths": "4.2.0", "typescript": "^5.8.3" }, - "jest": { - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], - "roots": [ - "src", - "test" - ], - "testRegex": ".*\\.spec\\.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - }, - "collectCoverageFrom": [ - "**/*.(t|j)s" - ], - "testEnvironment": "node", - "setupFilesAfterEnv": [ - "/jest.setup.js" - ] - }, "lint-staged": { "./src/**/*.{js,ts}": "yarn run lint" } diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 7baec5911..6af9cceb1 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -36,6 +36,7 @@ export default () => ({ redisConnectionString: process.env.REDIS_CONNECTION_STRING, }, secrets: { + serverSetup: process.env.OPAQUE_SECRET, cryptoSecret: process.env.CRYPTO_SECRET, cryptoSecret2: process.env.CRYPTO_SECRET2, jwt: process.env.JWT_SECRET, diff --git a/src/externals/crypto/crypto.service.ts b/src/externals/crypto/crypto.service.ts index 6bace479a..b4d94e0b3 100644 --- a/src/externals/crypto/crypto.service.ts +++ b/src/externals/crypto/crypto.service.ts @@ -4,6 +4,8 @@ import { AesService } from './aes'; import CryptoJS from 'crypto-js'; import crypto from 'crypto'; import bcrypt from 'bcryptjs'; +import * as opaque from '@serenity-kit/opaque'; +import { v4 as uuidv4 } from 'uuid'; export enum AsymmetricEncryptionAlgorithms { EllipticCurve = 'ed25519', @@ -14,9 +16,11 @@ export class CryptoService { private readonly configService: ConfigService; private readonly aesService: AesService; private readonly cryptoSecret: string; + private readonly serverSetup: string; constructor(configService: ConfigService) { this.configService = configService; + this.serverSetup = this.configService.get('secrets.serverSetup'); this.aesService = new AesService( this.configService.get('secrets.cryptoSecret2'), ); @@ -167,4 +171,33 @@ export class CryptoService { return null; } } + + startLoginOpaque( + email: string, + registrationRecord: string, + startLoginRequest: string, + ): { loginResponse: string; serverLoginState: string } { + const { loginResponse, serverLoginState } = opaque.server.startLogin({ + userIdentifier: email, + registrationRecord, + serverSetup: this.serverSetup, + startLoginRequest, + }); + + return { loginResponse, serverLoginState }; + } + + finishLoginOpaque( + finishLoginRequest: string, + serverLoginState: string, + ): { sessionKey: string } { + return opaque.server.finishLogin({ + finishLoginRequest, + serverLoginState, + }); + } + + generateSessionID(): string { + return uuidv4(); + } } diff --git a/src/modules/auth/auth.contoller.opaque.spec.ts b/src/modules/auth/auth.contoller.opaque.spec.ts new file mode 100644 index 000000000..d00c46196 --- /dev/null +++ b/src/modules/auth/auth.contoller.opaque.spec.ts @@ -0,0 +1,141 @@ +import { AuthController } from './auth.controller'; +import { UserUseCases } from '../user/user.usecase'; +import { CryptoService } from '../../externals/crypto/crypto.service'; + +import { + LoginAccessOpaqueFinishDto, + LoginAccessOpaqueStartDto, +} from './dto/login-access.dto'; +import { Logger } from '@nestjs/common'; +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { v4 } from 'uuid'; + +import { Test } from '@nestjs/testing'; +import * as opaque from '@serenity-kit/opaque'; +import { ConfigService } from '@nestjs/config'; + +describe('AuthController', () => { + let authController: AuthController; + let userUseCases: DeepMocked; + let cryptoService: DeepMocked; + + let serverSetupMock: string; + + beforeAll(async () => { + serverSetupMock = opaque.server.createSetup(); + }); + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [AuthController], + providers: [ + CryptoService, + { + provide: ConfigService, + useValue: { + get: (key: string) => { + if (key === 'secrets.serverSetup') return serverSetupMock; + if (key === 'secrets.cryptoSecret2') return 'a'.repeat(64); // Valid hex key + if (key === 'secrets.cryptoSecret') return 'b'.repeat(64); // Valid hex key + return null; + }, + }, + }, + ], + }) + .setLogger(createMock()) + .useMocker((token) => { + if (token === CryptoService || token === ConfigService) { + return undefined; + } + return createMock(); + }) + .compile(); + + authController = moduleRef.get(AuthController); + userUseCases = moduleRef.get(UserUseCases); + cryptoService = moduleRef.get(CryptoService); + }); + + describe('POST /login-opaque', () => { + it('Should sucessfully finish both phases of the login', async () => { + const email = 'USER_test@gmail.com'; + const password = v4(); + const { startLoginRequest, clientLoginState } = opaque.client.startLogin({ + password, + }); + const { clientRegistrationState, registrationRequest } = + opaque.client.startRegistration({ password }); + const { registrationResponse } = opaque.server.createRegistrationResponse( + { + serverSetup: serverSetupMock, + userIdentifier: email.toLowerCase(), + registrationRequest, + }, + ); + const { registrationRecord: registrationRecordMock } = + opaque.client.finishRegistration({ + clientRegistrationState, + registrationResponse, + password, + }); + const loginOpaqueDto = new LoginAccessOpaqueStartDto(); + loginOpaqueDto.email = email; + loginOpaqueDto.startLoginRequest = startLoginRequest; + + jest.spyOn(userUseCases, 'findByEmail').mockResolvedValue({ + registrationRecord: registrationRecordMock, + } as any); + const startLoginOpaqueSpy = jest.spyOn(cryptoService, 'startLoginOpaque'); + const resultPhaseOne = + await authController.loginOpaqueStart(loginOpaqueDto); + const serverLoginStateValue = userUseCases.setLoginState.mock.calls[0][1]; + + expect(startLoginOpaqueSpy).toHaveBeenCalledTimes(1); + expect(startLoginOpaqueSpy).toHaveBeenCalledWith( + loginOpaqueDto.email.toLowerCase(), + registrationRecordMock, + loginOpaqueDto.startLoginRequest, + ); + expect(resultPhaseOne.loginResponse).toBeDefined(); + expect(resultPhaseOne).toEqual({ loginResponse: expect.any(String) }); + + const loginOpaqueFinishDto = new LoginAccessOpaqueFinishDto(); + loginOpaqueFinishDto.email = email; + + jest + .spyOn(userUseCases, 'getLoginState') + .mockResolvedValue(serverLoginStateValue); + + const { finishLoginRequest, sessionKey } = opaque.client.finishLogin({ + clientLoginState, + loginResponse: resultPhaseOne.loginResponse, + password, + }); + + loginOpaqueFinishDto.finishLoginRequest = finishLoginRequest; + + const finishLoginOpaqueSpy = jest.spyOn( + cryptoService, + 'finishLoginOpaque', + ); + + const resultPhaseTwo = + await authController.loginOpaqueFinish(loginOpaqueFinishDto); + + expect(finishLoginOpaqueSpy).toHaveBeenCalledTimes(1); + expect(finishLoginOpaqueSpy).toHaveBeenCalledWith( + finishLoginRequest, + serverLoginStateValue, + ); + + expect(resultPhaseTwo.user).toBeDefined(); + expect(resultPhaseTwo.token).toBeDefined(); + expect(resultPhaseTwo.sessionID).toBeDefined(); + expect(userUseCases.setSessionKey).toHaveBeenCalledWith( + resultPhaseTwo.sessionID, + sessionKey, + ); + }); + }); +}); diff --git a/src/modules/auth/auth.controller.spec.ts b/src/modules/auth/auth.controller.spec.ts index 34e9966e5..c7e51adc9 100644 --- a/src/modules/auth/auth.controller.spec.ts +++ b/src/modules/auth/auth.controller.spec.ts @@ -80,6 +80,7 @@ describe('AuthController', () => { hasEccKeys: true, sKey: 'encryptedText', tfa: true, + useOpaqueLogin: false, }); }); diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 944d2380e..cbaf2571a 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -30,7 +30,11 @@ import { KeyServerUseCases } from '../keyserver/key-server.usecase'; import { ThrottlerGuard } from '../../guards/throttler.guard'; import { CryptoService } from '../../externals/crypto/crypto.service'; import { LoginDto } from './dto/login-dto'; -import { LoginAccessDto } from './dto/login-access.dto'; +import { + LoginAccessDto, + LoginAccessOpaqueStartDto, + LoginAccessOpaqueFinishDto, +} from './dto/login-access.dto'; import { User as UserDecorator } from './decorators/user.decorator'; import { TwoFactorAuthService } from './two-factor-auth.service'; import { DeleteTfaDto } from './dto/delete-tfa.dto'; @@ -40,7 +44,11 @@ import { WorkspaceLogType } from '../workspaces/attributes/workspace-logs.attrib import { AreCredentialsCorrectDto } from './dto/are-credentials-correct.dto'; import { AuditLog } from '../../common/audit-logs/decorators/audit-log.decorator'; import { AuditAction } from '../../common/audit-logs/audit-logs.attributes'; -import { LoginAccessResponseDto } from './dto/responses/login-access-response.dto'; +import { + LoginAccessResponseDto, + LoginAccessResponseOpaqueFinishDto, + LoginAccessResponseOpaqueStartDto, +} from './dto/responses/login-access-response.dto'; import { LoginResponseDto } from './dto/responses/login-response.dto'; import { JwtToken } from './decorators/get-jwt.decorator'; import { AuthUsecases } from './auth.usecase'; @@ -85,6 +93,9 @@ export class AuthController { user.secret_2FA && user.secret_2FA.length > 0, ); const keys = await this.keyServerUseCases.findUserKeys(user.id); + const isOpaqueEnabled = Boolean( + user.registrationRecord && user.registrationRecord.length > 0, + ); return { hasKeys: !!keys.ecc, @@ -92,6 +103,7 @@ export class AuthController { tfa: required2FA, hasKyberKeys: !!keys.kyber, hasEccKeys: !!keys.ecc, + useOpaqueLogin: isOpaqueEnabled, }; } catch (err) { if (!(err instanceof HttpException)) { @@ -145,6 +157,104 @@ export class AuthController { } } + @Post('/login-opaque/start') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Start opaque to access user account', + }) + @ApiOkResponse({ + description: 'User successfully complited first stage of opaque login', + type: LoginAccessResponseOpaqueStartDto, + }) + @Public() + @WorkspaceLogAction(WorkspaceLogType.LoginOpaqueStart) + async loginOpaqueStart( + @Body() body: LoginAccessOpaqueStartDto, + ): Promise { + Logger.log( + `[AUTH/LOGIN-ACCESS-OPAQUE-START] Attempting first step of opaque login for email: ${body.email}`, + ); + try { + const email = body.email.toLowerCase(); + const user = await this.userUseCases.findByEmail(email); + + if (!user) { + throw new UnauthorizedException('Wrong login credentials'); + } + + const { loginResponse, serverLoginState } = + this.cryptoService.startLoginOpaque( + email, + user.registrationRecord, + body.startLoginRequest, + ); + + await this.userUseCases.setLoginState(email, serverLoginState); + + Logger.log( + `[AUTH/LOGIN-ACCESS-OPAQUE-START] Successful first step of opaque login for email: ${body.email}`, + ); + return { loginResponse }; + } catch (error) { + Logger.error( + `[AUTH/LOGIN-ACCESS-OPAQUE-START] Failed first step of opaque login attempt for email: ${body.email}`, + error.stack, + ); + throw error; + } + } + + @Post('/login-opaque/finish') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Finish opaque to access user account', + }) + @ApiOkResponse({ + description: 'User successfully complited first stage of opaque login', + type: LoginAccessResponseOpaqueFinishDto, + }) + @Public() + @WorkspaceLogAction(WorkspaceLogType.LoginOpaqueFinish) + async loginOpaqueFinish( + @Body() body: LoginAccessOpaqueFinishDto, + ): Promise { + Logger.log( + `[AUTH/LOGIN-ACCESS-OPAQUE-FINISH] Attempting second step of opaque login for email: ${body.email}`, + ); + try { + const email = body.email.toLowerCase(); + const user = await this.userUseCases.findByEmail(email); + + if (!user) { + throw new UnauthorizedException('Wrong login credentials'); + } + + const serverLoginState = await this.userUseCases.getLoginState(email); + + const { sessionKey } = this.cryptoService.finishLoginOpaque( + body.finishLoginRequest, + serverLoginState, + ); + + const sessionID = this.cryptoService.generateSessionID(); + await this.userUseCases.setSessionKey(sessionID, sessionKey); + + const { user: userDTO, token } = + await this.userUseCases.loginAccessOpaque(email, body.tfa); + + Logger.log( + `[AUTH/LOGIN-ACCESS-OPAQUE-FINISH] Successful second step of opaque login for email: ${body.email}`, + ); + return { user: userDTO, sessionID, token }; + } catch (error) { + Logger.error( + `[AUTH/LOGIN-ACCESS-OPAQUE-START] Failed first step of opaque login attempt for email: ${body.email}`, + error.stack, + ); + throw error; + } + } + @Get('/logout') @HttpCode(HttpStatus.OK) @ApiBearerAuth() diff --git a/src/modules/auth/dto/login-access.dto.ts b/src/modules/auth/dto/login-access.dto.ts index fb337ed7f..e7d2b3975 100644 --- a/src/modules/auth/dto/login-access.dto.ts +++ b/src/modules/auth/dto/login-access.dto.ts @@ -77,3 +77,46 @@ export class LoginAccessDto { }) keys?: OptionalKeyGroup; } + +export class LoginAccessOpaqueStartDto { + @ApiProperty({ + example: 'user@internxt.com', + description: 'The email of the user', + }) + @IsNotEmpty() + @IsEmail() + email: string; + + @ApiProperty({ + example: 'startLoginRequest', + description: 'The request to start opaque login', + }) + @IsNotEmpty() + startLoginRequest: string; +} + +export class LoginAccessOpaqueFinishDto { + @ApiProperty({ + example: 'user@internxt.com', + description: 'The email of the user', + }) + @IsNotEmpty() + @IsEmail() + email: string; + + @ApiProperty({ + example: 'finishLoginRequest', + description: 'The request to finish opaque login', + }) + @IsNotEmpty() + finishLoginRequest: string; + + @ApiProperty({ + example: 'two_factor_authentication_code', + description: 'TFA', + required: false, + }) + @IsOptional() + @IsString() + tfa?: string; +} diff --git a/src/modules/auth/dto/responses/login-access-response.dto.ts b/src/modules/auth/dto/responses/login-access-response.dto.ts index 98859ba8b..51b86132c 100644 --- a/src/modules/auth/dto/responses/login-access-response.dto.ts +++ b/src/modules/auth/dto/responses/login-access-response.dto.ts @@ -14,3 +14,19 @@ export class LoginAccessResponseDto { @ApiProperty() newToken: string; } + +export class LoginAccessResponseOpaqueStartDto { + @ApiProperty() + loginResponse: string; +} + +export class LoginAccessResponseOpaqueFinishDto { + @ApiProperty({ type: UserDto }) + user: UserDto; + + @ApiProperty() + token: string; + + @ApiProperty() + sessionID: string; +} diff --git a/src/modules/auth/dto/responses/login-response.dto.ts b/src/modules/auth/dto/responses/login-response.dto.ts index 709b1dc6b..4c2ba3e07 100644 --- a/src/modules/auth/dto/responses/login-response.dto.ts +++ b/src/modules/auth/dto/responses/login-response.dto.ts @@ -15,4 +15,7 @@ export class LoginResponseDto { @ApiProperty() hasEccKeys: boolean; + + @ApiProperty() + useOpaqueLogin: boolean; } diff --git a/src/modules/cache-manager/cache-manager.service.spec.ts b/src/modules/cache-manager/cache-manager.service.spec.ts index c2fc92df3..b457f215d 100644 --- a/src/modules/cache-manager/cache-manager.service.spec.ts +++ b/src/modules/cache-manager/cache-manager.service.spec.ts @@ -64,6 +64,29 @@ describe('CacheManagerService', () => { ); }); + it('Should sucessfully set and get session key and login state', async () => { + const id = v4(); + const value = 'test value'; + + await cacheManagerService.setSessionKey(id, value); + expect(cacheManager.set).toHaveBeenCalledWith( + `session:${id}`, + value, + 10000 * 60, + ); + await cacheManagerService.getSessionKey(id); + expect(cacheManager.get).toHaveBeenCalledWith(`session:${id}`); + + await cacheManagerService.setLoginState(id, value); + expect(cacheManager.set).toHaveBeenCalledWith( + `loginstate:${id}`, + value, + 10000 * 60, + ); + await cacheManagerService.getLoginState(id); + expect(cacheManager.get).toHaveBeenCalledWith(`loginstate:${id}`); + }); + it('When user usage is set, then it should return set value', async () => { const userUuid = v4(); const usage = 1024; diff --git a/src/modules/cache-manager/cache-manager.service.ts b/src/modules/cache-manager/cache-manager.service.ts index d50622ca1..4a65730b3 100644 --- a/src/modules/cache-manager/cache-manager.service.ts +++ b/src/modules/cache-manager/cache-manager.service.ts @@ -8,6 +8,8 @@ export class CacheManagerService { private readonly LIMIT_KEY_PREFIX = 'limit:'; private readonly JWT_KEY_PREFIX = 'jwt:'; private readonly AVATAR_KEY_PREFIX = 'avatar:'; + private readonly SESSION_KEY_PREFIX = 'session:'; + private readonly LOGIN_STATE_PREFIX = 'loginstate:'; private readonly TTL_10_MINUTES = 10000 * 60; private readonly TTL_24_HOURS = 24 * 60 * 60 * 1000; @@ -100,4 +102,30 @@ export class CacheManagerService { async deleteUserAvatar(userUuid: string) { return this.cacheManager.del(`${this.AVATAR_KEY_PREFIX}${userUuid}`); } + + async getSessionKey(sessionID: string): Promise { + return this.cacheManager.get( + `${this.SESSION_KEY_PREFIX}${sessionID}`, + ); + } + + async setSessionKey(sessionID: string, sessionKey: string, ttl?: number) { + return this.cacheManager.set( + `${this.SESSION_KEY_PREFIX}${sessionID}`, + sessionKey, + ttl ?? this.TTL_10_MINUTES, + ); + } + + async getLoginState(email: string): Promise { + return this.cacheManager.get(`${this.LOGIN_STATE_PREFIX}${email}`); + } + + async setLoginState(email: string, serverLoginState: string, ttl?: number) { + return this.cacheManager.set( + `${this.LOGIN_STATE_PREFIX}${email}`, + serverLoginState, + ttl ?? this.TTL_10_MINUTES, + ); + } } diff --git a/src/modules/user/user.attributes.ts b/src/modules/user/user.attributes.ts index 7cf249341..832c73281 100644 --- a/src/modules/user/user.attributes.ts +++ b/src/modules/user/user.attributes.ts @@ -30,4 +30,5 @@ export interface UserAttributes { emailVerified: boolean; updatedAt?: Date; createdAt?: Date; + registrationRecord?: string; } diff --git a/src/modules/user/user.domain.ts b/src/modules/user/user.domain.ts index 680184e4a..8907256b8 100644 --- a/src/modules/user/user.domain.ts +++ b/src/modules/user/user.domain.ts @@ -31,6 +31,7 @@ export class User implements UserAttributes { emailVerified: boolean; updatedAt: Date; createdAt: Date; + registrationRecord?: string; constructor({ id, @@ -63,6 +64,7 @@ export class User implements UserAttributes { emailVerified, updatedAt, createdAt, + registrationRecord, }: UserAttributes) { this.id = id; this.userId = userId; @@ -94,6 +96,7 @@ export class User implements UserAttributes { this.emailVerified = emailVerified; this.updatedAt = updatedAt; this.createdAt = createdAt; + this.registrationRecord = registrationRecord; } static build(user: UserAttributes): User { diff --git a/src/modules/user/user.model.ts b/src/modules/user/user.model.ts index b47862ac6..dd5378bce 100644 --- a/src/modules/user/user.model.ts +++ b/src/modules/user/user.model.ts @@ -61,6 +61,9 @@ export class UserModel extends Model implements UserAttributes { @BelongsTo(() => FolderModel) rootFolder: FolderModel; + @Column + registrationRecord: string; + @Column hKey: Buffer; diff --git a/src/modules/user/user.usecase.spec.ts b/src/modules/user/user.usecase.spec.ts index 40ce1cbd3..839d6d790 100644 --- a/src/modules/user/user.usecase.spec.ts +++ b/src/modules/user/user.usecase.spec.ts @@ -1161,6 +1161,35 @@ describe('User use cases', () => { expect(result).toHaveProperty('newToken', 'newAuthToken'); }); + it('When opaque login is successful, then it should return user and token', async () => { + const email = 'test@example.com'; + + const user = newUser({ + attributes: { + email, + errorLoginCount: 0, + secret_2FA: null, + registrationRecord: 'test value', + }, + }); + const folder = newFolder({ owner: user, attributes: { bucket: v4() } }); + + jest.spyOn(userRepository, 'findByUsername').mockResolvedValue(user); + jest.spyOn(userUseCases, 'getNewToken').mockResolvedValueOnce('token'); + jest.spyOn(userUseCases, 'updateByUuid').mockResolvedValue(undefined); + jest + .spyOn(userUseCases, 'getOrCreateUserRootFolderAndBucket') + .mockResolvedValueOnce(folder); + jest.spyOn(keyServerRepository, 'findUserKeys').mockResolvedValue(null); + + const result = await userUseCases.loginAccessOpaque(email, ''); + + expect(result).toHaveProperty('user'); + expect(result.user).toHaveProperty('email', 'test@example.com'); + expect(result.user).toHaveProperty('bucket', folder.bucket); + expect(result).toHaveProperty('token', 'token'); + }); + it('When the 2FA code is wrong, then it should throw', async () => { const hashedPassword = v4(); const loginAccessDto = { diff --git a/src/modules/user/user.usecase.ts b/src/modules/user/user.usecase.ts index 7a84db634..a18729e7f 100644 --- a/src/modules/user/user.usecase.ts +++ b/src/modules/user/user.usecase.ts @@ -874,28 +874,20 @@ export class UserUseCases { return !hasBeenSubscribed; } - async getAuthTokens( + async getNewToken( user: User, - customIat?: number, tokenExpirationTime: string | number = '3d', platform?: string, - ): Promise<{ token: string; newToken: string }> { + customIat?: number, + ) { const jti = v4(); - const availableWorkspaces = await this.workspaceRepository.findUserAvailableWorkspaces(user.uuid); const owners = [ ...new Set(availableWorkspaces.map(({ workspace }) => workspace.ownerId)), ]; - - const token = SignEmail( - user.email, - this.configService.get('secrets.jwt'), - tokenExpirationTime, - customIat, - ); - const newToken = Sign( + const token = Sign( { jti, sub: user.uuid, @@ -917,6 +909,27 @@ export class UserUseCases { this.configService.get('secrets.jwt'), tokenExpirationTime, ); + return token; + } + + async getAuthTokens( + user: User, + customIat?: number, + tokenExpirationTime: string | number = '3d', + platform?: string, + ): Promise<{ token: string; newToken: string }> { + const token = SignEmail( + user.email, + this.configService.get('secrets.jwt'), + tokenExpirationTime, + customIat, + ); + const newToken = await this.getNewToken( + user, + tokenExpirationTime, + platform, + customIat, + ); return { token, newToken }; } @@ -1543,15 +1556,10 @@ export class UserUseCases { return this.userRepository.getNotificationTokens(user.uuid); } - async loginAccess( - loginAccessDto: Omit< - LoginAccessDto, - 'privateKey' | 'publicKey' | 'revocateKey' | 'revocationKey' - > & { platform?: string }, - ) { + async getUserData(email: string): Promise { const MAX_LOGIN_FAIL_ATTEMPTS = 10; - const userData = await this.findByEmail(loginAccessDto.email.toLowerCase()); + const userData = await this.findByEmail(email.toLowerCase()); if (!userData) { throw new UnauthorizedException('Wrong login credentials'); @@ -1565,20 +1573,15 @@ export class UserUseCases { 'Your account has been blocked for security reasons. Please reach out to us', ); } + return userData; + } - const hashedPass = this.cryptoService.decryptText(loginAccessDto.password); - - if (hashedPass !== userData.password.toString()) { - await this.userRepository.loginFailed(userData, true); - throw new UnauthorizedException('Wrong login credentials'); - } - - const twoFactorEnabled = - userData.secret_2FA && userData.secret_2FA.length > 0; + verify2FAcode(token: string, secret: string) { + const twoFactorEnabled = secret && secret.length > 0; if (twoFactorEnabled) { const tfaResult = speakeasy.totp.verifyDelta({ - secret: userData.secret_2FA, - token: loginAccessDto.tfa, + secret, + token, encoding: 'base32', window: 2, }); @@ -1587,6 +1590,24 @@ export class UserUseCases { throw new UnauthorizedException('Wrong 2-factor auth code'); } } + } + + async loginAccess( + loginAccessDto: Omit< + LoginAccessDto, + 'privateKey' | 'publicKey' | 'revocateKey' | 'revocationKey' + > & { platform?: string }, + ) { + const userData = await this.getUserData(loginAccessDto.email); + const hashedPass = this.cryptoService.decryptText(loginAccessDto.password); + + if (hashedPass !== userData.password.toString()) { + await this.userRepository.loginFailed(userData, true); + throw new UnauthorizedException('Wrong login credentials'); + } + + this.verify2FAcode(loginAccessDto.tfa, userData.secret_2FA); + const { token, newToken } = await this.getAuthTokens( userData, undefined, @@ -1668,6 +1689,54 @@ export class UserUseCases { return { user, token, userTeam: null, newToken }; } + async loginAccessOpaque(email: string, tfa: string) { + const userData = await this.getUserData(email); + this.verify2FAcode(tfa, userData.secret_2FA); + + const token = await this.getNewToken(userData); + await this.userRepository.loginFailed(userData, false); + + this.updateByUuid(userData.uuid, { updatedAt: new Date() }); + + const rootFolder = await this.getOrCreateUserRootFolderAndBucket(userData); + + const userBucket = rootFolder?.bucket; + + const keys = await this.keyServerUseCases.findUserKeys(userData.id); + + const user = { + email: userData.email, + userId: userData.userId, + mnemonic: userData.mnemonic.toString(), + root_folder_id: rootFolder?.id, + rootFolderId: rootFolder?.uuid, + name: userData.name, + lastname: userData.lastname, + uuid: userData.uuid, + credit: userData.credit, + createdAt: userData.createdAt, + tierId: userData.tierId, + privateKey: null, + publicKey: null, + revocateKey: null, + keys: keys, + bucket: userBucket, + registerCompleted: userData.registerCompleted, + teams: false, + username: userData.username, + bridgeUser: userData.bridgeUser, + sharedWorkspace: userData.sharedWorkspace, + appSumoDetails: null, + hasReferralsProgram: false, + backupsBucket: userData.backupsBucket, + avatar: userData.avatar ? await this.getAvatarUrl(userData.avatar) : null, + emailVerified: userData.emailVerified, + lastPasswordChangedAt: userData.lastPasswordChangedAt, + }; + + return { user, token }; + } + async getOrCreateUserRootFolderAndBucket(user: User) { const rootFolder = await this.folderUseCases.getFolder(user.rootFolderId); @@ -2109,4 +2178,20 @@ export class UserUseCases { newToken: newToken, }; } + + async getSessionKey(sessionID: string): Promise { + return this.cacheManager.getSessionKey(sessionID); + } + + async setSessionKey(sessionID: string, sessionKey: string): Promise { + await this.cacheManager.setSessionKey(sessionID, sessionKey); + } + + async setLoginState(email: string, serverLoginState: string): Promise { + this.cacheManager.setLoginState(email, serverLoginState); + } + + async getLoginState(email: string): Promise { + return this.cacheManager.getLoginState(email); + } } diff --git a/src/modules/workspaces/attributes/workspace-logs.attributes.ts b/src/modules/workspaces/attributes/workspace-logs.attributes.ts index 8a7cca862..51e6a7b38 100644 --- a/src/modules/workspaces/attributes/workspace-logs.attributes.ts +++ b/src/modules/workspaces/attributes/workspace-logs.attributes.ts @@ -1,5 +1,7 @@ export enum WorkspaceLogType { Login = 'login', + LoginOpaqueStart = 'login-opaque-start', + LoginOpaqueFinish = 'login-opaque-finish', ChangedPassword = 'changed-password', Logout = 'logout', ShareFile = 'share-file', diff --git a/src/modules/workspaces/interceptors/workspaces-logs.interceptor.ts b/src/modules/workspaces/interceptors/workspaces-logs.interceptor.ts index 4208587da..da3670a7e 100644 --- a/src/modules/workspaces/interceptors/workspaces-logs.interceptor.ts +++ b/src/modules/workspaces/interceptors/workspaces-logs.interceptor.ts @@ -42,6 +42,8 @@ export class WorkspacesLogsInterceptor implements NestInterceptor { ) { this.actionHandler = { [WorkspaceLogType.Login]: this.logIn.bind(this), + [WorkspaceLogType.LoginOpaqueStart]: this.loginOpaqueStart.bind(this), + [WorkspaceLogType.LoginOpaqueFinish]: this.loginOpaqueFinish.bind(this), [WorkspaceLogType.ChangedPassword]: this.changedPassword.bind(this), [WorkspaceLogType.Logout]: this.logout.bind(this), [WorkspaceLogType.DeleteFile]: this.deleteFile.bind(this), @@ -103,6 +105,24 @@ export class WorkspacesLogsInterceptor implements NestInterceptor { await this.handleUserAction(platform, WorkspaceLogType.Login, req, res); } + async loginOpaqueStart(platform: WorkspaceLogPlatform, req: any, res: any) { + await this.handleUserAction( + platform, + WorkspaceLogType.LoginOpaqueStart, + req, + res, + ); + } + + async loginOpaqueFinish(platform: WorkspaceLogPlatform, req: any, res: any) { + await this.handleUserAction( + platform, + WorkspaceLogType.LoginOpaqueFinish, + req, + res, + ); + } + async changedPassword(platform: WorkspaceLogPlatform, req: any, res: any) { await this.handleUserAction( platform, diff --git a/yarn.lock b/yarn.lock index 197d95a4f..864caa1a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2126,6 +2126,11 @@ "@sendgrid/client" "^8.1.5" "@sendgrid/helpers" "^8.0.0" +"@serenity-kit/opaque@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@serenity-kit/opaque/-/opaque-1.0.0.tgz#ef265667cd13977a53f22732f0d204452b5fe59c" + integrity sha512-UCu92RBFroWaUOdrIJzjXW8M/wqGGi7/2J8D5BPbCNAYU27iyzr5JDZ0N6wq31IhNhEIr7C/8V3+9aSgXi4F3A== + "@sinclair/typebox@^0.27.8": version "0.27.8" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e"