mirror of
https://github.com/standardnotes/server
synced 2026-07-13 05:01:06 -04:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c5c8b81d5 | |||
| 79c3e33434 | |||
| 5ab8729a31 | |||
| db0baf92f1 | |||
| a8974094db | |||
| 13c5c97ba7 | |||
| 894ebb3edd | |||
| cac899a7e5 | |||
| 901e0dd93b | |||
| a360231fd0 |
@@ -3,6 +3,12 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [1.45.0](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.44.0...@standardnotes/api-gateway@1.45.0) (2023-01-05)
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** add recovery sign in with recovery codes ([cac899a](https://github.com/standardnotes/api-gateway/commit/cac899a7e558d066895dfb3ba28418d94072f2b7))
|
||||
|
||||
# [1.44.0](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.43.0...@standardnotes/api-gateway@1.44.0) (2022-12-29)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/api-gateway",
|
||||
"version": "1.44.0",
|
||||
"version": "1.45.0",
|
||||
"engines": {
|
||||
"node": ">=18.0.0 <19.0.0"
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,30 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.81.2](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.81.1...@standardnotes/auth-server@1.81.2) (2023-01-05)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** return type to include user ([db0baf9](https://github.com/standardnotes/server/commit/db0baf92f1336071a8602a1a20ba6439f10028e6))
|
||||
|
||||
## [1.81.1](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.81.0...@standardnotes/auth-server@1.81.1) (2023-01-05)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** allow retrieval of recovery codes setting ([13c5c97](https://github.com/standardnotes/server/commit/13c5c97ba73fcf1f4bd2905a1d8668ef5b468016))
|
||||
|
||||
# [1.81.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.80.0...@standardnotes/auth-server@1.81.0) (2023-01-05)
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** add recovery sign in with recovery codes ([cac899a](https://github.com/standardnotes/server/commit/cac899a7e558d066895dfb3ba28418d94072f2b7))
|
||||
|
||||
# [1.80.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.79.1...@standardnotes/auth-server@1.80.0) (2023-01-04)
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** add generating recovery codes ([a360231](https://github.com/standardnotes/server/commit/a360231fd0b04afbc8cfa0ed74c8e21c4edadb4e))
|
||||
|
||||
## [1.79.1](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.79.0...@standardnotes/auth-server@1.79.1) (2023-01-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -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'],
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/auth-server",
|
||||
"version": "1.79.1",
|
||||
"version": "1.81.2",
|
||||
"engines": {
|
||||
"node": ">=18.0.0 <19.0.0"
|
||||
},
|
||||
|
||||
@@ -222,6 +222,9 @@ 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'
|
||||
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')
|
||||
@@ -596,6 +599,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)
|
||||
@@ -607,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)
|
||||
|
||||
@@ -141,6 +141,9 @@ const TYPES = {
|
||||
VerifyAuthenticatorAuthenticationResponse: Symbol.for('VerifyAuthenticatorAuthenticationResponse'),
|
||||
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,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: false,
|
||||
},
|
||||
})
|
||||
|
||||
return Result.ok(recoveryCodes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface GenerateRecoveryCodesDTO {
|
||||
userUuid: string
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export interface GetUserKeyParamsRecoveryDTO {
|
||||
codeChallenge: string
|
||||
username: string
|
||||
recoveryCodes: string
|
||||
}
|
||||
+218
@@ -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,11 @@
|
||||
import { KeyParamsData, SessionBody } from '@standardnotes/responses'
|
||||
|
||||
export interface SignInWithRecoveryCodesResponseBody {
|
||||
session: SessionBody
|
||||
key_params: KeyParamsData
|
||||
user: {
|
||||
uuid: string
|
||||
email: string
|
||||
protocolVersion: string
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.16.5](https://github.com/standardnotes/server/compare/@standardnotes/scheduler-server@1.16.4...@standardnotes/scheduler-server@1.16.5) (2023-01-06)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **scheduler:** change email levels ([79c3e33](https://github.com/standardnotes/server/commit/79c3e33434d186df7240d17cb598914361b1c9fe))
|
||||
|
||||
## [1.16.4](https://github.com/standardnotes/server/compare/@standardnotes/scheduler-server@1.16.3...@standardnotes/scheduler-server@1.16.4) (2022-12-28)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/scheduler-server
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/scheduler-server",
|
||||
"version": "1.16.4",
|
||||
"version": "1.16.5",
|
||||
"engines": {
|
||||
"node": ">=18.0.0 <19.0.0"
|
||||
},
|
||||
|
||||
@@ -97,7 +97,7 @@ export class JobDoneInterpreter implements JobDoneInterpreterInterface {
|
||||
messageIdentifier: 'ENCOURAGE_EMAIL_BACKUPS',
|
||||
subject: getEncourageEmailBackupsSubject(),
|
||||
body: getEncourageEmailBackupsBody(),
|
||||
level: EmailLevel.LEVELS.System,
|
||||
level: EmailLevel.LEVELS.Marketing,
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -113,7 +113,7 @@ export class JobDoneInterpreter implements JobDoneInterpreterInterface {
|
||||
body: getEncourageSubscriptionPurchasingBody(
|
||||
this.timer.convertMicrosecondsToDate(job.createdAt).toLocaleString(),
|
||||
),
|
||||
level: EmailLevel.LEVELS.System,
|
||||
level: EmailLevel.LEVELS.Marketing,
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -127,7 +127,7 @@ export class JobDoneInterpreter implements JobDoneInterpreterInterface {
|
||||
messageIdentifier: 'EXIT_INTERVIEW',
|
||||
subject: getExitInterviewSubject(),
|
||||
body: getExitInterviewBody(),
|
||||
level: EmailLevel.LEVELS.System,
|
||||
level: EmailLevel.LEVELS.Marketing,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [1.19.0](https://github.com/standardnotes/server/compare/@standardnotes/settings@1.18.4...@standardnotes/settings@1.19.0) (2023-01-04)
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** add generating recovery codes ([a360231](https://github.com/standardnotes/server/commit/a360231fd0b04afbc8cfa0ed74c8e21c4edadb4e))
|
||||
|
||||
## [1.18.4](https://github.com/standardnotes/server/compare/@standardnotes/settings@1.18.3...@standardnotes/settings@1.18.4) (2022-11-24)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/settings
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/settings",
|
||||
"version": "1.18.4",
|
||||
"version": "1.19.0",
|
||||
"engines": {
|
||||
"node": ">=18.0.0 <19.0.0"
|
||||
},
|
||||
|
||||
@@ -14,4 +14,5 @@ export enum SettingName {
|
||||
MuteMarketingEmails = 'MUTE_MARKETING_EMAILS',
|
||||
ListedAuthorSecrets = 'LISTED_AUTHOR_SECRETS',
|
||||
LogSessionUserAgent = 'LOG_SESSION_USER_AGENT',
|
||||
RecoveryCodes = 'RECOVERY_CODES',
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.28.5](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.28.4...@standardnotes/syncing-server@1.28.5) (2023-01-04)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/syncing-server
|
||||
|
||||
## [1.28.4](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.28.3...@standardnotes/syncing-server@1.28.4) (2023-01-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/syncing-server",
|
||||
"version": "1.28.4",
|
||||
"version": "1.28.5",
|
||||
"engines": {
|
||||
"node": ">=18.0.0 <19.0.0"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user