api/two_factor.php

<?php
declare(strict_types=1);

/*
|--------------------------------------------------------------------------
| Two-factor authentication endpoint
|--------------------------------------------------------------------------
|
| Authenticated-only management endpoint used by the settings modal.
| It never calls an external provider; it only generates a local TOTP secret,
| verifies the first code, and stores one-way hashes of backup codes.
|
*/

require_once __DIR__ . '/../includes/core.php';
require_once __DIR__ . '/../includes/modules/auth.php';

loadTranslations();

brivaciaSecurityHeaders();
header('Content-Type: application/json; charset=utf-8');
header('X-Robots-Tag: noindex, nofollow, noarchive, nosnippet');

function brivacia_api_two_factor_response(array $payload, int $status = 200): never
{
    http_response_code($status);
    echo json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    exit;
}

function brivacia_api_two_factor_require_post(): void
{
    if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
        brivacia_api_two_factor_response([
            'ok' => false,
            'error' => 'Method not allowed.',
        ], 405);
    }
}

function brivacia_api_two_factor_json(): array
{
    $raw = file_get_contents('php://input');

    if ($raw === false || trim($raw) === '') {
        return $_POST;
    }

    $data = json_decode($raw, true);

    if (!is_array($data)) {
        brivacia_api_two_factor_response([
            'ok' => false,
            'error' => 'Invalid JSON payload.',
        ], 400);
    }

    return $data;
}

try {
    brivacia_api_two_factor_require_post();
    brivaciaRequireApiAuth();
    brivaciaRequireCsrf();

    $action = (string)($_GET['action'] ?? '');

    if ($action === 'setup') {
        if (brivaciaTwoFactorEnabled()) {
            brivacia_api_two_factor_response([
                'ok' => false,
                'error' => 'Two-factor authentication is already enabled.',
            ], 409);
        }

        $setup = brivaciaTwoFactorCreateSetup();

        brivacia_api_two_factor_response([
            'ok' => true,
            'setup_token' => $setup['token'],
            'qr_svg' => brivaciaQrSvg($setup['otpauth_uri']),
        ]);
    }

    if ($action === 'manual') {
        if (brivaciaTwoFactorEnabled()) {
            brivacia_api_two_factor_response([
                'ok' => false,
                'error' => 'Two-factor authentication is already enabled.',
            ], 409);
        }

        $input = brivacia_api_two_factor_json();
        $secret = brivaciaTwoFactorPendingSecret((string)($input['setup_token'] ?? ''));

        if ($secret === '') {
            brivacia_api_two_factor_response([
                'ok' => false,
                'error' => 'Invalid or expired 2FA setup.',
            ], 422);
        }

        brivacia_api_two_factor_response([
            'ok' => true,
            'secret_display' => brivaciaTwoFactorFormatSecret($secret),
            'otpauth_uri' => brivaciaTwoFactorOtpauthUri($secret),
        ]);
    }

    if ($action === 'enable') {
        if (brivaciaTwoFactorEnabled()) {
            brivacia_api_two_factor_response([
                'ok' => false,
                'error' => 'Two-factor authentication is already enabled.',
            ], 409);
        }

        $input = brivacia_api_two_factor_json();
        $secret = brivaciaTwoFactorPendingSecret((string)($input['setup_token'] ?? ''));
        $code = (string)($input['code'] ?? '');
        $matchedCounter = null;

        if ($secret === '') {
            brivacia_api_two_factor_response([
                'ok' => false,
                'error' => 'Invalid or expired 2FA setup.',
            ], 422);
        }

        if (!brivaciaTwoFactorVerifyTotp($secret, $code, time(), 1, $matchedCounter)) {
            brivacia_api_two_factor_response([
                'ok' => false,
                'error' => 'Invalid verification code.',
            ], 422);
        }

        $backupCodes = brivaciaTwoFactorGenerateBackupCodes(10);
        brivaciaTwoFactorEnable($secret, $backupCodes, (int)($matchedCounter ?? 0));

        brivacia_api_two_factor_response([
            'ok' => true,
            'enabled' => true,
            'backup_codes' => $backupCodes,
        ]);
    }

    if ($action === 'disable') {
        $input = brivacia_api_two_factor_json();
        $password = (string)($input['password'] ?? '');

        // Disabling 2FA is a security-downgrading action, so it requires
        // re-confirming the password at this exact moment — not just a
        // valid session cookie, which could be stolen/hijacked without the
        // attacker ever knowing the password.
        if (!password_verify($password, brivaciaAuthPasswordHash())) {
            brivacia_api_two_factor_response([
                'ok' => false,
                'error' => t('settings.two_factor.disable.wrong_password'),
            ], 401);
        }

        brivaciaTwoFactorDisable();

        brivacia_api_two_factor_response([
            'ok' => true,
            'enabled' => false,
        ]);
    }

    brivacia_api_two_factor_response([
        'ok' => false,
        'error' => 'Unknown action.',
    ], 400);
} catch (Throwable $e) {
    brivacia_api_two_factor_response([
        'ok' => false,
        'error' => $e->getMessage(),
    ], 500);
}

Contribute