Compare commits

..

4 Commits

Author SHA1 Message Date
standardci
63401b7640 chore(release): publish new version
- @standardnotes/auth-server@1.87.0
2023-01-24 11:14:52 +00:00
Karol Sójko
6a5b669ec4 feat(auth): add U2F to MFA verification 2023-01-24 12:12:51 +01:00
standardci
ca201447d2 chore(release): publish new version
- @standardnotes/auth-server@1.86.4
2023-01-24 10:19:45 +00:00
Karol Sójko
f1d3117518 fix(auth): add cleanup of authenticator devices upon sign in with recovery codes 2023-01-24 11:17:51 +01:00
9 changed files with 420 additions and 173 deletions

View File

@@ -3,6 +3,18 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.87.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.86.4...@standardnotes/auth-server@1.87.0) (2023-01-24)
### Features
* **auth:** add U2F to MFA verification ([6a5b669](https://github.com/standardnotes/server/commit/6a5b669ec47d3fd71fec3e362d66480d91c544d0))
## [1.86.4](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.86.3...@standardnotes/auth-server@1.86.4) (2023-01-24)
### Bug Fixes
* **auth:** add cleanup of authenticator devices upon sign in with recovery codes ([f1d3117](https://github.com/standardnotes/server/commit/f1d311751832a2abdbe124cede7f020b28cbcd9d))
## [1.86.3](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.86.2...@standardnotes/auth-server@1.86.3) (2023-01-24)
### Reverts

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/auth-server",
"version": "1.86.3",
"version": "1.87.0",
"engines": {
"node": ">=18.0.0 <19.0.0"
},

View File

@@ -665,6 +665,7 @@ export class ContainerConfigLoader {
container.get(TYPES.IncreaseLoginAttempts),
container.get(TYPES.ClearLoginAttempts),
container.get(TYPES.DeleteSetting),
container.get(TYPES.AuthenticatorRepository),
),
)
container.bind<DeleteAccount>(TYPES.DeleteAccount).to(DeleteAccount)

View File

@@ -8,4 +8,5 @@ export interface AuthenticatorRepositoryInterface {
findByUserUuidAndCredentialId(userUuid: Uuid, credentialId: Buffer): Promise<Authenticator | null>
save(authenticator: Authenticator): Promise<void>
remove(authenticator: Authenticator): Promise<void>
removeByUserUuid(userUuid: Uuid): Promise<void>
}

View File

@@ -2,6 +2,7 @@ import { Result } from '@standardnotes/domain-core'
import { AuthResponse20200115 } from '../../Auth/AuthResponse20200115'
import { AuthResponseFactory20200115 } from '../../Auth/AuthResponseFactory20200115'
import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface'
import { CrypterInterface } from '../../Encryption/CrypterInterface'
import { Setting } from '../../Setting/Setting'
import { SettingServiceInterface } from '../../Setting/SettingServiceInterface'
@@ -24,6 +25,7 @@ describe('SignInWithRecoveryCodes', () => {
let increaseLoginAttempts: IncreaseLoginAttempts
let clearLoginAttempts: ClearLoginAttempts
let deleteSetting: DeleteSetting
let authenticatorRepository: AuthenticatorRepositoryInterface
const createUseCase = () =>
new SignInWithRecoveryCodes(
@@ -36,12 +38,13 @@ describe('SignInWithRecoveryCodes', () => {
increaseLoginAttempts,
clearLoginAttempts,
deleteSetting,
authenticatorRepository,
)
beforeEach(() => {
userRepository = {} as jest.Mocked<UserRepositoryInterface>
userRepository.findOneByEmail = jest.fn().mockReturnValue({
uuid: '1-2-3',
uuid: '00000000-0000-0000-0000-000000000000',
encryptedPassword: '$2a$11$K3g6XoTau8VmLJcai1bB0eD9/YvBSBRtBhMprJOaVZ0U3SgasZH3a',
} as jest.Mocked<User>)
@@ -69,6 +72,9 @@ describe('SignInWithRecoveryCodes', () => {
deleteSetting = {} as jest.Mocked<DeleteSetting>
deleteSetting.execute = jest.fn()
authenticatorRepository = {} as jest.Mocked<AuthenticatorRepositoryInterface>
authenticatorRepository.removeByUserUuid = jest.fn()
})
it('should return error if password is not provided', async () => {
@@ -209,6 +215,24 @@ describe('SignInWithRecoveryCodes', () => {
expect(result.getError()).toBe('Could not sign in with recovery codes: Oops')
})
it('should return error if user has an invalid uuid', async () => {
userRepository.findOneByEmail = jest.fn().mockReturnValue({
uuid: '1-2-3',
encryptedPassword: '$2a$11$K3g6XoTau8VmLJcai1bB0eD9/YvBSBRtBhMprJOaVZ0U3SgasZH3a',
} as jest.Mocked<User>)
const result = await createUseCase().execute({
userAgent: 'user-agent',
username: 'test@test.te',
password: 'qweqwe123123',
codeVerifier: 'code-verifier',
recoveryCodes: 'foo',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Invalid user uuid')
})
it('should return auth response', async () => {
const result = await createUseCase().execute({
userAgent: 'user-agent',
@@ -220,6 +244,7 @@ describe('SignInWithRecoveryCodes', () => {
expect(clearLoginAttempts.execute).toHaveBeenCalled()
expect(deleteSetting.execute).toHaveBeenCalled()
expect(authenticatorRepository.removeByUserUuid).toHaveBeenCalled()
expect(result.isFailed()).toBe(false)
})
})

View File

@@ -1,5 +1,5 @@
import * as bcrypt from 'bcryptjs'
import { Result, UseCaseInterface, Username, Validator } from '@standardnotes/domain-core'
import { Result, UseCaseInterface, Username, Uuid, Validator } from '@standardnotes/domain-core'
import { SettingName } from '@standardnotes/settings'
import { ApiVersion } from '@standardnotes/api'
@@ -15,6 +15,7 @@ import { AuthResponseFactory20200115 } from '../../Auth/AuthResponseFactory20200
import { IncreaseLoginAttempts } from '../IncreaseLoginAttempts'
import { ClearLoginAttempts } from '../ClearLoginAttempts'
import { DeleteSetting } from '../DeleteSetting/DeleteSetting'
import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface'
export class SignInWithRecoveryCodes implements UseCaseInterface<AuthResponse20200115> {
constructor(
@@ -27,6 +28,7 @@ export class SignInWithRecoveryCodes implements UseCaseInterface<AuthResponse202
private increaseLoginAttempts: IncreaseLoginAttempts,
private clearLoginAttempts: ClearLoginAttempts,
private deleteSetting: DeleteSetting,
private authenticatorRepository: AuthenticatorRepositoryInterface,
) {}
async execute(dto: SignInWithRecoveryCodesDTO): Promise<Result<AuthResponse20200115>> {
@@ -65,6 +67,14 @@ export class SignInWithRecoveryCodes implements UseCaseInterface<AuthResponse202
return Result.fail('Could not find user')
}
const userUuidOrError = Uuid.create(user.uuid)
if (userUuidOrError.isFailed()) {
await this.increaseLoginAttempts.execute({ email: username.value })
return Result.fail('Invalid user uuid')
}
const userUuid = userUuidOrError.getValue()
const passwordMatches = await bcrypt.compare(dto.password, user.encryptedPassword)
if (!passwordMatches) {
await this.increaseLoginAttempts.execute({ email: username.value })
@@ -110,6 +120,8 @@ export class SignInWithRecoveryCodes implements UseCaseInterface<AuthResponse202
userUuid: user.uuid,
})
await this.authenticatorRepository.removeByUserUuid(userUuid)
await this.clearLoginAttempts.execute({ email: username.value })
return Result.ok(authResponse as AuthResponse20200115)

View File

@@ -1,14 +1,19 @@
import 'reflect-metadata'
import { authenticator } from 'otplib'
import { SettingName } from '@standardnotes/settings'
import { SelectorInterface } from '@standardnotes/security'
import { Result, UseCaseInterface } from '@standardnotes/domain-core'
import { User } from '../User/User'
import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
import { VerifyMFA } from './VerifyMFA'
import { Setting } from '../Setting/Setting'
import { SettingServiceInterface } from '../Setting/SettingServiceInterface'
import { SettingName } from '@standardnotes/settings'
import { SelectorInterface } from '@standardnotes/security'
import { LockRepositoryInterface } from '../User/LockRepositoryInterface'
import { AuthenticatorRepositoryInterface } from '../Authenticator/AuthenticatorRepositoryInterface'
import { VerifyMFA } from './VerifyMFA'
import { Logger } from 'winston'
import { Authenticator } from '../Authenticator/Authenticator'
describe('VerifyMFA', () => {
let user: User
@@ -17,13 +22,27 @@ describe('VerifyMFA', () => {
let settingService: SettingServiceInterface
let booleanSelector: SelectorInterface<boolean>
let lockRepository: LockRepositoryInterface
let authenticatorRepository: AuthenticatorRepositoryInterface
let verifyAuthenticatorAuthenticationResponse: UseCaseInterface<boolean>
let logger: Logger
const pseudoKeyParamsKey = 'foobar'
const createVerifyMFA = () =>
new VerifyMFA(userRepository, settingService, booleanSelector, lockRepository, pseudoKeyParamsKey)
new VerifyMFA(
userRepository,
settingService,
booleanSelector,
lockRepository,
pseudoKeyParamsKey,
authenticatorRepository,
verifyAuthenticatorAuthenticationResponse,
logger,
)
beforeEach(() => {
user = {} as jest.Mocked<User>
user = {
uuid: '00000000-0000-0000-0000-000000000000',
} as jest.Mocked<User>
userRepository = {} as jest.Mocked<UserRepositoryInterface>
userRepository.findOneByEmail = jest.fn().mockReturnValue(user)
@@ -42,164 +61,270 @@ describe('VerifyMFA', () => {
settingService = {} as jest.Mocked<SettingServiceInterface>
settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(setting)
authenticatorRepository = {} as jest.Mocked<AuthenticatorRepositoryInterface>
authenticatorRepository.findByUserUuid = jest.fn().mockReturnValue([])
verifyAuthenticatorAuthenticationResponse = {} as jest.Mocked<UseCaseInterface<boolean>>
verifyAuthenticatorAuthenticationResponse.execute = jest.fn().mockReturnValue(Result.ok())
logger = {} as jest.Mocked<Logger>
logger.debug = jest.fn()
})
it('should pass MFA verification if user has no MFA enabled', async () => {
settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(null)
describe('2FA', () => {
it('should pass MFA verification if user has no MFA enabled', async () => {
settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(null)
expect(
await createVerifyMFA().execute({ email: 'test@test.te', requestParams: {}, preventOTPFromFurtherUsage: true }),
).toEqual({
success: true,
})
expect(lockRepository.lockSuccessfullOTP).not.toHaveBeenCalled()
})
it('should pass MFA verification if user has MFA deleted', async () => {
setting = {
name: SettingName.MfaSecret,
value: null,
} as jest.Mocked<Setting>
settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(setting)
expect(
await createVerifyMFA().execute({ email: 'test@test.te', requestParams: {}, preventOTPFromFurtherUsage: true }),
).toEqual({
success: true,
})
expect(lockRepository.lockSuccessfullOTP).not.toHaveBeenCalled()
})
it('should pass MFA verification if user is not found and pseudo mfa is not required', async () => {
userRepository.findOneByEmail = jest.fn().mockReturnValue(null)
expect(
await createVerifyMFA().execute({ email: 'test@test.te', requestParams: {}, preventOTPFromFurtherUsage: true }),
).toEqual({
success: true,
})
expect(lockRepository.lockSuccessfullOTP).not.toHaveBeenCalled()
})
it('should not pass MFA verification if user is not found and pseudo mfa is required', async () => {
booleanSelector.select = jest.fn().mockReturnValue(true)
userRepository.findOneByEmail = jest.fn().mockReturnValue(null)
expect(
await createVerifyMFA().execute({ email: 'test@test.te', requestParams: {}, preventOTPFromFurtherUsage: true }),
).toEqual({
success: false,
errorTag: 'mfa-required',
errorMessage: 'Please enter your two-factor authentication code.',
errorPayload: { mfa_key: expect.stringMatching(/^mfa_/) },
})
})
it('should pass MFA verification if mfa key is correctly encrypted', async () => {
expect(
await createVerifyMFA().execute({
email: 'test@test.te',
requestParams: { 'mfa_1-2-3': authenticator.generate('shhhh') },
preventOTPFromFurtherUsage: true,
}),
).toEqual({
success: true,
})
expect(lockRepository.lockSuccessfullOTP).toHaveBeenCalledWith('test@test.te', expect.any(String))
})
it('should pass MFA verification without locking otp', async () => {
expect(
await createVerifyMFA().execute({
email: 'test@test.te',
requestParams: { 'mfa_1-2-3': authenticator.generate('shhhh') },
preventOTPFromFurtherUsage: false,
}),
).toEqual({
success: true,
})
expect(lockRepository.lockSuccessfullOTP).not.toHaveBeenCalled()
})
it('should not pass MFA verification if otp is already used within lock out period', async () => {
lockRepository.isOTPLocked = jest.fn().mockReturnValue(true)
expect(
await createVerifyMFA().execute({
email: 'test@test.te',
requestParams: { 'mfa_1-2-3': authenticator.generate('shhhh') },
preventOTPFromFurtherUsage: true,
}),
).toEqual({
success: false,
errorTag: 'mfa-invalid',
errorMessage:
'The two-factor authentication code you entered has been already utilized. Please try again in a while.',
errorPayload: { mfa_key: 'mfa_1-2-3' },
})
expect(lockRepository.lockSuccessfullOTP).not.toHaveBeenCalled()
})
it('should not pass MFA verification if mfa is not correct', async () => {
setting = {
name: SettingName.MfaSecret,
value: 'shhhh2',
} as jest.Mocked<Setting>
settingService = {} as jest.Mocked<SettingServiceInterface>
settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(setting)
expect(
await createVerifyMFA().execute({
email: 'test@test.te',
requestParams: { 'mfa_1-2-3': 'test' },
preventOTPFromFurtherUsage: true,
}),
).toEqual({
success: false,
errorTag: 'mfa-invalid',
errorMessage: 'The two-factor authentication code you entered is incorrect. Please try again.',
errorPayload: { mfa_key: 'mfa_1-2-3' },
})
})
it('should not pass MFA verification if no mfa param is found in the request', async () => {
expect(
await createVerifyMFA().execute({
email: 'test@test.te',
requestParams: { foo: 'bar' },
preventOTPFromFurtherUsage: true,
}),
).toEqual({
success: false,
errorTag: 'mfa-required',
errorMessage: 'Please enter your two-factor authentication code.',
errorPayload: { mfa_key: expect.stringMatching(/^mfa_/) },
})
})
it('should throw an error if the error is not handled mfa validation error', async () => {
settingService.findSettingWithDecryptedValue = jest.fn().mockImplementation(() => {
throw new Error('oops!')
})
let error = null
try {
await createVerifyMFA().execute({
email: 'test@test.te',
requestParams: { 'mfa_1-2-3': 'test' },
preventOTPFromFurtherUsage: true,
expect(
await createVerifyMFA().execute({ email: 'test@test.te', requestParams: {}, preventOTPFromFurtherUsage: true }),
).toEqual({
success: true,
})
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
expect(lockRepository.lockSuccessfullOTP).not.toHaveBeenCalled()
})
it('should pass MFA verification if user has MFA deleted', async () => {
setting = {
name: SettingName.MfaSecret,
value: null,
} as jest.Mocked<Setting>
settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(setting)
expect(
await createVerifyMFA().execute({ email: 'test@test.te', requestParams: {}, preventOTPFromFurtherUsage: true }),
).toEqual({
success: true,
})
expect(lockRepository.lockSuccessfullOTP).not.toHaveBeenCalled()
})
it('should pass MFA verification if user is not found and pseudo mfa is not required', async () => {
userRepository.findOneByEmail = jest.fn().mockReturnValue(null)
expect(
await createVerifyMFA().execute({ email: 'test@test.te', requestParams: {}, preventOTPFromFurtherUsage: true }),
).toEqual({
success: true,
})
expect(lockRepository.lockSuccessfullOTP).not.toHaveBeenCalled()
})
it('should not pass MFA verification if user is not found and pseudo mfa is required', async () => {
booleanSelector.select = jest.fn().mockReturnValue(true)
userRepository.findOneByEmail = jest.fn().mockReturnValue(null)
expect(
await createVerifyMFA().execute({ email: 'test@test.te', requestParams: {}, preventOTPFromFurtherUsage: true }),
).toEqual({
success: false,
errorTag: 'mfa-required',
errorMessage: 'Please enter your two-factor authentication code.',
errorPayload: { mfa_key: expect.stringMatching(/^mfa_/) },
})
})
it('should pass MFA verification if mfa key is correctly encrypted', async () => {
expect(
await createVerifyMFA().execute({
email: 'test@test.te',
requestParams: { 'mfa_1-2-3': authenticator.generate('shhhh') },
preventOTPFromFurtherUsage: true,
}),
).toEqual({
success: true,
})
expect(lockRepository.lockSuccessfullOTP).toHaveBeenCalledWith('test@test.te', expect.any(String))
})
it('should pass MFA verification without locking otp', async () => {
expect(
await createVerifyMFA().execute({
email: 'test@test.te',
requestParams: { 'mfa_1-2-3': authenticator.generate('shhhh') },
preventOTPFromFurtherUsage: false,
}),
).toEqual({
success: true,
})
expect(lockRepository.lockSuccessfullOTP).not.toHaveBeenCalled()
})
it('should not pass MFA verification if otp is already used within lock out period', async () => {
lockRepository.isOTPLocked = jest.fn().mockReturnValue(true)
expect(
await createVerifyMFA().execute({
email: 'test@test.te',
requestParams: { 'mfa_1-2-3': authenticator.generate('shhhh') },
preventOTPFromFurtherUsage: true,
}),
).toEqual({
success: false,
errorTag: 'mfa-invalid',
errorMessage:
'The two-factor authentication code you entered has been already utilized. Please try again in a while.',
errorPayload: { mfa_key: 'mfa_1-2-3' },
})
expect(lockRepository.lockSuccessfullOTP).not.toHaveBeenCalled()
})
it('should not pass MFA verification if mfa is not correct', async () => {
setting = {
name: SettingName.MfaSecret,
value: 'shhhh2',
} as jest.Mocked<Setting>
settingService = {} as jest.Mocked<SettingServiceInterface>
settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(setting)
expect(
await createVerifyMFA().execute({
email: 'test@test.te',
requestParams: { 'mfa_1-2-3': 'test' },
preventOTPFromFurtherUsage: true,
}),
).toEqual({
success: false,
errorTag: 'mfa-invalid',
errorMessage: 'The two-factor authentication code you entered is incorrect. Please try again.',
errorPayload: { mfa_key: 'mfa_1-2-3' },
})
})
it('should not pass MFA verification if no mfa param is found in the request', async () => {
expect(
await createVerifyMFA().execute({
email: 'test@test.te',
requestParams: { foo: 'bar' },
preventOTPFromFurtherUsage: true,
}),
).toEqual({
success: false,
errorTag: 'mfa-required',
errorMessage: 'Please enter your two-factor authentication code.',
errorPayload: { mfa_key: expect.stringMatching(/^mfa_/) },
})
})
it('should throw an error if the error is not handled mfa validation error', async () => {
settingService.findSettingWithDecryptedValue = jest.fn().mockImplementation(() => {
throw new Error('oops!')
})
let error = null
try {
await createVerifyMFA().execute({
email: 'test@test.te',
requestParams: { 'mfa_1-2-3': 'test' },
preventOTPFromFurtherUsage: true,
})
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
})
describe('U2F', () => {
beforeEach(() => {
settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(null)
authenticatorRepository.findByUserUuid = jest.fn().mockReturnValue([{} as jest.Mocked<Authenticator>])
})
it('should not pass if the user has an invalid uuid', async () => {
userRepository.findOneByEmail = jest.fn().mockReturnValue({ uuid: 'invalid' } as jest.Mocked<User>)
expect(
await createVerifyMFA().execute({
email: 'test@test.te',
requestParams: {},
preventOTPFromFurtherUsage: true,
}),
).toEqual({
success: false,
errorMessage: 'User UUID is invalid.',
})
})
it('should not pass if the request is missing authenticator response', async () => {
expect(
await createVerifyMFA().execute({
email: 'test@test.te',
requestParams: {},
preventOTPFromFurtherUsage: true,
}),
).toEqual({
success: false,
errorTag: 'mfa-required',
errorMessage: 'Please authenticate with your U2F device.',
})
})
it('should not pass if the authenticator response verification fails', async () => {
verifyAuthenticatorAuthenticationResponse.execute = jest.fn().mockReturnValue(Result.fail('oops!'))
expect(
await createVerifyMFA().execute({
email: 'test@test.te',
requestParams: {
authenticator_response: {
id: Buffer.from([1]),
},
},
preventOTPFromFurtherUsage: true,
}),
).toEqual({
success: false,
errorTag: 'mfa-invalid',
errorMessage: 'Could not verify U2F device.',
})
})
it('should not pass if the authenticator is not verified', async () => {
verifyAuthenticatorAuthenticationResponse.execute = jest.fn().mockReturnValue(Result.ok(false))
expect(
await createVerifyMFA().execute({
email: 'test@test.te',
requestParams: {
authenticator_response: {
id: Buffer.from([1]),
},
},
preventOTPFromFurtherUsage: true,
}),
).toEqual({
success: false,
errorTag: 'mfa-invalid',
errorMessage: 'Could not verify U2F device.',
})
})
it('should pass if the authenticator is verified', async () => {
verifyAuthenticatorAuthenticationResponse.execute = jest.fn().mockReturnValue(Result.ok(true))
expect(
await createVerifyMFA().execute({
email: 'test@test.te',
requestParams: {
authenticator_response: {
id: Buffer.from([1]),
},
},
preventOTPFromFurtherUsage: true,
}),
).toEqual({
success: true,
})
})
})
})

View File

@@ -4,16 +4,21 @@ import { SettingName } from '@standardnotes/settings'
import { v4 as uuidv4 } from 'uuid'
import { inject, injectable } from 'inversify'
import { authenticator } from 'otplib'
import { SelectorInterface } from '@standardnotes/security'
import { UseCaseInterface as DomainUseCaseInterface, Uuid } from '@standardnotes/domain-core'
import TYPES from '../../Bootstrap/Types'
import { MFAValidationError } from '../Error/MFAValidationError'
import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
import { SettingServiceInterface } from '../Setting/SettingServiceInterface'
import { LockRepositoryInterface } from '../User/LockRepositoryInterface'
import { AuthenticatorRepositoryInterface } from '../Authenticator/AuthenticatorRepositoryInterface'
import { UseCaseInterface } from './UseCaseInterface'
import { VerifyMFADTO } from './VerifyMFADTO'
import { VerifyMFAResponse } from './VerifyMFAResponse'
import { SettingServiceInterface } from '../Setting/SettingServiceInterface'
import { SelectorInterface } from '@standardnotes/security'
import { LockRepositoryInterface } from '../User/LockRepositoryInterface'
import { Logger } from 'winston'
import { Setting } from '../Setting/Setting'
@injectable()
export class VerifyMFA implements UseCaseInterface {
@@ -23,6 +28,10 @@ export class VerifyMFA implements UseCaseInterface {
@inject(TYPES.BooleanSelector) private booleanSelector: SelectorInterface<boolean>,
@inject(TYPES.LockRepository) private lockRepository: LockRepositoryInterface,
@inject(TYPES.PSEUDO_KEY_PARAMS_KEY) private pseudoKeyParamsKey: string,
@inject(TYPES.AuthenticatorRepository) private authenticatorRepository: AuthenticatorRepositoryInterface,
@inject(TYPES.VerifyAuthenticatorAuthenticationResponse)
private verifyAuthenticatorAuthenticationResponse: DomainUseCaseInterface<boolean>,
@inject(TYPES.Logger) private logger: Logger,
) {}
async execute(dto: VerifyMFADTO): Promise<VerifyMFAResponse> {
@@ -48,24 +57,78 @@ export class VerifyMFA implements UseCaseInterface {
}
}
const userUuidOrError = Uuid.create(user.uuid)
if (userUuidOrError.isFailed()) {
return {
success: false,
errorMessage: 'User UUID is invalid.',
}
}
const userUuid = userUuidOrError.getValue()
let u2fEnabled = false
const u2fAuthenticators = await this.authenticatorRepository.findByUserUuid(userUuid)
if (u2fAuthenticators.length > 0) {
u2fEnabled = true
}
const mfaSecret = await this.settingService.findSettingWithDecryptedValue({
userUuid: user.uuid,
settingName: SettingName.MfaSecret,
})
if (mfaSecret === null || mfaSecret.value === null) {
const twoFactorEnabled = mfaSecret !== null && mfaSecret.value !== null
if (u2fEnabled === false && twoFactorEnabled === false) {
return {
success: true,
}
}
const verificationResult = await this.verifyMFASecret(
dto.email,
mfaSecret.value,
dto.requestParams,
dto.preventOTPFromFurtherUsage,
)
if (u2fEnabled) {
if (!dto.requestParams.authenticator_response) {
return {
success: false,
errorTag: ErrorTag.MfaRequired,
errorMessage: 'Please authenticate with your U2F device.',
}
}
return verificationResult
const verificationResultOrError = await this.verifyAuthenticatorAuthenticationResponse.execute({
userUuid: userUuid.value,
authenticatorResponse: dto.requestParams.authenticator_response,
})
if (verificationResultOrError.isFailed()) {
this.logger.debug(`Could not verify U2F authentication: ${verificationResultOrError.getError()}`)
return {
success: false,
errorTag: ErrorTag.MfaInvalid,
errorMessage: 'Could not verify U2F device.',
}
}
const verificationResult = verificationResultOrError.getValue()
if (verificationResult === false) {
return {
success: false,
errorTag: ErrorTag.MfaInvalid,
errorMessage: 'Could not verify U2F device.',
}
}
return {
success: true,
}
} else {
const verificationResult = await this.verifyMFASecret(
dto.email,
(mfaSecret as Setting).value as string,
dto.requestParams,
dto.preventOTPFromFurtherUsage,
)
return verificationResult
}
} catch (error) {
if (error instanceof MFAValidationError) {
return {

View File

@@ -11,6 +11,14 @@ export class MySQLAuthenticatorRepository implements AuthenticatorRepositoryInte
private mapper: MapperInterface<Authenticator, TypeORMAuthenticator>,
) {}
async removeByUserUuid(userUuid: Uuid): Promise<void> {
await this.ormRepository
.createQueryBuilder()
.delete()
.where('user_uuid = :userUuid', { userUuid: userUuid.value })
.execute()
}
async findById(id: UniqueEntityId): Promise<Authenticator | null> {
const persistence = await this.ormRepository
.createQueryBuilder('authenticator')