feat: Implement security handling for login process

- Added PasskeyHandler to manage passkey prompts and interactions.
- Introduced RecoveryHandler for handling recovery email mismatches.
- Created SecurityDetector to identify sign-in blocks and account locks.
- Developed SecurityUtils for incident reporting and documentation access.
- Implemented TotpHandler for managing two-factor authentication (2FA) processes.
- Defined SecurityIncident type for structured incident reporting.
This commit is contained in:
2025-11-21 07:40:57 +01:00
parent 3207abae4d
commit 7ca720f8c0
7 changed files with 1774 additions and 1757 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,204 @@
import type { Page } from 'playwright'
import { MicrosoftRewardsBot } from '../../index'
import { waitForElementSmart } from '../../util/browser/SmartWait'
import { logError } from '../../util/notifications/Logger'
export class PasskeyHandler {
private bot: MicrosoftRewardsBot
private passkeyHandled = false
private noPromptIterations = 0
private lastNoPromptLog = 0
private static readonly SELECTORS = {
passkeySecondary: 'button[data-testid="secondaryButton"]',
passkeyPrimary: 'button[data-testid="primaryButton"]',
passkeyTitle: '[data-testid="title"]',
kmsiVideo: '[data-testid="kmsiVideo"]',
biometricVideo: '[data-testid="biometricVideo"]'
} as const
constructor(bot: MicrosoftRewardsBot) {
this.bot = bot
}
public async disableFido(page: Page) {
await page.route('**/GetCredentialType.srf*', route => {
try {
const body = JSON.parse(route.request().postData() || '{}')
body.isFidoSupported = false
route.continue({ postData: JSON.stringify(body) })
} catch { /* Route continue on parse failure */ route.continue() }
}).catch(logError('LOGIN-FIDO', 'Route interception setup failed', this.bot.isMobile))
}
public async handlePasskeyPrompts(page: Page, context: 'main' | 'oauth') {
let did = false
// Priority 1: Direct detection of "Skip for now" button by data-testid
const skipBtnResult = await waitForElementSmart(page, 'button[data-testid="secondaryButton"]', {
initialTimeoutMs: 300,
extendedTimeoutMs: 500,
state: 'visible'
})
if (skipBtnResult.found && skipBtnResult.element) {
const text = (await skipBtnResult.element.textContent() || '').trim()
// Check if it's actually a skip button (could be other secondary buttons)
if (/skip|later|not now|non merci|pas maintenant/i.test(text)) {
await skipBtnResult.element.click().catch(logError('LOGIN-PASSKEY', 'Skip button click failed', this.bot.isMobile))
did = true
this.logPasskeyOnce('data-testid secondaryButton')
}
}
// Priority 2: Video heuristic (biometric prompt)
if (!did) {
const biometricResult = await waitForElementSmart(page, PasskeyHandler.SELECTORS.biometricVideo, {
initialTimeoutMs: 300,
extendedTimeoutMs: 500,
state: 'visible'
})
if (biometricResult.found) {
const btnResult = await waitForElementSmart(page, PasskeyHandler.SELECTORS.passkeySecondary, {
initialTimeoutMs: 200,
extendedTimeoutMs: 300,
state: 'visible'
})
if (btnResult.found && btnResult.element) {
await btnResult.element.click().catch(logError('LOGIN-PASSKEY', 'Video heuristic click failed', this.bot.isMobile))
did = true
this.logPasskeyOnce('video heuristic')
}
}
}
// Priority 3: Title + secondary button detection
if (!did) {
const titleResult = await waitForElementSmart(page, PasskeyHandler.SELECTORS.passkeyTitle, {
initialTimeoutMs: 300,
extendedTimeoutMs: 500,
state: 'attached'
})
if (titleResult.found && titleResult.element) {
const title = (await titleResult.element.textContent() || '').trim()
const looksLike = /sign in faster|passkey|fingerprint|face|pin|empreinte|visage|windows hello|hello/i.test(title)
if (looksLike) {
const secBtnResult = await waitForElementSmart(page, PasskeyHandler.SELECTORS.passkeySecondary, {
initialTimeoutMs: 200,
extendedTimeoutMs: 300,
state: 'visible'
})
if (secBtnResult.found && secBtnResult.element) {
await secBtnResult.element.click().catch(logError('LOGIN-PASSKEY', 'Title heuristic click failed', this.bot.isMobile))
did = true
this.logPasskeyOnce('title heuristic ' + title)
}
}
}
// Check secondary button text if title heuristic didn't work
if (!did) {
const secBtnResult = await waitForElementSmart(page, PasskeyHandler.SELECTORS.passkeySecondary, {
initialTimeoutMs: 200,
extendedTimeoutMs: 300,
state: 'visible'
})
if (secBtnResult.found && secBtnResult.element) {
const text = (await secBtnResult.element.textContent() || '').trim()
if (/skip for now|not now|later|passer|plus tard/i.test(text)) {
await secBtnResult.element.click().catch(logError('LOGIN-PASSKEY', 'Secondary button text click failed', this.bot.isMobile))
did = true
this.logPasskeyOnce('secondary button text')
}
}
}
}
// Priority 4: XPath fallback (includes Windows Hello specific patterns)
if (!did) {
const textBtn = await page.locator('xpath=//button[contains(normalize-space(.),"Skip for now") or contains(normalize-space(.),"Not now") or contains(normalize-space(.),"Passer") or contains(normalize-space(.),"No thanks")]').first()
// FIXED: Add explicit timeout to isVisible
if (await textBtn.isVisible({ timeout: 500 }).catch(() => false)) {
await textBtn.click().catch(logError('LOGIN-PASSKEY', 'XPath fallback click failed', this.bot.isMobile))
did = true
this.logPasskeyOnce('xpath fallback')
}
}
// Priority 4.5: Windows Hello specific detection
if (!did) {
// FIXED: Add explicit timeout
const windowsHelloTitle = await page.locator('text=/windows hello/i').first().isVisible({ timeout: 500 }).catch(() => false)
if (windowsHelloTitle) {
// Try common Windows Hello skip patterns
const skipPatterns = [
'button:has-text("Skip")',
'button:has-text("No thanks")',
'button:has-text("Maybe later")',
'button:has-text("Cancel")',
'[data-testid="secondaryButton"]',
'button[class*="secondary"]'
]
for (const pattern of skipPatterns) {
const btn = await page.locator(pattern).first()
// FIXED: Add explicit timeout
if (await btn.isVisible({ timeout: 300 }).catch(() => false)) {
await btn.click().catch(logError('LOGIN-PASSKEY', 'Windows Hello skip failed', this.bot.isMobile))
did = true
this.logPasskeyOnce('Windows Hello skip')
break
}
}
}
}
// Priority 5: Close button fallback (FIXED: Add explicit timeout instead of using page.$)
if (!did) {
const closeResult = await waitForElementSmart(page, '#close-button', {
initialTimeoutMs: 300,
extendedTimeoutMs: 500,
state: 'visible'
})
if (closeResult.found && closeResult.element) {
await closeResult.element.click().catch(logError('LOGIN-PASSKEY', 'Close button fallback failed', this.bot.isMobile))
did = true
this.logPasskeyOnce('close button')
}
}
// KMSI prompt
const kmsi = await page.waitForSelector(PasskeyHandler.SELECTORS.kmsiVideo, { timeout: 400 }).catch(() => null)
if (kmsi) {
const yes = await page.$(PasskeyHandler.SELECTORS.passkeyPrimary)
if (yes) {
await yes.click().catch(logError('LOGIN-KMSI', 'KMSI accept click failed', this.bot.isMobile))
did = true
this.bot.log(this.bot.isMobile, 'LOGIN-KMSI', 'Accepted KMSI prompt')
}
}
if (!did && context === 'main') {
this.noPromptIterations++
const now = Date.now()
if (this.noPromptIterations === 1 || now - this.lastNoPromptLog > 10000) {
this.lastNoPromptLog = now
this.bot.log(this.bot.isMobile, 'LOGIN-NO-PROMPT', `No dialogs (x${this.noPromptIterations})`)
if (this.noPromptIterations > 50) this.noPromptIterations = 0
}
} else if (did) {
this.noPromptIterations = 0
}
}
private logPasskeyOnce(reason: string) {
if (this.passkeyHandled) return
this.passkeyHandled = true
this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', `Dismissed passkey prompt (${reason})`)
}
}

