mirror of
https://github.com/standardnotes/server
synced 2026-01-16 20:04:32 -05:00
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:
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
85
packages/auth/src/Infra/TypeORM/TypeORMLockRepository.ts
Normal file
85
packages/auth/src/Infra/TypeORM/TypeORMLockRepository.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user