feat: Implement account suspension handling and disable accounts in configuration

This commit is contained in:
2026-01-02 19:28:04 +01:00
parent 0ad0b46e24
commit 6ea7a35d43
7 changed files with 387 additions and 2 deletions

View File

@@ -109,6 +109,9 @@ export class Login {
this.recoveryHandler = new RecoveryHandler(bot, this.securityUtils)
this.securityDetector = new SecurityDetector(bot, this.securityUtils)
// Connect PasskeyHandler to TotpHandler for post-TOTP passkey handling
this.totpHandler.setPasskeyHandler(this.passkeyHandler)
this.securityUtils.cleanupCompromisedInterval()
}
@@ -861,6 +864,12 @@ export class Login {
const portalSelector = await this.waitForRewardsRoot(page, DEFAULT_TIMEOUTS.portalWaitMs)
if (!portalSelector) {
// Before trying fallback, check if account is suspended/banned
const isSuspended = await this.securityDetector.checkAccountSuspended(page)
if (isSuspended) {
throw new Error('Account suspended by Microsoft Rewards - disabled in accounts.jsonc')
}
this.bot.log(this.bot.isMobile, 'LOGIN', 'Portal not found, trying goHome() fallback...', 'warn')
try {

View File

@@ -14,7 +14,12 @@ export class PasskeyHandler {
passkeyPrimary: 'button[data-testid="primaryButton"]',
passkeyTitle: '[data-testid="title"]',
kmsiVideo: '[data-testid="kmsiVideo"]',
biometricVideo: '[data-testid="biometricVideo"]'
biometricVideo: '[data-testid="biometricVideo"]',
// QR Code Passkey dialog specific selectors
qrCodeDialog: 'div[role="dialog"]',
qrCodeImage: 'img[alt*="QR"], canvas[aria-label*="QR"], div[class*="qr"]',
backButton: 'button:has-text("Back")',
cancelButton: 'button:has-text("Cancel")'
} as const
constructor(bot: MicrosoftRewardsBot) {
@@ -37,6 +42,13 @@ export class PasskeyHandler {
public async handlePasskeyPrompts(page: Page, context: 'main' | 'oauth') {
let did = false
// Priority 0: Handle QR Code Passkey dialog (appears after TOTP)
const qrCodeHandled = await this.handleQrCodePasskeyDialog(page)
if (qrCodeHandled) {
did = true
this.logPasskeyOnce('QR code passkey dialog')
}
// Early exit for passkey creation flows (common on mobile): hit cancel/skip if present
const currentUrl = page.url()
if (/fido\/create|passkey/i.test(currentUrl)) {
@@ -234,4 +246,89 @@ export class PasskeyHandler {
this.passkeyHandled = true
this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', `Dismissed passkey prompt (${reason})`)
}
/**
* Handle QR Code Passkey dialog that appears after TOTP authentication
* This dialog is a modal that blocks interaction with the page
*/
private async handleQrCodePasskeyDialog(page: Page): Promise<boolean> {
try {
// Method 1: Check for specific text content indicating QR code dialog
const qrCodeTextVisible = await page.locator('text=/use your phone or tablet|scan this QR code|passkeys/i')
.first()
.isVisible({ timeout: 800 })
.catch(() => false)
if (!qrCodeTextVisible) return false
this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', 'Detected QR code passkey dialog, attempting dismissal')
// Method 2: Try keyboard ESC first (works for many dialogs)
await page.keyboard.press('Escape').catch(() => { })
await this.bot.utils.wait(300)
// Method 3: Check if dialog still visible after ESC
const stillVisible = await page.locator('text=/use your phone or tablet|scan this QR code/i')
.first()
.isVisible({ timeout: 500 })
.catch(() => false)
if (!stillVisible) {
this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', 'QR code dialog dismissed via ESC')
return true
}
// Method 4: Try clicking Back or Cancel buttons
const dismissed = await this.clickFirstVisible(page, [
PasskeyHandler.SELECTORS.backButton,
PasskeyHandler.SELECTORS.cancelButton,
'button:has-text("Retour")', // French
'button:has-text("Annuler")', // French
'button:has-text("No thanks")',
'button:has-text("Maybe later")',
'[data-testid="secondaryButton"]',
'button[class*="secondary"]'
], 500)
if (dismissed) {
this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', 'QR code dialog dismissed via button click')
return true
}
// Method 5: JavaScript injection to close dialog
const jsResult = await page.evaluate(() => {
// Find dialog by role
const dialogs = document.querySelectorAll('[role="dialog"]')
for (const dialog of dialogs) {
const text = dialog.textContent || ''
if (/passkey|qr code|phone or tablet/i.test(text)) {
// Try to find and click cancel/back button
const buttons = dialog.querySelectorAll('button')
for (const btn of buttons) {
const btnText = (btn.textContent || '').toLowerCase()
if (/back|cancel|retour|annuler|no thanks|maybe later|skip/i.test(btnText)) {
btn.click()
return true
}
}
// If no button found, try to remove dialog from DOM
dialog.remove()
return true
}
}
return false
}).catch(() => false)
if (jsResult) {
this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', 'QR code dialog dismissed via JavaScript injection')
return true
}
this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', 'Failed to dismiss QR code dialog with all methods', 'warn')
return false
} catch (error) {
this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', `QR code dialog handler error: ${error}`, 'error')
return false
}
}
}

View File

@@ -63,4 +63,79 @@ export class SecurityDetector {
throw new Error('Account locked by Microsoft - please review account status')
}
}
/**
* Check if account is suspended/banned and disable it in accounts.jsonc
* @param page Playwright page
* @returns True if account is suspended
*/
public async checkAccountSuspended(page: Page): Promise<boolean> {
try {
// Check for suspension page elements
const suspendedSelectors = [
'#rewards-user-suspended-error',
'#fraudErrorBody',
'#suspendedAccountHeader'
]
for (const selector of suspendedSelectors) {
const element = await page.waitForSelector(selector, { timeout: 800 }).catch(() => null)
if (element) {
const email = this.bot.currentAccountEmail || 'unknown'
this.bot.log(this.bot.isMobile, 'ACCOUNT-SUSPENDED', `⛔ Account ${email} has been SUSPENDED by Microsoft`, 'error')
// Get suspension details from page
const headerText = await page.locator('#suspendedAccountHeader').textContent().catch(() => '')
const summaryText = await page.locator('#fraudSummary').textContent().catch(() => '')
// Log detailed information
if (headerText) {
this.bot.log(this.bot.isMobile, 'ACCOUNT-SUSPENDED', `Header: ${headerText.trim()}`, 'error')
}
if (summaryText) {
this.bot.log(this.bot.isMobile, 'ACCOUNT-SUSPENDED', `Summary: ${summaryText.trim().substring(0, 200)}...`, 'error')
}
// Disable account in accounts.jsonc
try {
const { disableBannedAccount } = await import('../../util/state/AccountDisabler')
await disableBannedAccount(email, 'Account suspended by Microsoft Rewards')
this.bot.log(this.bot.isMobile, 'ACCOUNT-SUSPENDED', `✓ Account ${email} disabled in accounts.jsonc`, 'warn')
} catch (disableError) {
this.bot.log(this.bot.isMobile, 'ACCOUNT-SUSPENDED', `Failed to disable account in config: ${disableError}`, 'error')
}
// Send incident alert
const incident: SecurityIncident = {
kind: 'Account Suspended',
account: email,
details: [
headerText?.trim() || 'Account suspended',
summaryText?.trim().substring(0, 300) || 'Microsoft Rewards violations detected'
],
next: [
'Account has been automatically disabled in accounts.jsonc',
'Review suspension details at https://rewards.bing.com',
'Contact Microsoft Support if you believe this is an error'
],
docsUrl: 'https://support.microsoft.com/topic/c5ab735d-c6d9-4bb9-30ad-d828e954b6a9'
}
await this.securityUtils.sendIncidentAlert(incident, 'critical')
// Engage global standby
this.bot.compromisedModeActive = true
this.bot.compromisedReason = 'account-suspended'
this.securityUtils.startCompromisedInterval()
await this.bot.engageGlobalStandby('account-suspended', email).catch(logError('LOGIN-SECURITY', 'Global standby engagement failed', this.bot.isMobile))
return true
}
}
return false
} catch (error) {
this.bot.log(this.bot.isMobile, 'ACCOUNT-SUSPENDED', `Check failed: ${error}`, 'warn')
return false
}
}
}

View File

@@ -10,6 +10,7 @@ export class TotpHandler {
private lastTotpSubmit = 0
private totpAttempts = 0
private currentTotpSecret?: string
private passkeyHandler?: any // Will be set by Login class to avoid circular dependency
// Unified selector system - DRY principle
private static readonly TOTP_SELECTORS = {
@@ -62,6 +63,10 @@ export class TotpHandler {
this.bot = bot
}
public setPasskeyHandler(handler: any) {
this.passkeyHandler = handler
}
public setTotpSecret(secret?: string) {
this.currentTotpSecret = (secret && secret.trim()) || undefined
this.lastTotpSubmit = 0
@@ -109,6 +114,18 @@ export class TotpHandler {
await this.submitTotpCode(page, selector, secret)
this.totpAttempts += 1
this.lastTotpSubmit = Date.now()
// Handle potential Passkey QR code dialog that appears after TOTP submission
if (this.passkeyHandler) {
await this.bot.utils.wait(800) // Brief wait for dialog to appear
try {
await this.passkeyHandler.handlePasskeyPrompts(page, 'main')
} catch (error) {
// Non-critical: continue even if passkey handling fails
this.bot.log(this.bot.isMobile, 'LOGIN', `Passkey handling after TOTP: ${error}`, 'warn')
}
}
await this.bot.utils.wait(1200)
return true
}