View File

@@ -0,0 +1,135 @@
import type { Page } from 'playwright'
import { MicrosoftRewardsBot } from '../../index'
import { logError } from '../../util/notifications/Logger'
import { SecurityUtils } from './SecurityUtils'
import { SecurityIncident } from './types'
export class RecoveryHandler {
private bot: MicrosoftRewardsBot
private securityUtils: SecurityUtils
constructor(bot: MicrosoftRewardsBot, securityUtils: SecurityUtils) {
this.bot = bot
this.securityUtils = securityUtils
}
public async tryRecoveryMismatchCheck(page: Page, email: string) {
try {
await this.detectAndHandleRecoveryMismatch(page, email)
} catch {
// Intentionally silent: Recovery mismatch check is a best-effort security check
// Failure here should not break the login flow as the page may simply not have recovery info
}
}
private async detectAndHandleRecoveryMismatch(page: Page, email: string) {
try {
const recoveryEmail: string | undefined = this.bot.currentAccountRecoveryEmail
if (!recoveryEmail || !/@/.test(recoveryEmail)) return
const accountEmail = email
const parseRef = (val: string) => { const [l, d] = val.split('@'); return { local: l || '', domain: (d || '').toLowerCase(), prefix2: (l || '').slice(0, 2).toLowerCase() } }
const refs = [parseRef(recoveryEmail), parseRef(accountEmail)].filter(r => r.domain && r.prefix2)
if (refs.length === 0) return
const candidates: string[] = []
// Direct selectors (Microsoft variants + French spans)
const sel = '[data-testid="recoveryEmailHint"], #recoveryEmail, [id*="ProofEmail"], [id*="EmailProof"], [data-testid*="Email"], span:has(span.fui-Text)'
const el = await page.waitForSelector(sel, { timeout: 1500 }).catch(() => null)
if (el) { const t = (await el.textContent() || '').trim(); if (t) candidates.push(t) }
// List items
const li = page.locator('[role="listitem"], li')
const liCount = await li.count().catch(() => 0)
for (let i = 0; i < liCount && i < 12; i++) { const t = (await li.nth(i).textContent().catch(() => ''))?.trim() || ''; if (t && /@/.test(t)) candidates.push(t) }
// XPath generic masked patterns
const xp = page.locator('xpath=//*[contains(normalize-space(.), "@") and (contains(normalize-space(.), "*") or contains(normalize-space(.), "•"))]')
const xpCount = await xp.count().catch(() => 0)
for (let i = 0; i < xpCount && i < 12; i++) { const t = (await xp.nth(i).textContent().catch(() => ''))?.trim() || ''; if (t && t.length < 300) candidates.push(t) }
// Normalize
const seen = new Set<string>()
const norm = (s: string) => s.replace(/\s+/g, ' ').trim()
const uniq = candidates.map(norm).filter(t => t && !seen.has(t) && seen.add(t))
// Masked filter
let masked = uniq.filter(t => /@/.test(t) && /[*•]/.test(t))
if (masked.length === 0) {
// Fallback full HTML scan
try {
const html = await page.content()
const generic = /[A-Za-z0-9]{1,4}[*•]{2,}[A-Za-z0-9*•._-]*@[A-Za-z0-9.-]+/g
const frPhrase = /Nous\s+enverrons\s+un\s+code\s+à\s+([^<@]*[A-Za-z0-9]{1,4}[*•]{2,}[A-Za-z0-9*•._-]*@[A-Za-z0-9.-]+)[^.]{0,120}?Pour\s+vérifier/gi
const found = new Set<string>()
let m: RegExpExecArray | null
while ((m = generic.exec(html)) !== null) found.add(m[0])
while ((m = frPhrase.exec(html)) !== null) { const raw = m[1]?.replace(/<[^>]+>/g, '').trim(); if (raw) found.add(raw) }
if (found.size > 0) masked = Array.from(found)
} catch { /* HTML parsing may fail on malformed content */ }
}
if (masked.length === 0) return
// Prefer one mentioning email/adresse
const preferred = masked.find(t => /email|courriel|adresse|mail/i.test(t)) || masked[0]!
// Extract the masked email: Microsoft sometimes shows only first 1 char (k*****@domain) or 2 chars (ko*****@domain).
// We ONLY compare (1 or 2) leading visible alphanumeric chars + full domain (case-insensitive).
// This avoids false positives when the displayed mask hides the 2nd char.
const maskRegex = /([a-zA-Z0-9]{1,2})[a-zA-Z0-9*•._-]*@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/
const m = maskRegex.exec(preferred)
// Fallback: try to salvage with looser pattern if first regex fails
const loose = !m ? /([a-zA-Z0-9])[*•][a-zA-Z0-9*•._-]*@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/.exec(preferred) : null
const use = m || loose
const extracted = use ? use[0] : preferred
const extractedLower = extracted.toLowerCase()
let observedPrefix = ((use && use[1]) ? use[1] : '').toLowerCase()
let observedDomain = ((use && use[2]) ? use[2] : '').toLowerCase()
if (!observedDomain && extractedLower.includes('@')) {
const parts = extractedLower.split('@')
observedDomain = parts[1] || ''
}
if (!observedPrefix && extractedLower.includes('@')) {
const parts = extractedLower.split('@')
observedPrefix = (parts[0] || '').replace(/[^a-z0-9]/gi, '').slice(0, 2)
}
// Determine if any reference (recoveryEmail or accountEmail) matches observed mask logic
const matchRef = refs.find(r => {
if (r.domain !== observedDomain) return false
// If only one char visible, only enforce first char; if two, enforce both.
if (observedPrefix.length === 1) {
return r.prefix2.startsWith(observedPrefix)
}
return r.prefix2 === observedPrefix
})
if (!matchRef) {
const docsUrl = this.securityUtils.getDocsUrl('recovery-email-mismatch')
const incident: SecurityIncident = {
kind: 'Recovery email mismatch',
account: email,
details: [
`MaskedShown: ${preferred}`,
`Extracted: ${extracted}`,
`Observed => ${observedPrefix || '??'}**@${observedDomain || '??'}`,
`Expected => ${refs.map(r => `${r.prefix2}**@${r.domain}`).join(' OR ')}`
],
next: [
'Automation halted globally (standby engaged).',
'Verify account security & recovery email in Microsoft settings.',
'Update accounts.json if the change was legitimate before restart.'
],
docsUrl
}
await this.securityUtils.sendIncidentAlert(incident, 'critical')
this.bot.compromisedModeActive = true
this.bot.compromisedReason = 'recovery-mismatch'
this.securityUtils.startCompromisedInterval()
await this.bot.engageGlobalStandby('recovery-mismatch', email).catch(logError('LOGIN-RECOVERY', 'Global standby failed', this.bot.isMobile))
await this.securityUtils.openDocsTab(page, docsUrl).catch(logError('LOGIN-RECOVERY', 'Failed to open docs tab', this.bot.isMobile))
} else {
const mode = observedPrefix.length === 1 ? 'lenient' : 'strict'
this.bot.log(this.bot.isMobile, 'LOGIN-RECOVERY', `Recovery OK (${mode}): ${extracted} matches ${matchRef.prefix2}**@${matchRef.domain}`)
}
} catch { /* Non-critical: Recovery email validation is best-effort */ }
}
}

