Files
standardnotes-server/packages/auth/src/Domain/UseCase/SignIn.ts
T

184 lines
6.9 KiB
TypeScript

import * as bcrypt from 'bcryptjs'
import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
import { Logger } from 'winston'
import { AuthResponseFactoryResolverInterface } from '../Auth/AuthResponseFactoryResolverInterface'
import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface'
import { SessionServiceInterface } from '../Session/SessionServiceInterface'
import { User } from '../User/User'
import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
import { SignInDTO } from './SignInDTO'
import { SignInResponse } from './SignInResponse'
import { UseCaseInterface } from './UseCaseInterface'
import { PKCERepositoryInterface } from '../User/PKCERepositoryInterface'
import { CrypterInterface } from '../Encryption/CrypterInterface'
import { EmailLevel, Result, Username } from '@standardnotes/domain-core'
import { getBody, getSubject } from '../Email/UserSignedIn'
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(
private userRepository: UserRepositoryInterface,
private authResponseFactoryResolver: AuthResponseFactoryResolverInterface,
private domainEventPublisher: DomainEventPublisherInterface,
private domainEventFactory: DomainEventFactoryInterface,
private sessionService: SessionServiceInterface,
private pkceRepository: PKCERepositoryInterface,
private crypter: CrypterInterface,
private logger: Logger,
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 this.failAfterIncrementingLoginAttempts(
dto.email,
'Please update your client application.',
HttpStatusCode.Gone,
)
}
const validCodeVerifier = await this.validateCodeVerifier(dto.codeVerifier)
if (!validCodeVerifier) {
this.logger.debug('Code verifier does not match')
return this.failAfterIncrementingLoginAttempts(dto.email, 'Invalid email or password')
}
const apiVersionOrError = ApiVersion.create(dto.apiVersion)
if (apiVersionOrError.isFailed()) {
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 this.failAfterIncrementingLoginAttempts(dto.email, usernameOrError.getError())
}
const username = usernameOrError.getValue()
const user = await this.userRepository.findOneByUsernameOrEmail(username)
const userIdentifier = user?.uuid ?? dto.email
const humanVerificationBeforeCheckingUsernameAndPasswordResult = await this.checkHumanVerificationIfNeeded(
userIdentifier,
dto.hvmToken,
)
if (humanVerificationBeforeCheckingUsernameAndPasswordResult.isFailed()) {
return {
success: false,
errorMessage: humanVerificationBeforeCheckingUsernameAndPasswordResult.getError(),
isNonCaptchaLimitReached: true,
}
}
if (!user) {
this.logger.debug(`User with email ${dto.email} was not found`)
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 this.failAfterIncrementingLoginAttempts(dto.email, 'Invalid email or password')
}
const authResponseFactory = this.authResponseFactoryResolver.resolveAuthResponseFactoryVersion(apiVersion)
await this.sendSignInEmailNotification(user, dto.userAgent)
const result = await authResponseFactory.createResponse({
user,
apiVersion,
userAgent: dto.userAgent,
ephemeralSession: dto.ephemeralSession,
readonlyAccess: false,
snjs: dto.snjs,
application: dto.application,
})
await this.clearLoginAttempts.execute({ email: dto.email })
return {
success: true,
result,
}
}
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): Promise<boolean> {
const codeChallenge = this.crypter.base64URLEncode(this.crypter.sha256Hash(codeVerifier))
const matchingCodeChallengeWasPresentAndRemoved = await this.pkceRepository.removeCodeChallenge(codeChallenge)
return matchingCodeChallengeWasPresentAndRemoved
}
private async sendSignInEmailNotification(user: User, userAgent: string): Promise<void> {
try {
await this.domainEventPublisher.publish(
this.domainEventFactory.createEmailRequestedEvent({
userEmail: user.email,
level: EmailLevel.LEVELS.SignIn,
body: getBody(
user.email,
this.sessionService.getOperatingSystemInfoFromUserAgent(userAgent),
this.sessionService.getBrowserInfoFromUserAgent(userAgent),
new Date(),
),
messageIdentifier: 'SIGN_IN',
subject: getSubject(user.email),
userUuid: user.uuid,
}),
)
} catch (error) {
this.logger.error(`Could not publish sign in event: ${(error as Error).message}`)
}
}
private async checkHumanVerificationIfNeeded(userIdentifier: string, hvmToken?: string): Promise<Result<void>> {
const numberOfFailedAttempts = await this.lockRepository.getLockCounter(userIdentifier, 'non-captcha')
const numberOfFailedAttemptsInCaptchaMode = await this.lockRepository.getLockCounter(userIdentifier, 'captcha')
const isEligibleForNonCaptchaMode =
numberOfFailedAttemptsInCaptchaMode === 0 && numberOfFailedAttempts < this.maxNonCaptchaAttempts
if (isEligibleForNonCaptchaMode) {
return Result.ok()
}
return this.verifyHumanInteractionUseCase.execute(hvmToken)
}
}