mirror of
https://github.com/LightZirconite/Microsoft-Rewards-Bot.git
synced 2026-01-10 01:06:17 +00:00
388 lines
18 KiB
TypeScript
388 lines
18 KiB
TypeScript
import type { Locator, Page } from 'playwright'
|
||
import readline from 'readline'
|
||
import { MicrosoftRewardsBot } from '../../index'
|
||
import { HumanTyping } from '../../util/browser/HumanTyping'
|
||
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
|
||
}
|
||
|
||
// FIXED: Use HumanTyping instead of .fill() to avoid bot detection
|
||
await HumanTyping.typeTotp(page.locator('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
|
||
}
|
||
// FIXED: Use HumanTyping instead of .fill() to avoid bot detection
|
||
await HumanTyping.typeTotp(input, 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
|
||
}
|
||
}
|