diff --git a/.pnp.cjs b/.pnp.cjs index 1b1011f72..9765822e4 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -4175,7 +4175,7 @@ const RAW_RUNTIME_STATE = ["@standardnotes/domain-core", "workspace:packages/domain-core"],\ ["@standardnotes/domain-events", "workspace:packages/domain-events"],\ ["@standardnotes/domain-events-infra", "workspace:packages/domain-events-infra"],\ - ["@standardnotes/features", "npm:1.58.9"],\ + ["@standardnotes/features", "npm:1.58.12"],\ ["@standardnotes/predicates", "workspace:packages/predicates"],\ ["@standardnotes/responses", "npm:1.13.9"],\ ["@standardnotes/security", "workspace:packages/security"],\ @@ -4360,10 +4360,10 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["@standardnotes/features", [\ - ["npm:1.58.8", {\ - "packageLocation": "./.yarn/cache/@standardnotes-features-npm-1.58.8-d97ff2aae1-77bac7d0a0.zip/node_modules/@standardnotes/features/",\ + ["npm:1.58.12", {\ + "packageLocation": "./.yarn/cache/@standardnotes-features-npm-1.58.12-9778b78276-3fcd9a9488.zip/node_modules/@standardnotes/features/",\ "packageDependencies": [\ - ["@standardnotes/features", "npm:1.58.8"],\ + ["@standardnotes/features", "npm:1.58.12"],\ ["@standardnotes/common", "workspace:packages/common"],\ ["@standardnotes/domain-core", "workspace:packages/domain-core"],\ ["@standardnotes/security", "workspace:packages/security"],\ @@ -4371,10 +4371,10 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "HARD"\ }],\ - ["npm:1.58.9", {\ - "packageLocation": "./.yarn/cache/@standardnotes-features-npm-1.58.9-c278f712cd-218350ee55.zip/node_modules/@standardnotes/features/",\ + ["npm:1.58.8", {\ + "packageLocation": "./.yarn/cache/@standardnotes-features-npm-1.58.8-d97ff2aae1-77bac7d0a0.zip/node_modules/@standardnotes/features/",\ "packageDependencies": [\ - ["@standardnotes/features", "npm:1.58.9"],\ + ["@standardnotes/features", "npm:1.58.8"],\ ["@standardnotes/common", "workspace:packages/common"],\ ["@standardnotes/domain-core", "workspace:packages/domain-core"],\ ["@standardnotes/security", "workspace:packages/security"],\ diff --git a/.yarn/cache/@standardnotes-features-npm-1.58.9-c278f712cd-218350ee55.zip b/.yarn/cache/@standardnotes-features-npm-1.58.12-9778b78276-3fcd9a9488.zip similarity index 68% rename from .yarn/cache/@standardnotes-features-npm-1.58.9-c278f712cd-218350ee55.zip rename to .yarn/cache/@standardnotes-features-npm-1.58.12-9778b78276-3fcd9a9488.zip index 8237ee664..56c5d5231 100644 Binary files a/.yarn/cache/@standardnotes-features-npm-1.58.9-c278f712cd-218350ee55.zip and b/.yarn/cache/@standardnotes-features-npm-1.58.12-9778b78276-3fcd9a9488.zip differ diff --git a/packages/auth/package.json b/packages/auth/package.json index 42d64bb83..44b991731 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -46,7 +46,7 @@ "@standardnotes/domain-core": "workspace:^", "@standardnotes/domain-events": "workspace:*", "@standardnotes/domain-events-infra": "workspace:*", - "@standardnotes/features": "^1.58.9", + "@standardnotes/features": "^1.58.12", "@standardnotes/predicates": "workspace:*", "@standardnotes/responses": "^1.13.9", "@standardnotes/security": "workspace:*", diff --git a/packages/auth/src/Bootstrap/Container.ts b/packages/auth/src/Bootstrap/Container.ts index bb381dca7..557797961 100644 --- a/packages/auth/src/Bootstrap/Container.ts +++ b/packages/auth/src/Bootstrap/Container.ts @@ -563,6 +563,8 @@ export class ContainerConfigLoader { container.get(TYPES.AuthenticatorChallengeRepository), container.get(TYPES.U2F_RELYING_PARTY_NAME), container.get(TYPES.U2F_RELYING_PARTY_ID), + container.get(TYPES.UserRepository), + container.get(TYPES.FeatureService), ), ) container @@ -574,6 +576,8 @@ export class ContainerConfigLoader { container.get(TYPES.U2F_RELYING_PARTY_ID), container.get(TYPES.U2F_EXPECTED_ORIGIN), container.get(TYPES.U2F_REQUIRE_USER_VERIFICATION), + container.get(TYPES.UserRepository), + container.get(TYPES.FeatureService), ), ) container @@ -599,10 +603,22 @@ export class ContainerConfigLoader { ) container .bind(TYPES.ListAuthenticators) - .toConstantValue(new ListAuthenticators(container.get(TYPES.AuthenticatorRepository))) + .toConstantValue( + new ListAuthenticators( + container.get(TYPES.AuthenticatorRepository), + container.get(TYPES.UserRepository), + container.get(TYPES.FeatureService), + ), + ) container .bind(TYPES.DeleteAuthenticator) - .toConstantValue(new DeleteAuthenticator(container.get(TYPES.AuthenticatorRepository))) + .toConstantValue( + new DeleteAuthenticator( + container.get(TYPES.AuthenticatorRepository), + container.get(TYPES.UserRepository), + container.get(TYPES.FeatureService), + ), + ) container .bind(TYPES.GenerateRecoveryCodes) .toConstantValue( diff --git a/packages/auth/src/Controller/AuthenticatorsController.ts b/packages/auth/src/Controller/AuthenticatorsController.ts index cfbf9da40..ad13b7096 100644 --- a/packages/auth/src/Controller/AuthenticatorsController.ts +++ b/packages/auth/src/Controller/AuthenticatorsController.ts @@ -34,6 +34,17 @@ export class AuthenticatorsController { userUuid: params.userUuid, }) + if (result.isFailed()) { + return { + status: HttpStatusCode.Unauthorized, + data: { + error: { + message: result.getError(), + }, + }, + } + } + return { status: HttpStatusCode.Success, data: { @@ -50,6 +61,17 @@ export class AuthenticatorsController { authenticatorId: params.authenticatorId, }) + if (result.isFailed()) { + return { + status: HttpStatusCode.Unauthorized, + data: { + error: { + message: result.getError(), + }, + }, + } + } + return { status: HttpStatusCode.Success, data: { diff --git a/packages/auth/src/Domain/Feature/FeatureService.spec.ts b/packages/auth/src/Domain/Feature/FeatureService.spec.ts index 4f96470e7..3edad38d8 100644 --- a/packages/auth/src/Domain/Feature/FeatureService.spec.ts +++ b/packages/auth/src/Domain/Feature/FeatureService.spec.ts @@ -30,7 +30,7 @@ jest.mock('@standardnotes/features', () => { const { GetFeatures } = jest.requireMock('@standardnotes/features') import { FeatureService } from './FeatureService' -import { Permission, PermissionName } from '@standardnotes/features' +import { FeatureIdentifier, Permission, PermissionName } from '@standardnotes/features' import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface' import { TimerInterface } from '@standardnotes/time' import { OfflineUserSubscription } from '../Subscription/OfflineUserSubscription' @@ -201,6 +201,62 @@ describe('FeatureService', () => { }) describe('online subscribers', () => { + it('should tell if a user is entitled to a feature', async () => { + expect(await createService().userIsEntitledToFeature(user, FeatureIdentifier.AutobiographyTheme)).toBe(true) + expect(await createService().userIsEntitledToFeature(user, FeatureIdentifier.DeprecatedBoldEditor)).toBe(false) + }) + + it('should tell if a user is not entitled to a feature because it is expired', async () => { + timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(777) + expect(await createService().userIsEntitledToFeature(user, FeatureIdentifier.AutobiographyTheme)).toBe(false) + }) + + it('should tell if a user is entitled to a feature that does not expire', async () => { + const nonSubscriptionPermission = { + uuid: 'files-beta-permission-1-1-1', + name: PermissionName.FilesBeta, + } as jest.Mocked + + GetFeatures.mockImplementation(() => [ + { + identifier: 'org.standardnotes.theme-autobiography', + permission_name: PermissionName.AutobiographyTheme, + expires_at: 555, + }, + { + identifier: 'org.standardnotes.bold-editor', + permission_name: PermissionName.BoldEditor, + expires_at: 777, + }, + { + identifier: 'files-beta', + permission_name: PermissionName.FilesBeta, + expires_at: undefined, + no_expire: true, + }, + ]) + + const nonSubscriptionRole = { + name: RoleName.NAMES.InternalTeamUser, + uuid: 'role-files-beta', + permissions: Promise.resolve([nonSubscriptionPermission]), + } as jest.Mocked + + roleToSubscriptionMap.filterNonSubscriptionRoles = jest.fn().mockReturnValue([nonSubscriptionRole]) + roleToSubscriptionMap.getSubscriptionNameForRoleName = jest + .fn() + .mockReturnValueOnce(SubscriptionName.PlusPlan) + .mockReturnValueOnce(SubscriptionName.ProPlan) + + user = { + uuid: 'user-1-1-1', + roles: Promise.resolve([role1, role2, nonSubscriptionRole]), + subscriptions: Promise.resolve([subscription1, subscription2]), + } as jest.Mocked + + expect(await createService().userIsEntitledToFeature(user, 'files-beta')).toBe(true) + }) + it('should return user features with `expires_at` field', async () => { const features = await createService().getFeaturesForUser(user) expect(features).toEqual( @@ -336,6 +392,7 @@ describe('FeatureService', () => { expires_at: 777, }, { + identifier: 'files-beta', permission_name: PermissionName.FilesBeta, expires_at: undefined, no_expire: true, diff --git a/packages/auth/src/Domain/Feature/FeatureService.ts b/packages/auth/src/Domain/Feature/FeatureService.ts index 6850c5edb..92b27bf9f 100644 --- a/packages/auth/src/Domain/Feature/FeatureService.ts +++ b/packages/auth/src/Domain/Feature/FeatureService.ts @@ -21,6 +21,25 @@ export class FeatureService implements FeatureServiceInterface { @inject(TYPES.Timer) private timer: TimerInterface, ) {} + async userIsEntitledToFeature(user: User, featureIdentifier: string): Promise { + const userFeatures = await this.getFeaturesForUser(user) + + const feature = userFeatures.find((userFeature) => userFeature.identifier === featureIdentifier) + + if (feature === undefined) { + return false + } + + if (feature.no_expire) { + return true + } + + const featureIsExpired = + feature.expires_at !== undefined && feature.expires_at < this.timer.getTimestampInMicroseconds() + + return !featureIsExpired + } + async getFeaturesForOfflineUser(email: string): Promise<{ features: FeatureDescription[]; roles: string[] }> { const userSubscriptions = await this.offlineUserSubscriptionRepository.findByEmail( email, diff --git a/packages/auth/src/Domain/Feature/FeatureServiceInterface.ts b/packages/auth/src/Domain/Feature/FeatureServiceInterface.ts index 551687d13..379dc256f 100644 --- a/packages/auth/src/Domain/Feature/FeatureServiceInterface.ts +++ b/packages/auth/src/Domain/Feature/FeatureServiceInterface.ts @@ -4,5 +4,6 @@ import { User } from '../User/User' export interface FeatureServiceInterface { getFeaturesForUser(user: User): Promise> + userIsEntitledToFeature(user: User, featureIdentifier: string): Promise getFeaturesForOfflineUser(email: string): Promise<{ features: FeatureDescription[]; roles: string[] }> } diff --git a/packages/auth/src/Domain/UseCase/DeleteAuthenticator/DeleteAuthenticator.spec.ts b/packages/auth/src/Domain/UseCase/DeleteAuthenticator/DeleteAuthenticator.spec.ts index a67e48971..0ecaebd14 100644 --- a/packages/auth/src/Domain/UseCase/DeleteAuthenticator/DeleteAuthenticator.spec.ts +++ b/packages/auth/src/Domain/UseCase/DeleteAuthenticator/DeleteAuthenticator.spec.ts @@ -2,12 +2,18 @@ import { Dates, Uuid } from '@standardnotes/domain-core' import { Authenticator } from '../../Authenticator/Authenticator' import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface' +import { FeatureServiceInterface } from '../../Feature/FeatureServiceInterface' +import { User } from '../../User/User' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' import { DeleteAuthenticator } from './DeleteAuthenticator' describe('DeleteAuthenticator', () => { let authenticatorRepository: AuthenticatorRepositoryInterface let authenticator: Authenticator - const createUseCase = () => new DeleteAuthenticator(authenticatorRepository) + let userRepository: UserRepositoryInterface + let featureService: FeatureServiceInterface + + const createUseCase = () => new DeleteAuthenticator(authenticatorRepository, userRepository, featureService) beforeEach(() => { authenticator = Authenticator.create({ @@ -24,6 +30,12 @@ describe('DeleteAuthenticator', () => { authenticatorRepository = {} as jest.Mocked authenticatorRepository.findById = jest.fn().mockReturnValue(authenticator) authenticatorRepository.remove = jest.fn() + + userRepository = {} as jest.Mocked + userRepository.findOneByUuid = jest.fn().mockReturnValue({} as jest.Mocked) + + featureService = {} as jest.Mocked + featureService.userIsEntitledToFeature = jest.fn().mockReturnValue(true) }) it('should return error if authenticator not found', async () => { @@ -38,6 +50,40 @@ describe('DeleteAuthenticator', () => { expect(result.getError()).toEqual('Authenticator not found') }) + it('should return error if user is not found', async () => { + userRepository.findOneByUuid = jest.fn().mockReturnValue(null) + + const result = await createUseCase().execute({ + userUuid: '00000000-0000-0000-0000-000000000000', + authenticatorId: '00000000-0000-0000-0000-000000000000', + }) + + expect(result.isFailed()).toBe(true) + expect(result.getError()).toEqual('Could not delete authenticator: user not found.') + }) + + it('should return error if user is not entitled to U2F', async () => { + featureService.userIsEntitledToFeature = jest.fn().mockReturnValue(false) + + const result = await createUseCase().execute({ + userUuid: '00000000-0000-0000-0000-000000000000', + authenticatorId: '00000000-0000-0000-0000-000000000000', + }) + + expect(result.isFailed()).toBe(true) + expect(result.getError()).toEqual('Could not delete authenticator: user is not entitled to U2F.') + }) + + it('should return error if user uuid is not valid', async () => { + const result = await createUseCase().execute({ + userUuid: 'invalid', + authenticatorId: '00000000-0000-0000-0000-000000000000', + }) + + expect(result.isFailed()).toBe(true) + expect(result.getError()).toEqual('Could not delete authenticator: Given value is not a valid uuid: invalid') + }) + it('should return error if authenticator does not belong to user', async () => { authenticatorRepository.findById = jest.fn().mockReturnValue({ ...authenticator, diff --git a/packages/auth/src/Domain/UseCase/DeleteAuthenticator/DeleteAuthenticator.ts b/packages/auth/src/Domain/UseCase/DeleteAuthenticator/DeleteAuthenticator.ts index 9c61fa273..45fdc313e 100644 --- a/packages/auth/src/Domain/UseCase/DeleteAuthenticator/DeleteAuthenticator.ts +++ b/packages/auth/src/Domain/UseCase/DeleteAuthenticator/DeleteAuthenticator.ts @@ -1,12 +1,41 @@ -import { Result, UniqueEntityId, UseCaseInterface } from '@standardnotes/domain-core' +import { Result, UniqueEntityId, UseCaseInterface, Uuid } from '@standardnotes/domain-core' +import { FeatureIdentifier } from '@standardnotes/features' + import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface' +import { FeatureServiceInterface } from '../../Feature/FeatureServiceInterface' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' import { DeleteAuthenticatorDTO } from './DeleteAuthenticatorDTO' export class DeleteAuthenticator implements UseCaseInterface { - constructor(private authenticatorRepository: AuthenticatorRepositoryInterface) {} + constructor( + private authenticatorRepository: AuthenticatorRepositoryInterface, + private userRepository: UserRepositoryInterface, + private featureService: FeatureServiceInterface, + ) {} + async execute(dto: DeleteAuthenticatorDTO): Promise> { + const userUuidOrError = Uuid.create(dto.userUuid) + if (userUuidOrError.isFailed()) { + return Result.fail(`Could not delete authenticator: ${userUuidOrError.getError()}`) + } + const userUuid = userUuidOrError.getValue() + + const user = await this.userRepository.findOneByUuid(userUuid.value) + if (user === null) { + return Result.fail('Could not delete authenticator: user not found.') + } + + const userIsEntitledToU2F = await this.featureService.userIsEntitledToFeature( + user, + FeatureIdentifier.UniversalSecondFactor, + ) + + if (!userIsEntitledToU2F) { + return Result.fail('Could not delete authenticator: user is not entitled to U2F.') + } + const authenticator = await this.authenticatorRepository.findById(new UniqueEntityId(dto.authenticatorId)) - if (!authenticator || authenticator.props.userUuid.value !== dto.userUuid) { + if (!authenticator || authenticator.props.userUuid.value !== userUuid.value) { return Result.fail('Authenticator not found') } diff --git a/packages/auth/src/Domain/UseCase/GenerateAuthenticatorRegistrationOptions/GenerateAuthenticatorRegistrationOptions.spec.ts b/packages/auth/src/Domain/UseCase/GenerateAuthenticatorRegistrationOptions/GenerateAuthenticatorRegistrationOptions.spec.ts index 17ebac431..c353739da 100644 --- a/packages/auth/src/Domain/UseCase/GenerateAuthenticatorRegistrationOptions/GenerateAuthenticatorRegistrationOptions.spec.ts +++ b/packages/auth/src/Domain/UseCase/GenerateAuthenticatorRegistrationOptions/GenerateAuthenticatorRegistrationOptions.spec.ts @@ -4,11 +4,16 @@ import { Authenticator } from '../../Authenticator/Authenticator' import { AuthenticatorChallenge } from '../../Authenticator/AuthenticatorChallenge' import { AuthenticatorChallengeRepositoryInterface } from '../../Authenticator/AuthenticatorChallengeRepositoryInterface' import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface' +import { FeatureServiceInterface } from '../../Feature/FeatureServiceInterface' +import { User } from '../../User/User' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' import { GenerateAuthenticatorRegistrationOptions } from './GenerateAuthenticatorRegistrationOptions' describe('GenerateAuthenticatorRegistrationOptions', () => { let authenticatorRepository: AuthenticatorRepositoryInterface let authenticatorChallengeRepository: AuthenticatorChallengeRepositoryInterface + let userRepository: UserRepositoryInterface + let featureService: FeatureServiceInterface const createUseCase = () => new GenerateAuthenticatorRegistrationOptions( @@ -16,6 +21,8 @@ describe('GenerateAuthenticatorRegistrationOptions', () => { authenticatorChallengeRepository, 'Standard Notes', 'standardnotes.com', + userRepository, + featureService, ) beforeEach(() => { @@ -35,6 +42,12 @@ describe('GenerateAuthenticatorRegistrationOptions', () => { authenticatorChallengeRepository = {} as jest.Mocked authenticatorChallengeRepository.save = jest.fn() + + userRepository = {} as jest.Mocked + userRepository.findOneByUuid = jest.fn().mockReturnValue({} as jest.Mocked) + + featureService = {} as jest.Mocked + featureService.userIsEntitledToFeature = jest.fn().mockReturnValue(true) }) it('should return error if userUuid is invalid', async () => { @@ -63,6 +76,36 @@ describe('GenerateAuthenticatorRegistrationOptions', () => { expect(result.getError()).toBe('Could not generate authenticator registration options: Username cannot be empty') }) + it('should return error if user is not entitled to u2f feature', async () => { + featureService.userIsEntitledToFeature = jest.fn().mockReturnValue(false) + + const useCase = createUseCase() + + const result = await useCase.execute({ + userUuid: '00000000-0000-0000-0000-000000000000', + username: 'username', + }) + + expect(result.isFailed()).toBe(true) + expect(result.getError()).toBe( + 'Could not generate authenticator registration options: user is not entitled to U2F.', + ) + }) + + it('should return error if user is not found', async () => { + userRepository.findOneByUuid = jest.fn().mockReturnValue(null) + + const useCase = createUseCase() + + const result = await useCase.execute({ + userUuid: '00000000-0000-0000-0000-000000000000', + username: 'username', + }) + + expect(result.isFailed()).toBe(true) + expect(result.getError()).toBe('Could not generate authenticator registration options: user not found.') + }) + it('should return error if authenticator challenge is invalid', async () => { const mock = jest.spyOn(AuthenticatorChallenge, 'create') mock.mockReturnValue(Result.fail('Oops')) diff --git a/packages/auth/src/Domain/UseCase/GenerateAuthenticatorRegistrationOptions/GenerateAuthenticatorRegistrationOptions.ts b/packages/auth/src/Domain/UseCase/GenerateAuthenticatorRegistrationOptions/GenerateAuthenticatorRegistrationOptions.ts index cc2ca9fc4..b24b211e3 100644 --- a/packages/auth/src/Domain/UseCase/GenerateAuthenticatorRegistrationOptions/GenerateAuthenticatorRegistrationOptions.ts +++ b/packages/auth/src/Domain/UseCase/GenerateAuthenticatorRegistrationOptions/GenerateAuthenticatorRegistrationOptions.ts @@ -5,6 +5,9 @@ import { GenerateAuthenticatorRegistrationOptionsDTO } from './GenerateAuthentic import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface' import { AuthenticatorChallengeRepositoryInterface } from '../../Authenticator/AuthenticatorChallengeRepositoryInterface' import { AuthenticatorChallenge } from '../../Authenticator/AuthenticatorChallenge' +import { FeatureIdentifier } from '@standardnotes/features' +import { FeatureServiceInterface } from '../../Feature/FeatureServiceInterface' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' export class GenerateAuthenticatorRegistrationOptions implements UseCaseInterface> { constructor( @@ -12,6 +15,8 @@ export class GenerateAuthenticatorRegistrationOptions implements UseCaseInterfac private authenticatorChallengeRepository: AuthenticatorChallengeRepositoryInterface, private relyingPartyName: string, private relyingPartyId: string, + private userRepository: UserRepositoryInterface, + private featureService: FeatureServiceInterface, ) {} async execute(dto: GenerateAuthenticatorRegistrationOptionsDTO): Promise>> { @@ -27,6 +32,20 @@ export class GenerateAuthenticatorRegistrationOptions implements UseCaseInterfac } const username = usernameOrError.getValue() + const user = await this.userRepository.findOneByUuid(userUuid.value) + if (user === null) { + return Result.fail('Could not generate authenticator registration options: user not found.') + } + + const userIsEntitledToU2F = await this.featureService.userIsEntitledToFeature( + user, + FeatureIdentifier.UniversalSecondFactor, + ) + + if (!userIsEntitledToU2F) { + return Result.fail('Could not generate authenticator registration options: user is not entitled to U2F.') + } + const authenticators = await this.authenticatorRepository.findByUserUuid(userUuid) const options = generateRegistrationOptions({ rpID: this.relyingPartyId, diff --git a/packages/auth/src/Domain/UseCase/ListAuthenticators/ListAuthenticators.spec.ts b/packages/auth/src/Domain/UseCase/ListAuthenticators/ListAuthenticators.spec.ts index af6b0953a..5cf2166bf 100644 --- a/packages/auth/src/Domain/UseCase/ListAuthenticators/ListAuthenticators.spec.ts +++ b/packages/auth/src/Domain/UseCase/ListAuthenticators/ListAuthenticators.spec.ts @@ -1,14 +1,25 @@ import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface' +import { FeatureServiceInterface } from '../../Feature/FeatureServiceInterface' +import { User } from '../../User/User' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' import { ListAuthenticators } from './ListAuthenticators' describe('ListAuthenticators', () => { let authenticatorRepository: AuthenticatorRepositoryInterface + let userRepository: UserRepositoryInterface + let featureService: FeatureServiceInterface - const createUseCase = () => new ListAuthenticators(authenticatorRepository) + const createUseCase = () => new ListAuthenticators(authenticatorRepository, userRepository, featureService) beforeEach(() => { authenticatorRepository = {} as jest.Mocked authenticatorRepository.findByUserUuid = jest.fn().mockReturnValue([]) + + userRepository = {} as jest.Mocked + userRepository.findOneByUuid = jest.fn().mockReturnValue({} as jest.Mocked) + + featureService = {} as jest.Mocked + featureService.userIsEntitledToFeature = jest.fn().mockReturnValue(true) }) it('should list authenticators', async () => { @@ -27,4 +38,24 @@ describe('ListAuthenticators', () => { expect(result.isFailed()).toBeTruthy() }) + + it('should fail if user is not found', async () => { + userRepository.findOneByUuid = jest.fn().mockReturnValue(null) + + const useCase = createUseCase() + + const result = await useCase.execute({ userUuid: '00000000-0000-0000-0000-000000000000' }) + + expect(result.isFailed()).toBeTruthy() + }) + + it('should fail if user is not entitled to U2F', async () => { + featureService.userIsEntitledToFeature = jest.fn().mockReturnValue(false) + + const useCase = createUseCase() + + const result = await useCase.execute({ userUuid: '00000000-0000-0000-0000-000000000000' }) + + expect(result.isFailed()).toBeTruthy() + }) }) diff --git a/packages/auth/src/Domain/UseCase/ListAuthenticators/ListAuthenticators.ts b/packages/auth/src/Domain/UseCase/ListAuthenticators/ListAuthenticators.ts index 6e14fdf52..6e4521df5 100644 --- a/packages/auth/src/Domain/UseCase/ListAuthenticators/ListAuthenticators.ts +++ b/packages/auth/src/Domain/UseCase/ListAuthenticators/ListAuthenticators.ts @@ -1,11 +1,19 @@ import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core' +import { FeatureIdentifier } from '@standardnotes/features' import { Authenticator } from '../../Authenticator/Authenticator' import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface' +import { FeatureServiceInterface } from '../../Feature/FeatureServiceInterface' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' import { ListAuthenticatorsDTO } from './ListAuthenticatorsDTO' export class ListAuthenticators implements UseCaseInterface { - constructor(private authenticatorRepository: AuthenticatorRepositoryInterface) {} + constructor( + private authenticatorRepository: AuthenticatorRepositoryInterface, + private userRepository: UserRepositoryInterface, + private featureService: FeatureServiceInterface, + ) {} + async execute(dto: ListAuthenticatorsDTO): Promise> { const userUuidOrError = Uuid.create(dto.userUuid) if (userUuidOrError.isFailed()) { @@ -13,6 +21,20 @@ export class ListAuthenticators implements UseCaseInterface { } const userUuid = userUuidOrError.getValue() + const user = await this.userRepository.findOneByUuid(userUuid.value) + if (user === null) { + return Result.fail('Could not list authenticators: user not found.') + } + + const userIsEntitledToU2F = await this.featureService.userIsEntitledToFeature( + user, + FeatureIdentifier.UniversalSecondFactor, + ) + + if (!userIsEntitledToU2F) { + return Result.fail('Could not list authenticators: user is not entitled to U2F.') + } + const authenticators = await this.authenticatorRepository.findByUserUuid(userUuid) return Result.ok(authenticators) diff --git a/packages/auth/src/Domain/UseCase/VerifyAuthenticatorRegistrationResponse/VerifyAuthenticatorRegistrationResponse.spec.ts b/packages/auth/src/Domain/UseCase/VerifyAuthenticatorRegistrationResponse/VerifyAuthenticatorRegistrationResponse.spec.ts index 944243de8..57b4b09ae 100644 --- a/packages/auth/src/Domain/UseCase/VerifyAuthenticatorRegistrationResponse/VerifyAuthenticatorRegistrationResponse.spec.ts +++ b/packages/auth/src/Domain/UseCase/VerifyAuthenticatorRegistrationResponse/VerifyAuthenticatorRegistrationResponse.spec.ts @@ -7,11 +7,16 @@ import { Authenticator } from '../../Authenticator/Authenticator' import { AuthenticatorChallenge } from '../../Authenticator/AuthenticatorChallenge' import { AuthenticatorChallengeRepositoryInterface } from '../../Authenticator/AuthenticatorChallengeRepositoryInterface' import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface' +import { FeatureServiceInterface } from '../../Feature/FeatureServiceInterface' +import { User } from '../../User/User' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' import { VerifyAuthenticatorRegistrationResponse } from './VerifyAuthenticatorRegistrationResponse' describe('VerifyAuthenticatorRegistrationResponse', () => { let authenticatorRepository: AuthenticatorRepositoryInterface let authenticatorChallengeRepository: AuthenticatorChallengeRepositoryInterface + let userRepository: UserRepositoryInterface + let featureService: FeatureServiceInterface const createUseCase = () => new VerifyAuthenticatorRegistrationResponse( @@ -20,6 +25,8 @@ describe('VerifyAuthenticatorRegistrationResponse', () => { 'standardnotes.com', ['localhost', 'https://app.standardnotes.com'], true, + userRepository, + featureService, ) beforeEach(() => { @@ -32,6 +39,12 @@ describe('VerifyAuthenticatorRegistrationResponse', () => { challenge: Buffer.from('challenge'), }, } as jest.Mocked) + + userRepository = {} as jest.Mocked + userRepository.findOneByUuid = jest.fn().mockReturnValue({} as jest.Mocked) + + featureService = {} as jest.Mocked + featureService.userIsEntitledToFeature = jest.fn().mockReturnValue(true) }) it('should return error if user uuid is invalid', async () => { @@ -57,6 +70,54 @@ describe('VerifyAuthenticatorRegistrationResponse', () => { ) }) + it('should return error if user is not entitled to feature', async () => { + featureService.userIsEntitledToFeature = jest.fn().mockReturnValue(false) + + const useCase = createUseCase() + + const result = await useCase.execute({ + userUuid: '00000000-0000-0000-0000-000000000000', + attestationResponse: { + id: 'id', + rawId: 'rawId', + response: { + attestationObject: 'attestationObject', + clientDataJSON: 'clientDataJSON', + }, + type: 'public-key', + clientExtensionResults: {}, + } as jest.Mocked, + }) + + expect(result.isFailed()).toBeTruthy() + expect(result.getError()).toEqual( + 'Could not verify authenticator registration response: user is not entitled to U2F.', + ) + }) + + it('should return error if user is not found', async () => { + userRepository.findOneByUuid = jest.fn().mockReturnValue(null) + + const useCase = createUseCase() + + const result = await useCase.execute({ + userUuid: '00000000-0000-0000-0000-000000000000', + attestationResponse: { + id: 'id', + rawId: 'rawId', + response: { + attestationObject: 'attestationObject', + clientDataJSON: 'clientDataJSON', + }, + type: 'public-key', + clientExtensionResults: {}, + } as jest.Mocked, + }) + + expect(result.isFailed()).toBeTruthy() + expect(result.getError()).toEqual('Could not verify authenticator registration response: user not found.') + }) + it('should return error if challenge is not found', async () => { authenticatorChallengeRepository.findByUserUuid = jest.fn().mockReturnValue(null) diff --git a/packages/auth/src/Domain/UseCase/VerifyAuthenticatorRegistrationResponse/VerifyAuthenticatorRegistrationResponse.ts b/packages/auth/src/Domain/UseCase/VerifyAuthenticatorRegistrationResponse/VerifyAuthenticatorRegistrationResponse.ts index 94b4d2681..ea759f6c5 100644 --- a/packages/auth/src/Domain/UseCase/VerifyAuthenticatorRegistrationResponse/VerifyAuthenticatorRegistrationResponse.ts +++ b/packages/auth/src/Domain/UseCase/VerifyAuthenticatorRegistrationResponse/VerifyAuthenticatorRegistrationResponse.ts @@ -1,10 +1,13 @@ import { Dates, Result, UniqueEntityId, UseCaseInterface, Uuid } from '@standardnotes/domain-core' import { VerifiedRegistrationResponse, verifyRegistrationResponse } from '@simplewebauthn/server' +import { FeatureIdentifier } from '@standardnotes/features' import { AuthenticatorChallengeRepositoryInterface } from '../../Authenticator/AuthenticatorChallengeRepositoryInterface' import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface' import { Authenticator } from '../../Authenticator/Authenticator' import { VerifyAuthenticatorRegistrationResponseDTO } from './VerifyAuthenticatorRegistrationResponseDTO' +import { FeatureServiceInterface } from '../../Feature/FeatureServiceInterface' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' export class VerifyAuthenticatorRegistrationResponse implements UseCaseInterface { constructor( @@ -13,6 +16,8 @@ export class VerifyAuthenticatorRegistrationResponse implements UseCaseInterface private relyingPartyId: string, private expectedOrigin: string[], private requireUserVerification: boolean, + private userRepository: UserRepositoryInterface, + private featureService: FeatureServiceInterface, ) {} async execute(dto: VerifyAuthenticatorRegistrationResponseDTO): Promise> { @@ -22,6 +27,20 @@ export class VerifyAuthenticatorRegistrationResponse implements UseCaseInterface } const userUuid = userUuidOrError.getValue() + const user = await this.userRepository.findOneByUuid(userUuid.value) + if (user === null) { + return Result.fail('Could not verify authenticator registration response: user not found.') + } + + const userIsEntitledToU2F = await this.featureService.userIsEntitledToFeature( + user, + FeatureIdentifier.UniversalSecondFactor, + ) + + if (!userIsEntitledToU2F) { + return Result.fail('Could not verify authenticator registration response: user is not entitled to U2F.') + } + const authenticatorChallenge = await this.authenticatorChallengeRepository.findByUserUuid(userUuid) if (!authenticatorChallenge) { return Result.fail('Could not verify authenticator registration response: challenge not found') diff --git a/yarn.lock b/yarn.lock index 0b1d5649d..b10cf7ff8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3299,7 +3299,7 @@ __metadata: "@standardnotes/domain-core": "workspace:^" "@standardnotes/domain-events": "workspace:*" "@standardnotes/domain-events-infra": "workspace:*" - "@standardnotes/features": "npm:^1.58.9" + "@standardnotes/features": "npm:^1.58.12" "@standardnotes/predicates": "workspace:*" "@standardnotes/responses": "npm:^1.13.9" "@standardnotes/security": "workspace:*" @@ -3482,15 +3482,15 @@ __metadata: languageName: node linkType: hard -"@standardnotes/features@npm:^1.58.9": - version: 1.58.9 - resolution: "@standardnotes/features@npm:1.58.9" +"@standardnotes/features@npm:^1.58.12": + version: 1.58.12 + resolution: "@standardnotes/features@npm:1.58.12" dependencies: "@standardnotes/common": "npm:^1.46.6" "@standardnotes/domain-core": "npm:^1.11.3" "@standardnotes/security": "npm:^1.7.6" reflect-metadata: "npm:^0.1.13" - checksum: 218350ee55d2f920e26c4041e1e307655cf9e755b83c7fd2165be2222d95b40154c0d325a362cc84ce960ccf8c07c6d95c6a8558ddabf6ee335462cf6bd22508 + checksum: 3fcd9a948848cf6fe567390a7740222fd96d10b8a9bceeaf608befcd7e24ac7374e1c87ed51c12ab62a9ed6036b3c6da82c78ab58f6b0c3f0c3c9aaa2b7ffdfe languageName: node linkType: hard