View File

@@ -0,0 +1,66 @@
import type { Page } from 'playwright'
import { MicrosoftRewardsBot } from '../../index'
import { logError } from '../../util/notifications/Logger'
import { SecurityUtils } from './SecurityUtils'
import { SecurityIncident } from './types'
export class SecurityDetector {
private bot: MicrosoftRewardsBot
private securityUtils: SecurityUtils
private static readonly SIGN_IN_BLOCK_PATTERNS: { re: RegExp; label: string }[] = [
{ re: /we can['`]?t sign you in/i, label: 'cant-sign-in' },
{ re: /incorrect account or password too many times/i, label: 'too-many-incorrect' },
{ re: /used an incorrect account or password too many times/i, label: 'too-many-incorrect-variant' },
{ re: /sign-in has been blocked/i, label: 'sign-in-blocked-phrase' },
{ re: /your account has been locked/i, label: 'account-locked' }
]
constructor(bot: MicrosoftRewardsBot, securityUtils: SecurityUtils) {
this.bot = bot
this.securityUtils = securityUtils
}
public async detectSignInBlocked(page: Page): Promise<boolean> {
if (this.bot.compromisedModeActive && this.bot.compromisedReason === 'sign-in-blocked') return true
try {
let text = ''
for (const sel of ['[data-testid="title"]', 'h1', 'div[role="heading"]', 'div.text-title']) {
const el = await page.waitForSelector(sel, { timeout: 600 }).catch(() => null)
if (el) {
const t = (await el.textContent() || '').trim()
if (t && t.length < 300) text += ' ' + t
}
}
const lower = text.toLowerCase()
let matched: string | null = null
for (const p of SecurityDetector.SIGN_IN_BLOCK_PATTERNS) { if (p.re.test(lower)) { matched = p.label; break } }
if (!matched) return false
const email = this.bot.currentAccountEmail || 'unknown'
const docsUrl = this.securityUtils.getDocsUrl('we-cant-sign-you-in')
const incident: SecurityIncident = {
kind: 'We can\'t sign you in (blocked)',
account: email,
details: [matched ? `Pattern: ${matched}` : 'Pattern: unknown'],
next: ['Manual recovery required before continuing'],
docsUrl
}
await this.securityUtils.sendIncidentAlert(incident, 'warn')
this.bot.compromisedModeActive = true
this.bot.compromisedReason = 'sign-in-blocked'
this.securityUtils.startCompromisedInterval()
await this.bot.engageGlobalStandby('sign-in-blocked', email).catch(logError('LOGIN-SECURITY', 'Global standby engagement failed', this.bot.isMobile))
// Open security docs for immediate guidance (best-effort)
await this.securityUtils.openDocsTab(page, docsUrl).catch(logError('LOGIN-SECURITY', 'Failed to open docs tab', this.bot.isMobile))
return true
} catch { return false }
}
public async checkAccountLocked(page: Page) {
const locked = await page.waitForSelector('#serviceAbuseLandingTitle', { timeout: 1200 }).then(() => true).catch(() => false)
if (locked) {
this.bot.log(this.bot.isMobile, 'CHECK-LOCKED', 'Account locked by Microsoft (serviceAbuseLandingTitle)', 'error')
throw new Error('Account locked by Microsoft - please review account status')
}
}
}

View File

@@ -0,0 +1,73 @@
import type { Page } from 'playwright'
import { DISCORD } from '../../constants'
import { MicrosoftRewardsBot } from '../../index'
import { SecurityIncident } from './types'
export class SecurityUtils {
private bot: MicrosoftRewardsBot
private compromisedInterval?: NodeJS.Timeout
constructor(bot: MicrosoftRewardsBot) {
this.bot = bot
}
public async sendIncidentAlert(incident: SecurityIncident, severity: 'warn' | 'critical' = 'warn') {
const lines = [`[Incident] ${incident.kind}`, `Account: ${incident.account}`]
if (incident.details?.length) lines.push(`Details: ${incident.details.join(' | ')}`)
if (incident.next?.length) lines.push(`Next: ${incident.next.join(' -> ')}`)
if (incident.docsUrl) lines.push(`Docs: ${incident.docsUrl}`)
const level: 'warn' | 'error' = severity === 'critical' ? 'error' : 'warn'
this.bot.log(this.bot.isMobile, 'SECURITY', lines.join(' | '), level)
try {
const { ConclusionWebhook } = await import('../../util/notifications/ConclusionWebhook')
const fields = [
{ name: 'Account', value: incident.account },
...(incident.details?.length ? [{ name: 'Details', value: incident.details.join('\n') }] : []),
...(incident.next?.length ? [{ name: 'Next steps', value: incident.next.join('\n') }] : []),
...(incident.docsUrl ? [{ name: 'Docs', value: incident.docsUrl }] : [])
]
await ConclusionWebhook(
this.bot.config,
`🔐 ${incident.kind}`,
Array.isArray(incident.details) ? incident.details.join('\n') : (incident.details || 'Security check detected unusual activity'),
fields,
severity === 'critical' ? DISCORD.COLOR_RED : DISCORD.COLOR_ORANGE
)
} catch { /* Non-critical: Webhook notification failures don't block login flow */ }
}
public getDocsUrl(anchor?: string) {
const base = process.env.DOCS_BASE?.trim() || 'https://github.com/LightZirconite/Microsoft-Rewards-Bot/blob/main/docs/security.md'
const map: Record<string, string> = {
'recovery-email-mismatch': '#recovery-email-mismatch',
'we-cant-sign-you-in': '#we-cant-sign-you-in-blocked'
}
return anchor && map[anchor] ? `${base}${map[anchor]}` : base
}
public startCompromisedInterval() {
this.cleanupCompromisedInterval()
this.compromisedInterval = setInterval(() => {
try {
this.bot.log(this.bot.isMobile, 'SECURITY', 'Security standby active. Manual review required before proceeding.', 'warn')
} catch {
// Intentionally silent
}
}, 300000) // 5 minutes
}
public cleanupCompromisedInterval() {
if (this.compromisedInterval) {
clearInterval(this.compromisedInterval)
this.compromisedInterval = undefined
}
}
public async openDocsTab(page: Page, url: string) {
try {
const ctx = page.context()
const tab = await ctx.newPage()
await tab.goto(url, { waitUntil: 'domcontentloaded' })
} catch { /* Non-critical */ }
}
}

View File

@@ -0,0 +1,386 @@
import type { Locator, Page } from 'playwright'
import readline from 'readline'
import { MicrosoftRewardsBot } from '../../index'
import { logError } from '../../util/notifications/Logger'
import { generateTOTP } from '../../util/security/Totp'
export class TotpHandler {
private bot: MicrosoftRewardsBot
private lastTotpSubmit = 0
private totpAttempts = 0
private currentTotpSecret?: string
// Unified selector system - DRY principle
private static readonly TOTP_SELECTORS = {
input: [
'input[name="otc"]',
'#idTxtBx_SAOTCC_OTC',
'#idTxtBx_SAOTCS_OTC',
'input[data-testid="otcInput"]',
'input[autocomplete="one-time-code"]',
'input[type="tel"][name="otc"]',
'input[id^="floatingLabelInput"]'
],
altOptions: [
'#idA_SAOTCS_ProofPickerChange',
'#idA_SAOTCC_AlternateLogin',
'a:has-text("Use a different verification option")',
'a:has-text("Sign in another way")',
'a:has-text("I can\'t use my Microsoft Authenticator app right now")',
'button:has-text("Use a different verification option")',
'button:has-text("Sign in another way")'
],
challenge: [
'[data-value="PhoneAppOTP"]',
'[data-value="OneTimeCode"]',
'button:has-text("Use a verification code")',
'button:has-text("Enter code manually")',
'button:has-text("Enter a code from your authenticator app")',
'button:has-text("Use code from your authentication app")',
'button:has-text("Utiliser un code de vérification")',
'button:has-text("Entrer un code depuis votre application")',
'button:has-text("Entrez un code")',
'div[role="button"]:has-text("Use a verification code")',
'div[role="button"]:has-text("Enter a code")'
],
submit: [
'#idSubmit_SAOTCC_Continue',
'#idSubmit_SAOTCC_OTC',
'button[type="submit"]:has-text("Verify")',
'button[type="submit"]:has-text("Continuer")',
'button:has-text("Verify")',
'button:has-text("Continuer")',
'button:has-text("Submit")',
'button[type="submit"]:has-text("Next")',
'button:has-text("Next")',
'button[data-testid="primaryButton"]:has-text("Next")'
]
} as const
constructor(bot: MicrosoftRewardsBot) {
this.bot = bot
}
public setTotpSecret(secret?: string) {
this.currentTotpSecret = (secret && secret.trim()) || undefined
this.lastTotpSubmit = 0
this.totpAttempts = 0
}
public reset() {
this.lastTotpSubmit = 0
this.totpAttempts = 0
}
public async handle2FA(page: Page) {
try {
// Dismiss any popups/dialogs before checking 2FA (Terms Update, etc.)
await this.bot.browser.utils.tryDismissAllMessages(page)
await this.bot.utils.wait(500)
const usedTotp = await this.tryAutoTotp(page, '2FA initial step', this.currentTotpSecret)
if (usedTotp) return
const number = await this.fetchAuthenticatorNumber(page)
if (number) { await this.approveAuthenticator(page, number); return }
await this.handleSMSOrTotp(page)
} catch (e) {
this.bot.log(this.bot.isMobile, 'LOGIN', '2FA error: ' + e, 'warn')
}
}
public async tryAutoTotp(page: Page, context: string, currentTotpSecret?: string): Promise<boolean> {
const secret = currentTotpSecret || this.currentTotpSecret
if (!secret) return false
const throttleMs = 5000
if (Date.now() - this.lastTotpSubmit < throttleMs) return false
const selector = await this.ensureTotpInput(page)
if (!selector) return false
if (this.totpAttempts >= 3) {
const errMsg = 'TOTP challenge still present after multiple attempts; verify authenticator secret or approvals.'
this.bot.log(this.bot.isMobile, 'LOGIN', errMsg, 'error')
throw new Error(errMsg)
}
this.bot.log(this.bot.isMobile, 'LOGIN', `Detected TOTP challenge during ${context}; submitting code automatically`)
await this.submitTotpCode(page, selector, secret)
this.totpAttempts += 1
this.lastTotpSubmit = Date.now()
await this.bot.utils.wait(1200)
return true
}
private async fetchAuthenticatorNumber(page: Page): Promise<string | null> {
try {
const el = await page.waitForSelector('#displaySign, div[data-testid="displaySign"]>span', { timeout: 2500 })
return (await el.textContent())?.trim() || null
} catch {
// Attempt resend loop in parallel mode
if (this.bot.config.parallel) {
this.bot.log(this.bot.isMobile, 'LOGIN', 'Parallel mode: throttling authenticator push requests', 'log', 'yellow')
for (let attempts = 0; attempts < 6; attempts++) { // max 6 minutes retry window
const resend = await page.waitForSelector('button[aria-describedby="pushNotificationsTitle errorDescription"]', { timeout: 1500 }).catch(() => null)
if (!resend) break
await this.bot.utils.wait(60000)
await resend.click().catch(logError('LOGIN', 'Resend click failed', this.bot.isMobile))
}
}
await page.click('button[aria-describedby="confirmSendTitle"]').catch(logError('LOGIN', 'Confirm send click failed', this.bot.isMobile))
await this.bot.utils.wait(1500)
try {
const el = await page.waitForSelector('#displaySign, div[data-testid="displaySign"]>span', { timeout: 2000 })
return (await el.textContent())?.trim() || null
} catch { return null }
}
}
private async approveAuthenticator(page: Page, numberToPress: string) {
for (let cycle = 0; cycle < 6; cycle++) { // max ~6 refresh cycles
try {
this.bot.log(this.bot.isMobile, 'LOGIN', `Approve login in Authenticator (press ${numberToPress})`)
await page.waitForSelector('form[name="f1"]', { state: 'detached', timeout: 60000 })
this.bot.log(this.bot.isMobile, 'LOGIN', 'Authenticator approval successful')
return
} catch {
this.bot.log(this.bot.isMobile, 'LOGIN', 'Authenticator code expired refreshing')
const retryBtn = await page.waitForSelector('button[data-testid="primaryButton"]', { timeout: 3000 }).catch(() => null)
if (retryBtn) await retryBtn.click().catch(logError('LOGIN-AUTH', 'Refresh button click failed', this.bot.isMobile))
const refreshed = await this.fetchAuthenticatorNumber(page)
if (!refreshed) { this.bot.log(this.bot.isMobile, 'LOGIN', 'Could not refresh authenticator code', 'warn'); return }
numberToPress = refreshed
}
}
this.bot.log(this.bot.isMobile, 'LOGIN', 'Authenticator approval loop exited (max cycles reached)', 'warn')
}
private async handleSMSOrTotp(page: Page) {
// TOTP auto entry (second chance if ensureTotpInput needed longer)
const usedTotp = await this.tryAutoTotp(page, 'manual 2FA entry', this.currentTotpSecret)
if (usedTotp) return
// Manual prompt with 120s timeout
this.bot.log(this.bot.isMobile, 'LOGIN', 'Waiting for user 2FA code (SMS / Email / App fallback)')
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
try {
// FIXED: Add 120s timeout with proper cleanup to prevent memory leak
let timeoutHandle: NodeJS.Timeout | undefined
const code = await Promise.race([
new Promise<string>(res => {
rl.question('Enter 2FA code:\n', ans => {
if (timeoutHandle) clearTimeout(timeoutHandle)
rl.close()
res(ans.trim())
})
}),
new Promise<string>((_, reject) => {
timeoutHandle = setTimeout(() => {
rl.close()
reject(new Error('2FA code input timeout after 120s'))
}, 120000)
})
])
// Check if input field still exists before trying to fill
const inputExists = await page.locator('input[name="otc"]').first().isVisible({ timeout: 1000 }).catch(() => false)
if (!inputExists) {
this.bot.log(this.bot.isMobile, 'LOGIN', 'Page changed while waiting for code (user progressed manually)', 'warn')
return
}
// Fill code and submit
await page.fill('input[name="otc"]', code)
await page.keyboard.press('Enter')
this.bot.log(this.bot.isMobile, 'LOGIN', '2FA code submitted')
} catch (error) {
if (error instanceof Error && error.message.includes('timeout')) {
this.bot.log(this.bot.isMobile, 'LOGIN', '2FA code input timeout (120s) - user AFK', 'error')
throw error
}
// Other errors, just log and continue
this.bot.log(this.bot.isMobile, 'LOGIN', '2FA code entry error: ' + error, 'warn')
} finally {
try {
rl.close()
} catch {
// Intentionally silent: readline interface already closed or error during cleanup
// This is a cleanup operation that shouldn't throw
}
}
}
public async ensureTotpInput(page: Page): Promise<string | null> {
const selector = await this.findFirstTotpInput(page)
if (selector) return selector
const attempts = 4
for (let i = 0; i < attempts; i++) {
let acted = false
// Step 1: expose alternative verification options if hidden
if (!acted) {
acted = await this.clickFirstVisibleSelector(page, TotpHandler.TOTP_SELECTORS.altOptions)
if (acted) await this.bot.utils.wait(900)
}
// Step 2: choose authenticator code option if available
if (!acted) {
acted = await this.clickFirstVisibleSelector(page, TotpHandler.TOTP_SELECTORS.challenge)
if (acted) await this.bot.utils.wait(900)
}
const ready = await this.findFirstTotpInput(page)
if (ready) return ready
if (!acted) break
}
return null
}
private async submitTotpCode(page: Page, selector: string, secret: string) {
try {
const code = generateTOTP(secret.trim())
const input = page.locator(selector).first()
if (!await input.isVisible().catch(() => false)) {
this.bot.log(this.bot.isMobile, 'LOGIN', 'TOTP input unexpectedly hidden', 'warn')
return
}
await input.fill('')
await input.fill(code)
// Use unified selector system
const submit = await this.findFirstVisibleLocator(page, TotpHandler.TOTP_SELECTORS.submit)
if (submit) {
await submit.click().catch(logError('LOGIN-TOTP', 'Auto-submit click failed', this.bot.isMobile))
} else {
await page.keyboard.press('Enter').catch(logError('LOGIN-TOTP', 'Auto-submit Enter failed', this.bot.isMobile))
}
this.bot.log(this.bot.isMobile, 'LOGIN', 'Submitted TOTP automatically')
} catch (error) {
this.bot.log(this.bot.isMobile, 'LOGIN', 'Failed to submit TOTP automatically: ' + error, 'warn')
}
}
// Locate the most likely authenticator input on the page using heuristics
private async findFirstTotpInput(page: Page): Promise<string | null> {
const headingHint = await this.detectTotpHeading(page)
for (const sel of TotpHandler.TOTP_SELECTORS.input) {
const loc = page.locator(sel).first()
if (await loc.isVisible().catch(() => false)) {
if (await this.isLikelyTotpInput(page, loc, sel, headingHint)) {
if (sel.includes('floatingLabelInput')) {
const idAttr = await loc.getAttribute('id')
if (idAttr) return `#${idAttr}`
}
return sel
}
}
}
return null
}
private async isLikelyTotpInput(page: Page, locator: Locator, selector: string, headingHint: string | null): Promise<boolean> {
try {
if (!await locator.isVisible().catch(() => false)) return false
const attr = async (name: string) => (await locator.getAttribute(name) || '').toLowerCase()
const type = await attr('type')
// Explicit exclusions: never treat email or password fields as TOTP
if (type === 'email' || type === 'password') return false
const nameAttr = await attr('name')
// Explicit exclusions: login/email/password field names
if (nameAttr.includes('loginfmt') || nameAttr.includes('passwd') || nameAttr.includes('email') || nameAttr.includes('login')) return false
// Strong positive signals for TOTP
if (nameAttr.includes('otc') || nameAttr.includes('otp') || nameAttr.includes('code')) return true
const autocomplete = await attr('autocomplete')
if (autocomplete.includes('one-time')) return true
const inputmode = await attr('inputmode')
if (inputmode === 'numeric') return true
const pattern = await locator.getAttribute('pattern') || ''
if (pattern && /\d/.test(pattern)) return true
const aria = await attr('aria-label')
if (aria.includes('code') || aria.includes('otp') || aria.includes('authenticator')) return true
const placeholder = await attr('placeholder')
if (placeholder.includes('code') || placeholder.includes('security') || placeholder.includes('authenticator')) return true
if (/otc|otp/.test(selector)) return true
const idAttr = await attr('id')
if (idAttr.startsWith('floatinglabelinput')) {
if (headingHint || await this.detectTotpHeading(page)) return true
}
if (selector.toLowerCase().includes('floatinglabelinput')) {
if (headingHint || await this.detectTotpHeading(page)) return true
}
const maxLength = await locator.getAttribute('maxlength')
if (maxLength && Number(maxLength) > 0 && Number(maxLength) <= 8) return true
const dataTestId = await attr('data-testid')
if (dataTestId.includes('otc') || dataTestId.includes('otp')) return true
const labelText = await locator.evaluate(node => {
const label = node.closest('label')
if (label && label.textContent) return label.textContent
const describedBy = node.getAttribute('aria-describedby')
if (!describedBy) return ''
const parts = describedBy.split(/\s+/).filter(Boolean)
const texts: string[] = []
parts.forEach(id => {
const el = document.getElementById(id)
if (el && el.textContent) texts.push(el.textContent)
})
return texts.join(' ')
}).catch(() => '')
if (labelText && /code|otp|authenticator|sécurité|securité|security/i.test(labelText)) return true
if (headingHint && /code|otp|authenticator/i.test(headingHint.toLowerCase())) return true
} catch {/* fall through to false */ }
return false
}
private async detectTotpHeading(page: Page): Promise<string | null> {
const headings = page.locator('[data-testid="title"], h1, h2, div[role="heading"]')
const count = await headings.count().catch(() => 0)
const max = Math.min(count, 6)
for (let i = 0; i < max; i++) {
const text = (await headings.nth(i).textContent().catch(() => null))?.trim()
if (!text) continue
const lowered = text.toLowerCase()
if (/authenticator/.test(lowered) && /code/.test(lowered)) return text
if (/code de vérification|code de verification|code de sécurité|code de securité/.test(lowered)) return text
if (/enter your security code|enter your code/.test(lowered)) return text
}
return null
}
private async clickFirstVisibleSelector(page: Page, selectors: readonly string[]): Promise<boolean> {
for (const sel of selectors) {
const loc = page.locator(sel).first()
if (await loc.isVisible().catch(() => false)) {
await loc.click().catch(logError('LOGIN', `Click failed for selector: ${sel}`, this.bot.isMobile))
return true
}
}
return false
}
private async findFirstVisibleLocator(page: Page, selectors: readonly string[]): Promise<Locator | null> {
for (const sel of selectors) {
const loc = page.locator(sel).first()
if (await loc.isVisible().catch(() => false)) return loc
}
return null
}
}

View File

@@ -0,0 +1,7 @@
export interface SecurityIncident {
kind: string
account: string
details?: string[]
next?: string[]
docsUrl?: string
}