feat(auth): add recovery sign in with recovery codes

This commit is contained in:
Karol Sójko
2023-01-05 11:42:47 +01:00
parent 901e0dd93b
commit cac899a7e5
22 changed files with 798 additions and 6 deletions

View File

@@ -39,4 +39,19 @@ export class ActionsController extends BaseHttpController {
request.body,
)
}
@httpPost('/recovery/codes', TYPES.AuthMiddleware)
async recoveryCodes(request: Request, response: Response): Promise<void> {
await this.httpService.callAuthServer(request, response, 'auth/recovery/codes', request.body)
}
@httpPost('/recovery/login')
async recoveryLogin(request: Request, response: Response): Promise<void> {
await this.httpService.callAuthServer(request, response, 'auth/recovery/login', request.body)
}
@httpPost('/recovery/login-params')
async recoveryParams(request: Request, response: Response): Promise<void> {
await this.httpService.callAuthServer(request, response, 'auth/recovery/params', request.body)
}
}

View File

@@ -7,6 +7,6 @@ module.exports = {
transform: {
...tsjPreset.transform,
},
coveragePathIgnorePatterns: ['/Bootstrap/', '/Infra/', '/Projection/', '/Domain/Email/', '/Mapping/'],
coveragePathIgnorePatterns: ['/Bootstrap/', '/Infra/', '/Controller/', '/Projection/', '/Domain/Email/', '/Mapping/'],
setupFilesAfterEnv: ['./test-setup.ts'],
}

View File

