From 9d7e63a7a78adcb9817084e460a01189012bc403 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karol=20S=C3=B3jko?= Date: Mon, 1 May 2023 12:02:52 +0200 Subject: [PATCH] feat(auth): add sqlite lock cache for home server (#577) * feat(auth): add sqlite lock cache for home server * fix(auth): lock repository binding --- packages/auth/src/Bootstrap/Container.ts | 39 ++++++++- packages/auth/src/Bootstrap/Types.ts | 3 + .../Cache/CacheEntryRepositoryInterface.ts | 5 +- .../TypeORM/TypeORMCacheEntryRepository.ts | 40 +++++++++ .../Infra/TypeORM/TypeORMLockRepository.ts | 85 +++++++++++++++++++ 5 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 packages/auth/src/Infra/TypeORM/TypeORMCacheEntryRepository.ts create mode 100644 packages/auth/src/Infra/TypeORM/TypeORMLockRepository.ts diff --git a/packages/auth/src/Bootstrap/Container.ts b/packages/auth/src/Bootstrap/Container.ts index c45a6b7d4..a1f972855 100644 --- a/packages/auth/src/Bootstrap/Container.ts +++ b/packages/auth/src/Bootstrap/Container.ts @@ -171,6 +171,7 @@ import { SubscriptionSettingProjector } from '../Projection/SubscriptionSettingP import { SubscriptionSettingsAssociationService } from '../Domain/Setting/SubscriptionSettingsAssociationService' import { SubscriptionSettingsAssociationServiceInterface } from '../Domain/Setting/SubscriptionSettingsAssociationServiceInterface' import { PKCERepositoryInterface } from '../Domain/User/PKCERepositoryInterface' +import { LockRepositoryInterface } from '../Domain/User/LockRepositoryInterface' import { RedisPKCERepository } from '../Infra/Redis/RedisPKCERepository' import { RoleRepositoryInterface } from '../Domain/Role/RoleRepositoryInterface' import { RevokedSessionRepositoryInterface } from '../Domain/Session/RevokedSessionRepositoryInterface' @@ -216,6 +217,12 @@ import { GenerateRecoveryCodes } from '../Domain/UseCase/GenerateRecoveryCodes/G import { SignInWithRecoveryCodes } from '../Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes' import { GetUserKeyParamsRecovery } from '../Domain/UseCase/GetUserKeyParamsRecovery/GetUserKeyParamsRecovery' import { CleanupExpiredSessions } from '../Domain/UseCase/CleanupExpiredSessions/CleanupExpiredSessions' +import { TypeORMCacheEntry } from '../Infra/TypeORM/TypeORMCacheEntry' +import { CacheEntryRepositoryInterface } from '../Domain/Cache/CacheEntryRepositoryInterface' +import { TypeORMCacheEntryRepository } from '../Infra/TypeORM/TypeORMCacheEntryRepository' +import { CacheEntry } from '../Domain/Cache/CacheEntry' +import { CacheEntryPersistenceMapper } from '../Mapping/CacheEntryPersistenceMapper' +import { TypeORMLockRepository } from '../Infra/TypeORM/TypeORMLockRepository' // eslint-disable-next-line @typescript-eslint/no-var-requires const newrelicFormatter = require('@newrelic/winston-enricher') @@ -229,6 +236,8 @@ export class ContainerConfigLoader { await AppDataSource.initialize() + const isConfiguredForHomeServer = env.get('DB_TYPE') === 'sqlite' + const redisUrl = env.get('REDIS_URL') const isRedisInClusterMode = redisUrl.indexOf(',') > 0 let redis @@ -298,6 +307,9 @@ export class ContainerConfigLoader { TYPES.AuthenticatorChallengePersistenceMapper, ) .toConstantValue(new AuthenticatorChallengePersistenceMapper()) + container + .bind>(TYPES.CacheEntryPersistenceMapper) + .toConstantValue(new CacheEntryPersistenceMapper()) // ORM container @@ -335,6 +347,9 @@ export class ContainerConfigLoader { container .bind>(TYPES.ORMAuthenticatorChallengeRepository) .toConstantValue(AppDataSource.getRepository(TypeORMAuthenticatorChallenge)) + container + .bind>(TYPES.ORMCacheEntryRepository) + .toConstantValue(AppDataSource.getRepository(TypeORMCacheEntry)) // Repositories container.bind(TYPES.SessionRepository).to(TypeORMSessionRepository) @@ -359,7 +374,6 @@ export class ContainerConfigLoader { container .bind(TYPES.EphemeralSessionRepository) .to(RedisEphemeralSessionRepository) - container.bind(TYPES.LockRepository).to(LockRepository) container .bind(TYPES.SubscriptionTokenRepository) .to(RedisSubscriptionTokenRepository) @@ -394,6 +408,14 @@ export class ContainerConfigLoader { container.get(TYPES.AuthenticatorChallengePersistenceMapper), ), ) + container + .bind(TYPES.CacheEntryRepository) + .toConstantValue( + new TypeORMCacheEntryRepository( + container.get(TYPES.ORMCacheEntryRepository), + container.get(TYPES.CacheEntryPersistenceMapper), + ), + ) // Middleware container.bind(TYPES.AuthMiddleware).to(AuthMiddleware) @@ -471,6 +493,21 @@ export class ContainerConfigLoader { .bind(TYPES.READONLY_USERS) .toConstantValue(env.get('READONLY_USERS', true) ? env.get('READONLY_USERS', true).split(',') : []) + if (isConfiguredForHomeServer) { + container + .bind(TYPES.LockRepository) + .toConstantValue( + new TypeORMLockRepository( + container.get(TYPES.CacheEntryRepository), + container.get(TYPES.Timer), + container.get(TYPES.MAX_LOGIN_ATTEMPTS), + container.get(TYPES.FAILED_LOGIN_LOCKOUT), + ), + ) + } else { + container.bind(TYPES.LockRepository).to(LockRepository) + } + // Services container.bind(TYPES.DeviceDetector).toConstantValue(new UAParser()) container.bind(TYPES.SessionService).to(SessionService) diff --git a/packages/auth/src/Bootstrap/Types.ts b/packages/auth/src/Bootstrap/Types.ts index f2a93a09b..40241fd3e 100644 --- a/packages/auth/src/Bootstrap/Types.ts +++ b/packages/auth/src/Bootstrap/Types.ts @@ -8,6 +8,7 @@ const TYPES = { AuthenticatorChallengePersistenceMapper: Symbol.for('AuthenticatorChallengePersistenceMapper'), AuthenticatorPersistenceMapper: Symbol.for('AuthenticatorPersistenceMapper'), AuthenticatorHttpMapper: Symbol.for('AuthenticatorHttpMapper'), + CacheEntryPersistenceMapper: Symbol.for('CacheEntryPersistenceMapper'), // Controller AuthController: Symbol.for('AuthController'), AuthenticatorsController: Symbol.for('AuthenticatorsController'), @@ -32,6 +33,7 @@ const TYPES = { SessionTraceRepository: Symbol.for('SessionTraceRepository'), AuthenticatorRepository: Symbol.for('AuthenticatorRepository'), AuthenticatorChallengeRepository: Symbol.for('AuthenticatorChallengeRepository'), + CacheEntryRepository: Symbol.for('CacheEntryRepository'), // ORM ORMOfflineSettingRepository: Symbol.for('ORMOfflineSettingRepository'), ORMOfflineUserSubscriptionRepository: Symbol.for('ORMOfflineUserSubscriptionRepository'), @@ -46,6 +48,7 @@ const TYPES = { ORMSessionTraceRepository: Symbol.for('ORMSessionTraceRepository'), ORMAuthenticatorRepository: Symbol.for('ORMAuthenticatorRepository'), ORMAuthenticatorChallengeRepository: Symbol.for('ORMAuthenticatorChallengeRepository'), + ORMCacheEntryRepository: Symbol.for('ORMCacheEntryRepository'), // Middleware AuthMiddleware: Symbol.for('AuthMiddleware'), ApiGatewayAuthMiddleware: Symbol.for('ApiGatewayAuthMiddleware'), diff --git a/packages/auth/src/Domain/Cache/CacheEntryRepositoryInterface.ts b/packages/auth/src/Domain/Cache/CacheEntryRepositoryInterface.ts index 738fc6d03..fe117042a 100644 --- a/packages/auth/src/Domain/Cache/CacheEntryRepositoryInterface.ts +++ b/packages/auth/src/Domain/Cache/CacheEntryRepositoryInterface.ts @@ -1,6 +1,7 @@ import { CacheEntry } from './CacheEntry' export interface CacheEntryRepositoryInterface { - save(cacheEntry: CacheEntry): Promise - findOneByKey(key: string): Promise + save(cacheEntry: CacheEntry): Promise + findUnexpiredOneByKey(key: string): Promise + removeByKey(key: string): Promise } diff --git a/packages/auth/src/Infra/TypeORM/TypeORMCacheEntryRepository.ts b/packages/auth/src/Infra/TypeORM/TypeORMCacheEntryRepository.ts new file mode 100644 index 000000000..9659572c2 --- /dev/null +++ b/packages/auth/src/Infra/TypeORM/TypeORMCacheEntryRepository.ts @@ -0,0 +1,40 @@ +import { MapperInterface } from '@standardnotes/domain-core' +import { Repository } from 'typeorm' +import { CacheEntry } from '../../Domain/Cache/CacheEntry' +import { CacheEntryRepositoryInterface } from '../../Domain/Cache/CacheEntryRepositoryInterface' +import { TypeORMCacheEntry } from './TypeORMCacheEntry' + +export class TypeORMCacheEntryRepository implements CacheEntryRepositoryInterface { + constructor( + private ormRepository: Repository, + private mapper: MapperInterface, + ) {} + + async save(cacheEntry: CacheEntry): Promise { + const persistence = this.mapper.toProjection(cacheEntry) + + await this.ormRepository.save(persistence) + } + + async findUnexpiredOneByKey(key: string): Promise { + const persistence = await this.ormRepository + .createQueryBuilder('cache') + .where('cache.key = :key', { + key, + }) + .andWhere('cache.expires_at > :now', { + now: new Date(), + }) + .getOne() + + if (persistence === null) { + return null + } + + return this.mapper.toDomain(persistence) + } + + async removeByKey(key: string): Promise { + await this.ormRepository.createQueryBuilder().delete().where('key = :key', { key }).execute() + } +} diff --git a/packages/auth/src/Infra/TypeORM/TypeORMLockRepository.ts b/packages/auth/src/Infra/TypeORM/TypeORMLockRepository.ts new file mode 100644 index 000000000..4d63e4099 --- /dev/null +++ b/packages/auth/src/Infra/TypeORM/TypeORMLockRepository.ts @@ -0,0 +1,85 @@ +import { TimerInterface } from '@standardnotes/time' + +import { CacheEntry } from '../../Domain/Cache/CacheEntry' +import { CacheEntryRepositoryInterface } from '../../Domain/Cache/CacheEntryRepositoryInterface' + +import { LockRepositoryInterface } from '../../Domain/User/LockRepositoryInterface' + +export class TypeORMLockRepository implements LockRepositoryInterface { + private readonly PREFIX = 'lock' + private readonly OTP_PREFIX = 'otp-lock' + + constructor( + private cacheEntryRepository: CacheEntryRepositoryInterface, + private timer: TimerInterface, + private maxLoginAttempts: number, + private failedLoginLockout: number, + ) {} + + async lockSuccessfullOTP(userIdentifier: string, otp: string): Promise { + const cacheEntryOrError = CacheEntry.create({ + key: `${this.OTP_PREFIX}:${userIdentifier}`, + value: otp, + expiresAt: this.timer.getUTCDateNSecondsAhead(60), + }) + if (cacheEntryOrError.isFailed()) { + throw new Error('Could not create cache entry') + } + + await this.cacheEntryRepository.save(cacheEntryOrError.getValue()) + } + + async isOTPLocked(userIdentifier: string, otp: string): Promise { + const lock = await this.cacheEntryRepository.findUnexpiredOneByKey(`${this.OTP_PREFIX}:${userIdentifier}`) + if (!lock) { + return false + } + + return lock.props.value === otp + } + + async resetLockCounter(userIdentifier: string): Promise { + await this.cacheEntryRepository.removeByKey(`${this.PREFIX}:${userIdentifier}`) + } + + async updateLockCounter(userIdentifier: string, counter: number): Promise { + let cacheEntry = await this.cacheEntryRepository.findUnexpiredOneByKey(`${this.PREFIX}:${userIdentifier}`) + if (!cacheEntry) { + cacheEntry = CacheEntry.create({ + key: `${this.PREFIX}:${userIdentifier}`, + value: counter.toString(), + expiresAt: this.timer.getUTCDateNSecondsAhead(this.failedLoginLockout), + }).getValue() + } else { + cacheEntry.props.value = counter.toString() + cacheEntry.props.expiresAt = this.timer.getUTCDateNSecondsAhead(this.failedLoginLockout) + } + + await this.cacheEntryRepository.save(cacheEntry) + } + + async getLockCounter(userIdentifier: string): Promise { + const counter = await this.cacheEntryRepository.findUnexpiredOneByKey(`${this.PREFIX}:${userIdentifier}`) + + if (!counter) { + return 0 + } + + return +counter.props.value + } + + async lockUser(userIdentifier: string): Promise { + const cacheEntry = await this.cacheEntryRepository.findUnexpiredOneByKey(`${this.PREFIX}:${userIdentifier}`) + if (cacheEntry !== null) { + cacheEntry.props.expiresAt = this.timer.getUTCDateNSecondsAhead(this.failedLoginLockout) + + await this.cacheEntryRepository.save(cacheEntry) + } + } + + async isUserLocked(userIdentifier: string): Promise { + const counter = await this.getLockCounter(userIdentifier) + + return counter >= this.maxLoginAttempts + } +}