mirror of
https://github.com/standardnotes/server
synced 2026-07-14 00:01:54 -04:00
feat(auth): add generating recovery codes
This commit is contained in:
@@ -222,6 +222,7 @@ import { ListAuthenticators } from '../Domain/UseCase/ListAuthenticators/ListAut
|
||||
import { AuthenticatorHttpProjection } from '../Infra/Http/Projection/AuthenticatorHttpProjection'
|
||||
import { AuthenticatorHttpMapper } from '../Mapping/AuthenticatorHttpMapper'
|
||||
import { DeleteAuthenticator } from '../Domain/UseCase/DeleteAuthenticator/DeleteAuthenticator'
|
||||
import { GenerateRecoveryCodes } from '../Domain/UseCase/GenerateRecoveryCodes/GenerateRecoveryCodes'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const newrelicFormatter = require('@newrelic/winston-enricher')
|
||||
@@ -596,6 +597,15 @@ export class ContainerConfigLoader {
|
||||
container
|
||||
.bind<DeleteAuthenticator>(TYPES.DeleteAuthenticator)
|
||||
.toConstantValue(new DeleteAuthenticator(container.get(TYPES.AuthenticatorRepository)))
|
||||
container
|
||||
.bind<GenerateRecoveryCodes>(TYPES.GenerateRecoveryCodes)
|
||||
.toConstantValue(
|
||||
new GenerateRecoveryCodes(
|
||||
container.get(TYPES.UserRepository),
|
||||
container.get(TYPES.SettingService),
|
||||
container.get(TYPES.CryptoNode),
|
||||
),
|
||||
)
|
||||
|
||||
container
|
||||
.bind<CleanupSessionTraces>(TYPES.CleanupSessionTraces)
|
||||
|
||||
@@ -141,6 +141,7 @@ const TYPES = {
|
||||
VerifyAuthenticatorAuthenticationResponse: Symbol.for('VerifyAuthenticatorAuthenticationResponse'),
|
||||
ListAuthenticators: Symbol.for('ListAuthenticators'),
|
||||
DeleteAuthenticator: Symbol.for('DeleteAuthenticator'),
|
||||
GenerateRecoveryCodes: Symbol.for('GenerateRecoveryCodes'),
|
||||
// Handlers
|
||||
UserRegisteredEventHandler: Symbol.for('UserRegisteredEventHandler'),
|
||||
AccountDeletionRequestedEventHandler: Symbol.for('AccountDeletionRequestedEventHandler'),
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { CryptoNode } from '@standardnotes/sncrypto-node'
|
||||
import { SettingServiceInterface } from '../../Setting/SettingServiceInterface'
|
||||
import { User } from '../../User/User'
|
||||
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
|
||||
import { GenerateRecoveryCodes } from './GenerateRecoveryCodes'
|
||||
|
||||
describe('GenerateRecoveryCodes', () => {
|
||||
let userRepository: UserRepositoryInterface
|
||||
let settingService: SettingServiceInterface
|
||||
let cryptoNode: CryptoNode
|
||||
|
||||
const createUseCase = () => new GenerateRecoveryCodes(userRepository, settingService, cryptoNode)
|
||||
|
||||
beforeEach(() => {
|
||||
userRepository = {} as jest.Mocked<UserRepositoryInterface>
|
||||
userRepository.findOneByUuid = jest.fn().mockReturnValue({} as jest.Mocked<User>)
|
||||
|
||||
settingService = {} as jest.Mocked<SettingServiceInterface>
|
||||
settingService.createOrReplace = jest.fn()
|
||||
|
||||
cryptoNode = {} as jest.Mocked<CryptoNode>
|
||||
cryptoNode.generateRandomKey = jest.fn().mockReturnValue('randomKey123')
|
||||
})
|
||||
|
||||
it('should generate recovery codes', async () => {
|
||||
const useCase = createUseCase()
|
||||
|
||||
const result = await useCase.execute({ userUuid: '2221101c-1da9-4d2b-9b32-b8be2a8d1c82' })
|
||||
|
||||
expect(settingService.createOrReplace).toHaveBeenCalled()
|
||||
expect(result.isFailed()).toBeFalsy()
|
||||
expect(result.getValue()).toEqual('RAND OMKE Y123')
|
||||
})
|
||||
|
||||
it('should return error if empty random string', async () => {
|
||||
cryptoNode.generateRandomKey = jest.fn().mockReturnValue('')
|
||||
|
||||
const useCase = createUseCase()
|
||||
|
||||
const result = await useCase.execute({ userUuid: '2221101c-1da9-4d2b-9b32-b8be2a8d1c82' })
|
||||
|
||||
expect(result.isFailed()).toBeTruthy()
|
||||
expect(result.getError()).toEqual('Could not generate recovery codes: random key is invalid')
|
||||
})
|
||||
|
||||
it('should return error if user not found', async () => {
|
||||
userRepository.findOneByUuid = jest.fn().mockReturnValue(null)
|
||||
|
||||
const useCase = createUseCase()
|
||||
|
||||
const result = await useCase.execute({ userUuid: '2221101c-1da9-4d2b-9b32-b8be2a8d1c82' })
|
||||
|
||||
expect(result.isFailed()).toBeTruthy()
|
||||
expect(result.getError()).toEqual('Could not generate recovery codes: user not found')
|
||||
})
|
||||
|
||||
it('should return error if user uuid is invalid', async () => {
|
||||
const useCase = createUseCase()
|
||||
|
||||
const result = await useCase.execute({ userUuid: 'invalid' })
|
||||
|
||||
expect(result.isFailed()).toBeTruthy()
|
||||
expect(result.getError()).toEqual('Could not generate recovery codes: Given value is not a valid uuid: invalid')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
|
||||
import { SettingName } from '@standardnotes/settings'
|
||||
import { CryptoNode } from '@standardnotes/sncrypto-node'
|
||||
import { EncryptionVersion } from '../../Encryption/EncryptionVersion'
|
||||
import { SettingServiceInterface } from '../../Setting/SettingServiceInterface'
|
||||
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
|
||||
import { GenerateRecoveryCodesDTO } from './GenerateRecoveryCodesDTO'
|
||||
|
||||
export class GenerateRecoveryCodes implements UseCaseInterface<string> {
|
||||
constructor(
|
||||
private userRepository: UserRepositoryInterface,
|
||||
private settingService: SettingServiceInterface,
|
||||
private cryptoNode: CryptoNode,
|
||||
) {}
|
||||
async execute(dto: GenerateRecoveryCodesDTO): Promise<Result<string>> {
|
||||
const userUuidOrError = Uuid.create(dto.userUuid)
|
||||
if (userUuidOrError.isFailed()) {
|
||||
return Result.fail(`Could not generate recovery codes: ${userUuidOrError.getError()}`)
|
||||
}
|
||||
const userUuid = userUuidOrError.getValue()
|
||||
|
||||
const user = await this.userRepository.findOneByUuid(userUuid.value)
|
||||
if (user === null) {
|
||||
return Result.fail('Could not generate recovery codes: user not found')
|
||||
}
|
||||
|
||||
const randomKey = await this.cryptoNode.generateRandomKey(160)
|
||||
const recoveryCodesSplit = randomKey.toUpperCase().match(/.{1,4}/g)
|
||||
if (!recoveryCodesSplit) {
|
||||
return Result.fail('Could not generate recovery codes: random key is invalid')
|
||||
}
|
||||
|
||||
const recoveryCodes = recoveryCodesSplit.join(' ')
|
||||
|
||||
await this.settingService.createOrReplace({
|
||||
user,
|
||||
props: {
|
||||
name: SettingName.RecoveryCodes,
|
||||
unencryptedValue: recoveryCodes,
|
||||
serverEncryptionVersion: EncryptionVersion.Default,
|
||||
sensitive: true,
|
||||
},
|
||||
})
|
||||
|
||||
return Result.ok(recoveryCodes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface GenerateRecoveryCodesDTO {
|
||||
userUuid: string
|
||||
}
|
||||
@@ -14,4 +14,5 @@ export enum SettingName {
|
||||
MuteMarketingEmails = 'MUTE_MARKETING_EMAILS',
|
||||
ListedAuthorSecrets = 'LISTED_AUTHOR_SECRETS',
|
||||
LogSessionUserAgent = 'LOG_SESSION_USER_AGENT',
|
||||
RecoveryCodes = 'RECOVERY_CODES',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user