mirror of
https://github.com/standardnotes/server
synced 2026-01-16 20:04:32 -05:00
feat(auth): add recovery sign in with recovery codes
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface GetUserKeyParamsRecoveryDTO {
|
||||
codeChallenge: string
|
||||
username: string
|
||||
recoveryCodes: string
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface SignInWithRecoveryCodesDTO {
|
||||
userAgent: string
|
||||
username: string
|
||||
password: string
|
||||
codeVerifier: string
|
||||
recoveryCodes: string
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface GenerateRecoveryCodesRequestParams {
|
||||
userUuid: string
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface RecoveryKeyParamsRequestParams {
|
||||
apiVersion: string
|
||||
username: string
|
||||
codeChallenge: string
|
||||
recoveryCodes: string
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface SignInWithRecoveryCodesRequestParams {
|
||||
apiVersion: string
|
||||
userAgent: string
|
||||
username: string
|
||||
password: string
|
||||
codeVerifier: string
|
||||
recoveryCodes: string
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface GenerateRecoveryCodesResponseBody {
|
||||
recoveryCodes: string
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { KeyParamsData } from '@standardnotes/responses'
|
||||
|
||||
export interface RecoveryKeyParamsResponseBody {
|
||||
keyParams: KeyParamsData
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { KeyParamsData, SessionBody } from '@standardnotes/responses'
|
||||
|
||||
export interface SignInWithRecoveryCodesResponseBody {
|
||||
session: SessionBody
|
||||
key_params: KeyParamsData
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user