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

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