feat(auth): add sqlite lock cache for home server (#577)

* feat(auth): add sqlite lock cache for home server

* fix(auth): lock repository binding
This commit is contained in:
Karol Sójko
2023-05-01 12:02:52 +02:00
committed by GitHub
parent 87c1ae2ac0
commit 9d7e63a7a7
5 changed files with 169 additions and 3 deletions

View File

@@ -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<MapperInterface<CacheEntry, TypeORMCacheEntry>>(TYPES.CacheEntryPersistenceMapper)
.toConstantValue(new CacheEntryPersistenceMapper())
// ORM
container
@@ -335,6 +347,9 @@ export class ContainerConfigLoader {
container
.bind<Repository<TypeORMAuthenticatorChallenge>>(TYPES.ORMAuthenticatorChallengeRepository)
.toConstantValue(AppDataSource.getRepository(TypeORMAuthenticatorChallenge))
container
.bind<Repository<TypeORMCacheEntry>>(TYPES.ORMCacheEntryRepository)
.toConstantValue(AppDataSource.getRepository(TypeORMCacheEntry))
// Repositories
container.bind<SessionRepositoryInterface>(TYPES.SessionRepository).to(TypeORMSessionRepository)
@@ -359,7 +374,6 @@ export class ContainerConfigLoader {
container
.bind<RedisEphemeralSessionRepository>(TYPES.EphemeralSessionRepository)
.to(RedisEphemeralSessionRepository)
container.bind<LockRepository>(TYPES.LockRepository).to(LockRepository)
container
.bind<SubscriptionTokenRepositoryInterface>(TYPES.SubscriptionTokenRepository)
.to(RedisSubscriptionTokenRepository)
@@ -394,6 +408,14 @@ export class ContainerConfigLoader {
container.get(TYPES.AuthenticatorChallengePersistenceMapper),
),
)
container
.bind<CacheEntryRepositoryInterface>(TYPES.CacheEntryRepository)
.toConstantValue(
new TypeORMCacheEntryRepository(
container.get(TYPES.ORMCacheEntryRepository),
container.get(TYPES.CacheEntryPersistenceMapper),
),
)
// Middleware
container.bind<AuthMiddleware>(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<LockRepositoryInterface>(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<LockRepositoryInterface>(TYPES.LockRepository).to(LockRepository)
}
// Services
container.bind<UAParser>(TYPES.DeviceDetector).toConstantValue(new UAParser())
container.bind<SessionService>(TYPES.SessionService).to(SessionService)

View File

@@ -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'),

View File

@@ -1,6 +1,7 @@
import { CacheEntry } from './CacheEntry'
export interface CacheEntryRepositoryInterface {
save(cacheEntry: CacheEntry): Promise<CacheEntry>
findOneByKey(key: string): Promise<CacheEntry | null>
save(cacheEntry: CacheEntry): Promise<void>
findUnexpiredOneByKey(key: string): Promise<CacheEntry | null>
removeByKey(key: string): Promise<void>
}

View File

@@ -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<TypeORMCacheEntry>,
private mapper: MapperInterface<CacheEntry, TypeORMCacheEntry>,
) {}
async save(cacheEntry: CacheEntry): Promise<void> {
const persistence = this.mapper.toProjection(cacheEntry)
await this.ormRepository.save(persistence)
}
async findUnexpiredOneByKey(key: string): Promise<CacheEntry | null> {
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<void> {
await this.ormRepository.createQueryBuilder().delete().where('key = :key', { key }).execute()
}
}

View File

@@ -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<void> {
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<boolean> {
const lock = await this.cacheEntryRepository.findUnexpiredOneByKey(`${this.OTP_PREFIX}:${userIdentifier}`)
if (!lock) {
return false
}
return lock.props.value === otp
}
async resetLockCounter(userIdentifier: string): Promise<void> {
await this.cacheEntryRepository.removeByKey(`${this.PREFIX}:${userIdentifier}`)
}
async updateLockCounter(userIdentifier: string, counter: number): Promise<void> {
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<number> {
const counter = await this.cacheEntryRepository.findUnexpiredOneByKey(`${this.PREFIX}:${userIdentifier}`)
if (!counter) {
return 0
}
return +counter.props.value
}
async lockUser(userIdentifier: string): Promise<void> {
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<boolean> {
const counter = await this.getLockCounter(userIdentifier)
return counter >= this.maxLoginAttempts
}
}