@@ -223,6 +223,8 @@ import { AuthenticatorHttpProjection } from '../Infra/Http/Projection/Authentica
import { AuthenticatorHttpMapper } from '../Mapping/AuthenticatorHttpMapper'
import { DeleteAuthenticator } from '../Domain/UseCase/DeleteAuthenticator/DeleteAuthenticator'
import { GenerateRecoveryCodes } from '../Domain/UseCase/GenerateRecoveryCodes/GenerateRecoveryCodes'
import { SignInWithRecoveryCodes } from '../Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes'
import { GetUserKeyParamsRecovery } from '../Domain/UseCase/GetUserKeyParamsRecovery/GetUserKeyParamsRecovery'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const newrelicFormatter = require('@newrelic/winston-enricher')
@@ -617,6 +619,30 @@ export class ContainerConfigLoader {
container.bind<VerifyMFA>(TYPES.VerifyMFA).to(VerifyMFA)
container.bind<ClearLoginAttempts>(TYPES.ClearLoginAttempts).to(ClearLoginAttempts)
container.bind<IncreaseLoginAttempts>(TYPES.IncreaseLoginAttempts).to(IncreaseLoginAttempts)
container
.bind<SignInWithRecoveryCodes>(TYPES.SignInWithRecoveryCodes)
.toConstantValue(
new SignInWithRecoveryCodes(
container.get(TYPES.UserRepository),
container.get(TYPES.AuthResponseFactory20200115),
container.get(TYPES.PKCERepository),
container.get(TYPES.Crypter),
container.get(TYPES.SettingService),
container.get(TYPES.GenerateRecoveryCodes),
container.get(TYPES.IncreaseLoginAttempts),
container.get(TYPES.ClearLoginAttempts),
),
)
container
.bind<GetUserKeyParamsRecovery>(TYPES.GetUserKeyParamsRecovery)
.toConstantValue(
new GetUserKeyParamsRecovery(
container.get(TYPES.KeyParamsFactory),
container.get(TYPES.UserRepository),
container.get(TYPES.PKCERepository),
container.get(TYPES.SettingService),
),
)
container.bind<GetUserKeyParams>(TYPES.GetUserKeyParams).to(GetUserKeyParams)
container.bind<UpdateUser>(TYPES.UpdateUser).to(UpdateUser)
container.bind<Register>(TYPES.Register).to(Register)

View File

@@ -142,6 +142,8 @@ const TYPES = {
ListAuthenticators: Symbol.for('ListAuthenticators'),
DeleteAuthenticator: Symbol.for('DeleteAuthenticator'),
GenerateRecoveryCodes: Symbol.for('GenerateRecoveryCodes'),
SignInWithRecoveryCodes: Symbol.for('SignInWithRecoveryCodes'),
GetUserKeyParamsRecovery: Symbol.for('GetUserKeyParamsRecovery'),
// Handlers
UserRegisteredEventHandler: Symbol.for('UserRegisteredEventHandler'),
AccountDeletionRequestedEventHandler: Symbol.for('AccountDeletionRequestedEventHandler'),

View File

@@ -9,6 +9,9 @@ import { Register } from '../Domain/UseCase/Register'
import { DomainEventFactoryInterface } from '../Domain/Event/DomainEventFactoryInterface'
import { KeyParamsOrigination, ProtocolVersion } from '@standardnotes/common'
import { ApiVersion } from '@standardnotes/api'
import { SignInWithRecoveryCodes } from '../Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes'
import { GetUserKeyParamsRecovery } from '../Domain/UseCase/GetUserKeyParamsRecovery/GetUserKeyParamsRecovery'
import { GenerateRecoveryCodes } from '../Domain/UseCase/GenerateRecoveryCodes/GenerateRecoveryCodes'
describe('AuthController', () => {
let clearLoginAttempts: ClearLoginAttempts
@@ -17,9 +20,20 @@ describe('AuthController', () => {
let domainEventFactory: DomainEventFactoryInterface
let event: DomainEventInterface
let user: User
let doSignInWithRecoveryCodes: SignInWithRecoveryCodes
let getUserKeyParamsRecovery: GetUserKeyParamsRecovery
let doGenerateRecoveryCodes: GenerateRecoveryCodes
const createController = () =>
new AuthController(clearLoginAttempts, register, domainEventPublisher, domainEventFactory)
new AuthController(
clearLoginAttempts,
register,
domainEventPublisher,
domainEventFactory,
doSignInWithRecoveryCodes,
getUserKeyParamsRecovery,
doGenerateRecoveryCodes,
)
beforeEach(() => {
register = {} as jest.Mocked<Register>
@@ -113,7 +127,7 @@ describe('AuthController', () => {
it('should throw error on the delete user method as it is still a part of the payments server', async () => {
let caughtError = null
try {
await createController().deleteAccount({ userUuid: '1-2-3' })
await createController().deleteAccount({} as never)
} catch (error) {
caughtError = error
}

View File

@@ -1,19 +1,28 @@
import { inject, injectable } from 'inversify'
import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
import {
ApiVersion,
HttpStatusCode,
UserDeletionResponse,
UserRegistrationRequestParams,
UserRegistrationResponse,
UserServerInterface,
} from '@standardnotes/api'
import { ProtocolVersion } from '@standardnotes/common'
import TYPES from '../Bootstrap/Types'
import { ClearLoginAttempts } from '../Domain/UseCase/ClearLoginAttempts'
import { Register } from '../Domain/UseCase/Register'
import { DomainEventFactoryInterface } from '../Domain/Event/DomainEventFactoryInterface'
import { ProtocolVersion } from '@standardnotes/common'
import { UserDeletionRequestParams } from '@standardnotes/api/dist/Domain/Request/User/UserDeletionRequestParams'
import { SignInWithRecoveryCodes } from '../Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes'
import { SignInWithRecoveryCodesRequestParams } from '../Infra/Http/Request/SignInWithRecoveryCodesRequestParams'
import { SignInWithRecoveryCodesResponse } from '../Infra/Http/Response/SignInWithRecoveryCodesResponse'
import { GetUserKeyParamsRecovery } from '../Domain/UseCase/GetUserKeyParamsRecovery/GetUserKeyParamsRecovery'
import { RecoveryKeyParamsRequestParams } from '../Infra/Http/Request/RecoveryKeyParamsRequestParams'
import { RecoveryKeyParamsResponse } from '../Infra/Http/Response/RecoveryKeyParamsResponse'
import { GenerateRecoveryCodes } from '../Domain/UseCase/GenerateRecoveryCodes/GenerateRecoveryCodes'
import { GenerateRecoveryCodesRequestParams } from '../Infra/Http/Request/GenerateRecoveryCodesRequestParams'
import { GenerateRecoveryCodesResponse } from '../Infra/Http/Response/GenerateRecoveryCodesResponse'
@injectable()
export class AuthController implements UserServerInterface {
@@ -22,9 +31,12 @@ export class AuthController implements UserServerInterface {
@inject(TYPES.Register) private registerUser: Register,
@inject(TYPES.DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface,
@inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface,
@inject(TYPES.SignInWithRecoveryCodes) private doSignInWithRecoveryCodes: SignInWithRecoveryCodes,
@inject(TYPES.GetUserKeyParamsRecovery) private getUserKeyParamsRecovery: GetUserKeyParamsRecovery,
@inject(TYPES.GenerateRecoveryCodes) private doGenerateRecoveryCodes: GenerateRecoveryCodes,
) {}
async deleteAccount(_params: UserDeletionRequestParams): Promise<UserDeletionResponse> {
async deleteAccount(_params: never): Promise<UserDeletionResponse> {
throw new Error('This method is implemented on the payments server.')
}
@@ -78,4 +90,104 @@ export class AuthController implements UserServerInterface {
data: registerResult.authResponse,
}
}
async generateRecoveryCodes(params: GenerateRecoveryCodesRequestParams): Promise<GenerateRecoveryCodesResponse> {
const result = await this.doGenerateRecoveryCodes.execute({
userUuid: params.userUuid,
})
if (result.isFailed()) {
return {
status: HttpStatusCode.BadRequest,
data: {
error: {
message: 'Could not generate recovery codes.',
},
},
}
}
return {
status: HttpStatusCode.Success,
data: {
recoveryCodes: result.getValue(),
},
}
}
async signInWithRecoveryCodes(
params: SignInWithRecoveryCodesRequestParams,
): Promise<SignInWithRecoveryCodesResponse> {
if (params.apiVersion !== ApiVersion.v0) {
return {
status: HttpStatusCode.BadRequest,
data: {
error: {
message: 'Invalid API version.',
},
},
}
}
const result = await this.doSignInWithRecoveryCodes.execute({
userAgent: params.userAgent,
username: params.username,
password: params.password,
codeVerifier: params.codeVerifier,
recoveryCodes: params.recoveryCodes,
})
if (result.isFailed()) {
return {
status: HttpStatusCode.Unauthorized,
data: {
error: {
message: 'Invalid login credentials.',
},
},
}
}
return {
status: HttpStatusCode.Success,
data: result.getValue(),
}
}
async recoveryKeyParams(params: RecoveryKeyParamsRequestParams): Promise<RecoveryKeyParamsResponse> {
if (params.apiVersion !== ApiVersion.v0) {
return {
status: HttpStatusCode.BadRequest,
data: {
error: {
message: 'Invalid API version.',
},
},
}
}
const result = await this.getUserKeyParamsRecovery.execute({
username: params.username,
codeChallenge: params.codeChallenge,
recoveryCodes: params.recoveryCodes,
})
if (result.isFailed()) {
return {
status: HttpStatusCode.Unauthorized,
data: {
error: {
message: 'Invalid login credentials.',
},
},
}
}
return {
status: HttpStatusCode.Success,
data: {
keyParams: result.getValue(),
},
}
}
}

View File

@@ -0,0 +1,116 @@
import { Setting } from '../../Setting/Setting'
import { SettingServiceInterface } from '../../Setting/SettingServiceInterface'
import { KeyParamsFactoryInterface } from '../../User/KeyParamsFactoryInterface'
import { PKCERepositoryInterface } from '../../User/PKCERepositoryInterface'
import { User } from '../../User/User'
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
import { GetUserKeyParamsRecovery } from './GetUserKeyParamsRecovery'
describe('GetUserKeyParamsRecovery', () => {
let keyParamsFactory: KeyParamsFactoryInterface
let userRepository: UserRepositoryInterface
let settingService: SettingServiceInterface
let user: User
let pkceRepository: PKCERepositoryInterface
const createUseCase = () =>
new GetUserKeyParamsRecovery(keyParamsFactory, userRepository, pkceRepository, settingService)
beforeEach(() => {
keyParamsFactory = {} as jest.Mocked<KeyParamsFactoryInterface>
keyParamsFactory.create = jest.fn().mockReturnValue({ foo: 'bar' })
keyParamsFactory.createPseudoParams = jest.fn().mockReturnValue({ bar: 'baz' })
user = {} as jest.Mocked<User>
userRepository = {} as jest.Mocked<UserRepositoryInterface>
userRepository.findOneByEmail = jest.fn().mockReturnValue(user)
settingService = {} as jest.Mocked<SettingServiceInterface>
settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue({ value: 'foo' } as Setting)
pkceRepository = {} as jest.Mocked<PKCERepositoryInterface>
pkceRepository.storeCodeChallenge = jest.fn()
})
it('should return error if code challenge is not provided', async () => {
const result = await createUseCase().execute({
username: 'username',
codeChallenge: '',
recoveryCodes: '1234 5678',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Invalid code challenge')
})
it('should return error if username is not provided', async () => {
const result = await createUseCase().execute({
username: '',
codeChallenge: 'code-challenge',
recoveryCodes: '1234 5678',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Could not sign in with recovery codes: Username cannot be empty')
})
it('should return error if recovery codes are not provided', async () => {
const result = await createUseCase().execute({
username: 'username',
codeChallenge: 'codeChallenge',
recoveryCodes: '',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Invalid recovery codes')
})
it('should return pseudo params if user does not exist', async () => {
userRepository.findOneByEmail = jest.fn().mockReturnValue(null)
const result = await createUseCase().execute({
username: 'username',
codeChallenge: 'codeChallenge',
recoveryCodes: '1234 5678',
})
expect(keyParamsFactory.createPseudoParams).toHaveBeenCalled()
expect(result.isFailed()).toBe(false)
})
it('should return error if user has no recovery codes generated', async () => {
settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(null)
const result = await createUseCase().execute({
username: 'username',
codeChallenge: 'codeChallenge',
recoveryCodes: '1234 5678',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('User does not have recovery codes generated')
})
it('should return error if recovery codes do not match', async () => {
const result = await createUseCase().execute({
username: 'username',
codeChallenge: 'codeChallenge',
recoveryCodes: '1234 5678',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Invalid recovery codes')
})
it('should return key params if recovery codes match', async () => {
const result = await createUseCase().execute({
username: 'username',
codeChallenge: 'codeChallenge',
recoveryCodes: 'foo',
})
expect(keyParamsFactory.create).toHaveBeenCalled()
expect(result.isFailed()).toBe(false)
})
})

View File

@@ -0,0 +1,64 @@
import { KeyParamsData } from '@standardnotes/responses'
import { Result, UseCaseInterface, Username, Validator } from '@standardnotes/domain-core'
import { SettingName } from '@standardnotes/settings'
import { KeyParamsFactoryInterface } from '../../User/KeyParamsFactoryInterface'
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
import { GetUserKeyParamsRecoveryDTO } from './GetUserKeyParamsRecoveryDTO'
import { User } from '../../User/User'
import { PKCERepositoryInterface } from '../../User/PKCERepositoryInterface'
import { SettingServiceInterface } from '../../Setting/SettingServiceInterface'
export class GetUserKeyParamsRecovery implements UseCaseInterface<KeyParamsData> {
constructor(
private keyParamsFactory: KeyParamsFactoryInterface,
private userRepository: UserRepositoryInterface,
private pkceRepository: PKCERepositoryInterface,
private settingService: SettingServiceInterface,
) {}
async execute(dto: GetUserKeyParamsRecoveryDTO): Promise<Result<KeyParamsData>> {
const usernameOrError = Username.create(dto.username)
if (usernameOrError.isFailed()) {
return Result.fail(`Could not sign in with recovery codes: ${usernameOrError.getError()}`)
}
const username = usernameOrError.getValue()
const recoveryCodesValidationResult = Validator.isNotEmpty(dto.recoveryCodes)
if (recoveryCodesValidationResult.isFailed()) {
return Result.fail('Invalid recovery codes')
}
const codeChallengeValidationResult = Validator.isNotEmpty(dto.codeChallenge)
if (codeChallengeValidationResult.isFailed()) {
return Result.fail('Invalid code challenge')
}
const user = await this.userRepository.findOneByEmail(username.value)
if (!user) {
return Result.ok(this.keyParamsFactory.createPseudoParams(username.value))
}
const recoveryCodesSetting = await this.settingService.findSettingWithDecryptedValue({
settingName: SettingName.RecoveryCodes,
userUuid: user.uuid,
})
if (!recoveryCodesSetting) {
return Result.fail('User does not have recovery codes generated')
}
if (recoveryCodesSetting.value !== dto.recoveryCodes) {
return Result.fail('Invalid recovery codes')
}
const keyParams = await this.createKeyParams(dto.codeChallenge, user)
return Result.ok(keyParams)
}
private async createKeyParams(codeChallenge: string, user: User): Promise<KeyParamsData> {
await this.pkceRepository.storeCodeChallenge(codeChallenge)
return this.keyParamsFactory.create(user, false)
}
}

View File

@@ -0,0 +1,5 @@
export interface GetUserKeyParamsRecoveryDTO {
codeChallenge: string
username: string
recoveryCodes: string
}

View File

@@ -0,0 +1,218 @@
import { Result } from '@standardnotes/domain-core'
import { AuthResponse20200115 } from '../../Auth/AuthResponse20200115'
import { AuthResponseFactory20200115 } from '../../Auth/AuthResponseFactory20200115'
import { CrypterInterface } from '../../Encryption/CrypterInterface'
import { Setting } from '../../Setting/Setting'
import { SettingServiceInterface } from '../../Setting/SettingServiceInterface'
import { PKCERepositoryInterface } from '../../User/PKCERepositoryInterface'
import { User } from '../../User/User'
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
import { ClearLoginAttempts } from '../ClearLoginAttempts'
import { GenerateRecoveryCodes } from '../GenerateRecoveryCodes/GenerateRecoveryCodes'
import { IncreaseLoginAttempts } from '../IncreaseLoginAttempts'
import { SignInWithRecoveryCodes } from './SignInWithRecoveryCodes'
describe('SignInWithRecoveryCodes', () => {
let userRepository: UserRepositoryInterface
let authResponseFactory: AuthResponseFactory20200115
let pkceRepository: PKCERepositoryInterface
let crypter: CrypterInterface
let settingService: SettingServiceInterface
let generateRecoveryCodes: GenerateRecoveryCodes
let increaseLoginAttempts: IncreaseLoginAttempts
let clearLoginAttempts: ClearLoginAttempts
const createUseCase = () =>
new SignInWithRecoveryCodes(
userRepository,
authResponseFactory,
pkceRepository,
crypter,
settingService,
generateRecoveryCodes,
increaseLoginAttempts,
clearLoginAttempts,
)
beforeEach(() => {
userRepository = {} as jest.Mocked<UserRepositoryInterface>
userRepository.findOneByEmail = jest.fn().mockReturnValue({
uuid: '1-2-3',
encryptedPassword: '$2a$11$K3g6XoTau8VmLJcai1bB0eD9/YvBSBRtBhMprJOaVZ0U3SgasZH3a',
} as jest.Mocked<User>)
authResponseFactory = {} as jest.Mocked<AuthResponseFactory20200115>
authResponseFactory.createResponse = jest.fn().mockReturnValue({} as jest.Mocked<AuthResponse20200115>)
pkceRepository = {} as jest.Mocked<PKCERepositoryInterface>
pkceRepository.removeCodeChallenge = jest.fn().mockReturnValue(true)
crypter = {} as jest.Mocked<CrypterInterface>
crypter.base64URLEncode = jest.fn().mockReturnValue('base64-url-encoded')
crypter.sha256Hash = jest.fn().mockReturnValue('sha256-hashed')
settingService = {} as jest.Mocked<SettingServiceInterface>
settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue({ value: 'foo' } as Setting)
generateRecoveryCodes = {} as jest.Mocked<GenerateRecoveryCodes>
generateRecoveryCodes.execute = jest.fn().mockReturnValue(Result.ok('1234 5678'))
increaseLoginAttempts = {} as jest.Mocked<IncreaseLoginAttempts>
increaseLoginAttempts.execute = jest.fn()
clearLoginAttempts = {} as jest.Mocked<ClearLoginAttempts>
clearLoginAttempts.execute = jest.fn()
})
it('should return error if password is not provided', async () => {
const result = await createUseCase().execute({
userAgent: 'user-agent',
username: 'test@test.te',
password: '',
codeVerifier: 'code-verifier',
recoveryCodes: '1234 5678',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Invalid email or password')
})
it('should return error if username is not provided', async () => {
const result = await createUseCase().execute({
userAgent: 'user-agent',
username: '',
password: 'qweqwe123123',
codeVerifier: 'code-verifier',
recoveryCodes: '1234 5678',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Could not sign in with recovery codes: Username cannot be empty')
})
it('should return error if code verifier is not provided', async () => {
const result = await createUseCase().execute({
userAgent: 'user-agent',
username: 'username',
password: 'qweqwe123123',
codeVerifier: '',
recoveryCodes: '1234 5678',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Invalid email or password')
})
it('should return error if recovery codes are not provided', async () => {
const result = await createUseCase().execute({
userAgent: 'user-agent',
username: 'username',
password: 'qweqwe123123',
codeVerifier: 'code-verifier',
recoveryCodes: '',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Invalid recovery codes')
})
it('should return error if code verifier is invalid', async () => {
pkceRepository.removeCodeChallenge = jest.fn().mockReturnValue(false)
const result = await createUseCase().execute({
userAgent: 'user-agent',
username: 'test@test.te',
password: 'qweqwe123123',
codeVerifier: 'code-verifier',
recoveryCodes: '1234 5678',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Invalid email or password')
})
it('should return error if user is not found', async () => {
userRepository.findOneByEmail = jest.fn().mockReturnValue(undefined)
const result = await createUseCase().execute({
userAgent: 'user-agent',
username: 'test@test.te',
password: 'qweqwe123123',
codeVerifier: 'code-verifier',
recoveryCodes: '1234 5678',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Invalid email or password')
})
it('should return error if recovery codes are invalid', async () => {
const result = await createUseCase().execute({
userAgent: 'user-agent',
username: 'test@test.te',
password: 'qweqwe123123',
codeVerifier: 'code-verifier',
recoveryCodes: '1234 5678',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Invalid recovery codes')
})
it('should return error if password does not match', async () => {
const result = await createUseCase().execute({
userAgent: 'user-agent',
username: 'test@test.te',
password: 'asdasd123123',
codeVerifier: 'code-verifier',
recoveryCodes: '1234 5678',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Invalid email or password')
})
it('should return error if recovery codes are not generated for user', async () => {
settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(null)
const result = await createUseCase().execute({
userAgent: 'user-agent',
username: 'test@test.te',
password: 'qweqwe123123',
codeVerifier: 'code-verifier',
recoveryCodes: '1234 5678',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('User does not have recovery codes generated')
})
it('should return error if generating new recovery codes fails', async () => {
generateRecoveryCodes.execute = jest.fn().mockReturnValue(Result.fail('Oops'))
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('Could not sign in with recovery codes: Oops')
})
it('should return auth response', async () => {
const result = await createUseCase().execute({
userAgent: 'user-agent',
username: 'test@test.te',
password: 'qweqwe123123',
codeVerifier: 'code-verifier',
recoveryCodes: 'foo',
})
expect(clearLoginAttempts.execute).toHaveBeenCalled()
expect(result.isFailed()).toBe(false)
})
})

View File

@@ -0,0 +1,123 @@
import * as bcrypt from 'bcryptjs'
import { Result, UseCaseInterface, Username, Validator } from '@standardnotes/domain-core'
import { SettingName } from '@standardnotes/settings'
import { ApiVersion } from '@standardnotes/api'
import { AuthResponse20200115 } from '../../Auth/AuthResponse20200115'
import { SettingServiceInterface } from '../../Setting/SettingServiceInterface'
import { CrypterInterface } from '../../Encryption/CrypterInterface'
import { PKCERepositoryInterface } from '../../User/PKCERepositoryInterface'
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
import { GenerateRecoveryCodes } from '../GenerateRecoveryCodes/GenerateRecoveryCodes'
import { SignInWithRecoveryCodesDTO } from './SignInWithRecoveryCodesDTO'
import { AuthResponseFactory20200115 } from '../../Auth/AuthResponseFactory20200115'
import { IncreaseLoginAttempts } from '../IncreaseLoginAttempts'
import { ClearLoginAttempts } from '../ClearLoginAttempts'
export class SignInWithRecoveryCodes implements UseCaseInterface<AuthResponse20200115> {
constructor(
private userRepository: UserRepositoryInterface,
private authResponseFactory: AuthResponseFactory20200115,
private pkceRepository: PKCERepositoryInterface,
private crypter: CrypterInterface,
private settingService: SettingServiceInterface,
private generateRecoveryCodes: GenerateRecoveryCodes,
private increaseLoginAttempts: IncreaseLoginAttempts,
private clearLoginAttempts: ClearLoginAttempts,
) {}
async execute(dto: SignInWithRecoveryCodesDTO): Promise<Result<AuthResponse20200115>> {
const usernameOrError = Username.create(dto.username)
if (usernameOrError.isFailed()) {
return Result.fail(`Could not sign in with recovery codes: ${usernameOrError.getError()}`)
}
const username = usernameOrError.getValue()
const validCodeVerifier = await this.validateCodeVerifier(dto.codeVerifier)
if (!validCodeVerifier) {
await this.increaseLoginAttempts.execute({ email: username.value })
return Result.fail('Invalid email or password')
}
const passwordValidationResult = Validator.isNotEmpty(dto.password)
if (passwordValidationResult.isFailed()) {
await this.increaseLoginAttempts.execute({ email: username.value })
return Result.fail('Invalid email or password')
}
const recoveryCodesValidationResult = Validator.isNotEmpty(dto.recoveryCodes)
if (recoveryCodesValidationResult.isFailed()) {
await this.increaseLoginAttempts.execute({ email: username.value })
return Result.fail('Invalid recovery codes')
}
const user = await this.userRepository.findOneByEmail(username.value)
if (!user) {
await this.increaseLoginAttempts.execute({ email: username.value })
return Result.fail('Invalid email or password')
}
const passwordMatches = await bcrypt.compare(dto.password, user.encryptedPassword)
if (!passwordMatches) {
await this.increaseLoginAttempts.execute({ email: username.value })
return Result.fail('Invalid email or password')
}
const recoveryCodesSetting = await this.settingService.findSettingWithDecryptedValue({
settingName: SettingName.RecoveryCodes,
userUuid: user.uuid,
})
if (!recoveryCodesSetting) {
await this.increaseLoginAttempts.execute({ email: username.value })
return Result.fail('User does not have recovery codes generated')
}
if (recoveryCodesSetting.value !== dto.recoveryCodes) {
await this.increaseLoginAttempts.execute({ email: username.value })
return Result.fail('Invalid recovery codes')
}
const authResponse = await this.authResponseFactory.createResponse({
user,
apiVersion: ApiVersion.v0,
userAgent: dto.userAgent,
ephemeralSession: false,
readonlyAccess: false,
})
const generateNewRecoveryCodesResult = await this.generateRecoveryCodes.execute({
userUuid: user.uuid,
})
if (generateNewRecoveryCodesResult.isFailed()) {
await this.increaseLoginAttempts.execute({ email: username.value })
return Result.fail(`Could not sign in with recovery codes: ${generateNewRecoveryCodesResult.getError()}`)
}
await this.clearLoginAttempts.execute({ email: username.value })
return Result.ok(authResponse as AuthResponse20200115)
}
private async validateCodeVerifier(codeVerifier: string): Promise<boolean> {
const codeEmptinessVerificationResult = Validator.isNotEmpty(codeVerifier)
if (codeEmptinessVerificationResult.isFailed()) {
return false
}
const codeChallenge = this.crypter.base64URLEncode(this.crypter.sha256Hash(codeVerifier))
const matchingCodeChallengeWasPresentAndRemoved = await this.pkceRepository.removeCodeChallenge(codeChallenge)
return matchingCodeChallengeWasPresentAndRemoved
}
}

View File

@@ -0,0 +1,7 @@
export interface SignInWithRecoveryCodesDTO {
userAgent: string
username: string
password: string
codeVerifier: string
recoveryCodes: string
}

View File

@@ -0,0 +1,3 @@
export interface GenerateRecoveryCodesRequestParams {
userUuid: string
}

View File

@@ -0,0 +1,6 @@
export interface RecoveryKeyParamsRequestParams {
apiVersion: string
username: string
codeChallenge: string
recoveryCodes: string
}

View File

@@ -0,0 +1,8 @@
export interface SignInWithRecoveryCodesRequestParams {
apiVersion: string
userAgent: string
username: string
password: string
codeVerifier: string
recoveryCodes: string
}

View File

@@ -0,0 +1,8 @@
import { HttpErrorResponseBody, HttpResponse } from '@standardnotes/api'
import { Either } from '@standardnotes/common'
import { GenerateRecoveryCodesResponseBody } from './GenerateRecoveryCodesResponseBody'
export interface GenerateRecoveryCodesResponse extends HttpResponse {
data: Either<GenerateRecoveryCodesResponseBody, HttpErrorResponseBody>
}

View File

@@ -0,0 +1,3 @@
export interface GenerateRecoveryCodesResponseBody {
recoveryCodes: string
}

View File

@@ -0,0 +1,8 @@
import { HttpErrorResponseBody, HttpResponse } from '@standardnotes/api'
import { Either } from '@standardnotes/common'
import { RecoveryKeyParamsResponseBody } from './RecoveryKeyParamsResponseBody'
export interface RecoveryKeyParamsResponse extends HttpResponse {
data: Either<RecoveryKeyParamsResponseBody, HttpErrorResponseBody>
}

View File

@@ -0,0 +1,5 @@
import { KeyParamsData } from '@standardnotes/responses'
export interface RecoveryKeyParamsResponseBody {
keyParams: KeyParamsData
}

View File

@@ -0,0 +1,8 @@
import { HttpErrorResponseBody, HttpResponse } from '@standardnotes/api'
import { Either } from '@standardnotes/common'
import { SignInWithRecoveryCodesResponseBody } from './SignInWithRecoveryCodesResponseBody'
export interface SignInWithRecoveryCodesResponse extends HttpResponse {
data: Either<SignInWithRecoveryCodesResponseBody, HttpErrorResponseBody>
}

View File

@@ -0,0 +1,6 @@
import { KeyParamsData, SessionBody } from '@standardnotes/responses'
export interface SignInWithRecoveryCodesResponseBody {
session: SessionBody
key_params: KeyParamsData
}

View File

@@ -252,6 +252,41 @@ export class InversifyExpressAuthController extends BaseHttpController {
return this.json(signInResult.authResponse)
}
@httpPost('/recovery/codes', TYPES.ApiGatewayAuthMiddleware)
async generateRecoveryCodes(_request: Request, response: Response): Promise<results.JsonResult> {
const result = await this.authController.generateRecoveryCodes({
userUuid: response.locals.user.uuid,
})
return this.json(result.data, result.status)
}
@httpPost('/recovery/login', TYPES.LockMiddleware)
async recoveryLogin(request: Request): Promise<results.JsonResult> {
const result = await this.authController.signInWithRecoveryCodes({
apiVersion: request.body.api,
userAgent: <string>request.headers['user-agent'],
codeVerifier: request.body.code_verifier,
username: request.body.email,
recoveryCodes: request.body.recovery_codes,
password: request.body.password,
})
return this.json(result.data, result.status)
}
@httpPost('/recovery/params')
async recoveryParams(request: Request): Promise<results.JsonResult> {
const result = await this.authController.recoveryKeyParams({
apiVersion: request.body.api,
username: request.body.email,
codeChallenge: request.body.code_challenge,
recoveryCodes: request.body.recovery_codes,
})
return this.json(result.data, result.status)
}
@httpPost('/sign_out', TYPES.AuthMiddlewareWithoutResponse)
async signOut(request: Request, response: Response): Promise<results.JsonResult | void> {
if (response.locals.readOnlyAccess) {