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 { 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 { 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 { 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 { 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> { 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) } }