mirror of
https://github.com/LightZirconite/Microsoft-Rewards-Bot.git
synced 2026-01-09 17:06:15 +00:00
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:
File diff suppressed because it is too large
Load Diff
204
src/functions/login/PasskeyHandler.ts
Normal file
204
src/functions/login/PasskeyHandler.ts
Normal 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})`)
|
||||
}
|
||||
}
|
||||
135
src/functions/login/RecoveryHandler.ts
Normal file
135
src/functions/login/RecoveryHandler.ts
Normal 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 */ }
|
||||
}
|
||||
}
|
||||
66
src/functions/login/SecurityDetector.ts
Normal file
66
src/functions/login/SecurityDetector.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
73
src/functions/login/SecurityUtils.ts
Normal file
73
src/functions/login/SecurityUtils.ts
Normal 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 */ }
|
||||
}
|
||||
}
|
||||
386
src/functions/login/TotpHandler.ts
Normal file
386
src/functions/login/TotpHandler.ts
Normal 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
|
||||
}
|
||||
}
|
||||
7
src/functions/login/types.ts
Normal file
7
src/functions/login/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface SecurityIncident {
|
||||
kind: string
|
||||
account: string
|
||||
details?: string[]
|
||||
next?: string[]
|
||||
docsUrl?: string
|
||||
}
|
||||
Reference in New Issue
Block a user