Merge branch 'chore/update-latest-code' into gh-main

This commit is contained in:
Antonella Sgarlatta
2026-09-10 10:02:34 -03:00
70 changed files with 1783 additions and 428 deletions
+7
View File
@@ -24,3 +24,10 @@ CACHE_TYPE=redis
AUTH_JWT_SECRET=
AUTH_SERVER_ENCRYPTION_SERVER_KEY=
VALET_TOKEN_SECRET=
# (Optional) U2F Setup
AUTH_SERVER_U2F_RELYING_PARTY_ID=
AUTH_SERVER_U2F_RELYING_PARTY_NAME=
AUTH_SERVER_U2F_EXPECTED_ORIGIN=
AUTH_SERVER_U2F_REQUIRE_USER_VERIFICATION=
AUTH_SERVER_U2F_CHALLENGE_MAX_AGE_SECONDS=
+3
View File
@@ -231,6 +231,9 @@ fi
if [ -z "$AUTH_SERVER_U2F_REQUIRE_USER_VERIFICATION" ]; then
export AUTH_SERVER_U2F_REQUIRE_USER_VERIFICATION=false
fi
if [ -z "$AUTH_SERVER_U2F_CHALLENGE_MAX_AGE_SECONDS" ]; then
export AUTH_SERVER_U2F_CHALLENGE_MAX_AGE_SECONDS=300
fi
printenv | grep AUTH_SERVER_ | sed 's/AUTH_SERVER_//g' > /opt/server/packages/auth/.env
@@ -30,14 +30,14 @@ export abstract class AuthMiddleware extends BaseMiddleware {
const authHeaderValue = request.headers.authorization as string
const sharedVaultOwnerContextHeaderValue = request.headers['x-shared-vault-owner-context'] as string | undefined
const cacheKey = `${authHeaderValue}${
sharedVaultOwnerContextHeaderValue ? `:${sharedVaultOwnerContextHeaderValue}` : ''
}`
const shouldUseCrossServiceTokenCache =
!!this.crossServiceTokenCacheTTL && sharedVaultOwnerContextHeaderValue === undefined
const cacheKey = authHeaderValue
try {
let crossServiceTokenFetchedFromCache = true
let crossServiceToken = null
if (this.crossServiceTokenCacheTTL) {
if (shouldUseCrossServiceTokenCache) {
crossServiceToken = await this.crossServiceTokenCache.get(cacheKey)
}
@@ -83,7 +83,7 @@ export abstract class AuthMiddleware extends BaseMiddleware {
const decodedToken = <CrossServiceTokenData>verify(crossServiceToken, this.jwtSecret, { algorithms: ['HS256'] })
if (this.crossServiceTokenCacheTTL && !crossServiceTokenFetchedFromCache) {
if (shouldUseCrossServiceTokenCache && !crossServiceTokenFetchedFromCache) {
await this.crossServiceTokenCache.set({
key: cacheKey,
encodedCrossServiceToken: crossServiceToken,
@@ -44,4 +44,14 @@ export class OfflineController extends BaseHttpController {
request.body,
)
}
@httpPost('/payments/checkout-session')
async createOfflineCheckoutSession(request: Request, response: Response): Promise<void> {
await this.httpService.callPaymentsServer(request, response, 'api/pro_users/checkout-session/offline', request.body)
}
@httpGet('/payments/get-bt-token')
async getOfflineBraintreeToken(request: Request, response: Response): Promise<void> {
await this.httpService.callPaymentsServer(request, response, 'api/pro_users/get-bt-token/offline', request.body)
}
}
@@ -145,6 +145,11 @@ export class PaymentsController extends BaseHttpController {
await this.httpService.callPaymentsServer(request, response, 'api/pro_users/stripe-setup-intent', request.body)
}
@httpGet('/pro_users/get-bt-token', TYPES.ApiGateway_SubscriptionTokenAuthMiddleware)
async getBraintreeToken(request: Request, response: Response): Promise<void> {
await this.httpService.callPaymentsServer(request, response, 'api/pro_users/get-bt-token', request.body)
}
@all('/pro_users(/*)?')
async proUsers(request: Request, response: Response): Promise<void> {
await this.httpService.callPaymentsServer(request, response, request.path.replace('v1', 'api'), request.body)
@@ -96,7 +96,7 @@ export class EndpointResolver implements EndpointResolverInterface {
resolveEndpointOrMethodIdentifier(method: string, endpoint: string, ...params: string[]): string {
if (!this.isConfiguredForHomeServer) {
if (params.length > 0) {
return params.reduce((acc, param) => acc.replace(/:[a-zA-Z0-9]+/, param), endpoint)
return params.reduce((acc, param) => acc.replace(/:[a-zA-Z0-9]+/, encodeURIComponent(param)), endpoint)
}
return endpoint
+1
View File
@@ -61,6 +61,7 @@ CAPTCHA_UI_URL=
U2F_RELYING_PARTY_ID=
U2F_RELYING_PARTY_NAME=
U2F_EXPECTED_ORIGIN=
U2F_CHALLENGE_MAX_AGE_SECONDS=
# Application versiom greater than this var will result in cross service token version 2
APPLICATION_VERSION_THRESHOLD_FOR_TOKEN_VERSION_2=
@@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class AddAuthenticatorsUserUuidIndex1778037105000 implements MigrationInterface {
name = 'AddAuthenticatorsUserUuidIndex1778037105000'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('CREATE INDEX `index_authenticators_on_user_uuid` ON `authenticators` (`user_uuid`)')
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX `index_authenticators_on_user_uuid` ON `authenticators`')
}
}
@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class UniqueIndexSubscriptionSettings1782907913924 implements MigrationInterface {
name = 'UniqueIndexSubscriptionSettings1782907913924'
public async up(queryRunner: QueryRunner): Promise<void> {
// Remove duplicate (name, user_subscription_uuid) rows, keeping only the most recently updated
// one per pair (tie-broken deterministically by uuid) so the unique index below can be created.
await queryRunner.query(
'DELETE s1 FROM `subscription_settings` s1 ' +
'INNER JOIN `subscription_settings` s2 ' +
'ON s1.name = s2.name ' +
'AND s1.user_subscription_uuid = s2.user_subscription_uuid ' +
'AND (s2.updated_at > s1.updated_at OR (s2.updated_at = s1.updated_at AND s2.uuid > s1.uuid))',
)
await queryRunner.query('DROP INDEX `index_settings_on_name_and_user_subscription_uuid` ON `subscription_settings`')
await queryRunner.query(
'CREATE UNIQUE INDEX `index_settings_on_name_and_user_subscription_uuid` ON `subscription_settings` (`name`, `user_subscription_uuid`)',
)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX `index_settings_on_name_and_user_subscription_uuid` ON `subscription_settings`')
await queryRunner.query(
'CREATE INDEX `index_settings_on_name_and_user_subscription_uuid` ON `subscription_settings` (`name`, `user_subscription_uuid`)',
)
}
}
@@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class AddAuthenticatorsUserUuidIndex1778037105000 implements MigrationInterface {
name = 'AddAuthenticatorsUserUuidIndex1778037105000'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('CREATE INDEX "index_authenticators_on_user_uuid" ON "authenticators" ("user_uuid")')
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX "index_authenticators_on_user_uuid"')
}
}
@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class UniqueIndexSubscriptionSettings1782907913924 implements MigrationInterface {
name = 'UniqueIndexSubscriptionSettings1782907913924'
public async up(queryRunner: QueryRunner): Promise<void> {
// Remove duplicate (name, user_subscription_uuid) rows, keeping only the most recently updated
// one per pair (tie-broken deterministically by uuid) so the unique index below can be created.
await queryRunner.query(
'DELETE FROM "subscription_settings" WHERE uuid NOT IN (' +
'SELECT uuid FROM (' +
'SELECT uuid, ROW_NUMBER() OVER (' +
'PARTITION BY name, user_subscription_uuid ORDER BY updated_at DESC, uuid DESC) rn ' +
'FROM "subscription_settings") WHERE rn = 1)',
)
await queryRunner.query('DROP INDEX "index_settings_on_name_and_user_subscription_uuid"')
await queryRunner.query(
'CREATE UNIQUE INDEX "index_settings_on_name_and_user_subscription_uuid" ON "subscription_settings" ("name", "user_subscription_uuid")',
)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX "index_settings_on_name_and_user_subscription_uuid"')
await queryRunner.query(
'CREATE INDEX "index_settings_on_name_and_user_subscription_uuid" ON "subscription_settings" ("name", "user_subscription_uuid")',
)
}
}
+32 -21
View File
@@ -675,6 +675,12 @@ export class ContainerConfigLoader {
container
.bind(TYPES.Auth_U2F_REQUIRE_USER_VERIFICATION)
.toConstantValue(env.get('U2F_REQUIRE_USER_VERIFICATION', true) === 'true')
const challengeMaxAgeSeconds = Number(env.get('U2F_CHALLENGE_MAX_AGE_SECONDS', true))
container
.bind(TYPES.Auth_AUTHENTICATOR_CHALLENGE_MAX_AGE_SECONDS)
.toConstantValue(
Number.isFinite(challengeMaxAgeSeconds) && challengeMaxAgeSeconds > 0 ? challengeMaxAgeSeconds : 300,
)
container
.bind(TYPES.Auth_READONLY_USERS)
.toConstantValue(env.get('READONLY_USERS', true) ? env.get('READONLY_USERS', true).split(',') : [])
@@ -1036,6 +1042,7 @@ export class ContainerConfigLoader {
container.get(TYPES.Auth_U2F_REQUIRE_USER_VERIFICATION),
container.get(TYPES.Auth_UserRepository),
container.get(TYPES.Auth_FeatureService),
container.get(TYPES.Auth_AUTHENTICATOR_CHALLENGE_MAX_AGE_SECONDS),
),
)
container
@@ -1057,6 +1064,7 @@ export class ContainerConfigLoader {
container.get(TYPES.Auth_U2F_RELYING_PARTY_ID),
container.get(TYPES.Auth_U2F_EXPECTED_ORIGIN),
container.get(TYPES.Auth_U2F_REQUIRE_USER_VERIFICATION),
container.get(TYPES.Auth_AUTHENTICATOR_CHALLENGE_MAX_AGE_SECONDS),
),
)
container
@@ -1171,6 +1179,24 @@ export class ContainerConfigLoader {
container.get<CaptchaServerInterface>(TYPES.Auth_CaptchaServer),
),
)
container
.bind<ClearLoginAttempts>(TYPES.Auth_ClearLoginAttempts)
.toConstantValue(
new ClearLoginAttempts(
container.get<UserRepositoryInterface>(TYPES.Auth_UserRepository),
container.get<LockRepositoryInterface>(TYPES.Auth_LockRepository),
container.get<winston.Logger>(TYPES.Auth_Logger),
),
)
container
.bind<IncreaseLoginAttempts>(TYPES.Auth_IncreaseLoginAttempts)
.toConstantValue(
new IncreaseLoginAttempts(
container.get<UserRepositoryInterface>(TYPES.Auth_UserRepository),
container.get<LockRepositoryInterface>(TYPES.Auth_LockRepository),
container.get<number>(TYPES.Auth_MAX_LOGIN_ATTEMPTS),
),
)
container
.bind<SignIn>(TYPES.Auth_SignIn)
.toConstantValue(
@@ -1186,6 +1212,8 @@ export class ContainerConfigLoader {
container.get<number>(TYPES.Auth_MAX_LOGIN_ATTEMPTS),
container.get<LockRepositoryInterface>(TYPES.Auth_LockRepository),
container.get<VerifyHumanInteraction>(TYPES.Auth_VerifyHumanInteraction),
container.get<IncreaseLoginAttempts>(TYPES.Auth_IncreaseLoginAttempts),
container.get<ClearLoginAttempts>(TYPES.Auth_ClearLoginAttempts),
),
)
container
@@ -1204,24 +1232,6 @@ export class ContainerConfigLoader {
container.get<winston.Logger>(TYPES.Auth_Logger),
),
)
container
.bind<ClearLoginAttempts>(TYPES.Auth_ClearLoginAttempts)
.toConstantValue(
new ClearLoginAttempts(
container.get<UserRepositoryInterface>(TYPES.Auth_UserRepository),
container.get<LockRepositoryInterface>(TYPES.Auth_LockRepository),
container.get<winston.Logger>(TYPES.Auth_Logger),
),
)
container
.bind<IncreaseLoginAttempts>(TYPES.Auth_IncreaseLoginAttempts)
.toConstantValue(
new IncreaseLoginAttempts(
container.get<UserRepositoryInterface>(TYPES.Auth_UserRepository),
container.get<LockRepositoryInterface>(TYPES.Auth_LockRepository),
container.get<number>(TYPES.Auth_MAX_LOGIN_ATTEMPTS),
),
)
container
.bind<GetUserKeyParamsRecovery>(TYPES.Auth_GetUserKeyParamsRecovery)
.toConstantValue(
@@ -1337,6 +1347,7 @@ export class ContainerConfigLoader {
container.get<number>(TYPES.Auth_MAX_LOGIN_ATTEMPTS),
container.get<LockRepositoryInterface>(TYPES.Auth_LockRepository),
container.get<VerifyHumanInteraction>(TYPES.Auth_VerifyHumanInteraction),
container.get<winston.Logger>(TYPES.Auth_Logger),
),
)
container
@@ -1432,8 +1443,8 @@ export class ContainerConfigLoader {
container.get<UserRepositoryInterface>(TYPES.Auth_UserRepository),
container.get<GetRegularSubscriptionForUser>(TYPES.Auth_GetRegularSubscriptionForUser),
container.get<GetSharedSubscriptionForUser>(TYPES.Auth_GetSharedSubscriptionForUser),
container.get<GetSubscriptionSetting>(TYPES.Auth_GetSubscriptionSetting),
container.get<SetSubscriptionSettingValue>(TYPES.Auth_SetSubscriptionSettingValue),
container.get<SubscriptionSettingRepositoryInterface>(TYPES.Auth_SubscriptionSettingRepository),
container.get<TimerInterface>(TYPES.Auth_Timer),
container.get<winston.Logger>(TYPES.Auth_Logger),
),
)
@@ -1871,7 +1882,6 @@ export class ContainerConfigLoader {
container.get<SignIn>(TYPES.Auth_SignIn),
container.get<GetUserKeyParams>(TYPES.Auth_GetUserKeyParams),
container.get<ClearLoginAttempts>(TYPES.Auth_ClearLoginAttempts),
container.get<IncreaseLoginAttempts>(TYPES.Auth_IncreaseLoginAttempts),
container.get<winston.Logger>(TYPES.Auth_Logger),
container.get<AuthController>(TYPES.Auth_AuthController),
container.get<Register>(TYPES.Auth_Register),
@@ -1963,6 +1973,7 @@ export class ContainerConfigLoader {
container.get<UserRepositoryInterface>(TYPES.Auth_UserRepository),
container.get<CreateSubscriptionToken>(TYPES.Auth_CreateSubscriptionToken),
container.get<CreateOfflineSubscriptionToken>(TYPES.Auth_CreateOfflineSubscriptionToken),
container.get<ClearLoginAttempts>(TYPES.Auth_ClearLoginAttempts),
container.get<ControllerContainerInterface>(TYPES.Auth_ControllerContainer),
),
)
+1
View File
@@ -101,6 +101,7 @@ const TYPES = {
Auth_U2F_RELYING_PARTY_NAME: Symbol.for('Auth_U2F_RELYING_PARTY_NAME'),
Auth_U2F_EXPECTED_ORIGIN: Symbol.for('Auth_U2F_EXPECTED_ORIGIN'),
Auth_U2F_REQUIRE_USER_VERIFICATION: Symbol.for('Auth_U2F_REQUIRE_USER_VERIFICATION'),
Auth_AUTHENTICATOR_CHALLENGE_MAX_AGE_SECONDS: Symbol.for('Auth_AUTHENTICATOR_CHALLENGE_MAX_AGE_SECONDS'),
Auth_READONLY_USERS: Symbol.for('Auth_READONLY_USERS'),
Auth_IS_CONFIGURED_FOR_HOME_SERVER_OR_SELF_HOSTING: Symbol.for('Auth_IS_CONFIGURED_FOR_HOME_SERVER_OR_SELF_HOSTING'),
Auth_CAPTCHA_SERVER_URL: Symbol.for('Auth_CAPTCHA_SERVER_URL'),
@@ -13,4 +13,15 @@ describe('AuthenticatorChallenge', () => {
expect(entityOrError.isFailed()).toBeFalsy()
expect(entityOrError.getValue().id).not.toBeNull()
})
it('should detect expired challenges', () => {
const challenge = AuthenticatorChallenge.create({
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
createdAt: new Date(Date.now() - 301_000),
challenge: 'challenge',
}).getValue()
expect(challenge.isExpired(300)).toBeTruthy()
expect(challenge.isExpired(600)).toBeFalsy()
})
})
@@ -10,4 +10,8 @@ export class AuthenticatorChallenge extends Entity<AuthenticatorChallengeProps>
static create(props: AuthenticatorChallengeProps, id?: UniqueEntityId): Result<AuthenticatorChallenge> {
return Result.ok<AuthenticatorChallenge>(new AuthenticatorChallenge(props, id))
}
isExpired(maxAgeSeconds: number, now: Date = new Date()): boolean {
return now.getTime() - this.props.createdAt.getTime() > maxAgeSeconds * 1000
}
}
@@ -5,4 +5,5 @@ import { AuthenticatorChallenge } from './AuthenticatorChallenge'
export interface AuthenticatorChallengeRepositoryInterface {
findByUserUuid(userUuid: Uuid): Promise<AuthenticatorChallenge | null>
save(authenticatorChallenge: AuthenticatorChallenge): Promise<void>
deleteByUserUuid(userUuid: Uuid): Promise<number>
}
@@ -5,4 +5,5 @@ export interface OfflineSettingRepositoryInterface {
findOneByNameAndEmail(name: OfflineSettingName, email: string): Promise<OfflineSetting | null>
findOneByNameAndValue(name: OfflineSettingName, value: string): Promise<OfflineSetting | null>
save(offlineSetting: OfflineSetting): Promise<OfflineSetting>
deleteByNameAndValueExcludingEmail(name: OfflineSettingName, value: string, email: string): Promise<void>
}
@@ -20,6 +20,7 @@ describe('OfflineSettingService', () => {
offlineSettingRepository = {} as jest.Mocked<OfflineSettingRepositoryInterface>
offlineSettingRepository.findOneByNameAndEmail = jest.fn().mockReturnValue(null)
offlineSettingRepository.save = jest.fn()
offlineSettingRepository.deleteByNameAndValueExcludingEmail = jest.fn()
timer = {} as jest.Mocked<TimerInterface>
timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123)
@@ -40,6 +41,11 @@ describe('OfflineSettingService', () => {
updatedAt: 123,
serverEncryptionVersion: 0,
})
expect(offlineSettingRepository.deleteByNameAndValueExcludingEmail).toHaveBeenCalledWith(
OfflineSettingName.FeaturesToken,
'test',
'[email protected]',
)
})
it('should update an existing offline setting', async () => {
@@ -55,5 +61,34 @@ describe('OfflineSettingService', () => {
value: 'test',
updatedAt: 123,
})
expect(offlineSettingRepository.deleteByNameAndValueExcludingEmail).toHaveBeenCalledWith(
OfflineSettingName.FeaturesToken,
'test',
'[email protected]',
)
})
it('should delete stale offline settings mapped to the same token under a different email', async () => {
await createService().createOrUpdate({
email: '[email protected]',
name: OfflineSettingName.FeaturesToken,
value: 'shared-token',
})
expect(offlineSettingRepository.deleteByNameAndValueExcludingEmail).toHaveBeenCalledWith(
OfflineSettingName.FeaturesToken,
'shared-token',
'[email protected]',
)
})
it('should not delete stale settings for non-extension-key offline settings', async () => {
await createService().createOrUpdate({
email: '[email protected]',
name: 'OTHER_SETTING' as OfflineSettingName,
value: 'test',
})
expect(offlineSettingRepository.deleteByNameAndValueExcludingEmail).not.toHaveBeenCalled()
})
})
@@ -35,6 +35,14 @@ export class OfflineSettingService implements OfflineSettingServiceInterface {
offlineSetting = await this.offlineSettingRepository.save(offlineSetting)
if (dto.name === OfflineSettingName.FeaturesToken) {
await this.offlineSettingRepository.deleteByNameAndValueExcludingEmail(
dto.name,
dto.value,
dto.email,
)
}
return {
success: true,
offlineSetting,
@@ -20,6 +20,9 @@ describe('SettingsAssociationService', () => {
expect(
createService().isSettingMutableByClient(SettingName.create(SettingName.NAMES.ListedAuthorSecrets).getValue()),
).toBeFalsy()
expect(
createService().isSettingMutableByClient(SettingName.create(SettingName.NAMES.RecoveryCodes).getValue()),
).toBeFalsy()
})
it('should return default encryption version for a setting which enecryption version is not strictly defined', () => {
@@ -34,6 +34,7 @@ export class SettingsAssociationService implements SettingsAssociationServiceInt
private readonly CLIENT_IMMUTABLE_SETTINGS = [
SettingName.NAMES.ListedAuthorSecrets,
SettingName.NAMES.RecoveryCodes,
SettingName.NAMES.FileUploadBytesLimit,
SettingName.NAMES.FileUploadBytesUsed,
]
@@ -8,4 +8,17 @@ export interface SubscriptionSettingRepositoryInterface {
findAllBySubscriptionUuid(userSubscriptionUuid: Uuid): Promise<SubscriptionSetting[]>
insert(subscriptionSetting: SubscriptionSetting): Promise<void>
update(subscriptionSetting: SubscriptionSetting): Promise<void>
/**
* Atomically adds `delta` to the numeric value of the `(name, userSubscriptionUuid)` counter
* setting at the database level, clamped to a minimum of 0, creating the row if it does not yet
* exist. Relies on the unique index on `(name, user_subscription_uuid)` to avoid the
* read-modify-write and create-if-missing races that occur when this is computed in application
* code.
*/
incrementCounterValueForNameAndUserSubscriptionUuid(
name: string,
userSubscriptionUuid: Uuid,
delta: number,
updatedAt: number,
): Promise<void>
}
@@ -65,7 +65,7 @@ describe('AcceptSharedSubscriptionInvitation', () => {
inviteeSubscription = { endsAt: 3, planName: SubscriptionName.PlusPlan } as jest.Mocked<UserSubscription>
inviterSubscription = { endsAt: 3, planName: SubscriptionName.PlusPlan } as jest.Mocked<UserSubscription>
inviterSubscription = { endsAt: 3, planName: SubscriptionName.PlusPlan, userUuid: '456' } as jest.Mocked<UserSubscription>
userSubscriptionRepository = {} as jest.Mocked<UserSubscriptionRepositoryInterface>
userSubscriptionRepository.findBySubscriptionIdAndType = jest.fn().mockReturnValue([inviterSubscription])
@@ -114,8 +114,8 @@ describe('AcceptSharedSubscriptionInvitation', () => {
})
it('should create a shared subscription upon accepting the invitation if inviter has a second subscription', async () => {
const inviterSubscription1 = { endsAt: 1, planName: SubscriptionName.PlusPlan } as jest.Mocked<UserSubscription>
const inviterSubscription2 = { endsAt: 5, planName: SubscriptionName.PlusPlan } as jest.Mocked<UserSubscription>
const inviterSubscription1 = { endsAt: 1, planName: SubscriptionName.PlusPlan, userUuid: '456' } as jest.Mocked<UserSubscription>
const inviterSubscription2 = { endsAt: 5, planName: SubscriptionName.PlusPlan, userUuid: '456' } as jest.Mocked<UserSubscription>
timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(3)
@@ -208,6 +208,25 @@ describe('AcceptSharedSubscriptionInvitation', () => {
expect(applyDefaultSubscriptionSettings.execute).not.toHaveBeenCalled()
})
it('should not create a shared subscription if invitee is the inviter', async () => {
inviterSubscription = { endsAt: 3, planName: SubscriptionName.PlusPlan, userUuid: '123' } as jest.Mocked<UserSubscription>
userSubscriptionRepository.findBySubscriptionIdAndType = jest.fn().mockReturnValue([inviterSubscription])
expect(
await createUseCase().execute({
sharedSubscriptionInvitationUuid: '1-2-3',
}),
).toEqual({
success: false,
message: 'You cannot accept a subscription invitation sent to yourself.',
})
expect(sharedSubscriptionInvitationRepository.save).not.toHaveBeenCalled()
expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
expect(roleService.addUserRoleBasedOnSubscription).not.toHaveBeenCalled()
expect(applyDefaultSubscriptionSettings.execute).not.toHaveBeenCalled()
})
it('should not create a shared subscription if inviter subscription is not found', async () => {
userSubscriptionRepository.findBySubscriptionIdAndType = jest.fn().mockReturnValue([])
expect(
@@ -226,8 +245,8 @@ describe('AcceptSharedSubscriptionInvitation', () => {
})
it('should not create a shared subscription if inviter subscriptions are not active', async () => {
const inviterSubscription1 = { endsAt: 1, planName: SubscriptionName.PlusPlan } as jest.Mocked<UserSubscription>
const inviterSubscription2 = { endsAt: 2, planName: SubscriptionName.PlusPlan } as jest.Mocked<UserSubscription>
const inviterSubscription1 = { endsAt: 1, planName: SubscriptionName.PlusPlan, userUuid: '456' } as jest.Mocked<UserSubscription>
const inviterSubscription2 = { endsAt: 2, planName: SubscriptionName.PlusPlan, userUuid: '456' } as jest.Mocked<UserSubscription>
timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(3)
@@ -74,6 +74,13 @@ export class AcceptSharedSubscriptionInvitation implements UseCaseInterface {
}
const inviterUserSubscription = activeUserSubscriptions[0]
if (invitee.uuid === inviterUserSubscription.userUuid) {
return {
success: false,
message: 'You cannot accept a subscription invitation sent to yourself.',
}
}
sharedSubscriptionInvitation.status = InvitationStatus.Accepted
sharedSubscriptionInvitation.updatedAt = this.timer.getTimestampInMicroseconds()
@@ -27,6 +27,10 @@ import { GetActiveSessionsForUser } from '../GetActiveSessionsForUser'
import { Permission } from '../../Permission/Permission'
describe('CreateCrossServiceToken', () => {
const authenticatedUserUuid = '00000000-0000-0000-0000-000000000000'
const sharedVaultOwnerContextUuid = '10000000-0000-0000-0000-000000000000'
const sharedVaultUuid = '00000000-0000-0000-0000-000000000000'
let userProjector: ProjectorInterface<User>
let sessionProjector: ProjectorInterface<Session>
let roleProjector: ProjectorInterface<Role>
@@ -78,15 +82,13 @@ describe('CreateCrossServiceToken', () => {
role.permissions = Promise.resolve([])
user = {
uuid: '00000000-0000-0000-0000-000000000000',
uuid: authenticatedUserUuid,
email: '[email protected]',
} as jest.Mocked<User>
user.roles = Promise.resolve([role])
userProjector = {} as jest.Mocked<ProjectorInterface<User>>
userProjector.projectSimple = jest
.fn()
.mockReturnValue({ uuid: '00000000-0000-0000-0000-000000000000', email: '[email protected]' })
userProjector.projectSimple = jest.fn().mockReturnValue({ uuid: authenticatedUserUuid, email: '[email protected]' })
roleProjector = {} as jest.Mocked<ProjectorInterface<Role>>
roleProjector.projectSimple = jest.fn().mockReturnValue({ name: 'role1', uuid: '1-3-4' })
@@ -110,7 +112,7 @@ describe('CreateCrossServiceToken', () => {
value: '100',
timestamps: Timestamps.create(123456789, 123456789).getValue(),
serverEncryptionVersion: EncryptionVersion.Unencrypted,
userSubscriptionUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
userSubscriptionUuid: Uuid.create(authenticatedUserUuid).getValue(),
}).getValue(),
}),
)
@@ -119,15 +121,21 @@ describe('CreateCrossServiceToken', () => {
getRegularSubscription.execute = jest.fn().mockReturnValue(Result.fail('not found'))
sharedVaultUserRepository = {} as jest.Mocked<SharedVaultUserRepositoryInterface>
sharedVaultUserRepository.findByUserUuid = jest.fn().mockReturnValue([
SharedVaultUser.create({
sharedVaultUserRepository.findByUserUuid = jest.fn().mockImplementation((userUuid: Uuid) => {
const commonAssociation = SharedVaultUser.create({
permission: SharedVaultUserPermission.create('read').getValue(),
sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
sharedVaultUuid: Uuid.create(sharedVaultUuid).getValue(),
timestamps: Timestamps.create(123456789, 123456789).getValue(),
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
userUuid: Uuid.create(userUuid.value).getValue(),
isDesignatedSurvivor: false,
}).getValue(),
])
}).getValue()
if ([authenticatedUserUuid, sharedVaultOwnerContextUuid].includes(userUuid.value)) {
return [commonAssociation]
}
return []
})
})
it('should create a cross service token for user', async () => {
@@ -358,7 +366,7 @@ describe('CreateCrossServiceToken', () => {
await createUseCase().execute({
user,
session,
sharedVaultOwnerContext: '00000000-0000-0000-0000-000000000000',
sharedVaultOwnerContext: sharedVaultOwnerContextUuid,
})
expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith(
@@ -396,7 +404,7 @@ describe('CreateCrossServiceToken', () => {
const result = await createUseCase().execute({
user,
session,
sharedVaultOwnerContext: '00000000-0000-0000-0000-000000000000',
sharedVaultOwnerContext: sharedVaultOwnerContextUuid,
})
expect(result.isFailed()).toBeTruthy()
@@ -411,11 +419,78 @@ describe('CreateCrossServiceToken', () => {
const result = await createUseCase().execute({
user,
session,
sharedVaultOwnerContext: '00000000-0000-0000-0000-000000000000',
sharedVaultOwnerContext: sharedVaultOwnerContextUuid,
})
expect(result.isFailed()).toBeTruthy()
})
it('should return an error if user does not belong to any shared vault with owner context user', async () => {
const regularSubscription = {} as jest.Mocked<UserSubscription>
getRegularSubscription.execute = jest.fn().mockReturnValue(Result.ok(regularSubscription))
sharedVaultUserRepository.findByUserUuid = jest.fn().mockImplementation((userUuid: Uuid) => {
const userSpecificSharedVaultUuid =
userUuid.value === authenticatedUserUuid
? Uuid.create('00000000-0000-0000-0000-000000000000').getValue()
: Uuid.create('20000000-0000-0000-0000-000000000000').getValue()
return [
SharedVaultUser.create({
permission: SharedVaultUserPermission.create('read').getValue(),
sharedVaultUuid: userSpecificSharedVaultUuid,
timestamps: Timestamps.create(123456789, 123456789).getValue(),
userUuid: Uuid.create(userUuid.value).getValue(),
isDesignatedSurvivor: false,
}).getValue(),
]
})
const result = await createUseCase().execute({
user,
session,
sharedVaultOwnerContext: sharedVaultOwnerContextUuid,
})
expect(result.isFailed()).toBeTruthy()
expect(getRegularSubscription.execute).not.toHaveBeenCalled()
})
it('should return an error if shared vault owner context is not a valid uuid', async () => {
const result = await createUseCase().execute({
user,
session,
sharedVaultOwnerContext: 'invalid-uuid',
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toContain('Could not create cross service token with shared vault owner context')
expect(getRegularSubscription.execute).not.toHaveBeenCalled()
})
it('should skip shared vault membership validation if shared vault owner context matches the authenticated user', async () => {
const regularSubscription = {} as jest.Mocked<UserSubscription>
getRegularSubscription.execute = jest.fn().mockReturnValue(Result.ok(regularSubscription))
await createUseCase().execute({
user,
session,
sharedVaultOwnerContext: authenticatedUserUuid,
})
expect(sharedVaultUserRepository.findByUserUuid).toHaveBeenCalledTimes(1)
expect(getRegularSubscription.execute).toHaveBeenCalledWith({
userUuid: authenticatedUserUuid,
})
expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith(
expect.objectContaining({
shared_vault_owner_context: {
upload_bytes_limit: 100,
},
}),
60,
)
})
})
describe('version determination', () => {
@@ -77,8 +77,34 @@ export class CreateCrossServiceToken implements UseCaseInterface<string> {
}
if (dto.sharedVaultOwnerContext !== undefined) {
const sharedVaultOwnerContextUuidOrError = Uuid.create(dto.sharedVaultOwnerContext)
if (sharedVaultOwnerContextUuidOrError.isFailed()) {
return Result.fail(
`Could not create cross service token with shared vault owner context: ${sharedVaultOwnerContextUuidOrError.getError()}`,
)
}
const sharedVaultOwnerContextUuid = sharedVaultOwnerContextUuidOrError.getValue()
if (sharedVaultOwnerContextUuid.value !== user.uuid) {
const sharedVaultOwnerAssociations = await this.sharedVaultUserRepository.findByUserUuid(
sharedVaultOwnerContextUuid,
)
const authenticatedUserSharedVaultUuids = new Set(
sharedVaultAssociations.map((association) => association.props.sharedVaultUuid.value),
)
const authenticatedUserSharesVaultWithContextOwner = sharedVaultOwnerAssociations.some((association) =>
authenticatedUserSharedVaultUuids.has(association.props.sharedVaultUuid.value),
)
if (!authenticatedUserSharesVaultWithContextOwner) {
return Result.fail(
`Could not create cross service token with shared vault owner context for user ${user.uuid}`,
)
}
}
const regularSubscriptionOrError = await this.getRegularSubscription.execute({
userUuid: dto.sharedVaultOwnerContext,
userUuid: sharedVaultOwnerContextUuid.value,
})
if (regularSubscriptionOrError.isFailed()) {
return Result.fail(regularSubscriptionOrError.getError())
@@ -8,15 +8,20 @@ import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInter
import { DeleteSetting } from './DeleteSetting'
import { SettingName, Timestamps, Uuid, Result } from '@standardnotes/domain-core'
import { VerifyUserServerPassword } from '../VerifyUserServerPassword/VerifyUserServerPassword'
import { SettingsAssociationService } from '../../Setting/SettingsAssociationService'
describe('DeleteSetting', () => {
let setting: Setting
let sensitiveSetting: Setting
let recoveryCodesSetting: Setting
let listedAuthorSecretsSetting: Setting
let settingRepository: SettingRepositoryInterface
let verifyUserServerPassword: VerifyUserServerPassword
let settingsAssociationService: SettingsAssociationService
let timer: TimerInterface
const createUseCase = () => new DeleteSetting(settingRepository, verifyUserServerPassword, timer)
const createUseCase = () =>
new DeleteSetting(settingRepository, verifyUserServerPassword, timer, settingsAssociationService)
beforeEach(() => {
setting = Setting.create({
@@ -37,6 +42,24 @@ describe('DeleteSetting', () => {
timestamps: Timestamps.create(123, 123).getValue(),
}).getValue()
recoveryCodesSetting = Setting.create({
name: SettingName.NAMES.RecoveryCodes,
value: 'ABCD EFGH IJKL MNOP',
serverEncryptionVersion: 0,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
sensitive: true,
timestamps: Timestamps.create(123, 123).getValue(),
}).getValue()
listedAuthorSecretsSetting = Setting.create({
name: SettingName.NAMES.ListedAuthorSecrets,
value: '[]',
serverEncryptionVersion: 0,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
sensitive: false,
timestamps: Timestamps.create(123, 123).getValue(),
}).getValue()
settingRepository = {} as jest.Mocked<SettingRepositoryInterface>
settingRepository.findLastByNameAndUserUuid = jest.fn().mockReturnValue(setting)
settingRepository.findOneByUuid = jest.fn().mockReturnValue(setting)
@@ -46,6 +69,8 @@ describe('DeleteSetting', () => {
verifyUserServerPassword = {} as jest.Mocked<VerifyUserServerPassword>
verifyUserServerPassword.execute = jest.fn()
settingsAssociationService = new SettingsAssociationService()
timer = {} as jest.Mocked<TimerInterface>
timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(1)
})
@@ -218,6 +243,90 @@ describe('DeleteSetting', () => {
})
})
describe('client permission validation for immutable settings', () => {
it('should not allow client to delete recovery codes', async () => {
settingRepository.findLastByNameAndUserUuid = jest.fn().mockReturnValue(recoveryCodesSetting)
const result = await createUseCase().execute({
settingName: SettingName.NAMES.RecoveryCodes,
userUuid: '00000000-0000-0000-0000-000000000000',
checkUserPermissions: true,
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.message).toBe(
'User 00000000-0000-0000-0000-000000000000 does not have permission to delete setting RECOVERY_CODES.',
)
}
expect(settingRepository.deleteByUserUuid).not.toHaveBeenCalled()
})
it('should not allow client to delete listed author secrets', async () => {
settingRepository.findLastByNameAndUserUuid = jest.fn().mockReturnValue(listedAuthorSecretsSetting)
const result = await createUseCase().execute({
settingName: SettingName.NAMES.ListedAuthorSecrets,
userUuid: '00000000-0000-0000-0000-000000000000',
checkUserPermissions: true,
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.message).toBe(
'User 00000000-0000-0000-0000-000000000000 does not have permission to delete setting LISTED_AUTHOR_SECRETS.',
)
}
expect(settingRepository.deleteByUserUuid).not.toHaveBeenCalled()
})
it('should allow delete of immutable settings when checkUserPermissions is false', async () => {
settingRepository.findLastByNameAndUserUuid = jest.fn().mockReturnValue(recoveryCodesSetting)
const result = await createUseCase().execute({
settingName: SettingName.NAMES.RecoveryCodes,
userUuid: '00000000-0000-0000-0000-000000000000',
checkUserPermissions: false,
})
expect(result.success).toBe(true)
expect(settingRepository.deleteByUserUuid).toHaveBeenCalledWith({
userUuid: '00000000-0000-0000-0000-000000000000',
settingName: SettingName.NAMES.RecoveryCodes,
})
})
it('should allow client to delete mutable settings when checkUserPermissions is true', async () => {
const result = await createUseCase().execute({
settingName: SettingName.NAMES.LogSessionUserAgent,
userUuid: '00000000-0000-0000-0000-000000000000',
checkUserPermissions: true,
})
expect(result.success).toBe(true)
expect(settingRepository.deleteByUserUuid).toHaveBeenCalledWith({
userUuid: '00000000-0000-0000-0000-000000000000',
settingName: SettingName.NAMES.LogSessionUserAgent,
})
})
it('should return error for invalid setting name when checkUserPermissions is true', async () => {
settingRepository.findLastByNameAndUserUuid = jest.fn().mockReturnValue(setting)
const result = await createUseCase().execute({
settingName: 'INVALID_SETTING',
userUuid: '00000000-0000-0000-0000-000000000000',
checkUserPermissions: true,
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.message).toBe('Invalid setting name: INVALID_SETTING')
}
expect(settingRepository.deleteByUserUuid).not.toHaveBeenCalled()
})
})
it('should delete a setting by name and user uuid', async () => {
const result = await createUseCase().execute({
settingName: SettingName.NAMES.LogSessionUserAgent,
@@ -240,6 +349,7 @@ describe('DeleteSetting', () => {
userUuid: '00000000-0000-0000-0000-000000000000',
serverPassword: 'correct-password',
shouldVerifyUserServerPassword: true,
checkUserPermissions: true,
})
expect(result.success).toBe(true)
@@ -8,6 +8,7 @@ import { UseCaseInterface } from '../UseCaseInterface'
import TYPES from '../../../Bootstrap/Types'
import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface'
import { Setting } from '../../Setting/Setting'
import { SettingsAssociationServiceInterface } from '../../Setting/SettingsAssociationServiceInterface'
import { VerifyUserServerPassword } from '../VerifyUserServerPassword/VerifyUserServerPassword'
@injectable()
@@ -16,6 +17,8 @@ export class DeleteSetting implements UseCaseInterface {
@inject(TYPES.Auth_SettingRepository) private settingRepository: SettingRepositoryInterface,
@inject(TYPES.Auth_VerifyUserServerPassword) private verifyUserServerPassword: VerifyUserServerPassword,
@inject(TYPES.Auth_Timer) private timer: TimerInterface,
@inject(TYPES.Auth_SettingsAssociationService)
private settingsAssociationService: SettingsAssociationServiceInterface,
) {}
async execute(dto: DeleteSettingDto): Promise<DeleteSettingResponse> {
@@ -32,6 +35,27 @@ export class DeleteSetting implements UseCaseInterface {
}
}
if (dto.checkUserPermissions) {
const settingNameOrError = SettingName.create(settingName)
if (settingNameOrError.isFailed()) {
return {
success: false,
error: {
message: settingNameOrError.getError(),
},
}
}
if (!this.settingsAssociationService.isSettingMutableByClient(settingNameOrError.getValue())) {
return {
success: false,
error: {
message: `User ${userUuid} does not have permission to delete setting ${settingName}.`,
},
}
}
}
if (shouldVerifyUserServerPassword && [SettingName.NAMES.MfaSecret].includes(setting.props.name)) {
const verifyUserServerPasswordResult = await this.verifyUserServerPassword.execute({
userUuid,
@@ -7,4 +7,5 @@ export type DeleteSettingDto = {
serverPassword?: string
authTokenVersion?: number
shouldVerifyUserServerPassword?: boolean
checkUserPermissions?: boolean
}
@@ -83,6 +83,20 @@ describe('GenerateAuthenticatorAuthenticationOptions', () => {
})
expect(result.isFailed()).toBe(false)
expect(result.getValue().userVerification).toBe('preferred')
expect(authenticatorChallengeRepository.save).not.toHaveBeenCalled()
})
it('should return pseudo options if user does not have authenticators', async () => {
authenticatorRepository.findByUserUuid = jest.fn().mockReturnValue([])
const result = await createUseCase().execute({
username: '[email protected]',
})
expect(result.isFailed()).toBe(false)
expect(result.getValue().userVerification).toBe('preferred')
expect(authenticatorChallengeRepository.save).not.toHaveBeenCalled()
})
it('should return error if authenticator challenge is invalid', async () => {
@@ -30,22 +30,7 @@ export class GenerateAuthenticatorAuthenticationOptions
const user = await this.userRepository.findOneByUsernameOrEmail(username)
if (user === null) {
const credentialIdHash = crypto
.createHash('sha256')
.update(`u2f-selector-${dto.username}${this.pseudoKeyParamsKey}`)
.digest('base64url')
const options = await generateAuthenticationOptions({
allowCredentials: [
{
id: Buffer.from(credentialIdHash),
type: 'public-key',
transports: [],
},
],
userVerification: 'discouraged',
})
const options = await this.generatePseudoOptions(dto.username)
return Result.ok(options)
}
@@ -56,6 +41,11 @@ export class GenerateAuthenticatorAuthenticationOptions
const userUuid = userUuidOrError.getValue()
const authenticators = await this.authenticatorRepository.findByUserUuid(userUuid)
if (authenticators.length === 0) {
const options = await this.generatePseudoOptions(dto.username)
return Result.ok(options)
}
const options = await generateAuthenticationOptions({
allowCredentials: authenticators.map((authenticator) => ({
id: authenticator.props.credentialId,
@@ -81,4 +71,22 @@ export class GenerateAuthenticatorAuthenticationOptions
return Result.ok(options)
}
private async generatePseudoOptions(username: string): Promise<PublicKeyCredentialRequestOptionsJSON> {
const credentialIdHash = crypto
.createHash('sha256')
.update(`u2f-selector-${username}${this.pseudoKeyParamsKey}`)
.digest('base64url')
return generateAuthenticationOptions({
allowCredentials: [
{
id: Buffer.from(credentialIdHash),
type: 'public-key',
transports: [],
},
],
userVerification: 'preferred',
})
}
}
@@ -21,7 +21,7 @@ describe('GetUserKeyParams', () => {
keyParamsFactory.create = jest.fn().mockReturnValue({ foo: 'bar' })
keyParamsFactory.createPseudoParams = jest.fn().mockReturnValue({ bar: 'baz' })
user = {} as jest.Mocked<User>
user = { uuid: '1-2-3' } as jest.Mocked<User>
userRepository = {} as jest.Mocked<UserRepositoryInterface>
userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue(user)
@@ -97,7 +97,7 @@ describe('GetUserKeyParams', () => {
},
})
expect(pkceRepository.storeCodeChallenge).toHaveBeenCalledWith('test')
expect(pkceRepository.storeCodeChallenge).toHaveBeenCalledWith('test', '1-2-3')
})
it('should get pseudo key params for a non existing user - when searching by email', async () => {
@@ -63,7 +63,7 @@ export class GetUserKeyParams implements UseCaseInterface {
private async createKeyParams(dto: GetUserKeyParamsDTO, user: User, authenticated: boolean): Promise<KeyParamsData> {
if (this.isCodeChallengedVersion(dto)) {
await this.pkceRepository.storeCodeChallenge(dto.codeChallenge)
await this.pkceRepository.storeCodeChallenge(dto.codeChallenge, user.uuid)
}
return this.keyParamsFactory.create(user, authenticated)
@@ -22,7 +22,7 @@ describe('GetUserKeyParamsRecovery', () => {
keyParamsFactory.create = jest.fn().mockReturnValue({ foo: 'bar' })
keyParamsFactory.createPseudoParams = jest.fn().mockReturnValue({ bar: 'baz' })
user = {} as jest.Mocked<User>
user = { uuid: 'user-uuid' } as jest.Mocked<User>
userRepository = {} as jest.Mocked<UserRepositoryInterface>
userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue(user)
@@ -121,6 +121,7 @@ describe('GetUserKeyParamsRecovery', () => {
})
expect(keyParamsFactory.create).toHaveBeenCalled()
expect(pkceRepository.storeCodeChallenge).toHaveBeenCalledWith('codeChallenge', 'user-uuid')
expect(result.isFailed()).toBe(false)
})
@@ -70,7 +70,7 @@ export class GetUserKeyParamsRecovery implements UseCaseInterface<KeyParamsData>
}
private async createKeyParams(codeChallenge: string, user: User): Promise<KeyParamsData> {
await this.pkceRepository.storeCodeChallenge(codeChallenge)
await this.pkceRepository.storeCodeChallenge(codeChallenge, user.uuid)
return this.keyParamsFactory.create(user, false)
}
@@ -58,6 +58,38 @@ describe('InviteToSharedSubscription', () => {
domainEventFactory.createEmailRequestedEvent = jest.fn().mockReturnValue({} as jest.Mocked<EmailRequestedEvent>)
})
it('should not create an invitation if user invites themselves', async () => {
expect(
await createUseCase().execute({
inviteeIdentifier: '[email protected]',
inviterUuid: '1-2-3',
inviterEmail: '[email protected]',
inviterRoles: [RoleName.NAMES.ProUser],
}),
).toEqual({
success: false,
})
expect(sharedSubscriptionInvitationRepository.save).not.toHaveBeenCalled()
expect(domainEventFactory.createSharedSubscriptionInvitationCreatedEvent).not.toHaveBeenCalled()
expect(domainEventPublisher.publish).not.toHaveBeenCalled()
})
it('should not create an inivitation if user invites themselves with different email casing', async () => {
expect(
await createUseCase().execute({
inviteeIdentifier: '[email protected]',
inviterUuid: '1-2-3',
inviterEmail: '[email protected]',
inviterRoles: [RoleName.NAMES.ProUser],
}),
).toEqual({
success: false,
})
expect(sharedSubscriptionInvitationRepository.save).not.toHaveBeenCalled()
})
it('should not create an inivitation for sharing the subscription if inviter has no subscription', async () => {
userSubscriptionRepository.findOneByUserUuid = jest.fn().mockReturnValue(null)
@@ -45,6 +45,16 @@ export class InviteToSharedSubscription implements UseCaseInterface {
}
}
const inviteeIdentifierType = this.isInviteeIdentifierPotentiallyAPrivateUsernameAccount(dto.inviteeIdentifier)
? InviteeIdentifierType.Hash
: InviteeIdentifierType.Email
if (this.isSelfInvite(dto.inviteeIdentifier, dto.inviterEmail, inviteeIdentifierType)) {
return {
success: false,
}
}
const numberOfUsedInvites = await this.sharedSubscriptionInvitationRepository.countByInviterEmailAndStatus(
dto.inviterEmail,
[InvitationStatus.Sent, InvitationStatus.Accepted],
@@ -69,11 +79,7 @@ export class InviteToSharedSubscription implements UseCaseInterface {
sharedSubscriptionInvition.inviterIdentifier = dto.inviterEmail
sharedSubscriptionInvition.inviterIdentifierType = InviterIdentifierType.Email
sharedSubscriptionInvition.inviteeIdentifier = dto.inviteeIdentifier
sharedSubscriptionInvition.inviteeIdentifierType = this.isInviteeIdentifierPotentiallyAPrivateUsernameAccount(
dto.inviteeIdentifier,
)
? InviteeIdentifierType.Hash
: InviteeIdentifierType.Email
sharedSubscriptionInvition.inviteeIdentifierType = inviteeIdentifierType
sharedSubscriptionInvition.status = InvitationStatus.Sent
sharedSubscriptionInvition.subscriptionId = inviterUserSubscription.subscriptionId as number
sharedSubscriptionInvition.createdAt = this.timer.getTimestampInMicroseconds()
@@ -110,4 +116,16 @@ export class InviteToSharedSubscription implements UseCaseInterface {
private isInviteeIdentifierPotentiallyAPrivateUsernameAccount(identifier: string): boolean {
return identifier.length === 64 && !identifier.includes('@')
}
private isSelfInvite(
inviteeIdentifier: string,
inviterEmail: string,
inviteeIdentifierType: InviteeIdentifierType,
): boolean {
if (inviteeIdentifierType !== InviteeIdentifierType.Email) {
return false
}
return inviteeIdentifier.trim().toLowerCase() === inviterEmail.trim().toLowerCase()
}
}
@@ -118,7 +118,7 @@ describe('SetSettingValue', () => {
)
})
it('should update an existing setting', async () => {
it('should return error when trying to overwrite an active MFA secret', async () => {
const setting = Setting.create({
name: SettingName.NAMES.MfaSecret,
value: '1243359u42395834',
@@ -138,6 +138,55 @@ describe('SetSettingValue', () => {
value: 'value',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Failed to set MFA secret.')
expect(settingRepository.update).not.toHaveBeenCalled()
})
it('should allow setting MFA secret when existing row has a null value', async () => {
const setting = Setting.create({
name: SettingName.NAMES.MfaSecret,
value: null,
serverEncryptionVersion: EncryptionVersion.Default,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
sensitive: true,
timestamps: Timestamps.create(123, 123).getValue(),
}).getValue()
getSetting.execute = jest.fn().mockReturnValue(Result.ok({ setting }))
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
settingName: SettingName.NAMES.MfaSecret,
value: 'value',
})
expect(result.isFailed()).toBe(false)
expect(settingRepository.update).toHaveBeenCalled()
})
it('should update an existing setting', async () => {
const setting = Setting.create({
name: SettingName.NAMES.EmailBackupFrequency,
value: 'daily',
serverEncryptionVersion: EncryptionVersion.Unencrypted,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
sensitive: false,
timestamps: Timestamps.create(123, 123).getValue(),
}).getValue()
getSetting.execute = jest.fn().mockReturnValue(Result.ok({ setting }))
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
settingName: SettingName.NAMES.EmailBackupFrequency,
value: 'weekly',
})
expect(result.isFailed()).toBe(false)
expect(settingRepository.update).toHaveBeenCalled()
})
@@ -48,6 +48,14 @@ export class SetSettingValue implements UseCaseInterface<Setting> {
decrypted: false,
})
if (
settingName.value === SettingName.NAMES.MfaSecret &&
!settingExists.isFailed() &&
settingExists.getValue().setting.props.value !== null
) {
return Result.fail('Failed to set MFA secret.')
}
const sensitive = this.settingsAssociationService.getSensitivityForSetting(settingName)
const encryptionVersion = this.settingsAssociationService.getEncryptionVersionForSetting(settingName)
+176 -1
View File
@@ -17,6 +17,8 @@ import { Session } from '../Session/Session'
import { LockRepositoryInterface } from '../User/LockRepositoryInterface'
import { VerifyHumanInteraction } from './VerifyHumanInteraction/VerifyHumanInteraction'
import { Result } from '@standardnotes/domain-core'
import { IncreaseLoginAttempts } from './IncreaseLoginAttempts'
import { ClearLoginAttempts } from './ClearLoginAttempts'
describe('SignIn', () => {
let user: User
@@ -33,7 +35,8 @@ describe('SignIn', () => {
let maxNonCaptchaAttempts: number
let lockRepository: LockRepositoryInterface
let verifyHumanInteractionUseCase: VerifyHumanInteraction
let increaseLoginAttempts: IncreaseLoginAttempts
let clearLoginAttempts: ClearLoginAttempts
const createUseCase = () =>
new SignIn(
userRepository,
@@ -47,6 +50,8 @@ describe('SignIn', () => {
maxNonCaptchaAttempts,
lockRepository,
verifyHumanInteractionUseCase,
increaseLoginAttempts,
clearLoginAttempts,
)
beforeEach(() => {
@@ -92,8 +97,22 @@ describe('SignIn', () => {
lockRepository.getLockCounter = jest.fn().mockReturnValue(0)
maxNonCaptchaAttempts = 6
increaseLoginAttempts = {} as jest.Mocked<IncreaseLoginAttempts>
increaseLoginAttempts.execute = jest.fn().mockReturnValue(Result.ok({ isNonCaptchaLimitReached: false }))
clearLoginAttempts = {} as jest.Mocked<ClearLoginAttempts>
clearLoginAttempts.execute = jest.fn()
verifyHumanInteractionUseCase = {} as jest.Mocked<VerifyHumanInteraction>
verifyHumanInteractionUseCase.execute = jest.fn().mockReturnValue(Result.ok())
})
const requireHumanVerification = () => {
lockRepository.getLockCounter = jest.fn().mockReturnValueOnce(maxNonCaptchaAttempts).mockReturnValueOnce(0)
verifyHumanInteractionUseCase.execute = jest.fn()
}
it('should fail sign in a legacy user without code verifier', async () => {
pkceRepository.removeCodeChallenge = jest.fn().mockReturnValue(false)
@@ -113,6 +132,7 @@ describe('SignIn', () => {
success: false,
errorCode: 410,
errorMessage: 'Please update your client application.',
isNonCaptchaLimitReached: false,
})
})
@@ -132,6 +152,7 @@ describe('SignIn', () => {
success: false,
errorCode: 410,
errorMessage: 'Please update your client application.',
isNonCaptchaLimitReached: false,
})
})
@@ -157,6 +178,7 @@ describe('SignIn', () => {
success: false,
errorCode: 410,
errorMessage: 'Please update your client application.',
isNonCaptchaLimitReached: false,
})
})
@@ -173,6 +195,7 @@ describe('SignIn', () => {
).toEqual({
success: false,
errorMessage: 'Username cannot be empty',
isNonCaptchaLimitReached: false,
})
expect(domainEventFactory.createEmailRequestedEvent).not.toHaveBeenCalled()
@@ -192,6 +215,7 @@ describe('SignIn', () => {
).toEqual({
success: false,
errorMessage: 'Invalid api version: invalid',
isNonCaptchaLimitReached: false,
})
expect(domainEventFactory.createEmailRequestedEvent).not.toHaveBeenCalled()
@@ -218,6 +242,29 @@ describe('SignIn', () => {
expect(domainEventFactory.createEmailRequestedEvent).toHaveBeenCalled()
expect(domainEventPublisher.publish).toHaveBeenCalled()
expect(pkceRepository.removeCodeChallenge).toHaveBeenCalledWith('base64-url-encoded', '1-2-3')
expect(clearLoginAttempts.execute).toHaveBeenCalledWith({ email: '[email protected]' })
})
it('should not sign in when pkce challenge was registered for a different user', async () => {
pkceRepository.removeCodeChallenge = jest.fn().mockReturnValue(false)
expect(
await createUseCase().execute({
email: '[email protected]',
password: 'qweqwe123123',
userAgent: 'Google Chrome',
apiVersion: '20190520',
ephemeralSession: false,
codeVerifier: 'test',
}),
).toEqual({
success: false,
errorMessage: 'Invalid email or password',
isNonCaptchaLimitReached: false,
})
expect(pkceRepository.removeCodeChallenge).toHaveBeenCalledWith('base64-url-encoded', '1-2-3')
})
it('should sign in a user even if publishing a sign in event fails', async () => {
@@ -256,6 +303,7 @@ describe('SignIn', () => {
).toEqual({
success: false,
errorMessage: 'Invalid email or password',
isNonCaptchaLimitReached: false,
})
})
@@ -274,6 +322,7 @@ describe('SignIn', () => {
).toEqual({
success: false,
errorMessage: 'Invalid email or password',
isNonCaptchaLimitReached: false,
})
})
@@ -292,6 +341,7 @@ describe('SignIn', () => {
).toEqual({
success: false,
errorMessage: 'Invalid email or password',
isNonCaptchaLimitReached: false,
})
})
@@ -363,6 +413,131 @@ describe('SignIn', () => {
).toEqual({
success: false,
errorMessage: 'Human verification step failed.',
isNonCaptchaLimitReached: true,
})
})
it('should return isNonCaptchaLimitReached when incrementing login attempts reaches the limit', async () => {
increaseLoginAttempts.execute = jest.fn().mockReturnValue(Result.ok({ isNonCaptchaLimitReached: true }))
expect(
await createUseCase().execute({
email: '[email protected]',
password: 'asdasd123123',
userAgent: 'Google Chrome',
apiVersion: '20190520',
ephemeralSession: false,
codeVerifier: 'test',
}),
).toEqual({
success: false,
errorMessage: 'Invalid email or password',
isNonCaptchaLimitReached: true,
})
})
it('should increment login attempts on invalid password', async () => {
await createUseCase().execute({
email: '[email protected]',
password: 'asdasd123123',
userAgent: 'Google Chrome',
apiVersion: '20190520',
ephemeralSession: false,
codeVerifier: 'test',
})
expect(increaseLoginAttempts.execute).toHaveBeenCalledWith({
email: '[email protected]',
skipUsernameValidation: true,
})
})
it('should not increment login attempts when human verification fails', async () => {
requireHumanVerification()
verifyHumanInteractionUseCase.execute = jest
.fn()
.mockReturnValueOnce(Result.fail('Human verification step failed.'))
await createUseCase().execute({
email: '[email protected]',
password: 'qweqwe123123',
userAgent: 'Google Chrome',
apiVersion: '20190520',
ephemeralSession: false,
codeVerifier: 'test',
hvmToken: 'bad-token',
})
expect(increaseLoginAttempts.execute).not.toHaveBeenCalled()
})
it('should not increment login attempts when human verification token is missing', async () => {
requireHumanVerification()
verifyHumanInteractionUseCase.execute = jest.fn().mockReturnValueOnce(Result.fail('No HVM token available.'))
await createUseCase().execute({
email: '[email protected]',
password: 'qweqwe123123',
userAgent: 'Google Chrome',
apiVersion: '20190520',
ephemeralSession: false,
codeVerifier: 'test',
})
expect(increaseLoginAttempts.execute).not.toHaveBeenCalled()
})
it('should not set isNonCaptchaLimitReached when increasing login attempts fails', async () => {
increaseLoginAttempts.execute = jest.fn().mockReturnValue(Result.fail('invalid email'))
expect(
await createUseCase().execute({
email: '[email protected]',
password: 'asdasd123123',
userAgent: 'Google Chrome',
apiVersion: '20190520',
ephemeralSession: false,
codeVerifier: 'test',
}),
).toEqual({
success: false,
errorMessage: 'Invalid email or password',
})
})
it('should require human verification in captcha mode and not increment on failure', async () => {
lockRepository.getLockCounter = jest.fn().mockReturnValueOnce(0).mockReturnValueOnce(1)
verifyHumanInteractionUseCase.execute = jest
.fn()
.mockReturnValueOnce(Result.fail('Human verification step failed.'))
await createUseCase().execute({
email: '[email protected]',
password: 'qweqwe123123',
userAgent: 'Google Chrome',
apiVersion: '20190520',
ephemeralSession: false,
codeVerifier: 'test',
hvmToken: 'bad-token',
})
expect(increaseLoginAttempts.execute).not.toHaveBeenCalled()
})
it('should not increment login attempts on human verification failure for unknown user', async () => {
userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue(null)
lockRepository.getLockCounter = jest.fn().mockReturnValueOnce(maxNonCaptchaAttempts).mockReturnValueOnce(0)
verifyHumanInteractionUseCase.execute = jest.fn().mockReturnValueOnce(Result.fail('No HVM token available.'))
await createUseCase().execute({
email: '[email protected]',
password: 'asdasd123123',
userAgent: 'Google Chrome',
apiVersion: '20190520',
ephemeralSession: false,
codeVerifier: 'test',
})
expect(increaseLoginAttempts.execute).not.toHaveBeenCalled()
})
})
+48 -33
View File
@@ -18,6 +18,8 @@ import { ApiVersion } from '../Api/ApiVersion'
import { HttpStatusCode } from '@standardnotes/responses'
import { VerifyHumanInteraction } from './VerifyHumanInteraction/VerifyHumanInteraction'
import { LockRepositoryInterface } from '../User/LockRepositoryInterface'
import { IncreaseLoginAttempts } from './IncreaseLoginAttempts'
import { ClearLoginAttempts } from './ClearLoginAttempts'
export class SignIn implements UseCaseInterface {
constructor(
@@ -32,43 +34,29 @@ export class SignIn implements UseCaseInterface {
private maxNonCaptchaAttempts: number,
private lockRepository: LockRepositoryInterface,
private verifyHumanInteractionUseCase: VerifyHumanInteraction,
private increaseLoginAttempts: IncreaseLoginAttempts,
private clearLoginAttempts: ClearLoginAttempts,
) {}
async execute(dto: SignInDTO): Promise<SignInResponse> {
if (!dto.codeVerifier) {
return {
success: false,
errorMessage: 'Please update your client application.',
errorCode: HttpStatusCode.Gone,
}
}
const validCodeVerifier = await this.validateCodeVerifier(dto.codeVerifier)
if (!validCodeVerifier) {
this.logger.debug('Code verifier does not match')
return {
success: false,
errorMessage: 'Invalid email or password',
}
return this.failAfterIncrementingLoginAttempts(
dto.email,
'Please update your client application.',
HttpStatusCode.Gone,
)
}
const apiVersionOrError = ApiVersion.create(dto.apiVersion)
if (apiVersionOrError.isFailed()) {
return {
success: false,
errorMessage: apiVersionOrError.getError(),
}
return this.failAfterIncrementingLoginAttempts(dto.email, apiVersionOrError.getError())
}
const apiVersion = apiVersionOrError.getValue()
/** Skip validation which was newly added in 2025, to allow existing users to continue to sign in */
const usernameOrError = Username.create(dto.email, { skipValidation: true })
if (usernameOrError.isFailed()) {
return {
success: false,
errorMessage: usernameOrError.getError(),
}
return this.failAfterIncrementingLoginAttempts(dto.email, usernameOrError.getError())
}
const username = usernameOrError.getValue()
@@ -83,26 +71,28 @@ export class SignIn implements UseCaseInterface {
return {
success: false,
errorMessage: humanVerificationBeforeCheckingUsernameAndPasswordResult.getError(),
isNonCaptchaLimitReached: true,
}
}
if (!user) {
this.logger.debug(`User with email ${dto.email} was not found`)
return {
success: false,
errorMessage: 'Invalid email or password',
}
return this.failAfterIncrementingLoginAttempts(dto.email, 'Invalid email or password')
}
const validCodeVerifier = await this.validateCodeVerifier(dto.codeVerifier, user.uuid)
if (!validCodeVerifier) {
this.logger.debug('Code verifier does not match')
return this.failAfterIncrementingLoginAttempts(dto.email, 'Invalid email or password')
}
const passwordMatches = await bcrypt.compare(dto.password, user.encryptedPassword)
if (!passwordMatches) {
this.logger.debug('Password does not match')
return {
success: false,
errorMessage: 'Invalid email or password',
}
return this.failAfterIncrementingLoginAttempts(dto.email, 'Invalid email or password')
}
const authResponseFactory = this.authResponseFactoryResolver.resolveAuthResponseFactoryVersion(apiVersion)
@@ -119,16 +109,41 @@ export class SignIn implements UseCaseInterface {
application: dto.application,
})
await this.clearLoginAttempts.execute({ email: dto.email })
return {
success: true,
result,
}
}
private async validateCodeVerifier(codeVerifier: string): Promise<boolean> {
private async failAfterIncrementingLoginAttempts(
email: string,
errorMessage: string,
errorCode?: HttpStatusCode,
): Promise<SignInResponse> {
const increaseResultOrError = await this.increaseLoginAttempts.execute({
email,
skipUsernameValidation: true,
})
return {
success: false,
errorMessage,
errorCode,
isNonCaptchaLimitReached: increaseResultOrError.isFailed()
? undefined
: increaseResultOrError.getValue().isNonCaptchaLimitReached,
}
}
private async validateCodeVerifier(codeVerifier: string, userUuid: string): Promise<boolean> {
const codeChallenge = this.crypter.base64URLEncode(this.crypter.sha256Hash(codeVerifier))
const matchingCodeChallengeWasPresentAndRemoved = await this.pkceRepository.removeCodeChallenge(codeChallenge)
const matchingCodeChallengeWasPresentAndRemoved = await this.pkceRepository.removeCodeChallenge(
codeChallenge,
userUuid,
)
return matchingCodeChallengeWasPresentAndRemoved
}
@@ -7,6 +7,7 @@ export type SignInResponse =
success: false
errorMessage: string
errorCode?: HttpStatusCode
isNonCaptchaLimitReached?: boolean
}
| {
success: true
@@ -1,6 +1,5 @@
import { Result } from '@standardnotes/domain-core'
import { AuthResponse20200115 } from '../../Auth/AuthResponse20200115'
import { AuthResponseFactory20200115 } from '../../Auth/AuthResponseFactory20200115'
import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface'
import { CrypterInterface } from '../../Encryption/CrypterInterface'
@@ -17,6 +16,7 @@ import { GetSetting } from '../GetSetting/GetSetting'
import { ApiVersion } from '../../Api/ApiVersion'
import { LockRepositoryInterface } from '../../User/LockRepositoryInterface'
import { VerifyHumanInteraction } from '../VerifyHumanInteraction/VerifyHumanInteraction'
import { Logger } from 'winston'
describe('SignInWithRecoveryCodes', () => {
let userRepository: UserRepositoryInterface
@@ -32,6 +32,7 @@ describe('SignInWithRecoveryCodes', () => {
let maxNonCaptchaAttempts: number
let lockRepository: LockRepositoryInterface
let verifyHumanInteractionUseCase: VerifyHumanInteraction
let logger: Logger
const createUseCase = () =>
new SignInWithRecoveryCodes(
@@ -48,6 +49,7 @@ describe('SignInWithRecoveryCodes', () => {
maxNonCaptchaAttempts,
lockRepository,
verifyHumanInteractionUseCase,
logger,
)
beforeEach(() => {
@@ -58,7 +60,7 @@ describe('SignInWithRecoveryCodes', () => {
} as jest.Mocked<User>)
authResponseFactory = {} as jest.Mocked<AuthResponseFactory20200115>
authResponseFactory.createResponse = jest.fn().mockReturnValue({} as jest.Mocked<AuthResponse20200115>)
authResponseFactory.createResponse = jest.fn().mockReturnValue({ response: { foo: 'bar' }, session: {} })
pkceRepository = {} as jest.Mocked<PKCERepositoryInterface>
pkceRepository.removeCodeChallenge = jest.fn().mockReturnValue(true)
@@ -76,7 +78,7 @@ describe('SignInWithRecoveryCodes', () => {
generateRecoveryCodes.execute = jest.fn().mockReturnValue(Result.ok('1234 5678'))
increaseLoginAttempts = {} as jest.Mocked<IncreaseLoginAttempts>
increaseLoginAttempts.execute = jest.fn()
increaseLoginAttempts.execute = jest.fn().mockReturnValue(Result.ok({ isNonCaptchaLimitReached: false }))
clearLoginAttempts = {} as jest.Mocked<ClearLoginAttempts>
clearLoginAttempts.execute = jest.fn()
@@ -91,8 +93,19 @@ describe('SignInWithRecoveryCodes', () => {
lockRepository.getLockCounter = jest.fn().mockReturnValue(0)
maxNonCaptchaAttempts = 6
verifyHumanInteractionUseCase = {} as jest.Mocked<VerifyHumanInteraction>
verifyHumanInteractionUseCase.execute = jest.fn().mockReturnValue(Result.ok())
logger = {} as jest.Mocked<Logger>
logger.debug = jest.fn()
})
const requireHumanVerification = () => {
lockRepository.getLockCounter = jest.fn().mockReturnValueOnce(maxNonCaptchaAttempts).mockReturnValueOnce(0)
verifyHumanInteractionUseCase.execute = jest.fn()
}
it('should return error if password is not provided', async () => {
const result = await createUseCase().execute({
apiVersion: ApiVersion.VERSIONS.v20200115,
@@ -103,8 +116,11 @@ describe('SignInWithRecoveryCodes', () => {
recoveryCodes: '1234 5678',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Empty password')
expect(result).toEqual({
success: false,
errorMessage: 'Empty password',
isNonCaptchaLimitReached: false,
})
})
it('should return error if username is not provided', async () => {
@@ -117,8 +133,11 @@ describe('SignInWithRecoveryCodes', () => {
recoveryCodes: '1234 5678',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Could not sign in with recovery codes: Username cannot be empty')
expect(result).toEqual({
success: false,
errorMessage: 'Could not sign in with recovery codes: Username cannot be empty',
isNonCaptchaLimitReached: false,
})
})
it('should return error if code verifier is not provided', async () => {
@@ -131,8 +150,11 @@ describe('SignInWithRecoveryCodes', () => {
recoveryCodes: '1234 5678',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Invalid code verifier')
expect(result).toEqual({
success: false,
errorMessage: 'Invalid code verifier',
isNonCaptchaLimitReached: false,
})
})
it('should return error if recovery codes are not provided', async () => {
@@ -145,8 +167,11 @@ describe('SignInWithRecoveryCodes', () => {
recoveryCodes: '',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Empty recovery codes')
expect(result).toEqual({
success: false,
errorMessage: 'Empty recovery codes',
isNonCaptchaLimitReached: false,
})
})
it('should return error if code verifier is invalid', async () => {
@@ -161,8 +186,11 @@ describe('SignInWithRecoveryCodes', () => {
recoveryCodes: '1234 5678',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Invalid code verifier')
expect(result).toEqual({
success: false,
errorMessage: 'Invalid code verifier',
isNonCaptchaLimitReached: false,
})
})
it('should return error if user is not found', async () => {
@@ -177,8 +205,11 @@ describe('SignInWithRecoveryCodes', () => {
recoveryCodes: '1234 5678',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Could not find user')
expect(result).toEqual({
success: false,
errorMessage: 'Invalid code verifier',
isNonCaptchaLimitReached: false,
})
})
it('should return error if recovery codes are invalid', async () => {
@@ -191,8 +222,11 @@ describe('SignInWithRecoveryCodes', () => {
recoveryCodes: '1234 5678',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Invalid recovery codes')
expect(result).toEqual({
success: false,
errorMessage: 'Invalid recovery codes',
isNonCaptchaLimitReached: false,
})
})
it('should return error if api version is invalid', async () => {
@@ -205,7 +239,15 @@ describe('SignInWithRecoveryCodes', () => {
recoveryCodes: '1234 5678',
})
expect(result.isFailed()).toBe(true)
expect(result).toEqual({
success: false,
errorMessage: 'Invalid api version: invalid',
isNonCaptchaLimitReached: false,
})
expect(increaseLoginAttempts.execute).toHaveBeenCalledWith({
email: '[email protected]',
skipUsernameValidation: true,
})
})
it('should return error if api version does not support recovery sign in', async () => {
@@ -218,7 +260,15 @@ describe('SignInWithRecoveryCodes', () => {
recoveryCodes: '1234 5678',
})
expect(result.isFailed()).toBe(true)
expect(result).toEqual({
success: false,
errorMessage: 'Unsupported api version',
isNonCaptchaLimitReached: false,
})
expect(increaseLoginAttempts.execute).toHaveBeenCalledWith({
email: '[email protected]',
skipUsernameValidation: true,
})
})
it('should return error if password does not match', async () => {
@@ -231,8 +281,11 @@ describe('SignInWithRecoveryCodes', () => {
recoveryCodes: '1234 5678',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Invalid password')
expect(result).toEqual({
success: false,
errorMessage: 'Invalid password',
isNonCaptchaLimitReached: false,
})
})
it('should return error if recovery codes are not generated for user', async () => {
@@ -247,8 +300,11 @@ describe('SignInWithRecoveryCodes', () => {
recoveryCodes: '1234 5678',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('User does not have recovery codes generated')
expect(result).toEqual({
success: false,
errorMessage: 'User does not have recovery codes generated',
isNonCaptchaLimitReached: false,
})
})
it('should return error if generating new recovery codes fails', async () => {
@@ -263,8 +319,12 @@ describe('SignInWithRecoveryCodes', () => {
recoveryCodes: 'foo',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Could not sign in with recovery codes: Oops')
expect(result).toEqual({
success: false,
errorMessage: 'Could not sign in with recovery codes: Oops',
isNonCaptchaLimitReached: false,
})
expect(authResponseFactory.createResponse).not.toHaveBeenCalled()
})
it('should return error if user has an invalid uuid', async () => {
@@ -282,8 +342,11 @@ describe('SignInWithRecoveryCodes', () => {
recoveryCodes: 'foo',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Invalid user uuid')
expect(result).toEqual({
success: false,
errorMessage: 'Invalid user uuid',
isNonCaptchaLimitReached: false,
})
})
it('should return error if user requires human verification but no hvmtoken provided', async () => {
@@ -302,8 +365,100 @@ describe('SignInWithRecoveryCodes', () => {
recoveryCodes: 'foo',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Human verification step failed.')
expect(result).toEqual({
success: false,
errorMessage: 'Human verification step failed.',
isNonCaptchaLimitReached: true,
})
})
it('should return isNonCaptchaLimitReached when incrementing login attempts reaches the limit', async () => {
increaseLoginAttempts.execute = jest.fn().mockReturnValue(Result.ok({ isNonCaptchaLimitReached: true }))
const result = await createUseCase().execute({
apiVersion: ApiVersion.VERSIONS.v20200115,
userAgent: 'user-agent',
username: '[email protected]',
password: 'asdasd123123',
codeVerifier: 'code-verifier',
recoveryCodes: '1234 5678',
})
expect(result).toEqual({
success: false,
errorMessage: 'Invalid password',
isNonCaptchaLimitReached: true,
})
})
it('should increment login attempts once on invalid password', async () => {
await createUseCase().execute({
apiVersion: ApiVersion.VERSIONS.v20200115,
userAgent: 'user-agent',
username: '[email protected]',
password: 'asdasd123123',
codeVerifier: 'code-verifier',
recoveryCodes: '1234 5678',
})
expect(increaseLoginAttempts.execute).toHaveBeenCalledTimes(1)
expect(increaseLoginAttempts.execute).toHaveBeenCalledWith({
email: '[email protected]',
skipUsernameValidation: true,
})
})
it('should not increment login attempts when human verification fails', async () => {
requireHumanVerification()
verifyHumanInteractionUseCase.execute = jest
.fn()
.mockReturnValueOnce(Result.fail('Human verification step failed.'))
await createUseCase().execute({
apiVersion: ApiVersion.VERSIONS.v20200115,
userAgent: 'user-agent',
username: '[email protected]',
password: 'qweqwe123123',
codeVerifier: 'code-verifier',
recoveryCodes: 'foo',
hvmToken: 'bad-token',
})
expect(increaseLoginAttempts.execute).not.toHaveBeenCalled()
})
it('should not increment login attempts when human verification token is missing', async () => {
requireHumanVerification()
verifyHumanInteractionUseCase.execute = jest.fn().mockReturnValueOnce(Result.fail('No HVM token available.'))
await createUseCase().execute({
apiVersion: ApiVersion.VERSIONS.v20200115,
userAgent: 'user-agent',
username: '[email protected]',
password: 'qweqwe123123',
codeVerifier: 'code-verifier',
recoveryCodes: 'foo',
})
expect(increaseLoginAttempts.execute).not.toHaveBeenCalled()
})
it('should not set isNonCaptchaLimitReached when increasing login attempts fails', async () => {
increaseLoginAttempts.execute = jest.fn().mockReturnValue(Result.fail('invalid email'))
const result = await createUseCase().execute({
apiVersion: ApiVersion.VERSIONS.v20200115,
userAgent: 'user-agent',
username: '[email protected]',
password: 'asdasd123123',
codeVerifier: 'code-verifier',
recoveryCodes: '1234 5678',
})
expect(result).toEqual({
success: false,
errorMessage: 'Invalid password',
})
})
it('should return auth response with human verification required and passing', async () => {
@@ -326,7 +481,7 @@ describe('SignInWithRecoveryCodes', () => {
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(authenticatorRepository.removeByUserUuid).toHaveBeenCalled()
expect(result.isFailed()).toBe(false)
expect(result.success).toBe(true)
})
it('should return auth response', async () => {
@@ -345,6 +500,6 @@ describe('SignInWithRecoveryCodes', () => {
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(authenticatorRepository.removeByUserUuid).toHaveBeenCalled()
expect(result.isFailed()).toBe(false)
expect(result.success).toBe(true)
})
})
@@ -1,13 +1,14 @@
import * as bcrypt from 'bcryptjs'
import { Result, SettingName, UseCaseInterface, Username, Uuid, Validator } from '@standardnotes/domain-core'
import { Result, SettingName, Username, Uuid, Validator } from '@standardnotes/domain-core'
import { Logger } from 'winston'
import { AuthResponse20200115 } from '../../Auth/AuthResponse20200115'
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 { SignInWithRecoveryCodesResponse } from './SignInWithRecoveryCodesResponse'
import { AuthResponseFactory20200115 } from '../../Auth/AuthResponseFactory20200115'
import { IncreaseLoginAttempts } from '../IncreaseLoginAttempts'
import { ClearLoginAttempts } from '../ClearLoginAttempts'
@@ -17,8 +18,9 @@ import { ApiVersion } from '../../Api/ApiVersion'
import { GetSetting } from '../GetSetting/GetSetting'
import { LockRepositoryInterface } from '../../User/LockRepositoryInterface'
import { VerifyHumanInteraction } from '../VerifyHumanInteraction/VerifyHumanInteraction'
import { UseCaseInterface } from '../UseCaseInterface'
export class SignInWithRecoveryCodes implements UseCaseInterface<AuthResponse20200115> {
export class SignInWithRecoveryCodes implements UseCaseInterface {
constructor(
private userRepository: UserRepositoryInterface,
private authResponseFactory: AuthResponseFactory20200115,
@@ -33,22 +35,26 @@ export class SignInWithRecoveryCodes implements UseCaseInterface<AuthResponse202
private maxNonCaptchaAttempts: number,
private lockRepository: LockRepositoryInterface,
private verifyHumanInteractionUseCase: VerifyHumanInteraction,
private logger: Logger,
) {}
async execute(dto: SignInWithRecoveryCodesDTO): Promise<Result<AuthResponse20200115>> {
async execute(dto: SignInWithRecoveryCodesDTO): Promise<SignInWithRecoveryCodesResponse> {
const apiVersionOrError = ApiVersion.create(dto.apiVersion)
if (apiVersionOrError.isFailed()) {
return Result.fail(apiVersionOrError.getError())
return this.failAfterIncrementingLoginAttempts(dto.username, apiVersionOrError.getError())
}
const apiVersion = apiVersionOrError.getValue()
if (!apiVersion.isSupportedForRecoverySignIn()) {
return Result.fail('Unsupported api version')
return this.failAfterIncrementingLoginAttempts(dto.username, 'Unsupported api version')
}
const usernameOrError = Username.create(dto.username)
if (usernameOrError.isFailed()) {
return Result.fail(`Could not sign in with recovery codes: ${usernameOrError.getError()}`)
return this.failAfterIncrementingLoginAttempts(
dto.username,
`Could not sign in with recovery codes: ${usernameOrError.getError()}`,
)
}
const username = usernameOrError.getValue()
@@ -60,49 +66,45 @@ export class SignInWithRecoveryCodes implements UseCaseInterface<AuthResponse202
dto.hvmToken,
)
if (humanVerificationBeforeCheckingUsernameAndPasswordResult.isFailed()) {
return Result.fail(humanVerificationBeforeCheckingUsernameAndPasswordResult.getError())
return {
success: false,
errorMessage: humanVerificationBeforeCheckingUsernameAndPasswordResult.getError(),
isNonCaptchaLimitReached: true,
}
}
const validCodeVerifier = await this.validateCodeVerifier(dto.codeVerifier)
if (!validCodeVerifier) {
await this.increaseLoginAttempts.execute({ email: username.value })
if (!user) {
this.logger.debug(`User with username ${username.value} was not found`)
return Result.fail('Invalid code verifier')
return this.failAfterIncrementingLoginAttempts(username.value, 'Invalid code verifier')
}
const validCodeVerifier = await this.validateCodeVerifier(dto.codeVerifier, user.uuid)
if (!validCodeVerifier) {
this.logger.debug('Code verifier does not match')
return this.failAfterIncrementingLoginAttempts(username.value, 'Invalid code verifier')
}
const passwordValidationResult = Validator.isNotEmpty(dto.password)
if (passwordValidationResult.isFailed()) {
await this.increaseLoginAttempts.execute({ email: username.value })
return Result.fail('Empty password')
return this.failAfterIncrementingLoginAttempts(username.value, 'Empty password')
}
const recoveryCodesValidationResult = Validator.isNotEmpty(dto.recoveryCodes)
if (recoveryCodesValidationResult.isFailed()) {
await this.increaseLoginAttempts.execute({ email: username.value })
return Result.fail('Empty recovery codes')
}
if (!user) {
await this.increaseLoginAttempts.execute({ email: username.value })
return Result.fail('Could not find user')
return this.failAfterIncrementingLoginAttempts(username.value, 'Empty recovery codes')
}
const userUuidOrError = Uuid.create(user.uuid)
if (userUuidOrError.isFailed()) {
await this.increaseLoginAttempts.execute({ email: username.value })
return Result.fail('Invalid user uuid')
return this.failAfterIncrementingLoginAttempts(username.value, 'Invalid user uuid')
}
const userUuid = userUuidOrError.getValue()
const passwordMatches = await bcrypt.compare(dto.password, user.encryptedPassword)
if (!passwordMatches) {
await this.increaseLoginAttempts.execute({ email: username.value })
return Result.fail('Invalid password')
return this.failAfterIncrementingLoginAttempts(username.value, 'Invalid password')
}
const recoveryCodesSettingOrError = await this.getSetting.execute({
@@ -112,19 +114,25 @@ export class SignInWithRecoveryCodes implements UseCaseInterface<AuthResponse202
allowSensitiveRetrieval: true,
})
if (recoveryCodesSettingOrError.isFailed()) {
await this.increaseLoginAttempts.execute({ email: username.value })
return Result.fail('User does not have recovery codes generated')
return this.failAfterIncrementingLoginAttempts(username.value, 'User does not have recovery codes generated')
}
const recoveryCodesSetting = recoveryCodesSettingOrError.getValue()
if (recoveryCodesSetting.decryptedValue !== dto.recoveryCodes) {
await this.increaseLoginAttempts.execute({ email: username.value })
return Result.fail('Invalid recovery codes')
return this.failAfterIncrementingLoginAttempts(username.value, 'Invalid recovery codes')
}
const authResponse = await this.authResponseFactory.createResponse({
const generateNewRecoveryCodesResult = await this.generateRecoveryCodes.execute({
userUuid: user.uuid,
})
if (generateNewRecoveryCodesResult.isFailed()) {
return this.failAfterIncrementingLoginAttempts(
username.value,
`Could not sign in with recovery codes: ${generateNewRecoveryCodesResult.getError()}`,
)
}
const authResponseCreationResult = await this.authResponseFactory.createResponse({
user,
apiVersion,
userAgent: dto.userAgent,
@@ -134,15 +142,6 @@ export class SignInWithRecoveryCodes implements UseCaseInterface<AuthResponse202
application: dto.application,
})
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.deleteSetting.execute({
settingName: SettingName.NAMES.MfaSecret,
userUuid: user.uuid,
@@ -152,10 +151,31 @@ export class SignInWithRecoveryCodes implements UseCaseInterface<AuthResponse202
await this.clearLoginAttempts.execute({ email: username.value })
return Result.ok(authResponse.response as AuthResponse20200115)
return {
success: true,
result: authResponseCreationResult,
}
}
private async validateCodeVerifier(codeVerifier: string): Promise<boolean> {
private async failAfterIncrementingLoginAttempts(
email: string,
errorMessage: string,
): Promise<SignInWithRecoveryCodesResponse> {
const increaseResultOrError = await this.increaseLoginAttempts.execute({
email,
skipUsernameValidation: true,
})
return {
success: false,
errorMessage,
isNonCaptchaLimitReached: increaseResultOrError.isFailed()
? undefined
: increaseResultOrError.getValue().isNonCaptchaLimitReached,
}
}
private async validateCodeVerifier(codeVerifier: string, userUuid: string): Promise<boolean> {
const codeEmptinessVerificationResult = Validator.isNotEmpty(codeVerifier)
if (codeEmptinessVerificationResult.isFailed()) {
return false
@@ -163,7 +183,10 @@ export class SignInWithRecoveryCodes implements UseCaseInterface<AuthResponse202
const codeChallenge = this.crypter.base64URLEncode(this.crypter.sha256Hash(codeVerifier))
const matchingCodeChallengeWasPresentAndRemoved = await this.pkceRepository.removeCodeChallenge(codeChallenge)
const matchingCodeChallengeWasPresentAndRemoved = await this.pkceRepository.removeCodeChallenge(
codeChallenge,
userUuid,
)
return matchingCodeChallengeWasPresentAndRemoved
}
@@ -0,0 +1,12 @@
import { AuthResponseCreationResult } from '../../Auth/AuthResponseCreationResult'
export type SignInWithRecoveryCodesResponse =
| {
success: false
errorMessage: string
isNonCaptchaLimitReached?: boolean
}
| {
success: true
result: AuthResponseCreationResult
}
@@ -6,12 +6,10 @@ import { User } from '../../User/User'
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
import { GetSharedSubscriptionForUser } from '../GetSharedSubscriptionForUser/GetSharedSubscriptionForUser'
import { GetRegularSubscriptionForUser } from '../GetRegularSubscriptionForUser/GetRegularSubscriptionForUser'
import { GetSubscriptionSetting } from '../GetSubscriptionSetting/GetSubscriptionSetting'
import { SetSubscriptionSettingValue } from '../SetSubscriptionSettingValue/SetSubscriptionSettingValue'
import { Logger } from 'winston'
import { Result, SettingName, Timestamps, Uuid } from '@standardnotes/domain-core'
import { SubscriptionSetting } from '../../Setting/SubscriptionSetting'
import { EncryptionVersion } from '../../Encryption/EncryptionVersion'
import { Result, SettingName } from '@standardnotes/domain-core'
import { TimerInterface } from '@standardnotes/time'
import { SubscriptionSettingRepositoryInterface } from '../../Setting/SubscriptionSettingRepositoryInterface'
describe('UpdateStorageQuotaUsedForUser', () => {
let userRepository: UserRepositoryInterface
@@ -20,17 +18,20 @@ describe('UpdateStorageQuotaUsedForUser', () => {
let sharedSubscription: UserSubscription
let getSharedSubscription: GetSharedSubscriptionForUser
let getRegularSubscription: GetRegularSubscriptionForUser
let getSubscriptionSetting: GetSubscriptionSetting
let setSubscriptonSettingValue: SetSubscriptionSettingValue
let subscriptionSettingRepository: SubscriptionSettingRepositoryInterface
let timer: TimerInterface
let logger: Logger
const regularSubscriptionUuid = '00000000-0000-0000-0000-000000000000'
const sharedSubscriptionUuid = '11111111-1111-1111-1111-111111111111'
const createUseCase = () =>
new UpdateStorageQuotaUsedForUser(
userRepository,
getRegularSubscription,
getSharedSubscription,
getSubscriptionSetting,
setSubscriptonSettingValue,
subscriptionSettingRepository,
timer,
logger,
)
@@ -43,13 +44,13 @@ describe('UpdateStorageQuotaUsedForUser', () => {
userRepository.findOneByUuid = jest.fn().mockReturnValue(user)
regularSubscription = {
uuid: '00000000-0000-0000-0000-000000000000',
uuid: regularSubscriptionUuid,
subscriptionType: UserSubscriptionType.Regular,
userUuid: '123',
} as jest.Mocked<UserSubscription>
sharedSubscription = {
uuid: '2-3-4',
uuid: sharedSubscriptionUuid,
subscriptionType: UserSubscriptionType.Shared,
userUuid: '123',
} as jest.Mocked<UserSubscription>
@@ -60,28 +61,71 @@ describe('UpdateStorageQuotaUsedForUser', () => {
getRegularSubscription = {} as jest.Mocked<GetRegularSubscriptionForUser>
getRegularSubscription.execute = jest.fn().mockReturnValue(Result.ok(regularSubscription))
getSubscriptionSetting = {} as jest.Mocked<GetSubscriptionSetting>
getSubscriptionSetting.execute = jest.fn().mockReturnValue(Result.fail('not found'))
subscriptionSettingRepository = {} as jest.Mocked<SubscriptionSettingRepositoryInterface>
subscriptionSettingRepository.incrementCounterValueForNameAndUserSubscriptionUuid = jest
.fn()
.mockResolvedValue(undefined)
setSubscriptonSettingValue = {} as jest.Mocked<SetSubscriptionSettingValue>
setSubscriptonSettingValue.execute = jest.fn().mockReturnValue(Result.ok())
timer = {} as jest.Mocked<TimerInterface>
timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123)
logger = {} as jest.Mocked<Logger>
logger.error = jest.fn()
})
it('should create a bytes used setting if one does not exist', async () => {
it('should atomically add the bytes used delta for the subscription', async () => {
const result = await createUseCase().execute({
userUuid: '00000000-0000-0000-0000-000000000000',
userUuid: regularSubscriptionUuid,
bytesUsed: 123,
})
expect(result.isFailed()).toBeFalsy()
expect(setSubscriptonSettingValue.execute).toHaveBeenCalledWith({
settingName: 'FILE_UPLOAD_BYTES_USED',
value: '123',
userSubscriptionUuid: '00000000-0000-0000-0000-000000000000',
expect(
subscriptionSettingRepository.incrementCounterValueForNameAndUserSubscriptionUuid,
).toHaveBeenCalledWith(SettingName.NAMES.FileUploadBytesUsed, expect.objectContaining({ value: regularSubscriptionUuid }), 123, 123)
})
it('should atomically subtract the bytes used delta for the subscription', async () => {
const result = await createUseCase().execute({
userUuid: regularSubscriptionUuid,
bytesUsed: -123,
})
expect(result.isFailed()).toBeFalsy()
expect(
subscriptionSettingRepository.incrementCounterValueForNameAndUserSubscriptionUuid,
).toHaveBeenCalledWith(SettingName.NAMES.FileUploadBytesUsed, expect.objectContaining({ value: regularSubscriptionUuid }), -123, 123)
})
it('should update the bytes used on both the regular and shared subscription', async () => {
const result = await createUseCase().execute({
userUuid: regularSubscriptionUuid,
bytesUsed: 123,
})
expect(result.isFailed()).toBeFalsy()
expect(
subscriptionSettingRepository.incrementCounterValueForNameAndUserSubscriptionUuid,
).toHaveBeenCalledWith(SettingName.NAMES.FileUploadBytesUsed, expect.objectContaining({ value: sharedSubscriptionUuid }), 123, 123)
expect(
subscriptionSettingRepository.incrementCounterValueForNameAndUserSubscriptionUuid,
).toHaveBeenCalledWith(SettingName.NAMES.FileUploadBytesUsed, expect.objectContaining({ value: regularSubscriptionUuid }), 123, 123)
expect(subscriptionSettingRepository.incrementCounterValueForNameAndUserSubscriptionUuid).toHaveBeenCalledTimes(2)
})
it('should update only the regular subscription when there is no shared subscription', async () => {
getSharedSubscription.execute = jest.fn().mockReturnValue(Result.fail('no shared subscription'))
const result = await createUseCase().execute({
userUuid: regularSubscriptionUuid,
bytesUsed: 123,
})
expect(result.isFailed()).toBeFalsy()
expect(subscriptionSettingRepository.incrementCounterValueForNameAndUserSubscriptionUuid).toHaveBeenCalledTimes(1)
})
it('should not do anything if a user uuid is invalid', async () => {
@@ -91,110 +135,45 @@ describe('UpdateStorageQuotaUsedForUser', () => {
})
expect(result.isFailed()).toBeTruthy()
expect(setSubscriptonSettingValue.execute).not.toHaveBeenCalled()
expect(subscriptionSettingRepository.incrementCounterValueForNameAndUserSubscriptionUuid).not.toHaveBeenCalled()
})
it('should not do anything if a user is not found', async () => {
userRepository.findOneByUuid = jest.fn().mockReturnValue(null)
const result = await createUseCase().execute({
userUuid: '00000000-0000-0000-0000-000000000000',
userUuid: regularSubscriptionUuid,
bytesUsed: 123,
})
expect(result.isFailed()).toBeTruthy()
expect(setSubscriptonSettingValue.execute).not.toHaveBeenCalled()
expect(subscriptionSettingRepository.incrementCounterValueForNameAndUserSubscriptionUuid).not.toHaveBeenCalled()
})
describe('updating existing quota', () => {
beforeEach(() => {
getSubscriptionSetting.execute = jest.fn().mockReturnValue(
Result.ok({
setting: SubscriptionSetting.create({
name: SettingName.NAMES.FileUploadBytesUsed,
sensitive: false,
serverEncryptionVersion: EncryptionVersion.Unencrypted,
timestamps: Timestamps.create(123, 123).getValue(),
userSubscriptionUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
value: '345',
}).getValue(),
}),
)
it('should not do anything if a user subscription is not found', async () => {
getRegularSubscription.execute = jest.fn().mockReturnValue(Result.fail('error'))
getSharedSubscription.execute = jest.fn().mockReturnValue(Result.fail('error'))
const result = await createUseCase().execute({
userUuid: regularSubscriptionUuid,
bytesUsed: 123,
})
expect(result.isFailed()).toBeTruthy()
expect(subscriptionSettingRepository.incrementCounterValueForNameAndUserSubscriptionUuid).not.toHaveBeenCalled()
})
it('should log an error and skip the update if a subscription has an invalid uuid', async () => {
getSharedSubscription.execute = jest.fn().mockReturnValue(Result.fail('no shared subscription'))
regularSubscription.uuid = 'invalid-subscription-uuid'
const result = await createUseCase().execute({
userUuid: regularSubscriptionUuid,
bytesUsed: 123,
})
it('should not do anything if a user subscription is not found', async () => {
getRegularSubscription.execute = jest.fn().mockReturnValue(Result.fail('error'))
getSharedSubscription.execute = jest.fn().mockReturnValue(Result.fail('error'))
const result = await createUseCase().execute({
userUuid: '00000000-0000-0000-0000-000000000000',
bytesUsed: 123,
})
expect(result.isFailed()).toBeTruthy()
expect(setSubscriptonSettingValue.execute).not.toHaveBeenCalled()
})
it('should add bytes used setting if one does exist', async () => {
const result = await createUseCase().execute({
userUuid: '00000000-0000-0000-0000-000000000000',
bytesUsed: 123,
})
expect(result.isFailed()).toBeFalsy()
expect(setSubscriptonSettingValue.execute).toHaveBeenCalledWith({
settingName: 'FILE_UPLOAD_BYTES_USED',
value: '468',
userSubscriptionUuid: '00000000-0000-0000-0000-000000000000',
})
})
it('should subtract bytes used setting if one does exist', async () => {
const result = await createUseCase().execute({
userUuid: '00000000-0000-0000-0000-000000000000',
bytesUsed: -123,
})
expect(result.isFailed()).toBeFalsy()
expect(setSubscriptonSettingValue.execute).toHaveBeenCalledWith({
settingName: 'FILE_UPLOAD_BYTES_USED',
value: '222',
userSubscriptionUuid: '00000000-0000-0000-0000-000000000000',
})
})
it('should not subtract below 0', async () => {
const result = await createUseCase().execute({
userUuid: '00000000-0000-0000-0000-000000000000',
bytesUsed: -1234,
})
expect(result.isFailed()).toBeFalsy()
expect(setSubscriptonSettingValue.execute).toHaveBeenCalledWith({
settingName: 'FILE_UPLOAD_BYTES_USED',
value: '0',
userSubscriptionUuid: '00000000-0000-0000-0000-000000000000',
})
})
it('should update a bytes used setting on both regular and shared subscription', async () => {
const result = await createUseCase().execute({
userUuid: '00000000-0000-0000-0000-000000000000',
bytesUsed: 123,
})
expect(result.isFailed()).toBeFalsy()
expect(setSubscriptonSettingValue.execute).toHaveBeenCalledWith({
settingName: 'FILE_UPLOAD_BYTES_USED',
value: '468',
userSubscriptionUuid: '00000000-0000-0000-0000-000000000000',
})
expect(setSubscriptonSettingValue.execute).toHaveBeenCalledWith({
settingName: 'FILE_UPLOAD_BYTES_USED',
value: '468',
userSubscriptionUuid: '2-3-4',
})
})
expect(result.isFailed()).toBeFalsy()
expect(logger.error).toHaveBeenCalled()
expect(subscriptionSettingRepository.incrementCounterValueForNameAndUserSubscriptionUuid).not.toHaveBeenCalled()
})
})
@@ -1,21 +1,21 @@
import { Result, SettingName, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
import { TimerInterface } from '@standardnotes/time'
import { UserSubscription } from '../../Subscription/UserSubscription'
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
import { UpdateStorageQuotaUsedForUserDTO } from './UpdateStorageQuotaUsedForUserDTO'
import { GetRegularSubscriptionForUser } from '../GetRegularSubscriptionForUser/GetRegularSubscriptionForUser'
import { GetSubscriptionSetting } from '../GetSubscriptionSetting/GetSubscriptionSetting'
import { SetSubscriptionSettingValue } from '../SetSubscriptionSettingValue/SetSubscriptionSettingValue'
import { Logger } from 'winston'
import { GetSharedSubscriptionForUser } from '../GetSharedSubscriptionForUser/GetSharedSubscriptionForUser'
import { SubscriptionSettingRepositoryInterface } from '../../Setting/SubscriptionSettingRepositoryInterface'
export class UpdateStorageQuotaUsedForUser implements UseCaseInterface<void> {
constructor(
private userRepository: UserRepositoryInterface,
private getRegularSubscription: GetRegularSubscriptionForUser,
private getSharedSubscription: GetSharedSubscriptionForUser,
private getSubscriptionSetting: GetSubscriptionSetting,
private setSubscriptonSettingValue: SetSubscriptionSettingValue,
private subscriptionSettingRepository: SubscriptionSettingRepositoryInterface,
private timer: TimerInterface,
private logger: Logger,
) {}
@@ -55,31 +55,23 @@ export class UpdateStorageQuotaUsedForUser implements UseCaseInterface<void> {
}
private async updateUploadBytesUsedSetting(subscription: UserSubscription, bytesUsed: number): Promise<void> {
let bytesAlreadyUsed = '0'
const bytesUsedSettingExists = await this.getSubscriptionSetting.execute({
userSubscriptionUuid: subscription.uuid,
settingName: SettingName.NAMES.FileUploadBytesUsed,
allowSensitiveRetrieval: false,
})
if (!bytesUsedSettingExists.isFailed()) {
const bytesUsedSetting = bytesUsedSettingExists.getValue()
bytesAlreadyUsed = bytesUsedSetting.setting.props.value as string
const userSubscriptionUuidOrError = Uuid.create(subscription.uuid)
if (userSubscriptionUuidOrError.isFailed()) {
this.logger.error(
`Could not update file upload bytes used for subscription ${subscription.uuid}: ${userSubscriptionUuidOrError.getError()}`,
)
return
}
const userSubscriptionUuid = userSubscriptionUuidOrError.getValue()
const bytesUsedNewTotal = +bytesAlreadyUsed + bytesUsed
const bytesUsedValue = bytesUsedNewTotal < 0 ? 0 : bytesUsedNewTotal
const result = await this.setSubscriptonSettingValue.execute({
userSubscriptionUuid: subscription.uuid,
settingName: SettingName.NAMES.FileUploadBytesUsed,
value: bytesUsedValue.toString(),
})
/* istanbul ignore next */
if (result.isFailed()) {
this.logger.error(`Could not set file upload bytes used for subscription ${subscription.uuid}`)
}
// Apply the change as an atomic database-level delta that creates the row if it does not exist.
// The unique index on (name, user_subscription_uuid) makes this race-safe for both concurrent
// increments and concurrent first-time creations, so no read/lock/transaction is needed here.
await this.subscriptionSettingRepository.incrementCounterValueForNameAndUserSubscriptionUuid(
SettingName.NAMES.FileUploadBytesUsed,
userSubscriptionUuid,
bytesUsed,
this.timer.getTimestampInMicroseconds(),
)
}
}
@@ -19,6 +19,7 @@ describe('VerifyAuthenticatorAuthenticationResponse', () => {
'standardnotes.com',
['localhost', 'https://app.standardnotes.com'],
true,
300,
)
beforeEach(() => {
@@ -38,11 +39,14 @@ describe('VerifyAuthenticatorAuthenticationResponse', () => {
authenticatorRepository.updateCounter = jest.fn()
authenticatorChallengeRepository = {} as jest.Mocked<AuthenticatorChallengeRepositoryInterface>
authenticatorChallengeRepository.findByUserUuid = jest.fn().mockReturnValue({
props: {
authenticatorChallengeRepository.findByUserUuid = jest.fn().mockReturnValue(
AuthenticatorChallenge.create({
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
challenge: 'challenge',
},
} as jest.Mocked<AuthenticatorChallenge>)
createdAt: new Date(),
}).getValue(),
)
authenticatorChallengeRepository.deleteByUserUuid = jest.fn().mockResolvedValue(1)
})
it('should return error if user uuid is invalid', async () => {
@@ -221,5 +225,67 @@ describe('VerifyAuthenticatorAuthenticationResponse', () => {
expect(result.isFailed()).toBeFalsy()
expect(authenticatorRepository.updateCounter).toHaveBeenCalled()
expect(authenticatorChallengeRepository.deleteByUserUuid).toHaveBeenCalled()
})
it('should return error if authenticator challenge is already consumed', async () => {
authenticatorChallengeRepository.deleteByUserUuid = jest.fn().mockResolvedValue(0)
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
authenticatorResponse: {
authenticatorAttachment: 'platform',
clientExtensionResults: {},
id: 'id',
rawId: 'rawId',
response: {
authenticatorData: 'authenticatorData',
clientDataJSON: 'clientDataJSON',
signature: 'signature',
userHandle: 'userHandle',
},
type: 'public-key',
},
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual(
'Could not verify authenticator authentication response: challenge already consumed',
)
})
it('should return error if authenticator challenge is expired', async () => {
authenticatorChallengeRepository.findByUserUuid = jest.fn().mockReturnValue(
AuthenticatorChallenge.create({
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
challenge: 'challenge',
createdAt: new Date(Date.now() - 301_000),
}).getValue(),
)
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
authenticatorResponse: {
authenticatorAttachment: 'platform',
clientExtensionResults: {},
id: 'id',
rawId: 'rawId',
response: {
authenticatorData: 'authenticatorData',
clientDataJSON: 'clientDataJSON',
signature: 'signature',
userHandle: 'userHandle',
},
type: 'public-key',
},
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('Could not verify authenticator authentication response: challenge expired')
expect(authenticatorChallengeRepository.deleteByUserUuid).toHaveBeenCalled()
})
})
@@ -13,6 +13,7 @@ export class VerifyAuthenticatorAuthenticationResponse implements UseCaseInterfa
private relyingPartyId: string,
private expectedOrigin: string[],
private requireUserVerification: boolean,
private authenticatorChallengeMaxAgeSeconds: number,
) {}
async execute(dto: VerifyAuthenticatorAuthenticationResponseDTO): Promise<Result<boolean>> {
@@ -27,6 +28,18 @@ export class VerifyAuthenticatorAuthenticationResponse implements UseCaseInterfa
return Result.fail('Could not verify authenticator authentication response: challenge not found')
}
if (authenticatorChallenge.isExpired(this.authenticatorChallengeMaxAgeSeconds)) {
await this.authenticatorChallengeRepository.deleteByUserUuid(userUuid)
return Result.fail('Could not verify authenticator authentication response: challenge expired')
}
const expectedChallenge = authenticatorChallenge.props.challenge.toString()
const deletedRows = await this.authenticatorChallengeRepository.deleteByUserUuid(userUuid)
if (deletedRows === 0) {
return Result.fail('Could not verify authenticator authentication response: challenge already consumed')
}
const authenticator = await this.authenticatorRepository.findByUserUuidAndCredentialId(
userUuid,
dto.authenticatorResponse.id as string,
@@ -41,7 +54,7 @@ export class VerifyAuthenticatorAuthenticationResponse implements UseCaseInterfa
try {
verification = await verifyAuthenticationResponse({
response: dto.authenticatorResponse,
expectedChallenge: authenticatorChallenge.props.challenge.toString(),
expectedChallenge,
expectedOrigin: this.expectedOrigin,
expectedRPID: this.relyingPartyId,
requireUserVerification: this.requireUserVerification,
@@ -1,7 +1,7 @@
import * as simeplWebAuthnServer from '@simplewebauthn/server'
import { VerifiedRegistrationResponse } from '@simplewebauthn/server'
import { RegistrationResponseJSON } from '@simplewebauthn/typescript-types'
import { Result } from '@standardnotes/domain-core'
import { Result, Uuid } from '@standardnotes/domain-core'
import { Authenticator } from '../../Authenticator/Authenticator'
import { AuthenticatorChallenge } from '../../Authenticator/AuthenticatorChallenge'
@@ -18,6 +18,13 @@ describe('VerifyAuthenticatorRegistrationResponse', () => {
let userRepository: UserRepositoryInterface
let featureService: FeatureServiceInterface
const createChallenge = (createdAt = new Date()) =>
AuthenticatorChallenge.create({
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
challenge: 'challenge',
createdAt,
}).getValue()
const createUseCase = () =>
new VerifyAuthenticatorRegistrationResponse(
authenticatorRepository,
@@ -27,6 +34,7 @@ describe('VerifyAuthenticatorRegistrationResponse', () => {
true,
userRepository,
featureService,
300,
)
beforeEach(() => {
@@ -34,11 +42,8 @@ describe('VerifyAuthenticatorRegistrationResponse', () => {
authenticatorRepository.save = jest.fn()
authenticatorChallengeRepository = {} as jest.Mocked<AuthenticatorChallengeRepositoryInterface>
authenticatorChallengeRepository.findByUserUuid = jest.fn().mockReturnValue({
props: {
challenge: 'challenge',
},
} as jest.Mocked<AuthenticatorChallenge>)
authenticatorChallengeRepository.findByUserUuid = jest.fn().mockReturnValue(createChallenge())
authenticatorChallengeRepository.deleteByUserUuid = jest.fn().mockResolvedValue(1)
userRepository = {} as jest.Mocked<UserRepositoryInterface>
userRepository.findOneByUuid = jest.fn().mockReturnValue({} as jest.Mocked<User>)
@@ -142,12 +147,6 @@ describe('VerifyAuthenticatorRegistrationResponse', () => {
})
it('should return error if verification could not verify', async () => {
authenticatorChallengeRepository.findByUserUuid = jest.fn().mockReturnValue({
props: {
challenge: 'challenge',
},
} as jest.Mocked<AuthenticatorChallenge>)
const useCase = createUseCase()
const mock = jest.spyOn(simeplWebAuthnServer, 'verifyRegistrationResponse')
@@ -185,12 +184,6 @@ describe('VerifyAuthenticatorRegistrationResponse', () => {
})
it('should return error if verification throws error', async () => {
authenticatorChallengeRepository.findByUserUuid = jest.fn().mockReturnValue({
props: {
challenge: 'challenge',
},
} as jest.Mocked<AuthenticatorChallenge>)
const useCase = createUseCase()
const mock = jest.spyOn(simeplWebAuthnServer, 'verifyRegistrationResponse')
@@ -219,12 +212,6 @@ describe('VerifyAuthenticatorRegistrationResponse', () => {
})
it('should return error if verification is missing registration info', async () => {
authenticatorChallengeRepository.findByUserUuid = jest.fn().mockReturnValue({
props: {
challenge: 'challenge',
},
} as jest.Mocked<AuthenticatorChallenge>)
const useCase = createUseCase()
const mock = jest.spyOn(simeplWebAuthnServer, 'verifyRegistrationResponse')
@@ -257,12 +244,6 @@ describe('VerifyAuthenticatorRegistrationResponse', () => {
})
it('should return error if authenticator could not be created', async () => {
authenticatorChallengeRepository.findByUserUuid = jest.fn().mockReturnValue({
props: {
challenge: 'challenge',
},
} as jest.Mocked<AuthenticatorChallenge>)
const useCase = createUseCase()
const mock = jest.spyOn(simeplWebAuthnServer, 'verifyRegistrationResponse')
@@ -306,12 +287,6 @@ describe('VerifyAuthenticatorRegistrationResponse', () => {
})
it('should verify authenticator registration response', async () => {
authenticatorChallengeRepository.findByUserUuid = jest.fn().mockReturnValue({
props: {
challenge: 'challenge',
},
} as jest.Mocked<AuthenticatorChallenge>)
const useCase = createUseCase()
const mock = jest.spyOn(simeplWebAuthnServer, 'verifyRegistrationResponse')
@@ -343,7 +318,59 @@ describe('VerifyAuthenticatorRegistrationResponse', () => {
})
expect(result.isFailed()).toBeFalsy()
expect(authenticatorChallengeRepository.deleteByUserUuid).toHaveBeenCalled()
mock.mockRestore()
})
it('should return error if authenticator challenge is already consumed', async () => {
authenticatorChallengeRepository.deleteByUserUuid = jest.fn().mockResolvedValue(0)
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<RegistrationResponseJSON>,
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual(
'Could not verify authenticator registration response: challenge already consumed',
)
})
it('should return error if authenticator challenge is expired', async () => {
authenticatorChallengeRepository.findByUserUuid = jest
.fn()
.mockReturnValue(createChallenge(new Date(Date.now() - 301_000)))
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<RegistrationResponseJSON>,
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('Could not verify authenticator registration response: challenge expired')
expect(authenticatorChallengeRepository.deleteByUserUuid).toHaveBeenCalled()
})
})
@@ -18,6 +18,7 @@ export class VerifyAuthenticatorRegistrationResponse implements UseCaseInterface
private requireUserVerification: boolean,
private userRepository: UserRepositoryInterface,
private featureService: FeatureServiceInterface,
private authenticatorChallengeMaxAgeSeconds: number,
) {}
async execute(dto: VerifyAuthenticatorRegistrationResponseDTO): Promise<Result<UniqueEntityId>> {
@@ -46,11 +47,23 @@ export class VerifyAuthenticatorRegistrationResponse implements UseCaseInterface
return Result.fail('Could not verify authenticator registration response: challenge not found')
}
if (authenticatorChallenge.isExpired(this.authenticatorChallengeMaxAgeSeconds)) {
await this.authenticatorChallengeRepository.deleteByUserUuid(userUuid)
return Result.fail('Could not verify authenticator registration response: challenge expired')
}
const expectedChallenge = authenticatorChallenge.props.challenge.toString()
const deletedRows = await this.authenticatorChallengeRepository.deleteByUserUuid(userUuid)
if (deletedRows === 0) {
return Result.fail('Could not verify authenticator registration response: challenge already consumed')
}
let verification: VerifiedRegistrationResponse
try {
verification = await verifyRegistrationResponse({
response: dto.attestationResponse,
expectedChallenge: authenticatorChallenge.props.challenge.toString(),
expectedChallenge,
expectedOrigin: this.expectedOrigin,
expectedRPID: this.relyingPartyId,
requireUserVerification: this.requireUserVerification,
@@ -1,4 +1,4 @@
export interface PKCERepositoryInterface {
storeCodeChallenge(codeChallenge: string): Promise<void>
removeCodeChallenge(codeChallenge: string): Promise<boolean>
storeCodeChallenge(codeChallenge: string, userUuid: string): Promise<void>
removeCodeChallenge(codeChallenge: string, userUuid: string): Promise<boolean>
}
@@ -12,6 +12,7 @@ import TYPES from '../../Bootstrap/Types'
import { BaseAdminController } from './Base/BaseAdminController'
import { CreateOfflineSubscriptionToken } from '../../Domain/UseCase/CreateOfflineSubscriptionToken/CreateOfflineSubscriptionToken'
import { CreateSubscriptionToken } from '../../Domain/UseCase/CreateSubscriptionToken/CreateSubscriptionToken'
import { ClearLoginAttempts } from '../../Domain/UseCase/ClearLoginAttempts'
import { DeleteSetting } from '../../Domain/UseCase/DeleteSetting/DeleteSetting'
import { GetSetting } from './../../Domain/UseCase/GetSetting/GetSetting'
import { UserRepositoryInterface } from '../../Domain/User/UserRepositoryInterface'
@@ -25,8 +26,16 @@ export class AnnotatedAdminController extends BaseAdminController {
@inject(TYPES.Auth_CreateSubscriptionToken) override createSubscriptionToken: CreateSubscriptionToken,
@inject(TYPES.Auth_CreateOfflineSubscriptionToken)
override createOfflineSubscriptionToken: CreateOfflineSubscriptionToken,
@inject(TYPES.Auth_ClearLoginAttempts) override clearLoginAttempts: ClearLoginAttempts,
) {
super(doDeleteSetting, doGetSetting, userRepository, createSubscriptionToken, createOfflineSubscriptionToken)
super(
doDeleteSetting,
doGetSetting,
userRepository,
createSubscriptionToken,
createOfflineSubscriptionToken,
clearLoginAttempts,
)
}
@httpGet('/user/:email')
@@ -60,4 +69,11 @@ export class AnnotatedAdminController extends BaseAdminController {
): Promise<results.BadRequestErrorMessageResult | results.OkResult> {
return super.disableEmailBackups(request)
}
@httpDelete('/users/:email/login-attempts')
override async deleteLoginAttempts(
request: Request,
): Promise<results.JsonResult | results.OkResult> {
return super.deleteLoginAttempts(request)
}
}
@@ -10,7 +10,6 @@ import TYPES from '../../Bootstrap/Types'
import { SignIn } from '../../Domain/UseCase/SignIn'
import { ClearLoginAttempts } from '../../Domain/UseCase/ClearLoginAttempts'
import { VerifyMFA } from '../../Domain/UseCase/VerifyMFA'
import { IncreaseLoginAttempts } from '../../Domain/UseCase/IncreaseLoginAttempts'
import { Logger } from 'winston'
import { GetUserKeyParams } from '../../Domain/UseCase/GetUserKeyParams/GetUserKeyParams'
import { AuthController } from '../../Controller/AuthController'
@@ -32,7 +31,6 @@ export class AnnotatedAuthController extends BaseAuthController {
@inject(TYPES.Auth_SignIn) override signInUseCase: SignIn,
@inject(TYPES.Auth_GetUserKeyParams) override getUserKeyParams: GetUserKeyParams,
@inject(TYPES.Auth_ClearLoginAttempts) override clearLoginAttempts: ClearLoginAttempts,
@inject(TYPES.Auth_IncreaseLoginAttempts) override increaseLoginAttempts: IncreaseLoginAttempts,
@inject(TYPES.Auth_Logger) override logger: Logger,
@inject(TYPES.Auth_AuthController) override authController: AuthController,
@inject(TYPES.Auth_Register) override registerUser: Register,
@@ -50,7 +48,6 @@ export class AnnotatedAuthController extends BaseAuthController {
signInUseCase,
getUserKeyParams,
clearLoginAttempts,
increaseLoginAttempts,
logger,
authController,
registerUser,
@@ -4,6 +4,7 @@ import { Request } from 'express'
import { CreateOfflineSubscriptionToken } from '../../../Domain/UseCase/CreateOfflineSubscriptionToken/CreateOfflineSubscriptionToken'
import { CreateSubscriptionToken } from '../../../Domain/UseCase/CreateSubscriptionToken/CreateSubscriptionToken'
import { ClearLoginAttempts } from '../../../Domain/UseCase/ClearLoginAttempts'
import { GetSetting } from './../../../Domain/UseCase/GetSetting/GetSetting'
import { DeleteSetting } from '../../../Domain/UseCase/DeleteSetting/DeleteSetting'
import { UserRepositoryInterface } from '../../../Domain/User/UserRepositoryInterface'
@@ -16,6 +17,7 @@ export class BaseAdminController extends BaseHttpController {
protected userRepository: UserRepositoryInterface,
protected createSubscriptionToken: CreateSubscriptionToken,
protected createOfflineSubscriptionToken: CreateOfflineSubscriptionToken,
protected clearLoginAttempts: ClearLoginAttempts,
private controllerContainer?: ControllerContainerInterface,
) {
super()
@@ -26,6 +28,7 @@ export class BaseAdminController extends BaseHttpController {
this.controllerContainer.register('admin.createToken', this.createToken.bind(this))
this.controllerContainer.register('admin.createOfflineToken', this.createOfflineToken.bind(this))
this.controllerContainer.register('admin.disableEmailBackups', this.disableEmailBackups.bind(this))
this.controllerContainer.register('admin.deleteLoginAttempts', this.deleteLoginAttempts.bind(this))
}
}
@@ -145,4 +148,34 @@ export class BaseAdminController extends BaseHttpController {
return this.badRequest('No email backups found')
}
async deleteLoginAttempts(request: Request): Promise<results.JsonResult | results.OkResult> {
const { email } = request.params
if (!email) {
return this.json(
{
error: {
message: 'Missing email parameter.',
},
},
400,
)
}
const result = await this.clearLoginAttempts.execute({ email })
if (result.isFailed()) {
return this.json(
{
error: {
message: result.getError(),
},
},
400,
)
}
return this.ok()
}
}
@@ -4,7 +4,6 @@ import { Logger } from 'winston'
import { ClearLoginAttempts } from '../../../Domain/UseCase/ClearLoginAttempts'
import { GetUserKeyParams } from '../../../Domain/UseCase/GetUserKeyParams/GetUserKeyParams'
import { IncreaseLoginAttempts } from '../../../Domain/UseCase/IncreaseLoginAttempts'
import { SignIn } from '../../../Domain/UseCase/SignIn'
import { VerifyMFA } from '../../../Domain/UseCase/VerifyMFA'
import { AuthController } from '../../../Controller/AuthController'
@@ -29,7 +28,6 @@ export class BaseAuthController extends BaseHttpController {
protected signInUseCase: SignIn,
protected getUserKeyParams: GetUserKeyParams,
protected clearLoginAttempts: ClearLoginAttempts,
protected increaseLoginAttempts: IncreaseLoginAttempts,
protected logger: Logger,
protected authController: AuthController,
protected registerUser: Register,
@@ -147,16 +145,8 @@ export class BaseAuthController extends BaseHttpController {
})
if (!signInResult.success) {
const resultOrError = await this.increaseLoginAttempts.execute({ email: request.body.email, skipUsernameValidation: true })
if (resultOrError.isFailed()) {
this.logger.error(`Failed to increase login attempts: ${resultOrError.getError()}`, {
application: request.headers['x-application-version'] as string,
})
} else {
const result = resultOrError.getValue()
if (result.isNonCaptchaLimitReached) {
response.setHeader('x-captcha-required', this.captchaUIUrl)
}
if (signInResult.isNonCaptchaLimitReached) {
response.setHeader('x-captcha-required', this.captchaUIUrl)
}
return this.json(
@@ -169,8 +159,6 @@ export class BaseAuthController extends BaseHttpController {
)
}
await this.clearLoginAttempts.execute({ email: request.body.email })
if (signInResult.result.response !== undefined) {
const session = signInResult.result.session as Session
const user = signInResult.result.response.user
@@ -208,7 +196,7 @@ export class BaseAuthController extends BaseHttpController {
}
async recoveryLogin(request: Request, response: Response): Promise<results.JsonResult> {
const result = await this.signInWithRecoveryCodes.execute({
const signInResponse = await this.signInWithRecoveryCodes.execute({
apiVersion: request.body.api_version,
userAgent: <string>request.headers['user-agent'],
codeVerifier: request.body.code_verifier,
@@ -220,24 +208,11 @@ export class BaseAuthController extends BaseHttpController {
application: request.headers['x-application-version'] as string,
})
if (result.isFailed()) {
this.logger.debug(`Failed to sign in with recovery codes: ${result.getError()}`)
if (!signInResponse.success) {
this.logger.debug(`Failed to sign in with recovery codes: ${signInResponse.errorMessage}`)
const increasLoginAttemtpsResultOrError = await this.increaseLoginAttempts.execute({
email: request.body.username,
})
if (increasLoginAttemtpsResultOrError.isFailed()) {
this.logger.error(
`Failed to increase login attempts on recovery login: ${increasLoginAttemtpsResultOrError.getError()}`,
{
application: request.headers['x-application-version'] as string,
},
)
} else {
const increasLoginAttemtpsResult = increasLoginAttemtpsResultOrError.getValue()
if (increasLoginAttemtpsResult.isNonCaptchaLimitReached) {
response.setHeader('x-captcha-required', this.captchaUIUrl)
}
if (signInResponse.isNonCaptchaLimitReached) {
response.setHeader('x-captcha-required', this.captchaUIUrl)
}
return this.json(
@@ -250,14 +225,23 @@ export class BaseAuthController extends BaseHttpController {
)
}
await this.clearLoginAttempts.execute({ email: request.body.username })
const authResponse = signInResponse.result.response
const signInWithRecoveryCodesResult = result.getValue()
if (authResponse === undefined) {
return this.json(
{
error: {
message: 'Invalid login credentials.',
},
},
HttpStatusCode.Unauthorized,
)
}
return this.json({
session: signInWithRecoveryCodesResult.sessionBody,
key_params: signInWithRecoveryCodesResult.keyParams,
user: signInWithRecoveryCodesResult.user,
session: authResponse.sessionBody,
key_params: authResponse.keyParams,
user: authResponse.user,
})
}
@@ -265,6 +265,7 @@ export class BaseSettingsController extends BaseHttpController {
serverPassword,
authTokenVersion: locals.authTokenVersion,
shouldVerifyUserServerPassword: true,
checkUserPermissions: true,
})
if (result.success) {
@@ -0,0 +1,60 @@
import * as IORedis from 'ioredis'
import { EphemeralSession } from '../../Domain/Session/EphemeralSession'
import { RedisEphemeralSessionRepository } from './RedisEphemeralSessionRepository'
describe('RedisEphemeralSessionRepository', () => {
let redisClient: jest.Mocked<IORedis.Redis>
let pipeline: {
del: jest.Mock
srem: jest.Mock
exec: jest.Mock
}
let repository: RedisEphemeralSessionRepository
beforeEach(() => {
pipeline = {
del: jest.fn().mockReturnThis(),
srem: jest.fn().mockReturnThis(),
exec: jest.fn().mockResolvedValue([]),
}
redisClient = {
get: jest.fn(),
pipeline: jest.fn().mockReturnValue(pipeline),
} as unknown as jest.Mocked<IORedis.Redis>
repository = new RedisEphemeralSessionRepository(redisClient, 3600)
})
describe('deleteOne', () => {
it('should delete private identifier mapping when revoking a session', async () => {
const session = {
uuid: 'session-uuid',
userUuid: 'user-uuid',
privateIdentifier: 'private-id',
} as EphemeralSession
redisClient.get.mockResolvedValue(JSON.stringify(session))
await repository.deleteOne('session-uuid', 'user-uuid')
expect(redisClient.get).toHaveBeenCalledWith('session:session-uuid:user-uuid')
expect(pipeline.del).toHaveBeenCalledWith('session:session-uuid')
expect(pipeline.del).toHaveBeenCalledWith('session:session-uuid:user-uuid')
expect(pipeline.del).toHaveBeenCalledWith('session-private-id:private-id')
expect(pipeline.srem).toHaveBeenCalledWith('user-sessions:user-uuid', 'session-uuid')
expect(pipeline.exec).toHaveBeenCalled()
})
it('should skip private identifier deletion when session is not found', async () => {
redisClient.get.mockResolvedValue(null)
await repository.deleteOne('session-uuid', 'user-uuid')
expect(pipeline.del).toHaveBeenCalledWith('session:session-uuid')
expect(pipeline.del).toHaveBeenCalledWith('session:session-uuid:user-uuid')
expect(pipeline.del).not.toHaveBeenCalledWith(expect.stringContaining('session-private-id'))
})
})
})
@@ -26,12 +26,18 @@ export class RedisEphemeralSessionRepository implements EphemeralSessionReposito
}
async deleteOne(uuid: string, userUuid: string): Promise<void> {
const session = await this.findOneByUuidAndUserUuid(uuid, userUuid)
const pipeline = this.redisClient.pipeline()
pipeline.del(`${this.PREFIX}:${uuid}`)
pipeline.del(`${this.PREFIX}:${uuid}:${userUuid}`)
pipeline.srem(`${this.USER_SESSIONS_PREFIX}:${userUuid}`, uuid)
if (session?.privateIdentifier) {
pipeline.del(`${this.PREFIX_PRIVATE_ID}:${session.privateIdentifier}`)
}
await pipeline.exec()
}
@@ -14,14 +14,22 @@ export class RedisPKCERepository implements PKCERepositoryInterface {
@inject(TYPES.Auth_Logger) private logger: Logger,
) {}
async storeCodeChallenge(codeChallenge: string): Promise<void> {
async storeCodeChallenge(codeChallenge: string, userUuid: string): Promise<void> {
this.logger.debug(`Storing code challenge: ${codeChallenge}`)
await this.redisClient.setex(`${this.PREFIX}:${codeChallenge}`, 3600, codeChallenge)
await this.redisClient.setex(`${this.PREFIX}:${codeChallenge}`, 3600, userUuid)
}
async removeCodeChallenge(codeChallenge: string): Promise<boolean> {
const entriesRemoved = await this.redisClient.del(`${this.PREFIX}:${codeChallenge}`)
async removeCodeChallenge(codeChallenge: string, userUuid: string): Promise<boolean> {
const key = `${this.PREFIX}:${codeChallenge}`
const storedUserUuid = await this.redisClient.get(key)
// Legacy entries (pre user-uuid binding) stored value = codeChallenge; remove after 3600s TTL window.
if (!storedUserUuid || (storedUserUuid !== userUuid && storedUserUuid !== codeChallenge)) {
return false
}
const entriesRemoved = await this.redisClient.del(key)
this.logger.debug(`Removed ${entriesRemoved} entries for code challenge: ${codeChallenge}`)
@@ -1,4 +1,4 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'
@Entity({ name: 'authenticators' })
export class TypeORMAuthenticator {
@@ -9,6 +9,7 @@ export class TypeORMAuthenticator {
name: 'user_uuid',
length: 36,
})
@Index('index_authenticators_on_user_uuid')
declare userUuid: string
@Column({
@@ -40,4 +40,15 @@ export class TypeORMAuthenticatorChallengeRepository implements AuthenticatorCha
return this.mapper.toDomain(persistence)
}
async deleteByUserUuid(userUuid: Uuid): Promise<number> {
const result = await this.ormRepository
.createQueryBuilder()
.delete()
.from(TypeORMAuthenticatorChallenge)
.where('user_uuid = :userUuid', { userUuid: userUuid.value })
.execute()
return result.affected ?? 0
}
}
@@ -0,0 +1,57 @@
import { CacheEntry, CacheEntryRepositoryInterface } from '@standardnotes/domain-core'
import { TimerInterface } from '@standardnotes/time'
import { EphemeralSession } from '../../Domain/Session/EphemeralSession'
import { TypeORMEphemeralSessionRepository } from './TypeORMEphemeralSessionRepository'
describe('TypeORMEphemeralSessionRepository', () => {
let cacheEntryRepository: jest.Mocked<CacheEntryRepositoryInterface>
let timer: jest.Mocked<TimerInterface>
let repository: TypeORMEphemeralSessionRepository
beforeEach(() => {
cacheEntryRepository = {
findUnexpiredOneByKey: jest.fn(),
removeByKey: jest.fn(),
save: jest.fn(),
} as unknown as jest.Mocked<CacheEntryRepositoryInterface>
timer = {} as jest.Mocked<TimerInterface>
repository = new TypeORMEphemeralSessionRepository(cacheEntryRepository, 3600, timer)
})
describe('deleteOne', () => {
it('should delete private identifier mapping when revoking a session', async () => {
const session = {
uuid: 'session-uuid',
userUuid: 'user-uuid',
privateIdentifier: 'private-id',
} as EphemeralSession
cacheEntryRepository.findUnexpiredOneByKey.mockResolvedValueOnce(
CacheEntry.create({
key: 'session:session-uuid:user-uuid',
value: JSON.stringify(session),
expiresAt: new Date(),
}).getValue(),
)
await repository.deleteOne('session-uuid', 'user-uuid')
expect(cacheEntryRepository.removeByKey).toHaveBeenCalledWith('session:session-uuid')
expect(cacheEntryRepository.removeByKey).toHaveBeenCalledWith('session:session-uuid:user-uuid')
expect(cacheEntryRepository.removeByKey).toHaveBeenCalledWith('session-private-id:private-id')
})
it('should skip private identifier deletion when session is not found', async () => {
cacheEntryRepository.findUnexpiredOneByKey.mockResolvedValueOnce(null)
await repository.deleteOne('session-uuid', 'user-uuid')
expect(cacheEntryRepository.removeByKey).toHaveBeenCalledWith('session:session-uuid')
expect(cacheEntryRepository.removeByKey).toHaveBeenCalledWith('session:session-uuid:user-uuid')
expect(cacheEntryRepository.removeByKey).not.toHaveBeenCalledWith(expect.stringContaining('session-private-id'))
})
})
})
@@ -27,9 +27,15 @@ export class TypeORMEphemeralSessionRepository implements EphemeralSessionReposi
}
async deleteOne(uuid: string, userUuid: string): Promise<void> {
const session = await this.findOneByUuidAndUserUuid(uuid, userUuid)
await this.cacheEntryRepository.removeByKey(`${this.PREFIX}:${uuid}`)
await this.cacheEntryRepository.removeByKey(`${this.PREFIX}:${uuid}:${userUuid}`)
if (session?.privateIdentifier) {
await this.cacheEntryRepository.removeByKey(`${this.PREFIX_PRIVATE_ID}:${session.privateIdentifier}`)
}
const userSessionsJSON = await this.cacheEntryRepository.findUnexpiredOneByKey(
`${this.USER_SESSIONS_PREFIX}:${userUuid}`,
)
@@ -33,4 +33,16 @@ export class TypeORMOfflineSettingRepository implements OfflineSettingRepository
})
.getOne()
}
async deleteByNameAndValueExcludingEmail(name: OfflineSettingName, value: string, email: string): Promise<void> {
await this.ormRepository
.createQueryBuilder()
.delete()
.where('name = :name AND value = :value AND email != :email', {
name,
value,
email,
})
.execute()
}
}
@@ -13,20 +13,29 @@ export class TypeORMPKCERepository implements PKCERepositoryInterface {
private timer: TimerInterface,
) {}
async storeCodeChallenge(codeChallenge: string): Promise<void> {
async storeCodeChallenge(codeChallenge: string, userUuid: string): Promise<void> {
this.logger.debug(`Storing code challenge: ${codeChallenge}`)
await this.cacheEntryRepository.save(
CacheEntry.create({
key: `${this.PREFIX}:${codeChallenge}`,
value: codeChallenge,
value: userUuid,
expiresAt: this.timer.getUTCDateNSecondsAhead(3600),
}).getValue(),
)
}
async removeCodeChallenge(codeChallenge: string): Promise<boolean> {
await this.cacheEntryRepository.removeByKey(`${this.PREFIX}:${codeChallenge}`)
async removeCodeChallenge(codeChallenge: string, userUuid: string): Promise<boolean> {
const key = `${this.PREFIX}:${codeChallenge}`
const cacheEntry = await this.cacheEntryRepository.findUnexpiredOneByKey(key)
// Legacy entries (pre user-uuid binding) stored value = codeChallenge; remove after 3600s TTL window.
const storedValue = cacheEntry?.props.value
if (!storedValue || (storedValue !== userUuid && storedValue !== codeChallenge)) {
return false
}
await this.cacheEntryRepository.removeByKey(key)
return true
}
@@ -1,7 +1,7 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'
@Entity({ name: 'subscription_settings' })
@Index('index_settings_on_name_and_user_subscription_uuid', ['name', 'userSubscriptionUuid'])
@Index('index_settings_on_name_and_user_subscription_uuid', ['name', 'userSubscriptionUuid'], { unique: true })
export class TypeORMSubscriptionSetting {
@PrimaryGeneratedColumn('uuid')
declare uuid: string
@@ -1,5 +1,5 @@
import { Repository } from 'typeorm'
import { MapperInterface, Uuid } from '@standardnotes/domain-core'
import { MapperInterface, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
import { SubscriptionSettingRepositoryInterface } from '../../Domain/Setting/SubscriptionSettingRepositoryInterface'
import { SubscriptionSetting } from '../../Domain/Setting/SubscriptionSetting'
@@ -69,4 +69,41 @@ export class TypeORMSubscriptionSettingRepository implements SubscriptionSetting
return this.mapper.toDomain(persistence)
}
async incrementCounterValueForNameAndUserSubscriptionUuid(
name: string,
userSubscriptionUuid: Uuid,
delta: number,
updatedAt: number,
): Promise<void> {
// Atomic upsert: relies on the unique index on (name, user_subscription_uuid) so that
// concurrent updates for the same subscription cannot create duplicate rows or lose an
// increment. The value is stored as text but treated as a signed integer counter, clamped
// to a minimum of 0. `created_at` is only set when a new row is inserted.
const uuid = new UniqueEntityId().toString()
const isSQLite = this.ormRepository.manager.connection.options.type === 'sqlite'
const query = isSQLite
? 'INSERT INTO "subscription_settings" ' +
'("uuid", "name", "value", "server_encryption_version", "created_at", "updated_at", "sensitive", "user_subscription_uuid") ' +
'VALUES (?, ?, MAX(?, 0), 0, ?, ?, 0, ?) ' +
'ON CONFLICT ("name", "user_subscription_uuid") DO UPDATE SET ' +
"value = MAX(CAST(COALESCE(value, '0') AS INTEGER) + ?, 0), updated_at = ?"
: 'INSERT INTO `subscription_settings` ' +
'(`uuid`, `name`, `value`, `server_encryption_version`, `created_at`, `updated_at`, `sensitive`, `user_subscription_uuid`) ' +
'VALUES (?, ?, GREATEST(?, 0), 0, ?, ?, 0, ?) ' +
'ON DUPLICATE KEY UPDATE ' +
"value = GREATEST(CAST(COALESCE(value, '0') AS SIGNED) + ?, 0), updated_at = ?"
await this.ormRepository.manager.query(query, [
uuid,
name,
delta,
updatedAt,
updatedAt,
userSubscriptionUuid.value,
delta,
updatedAt,
])
}
}
@@ -754,6 +754,51 @@ describe('SharedVaultFilter', () => {
expect(result.passed).toBe(false)
})
it('should return as not passed if existing item is key system items key and incoming content type is null', async () => {
sharedVaultUser = SharedVaultUser.create({
permission: SharedVaultUserPermission.create(SharedVaultUserPermission.PERMISSIONS.Write).getValue(),
sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
isDesignatedSurvivor: false,
}).getValue()
existingItem = Item.create({
...existingItem.props,
contentType: ContentType.create(ContentType.TYPES.KeySystemItemsKey).getValue(),
}).getValue()
itemHash = ItemHash.create({
...itemHash.props,
content_type: null,
}).getValue()
determineSharedVaultOperationOnItem.execute = jest.fn().mockReturnValue(
Result.ok(
SharedVaultOperationOnItem.create({
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
type: SharedVaultOperationOnItem.TYPES.SaveToSharedVault,
incomingItemHash: itemHash,
existingItem,
}).getValue(),
),
)
sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockResolvedValue(sharedVaultUser)
const filter = createFilter()
const result = await filter.check({
apiVersion: '001',
existingItem: existingItem,
itemHash: itemHash,
userUuid: '00000000-0000-0000-0000-000000000000',
snjsVersion: '2.200.0',
})
expect(result.passed).toBe(false)
})
})
describe('when the shared vault operation on item is: create to shared vault', () => {
@@ -101,8 +101,12 @@ export class SharedVaultFilter implements ItemSaveRuleInterface {
}
}
private isAuthorizedToSaveContentType(contentType: string | null, permission: SharedVaultUserPermission): boolean {
if (contentType === ContentType.TYPES.KeySystemItemsKey) {
private isAuthorizedToSaveContentType(
incomingContentType: string | null,
existingContentType: string | null,
permission: SharedVaultUserPermission,
): boolean {
if ([incomingContentType, existingContentType].includes(ContentType.TYPES.KeySystemItemsKey)) {
return permission.value === SharedVaultUserPermission.PERMISSIONS.Admin
}
@@ -177,8 +181,14 @@ export class SharedVaultFilter implements ItemSaveRuleInterface {
operation: SharedVaultOperationOnItem,
sharedVaultPermission: SharedVaultUserPermission,
): boolean {
const existingContentType = operation.props.existingItem?.props.contentType.value ?? null
if (
!this.isAuthorizedToSaveContentType(operation.props.incomingItemHash.props.content_type, sharedVaultPermission)
!this.isAuthorizedToSaveContentType(
operation.props.incomingItemHash.props.content_type,
existingContentType,
sharedVaultPermission,
)
) {
return false
}