mirror of
https://github.com/standardnotes/server
synced 2026-09-11 21:45:28 -04:00
Merge branch 'chore/update-latest-code' into gh-main
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")',
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
+24
-5
@@ -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)
|
||||
|
||||
|
||||
+7
@@ -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()
|
||||
|
||||
|
||||
+89
-14
@@ -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', () => {
|
||||
|
||||
+27
-1
@@ -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
|
||||
}
|
||||
|
||||
+14
@@ -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 () => {
|
||||
|
||||
+24
-16
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
|
||||
+32
@@ -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)
|
||||
|
||||
|
||||
+23
-5
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
+186
-31
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
+72
-49
@@ -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
|
||||
}
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { AuthResponseCreationResult } from '../../Auth/AuthResponseCreationResult'
|
||||
|
||||
export type SignInWithRecoveryCodesResponse =
|
||||
| {
|
||||
success: false
|
||||
errorMessage: string
|
||||
isNonCaptchaLimitReached?: boolean
|
||||
}
|
||||
| {
|
||||
success: true
|
||||
result: AuthResponseCreationResult
|
||||
}
|
||||
+91
-112
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
+20
-28
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+70
-4
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
+14
-1
@@ -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,
|
||||
|
||||
+63
-36
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
+14
-1
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user