Compare commits

...

10 Commits

Author SHA1 Message Date
standardci 1c5c8b81d5 chore(release): publish new version
- @standardnotes/scheduler-server@1.16.5
2023-01-06 08:17:13 +00:00
Karol Sójko 79c3e33434 fix(scheduler): change email levels 2023-01-06 09:15:22 +01:00
standardci 5ab8729a31 chore(release): publish new version
- @standardnotes/auth-server@1.81.2
2023-01-05 13:36:08 +00:00
Karol Sójko db0baf92f1 fix(auth): return type to include user 2023-01-05 14:33:34 +01:00
standardci a8974094db chore(release): publish new version
- @standardnotes/auth-server@1.81.1
2023-01-05 11:31:08 +00:00
Karol Sójko 13c5c97ba7 fix(auth): allow retrieval of recovery codes setting 2023-01-05 12:28:56 +01:00
standardci 894ebb3edd chore(release): publish new version
- @standardnotes/api-gateway@1.45.0
 - @standardnotes/auth-server@1.81.0
2023-01-05 10:44:51 +00:00
Karol Sójko cac899a7e5 feat(auth): add recovery sign in with recovery codes 2023-01-05 11:42:55 +01:00
standardci 901e0dd93b chore(release): publish new version
- @standardnotes/auth-server@1.80.0
 - @standardnotes/settings@1.19.0
 - @standardnotes/syncing-server@1.28.5
2023-01-04 14:31:36 +00:00
Karol Sójko a360231fd0 feat(auth): add generating recovery codes 2023-01-04 15:29:15 +01:00
37 changed files with 984 additions and 14 deletions
+6
View File
@@ -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 -1
View File
@@ -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)
}
}
+24
View File
@@ -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
+1 -1
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'],
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/auth-server",
"version": "1.79.1",
"version": "1.81.2",
"engines": {
"node": ">=18.0.0 <19.0.0"
},
+36
View File
@@ -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)
+3
View File
@@ -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
}
+115 -3
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(),
},
}
}
}
@@ -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)
}
}
@@ -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,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) {
+6
View File
@@ -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 -1
View File
@@ -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,
}),
)
}
+6
View File
@@ -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 -1
View File
@@ -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',
}
+4
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/syncing-server",
"version": "1.28.4",
"version": "1.28.5",
"engines": {
"node": ">=18.0.0 <19.0.0"
},