1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 04:18:09 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-08 19:02:12 +02:00
parent c1591fa849
commit 3bd9a24559
24 changed files with 907 additions and 9 deletions

View File

@@ -0,0 +1,49 @@
---
description: Remind developer about help_request.pdf when agent work depends on human input
alwaysApply: true
---
# Developer help-request reminder
When **any** of your work, replies, or session wrap-ups depend on the **developer / project owner** (not agent-only), you **must** remind them about the hands-on checklist.
## Trigger — remind when
- Tasks are **blocked** or **slowed** waiting on dev (DNS, MX/SPF, SMTP, device QA, OTA URLs, WireGuard bastion ops, inbound mail test, etc.)
- You end a session where dev-owned items from [docs/20260608_ALPHA_PRIORITIES.md](docs/20260608_ALPHA_PRIORITIES.md) remain open
- You mention “blocked on you”, “developer when back”, or similar
- Auth/mail path needs live email (tasks **2.12.4**, **E1E5** in 2FA doc)
- LAN alpha QA (**6.x**) or backend validation needing a physical device (**5.2**)
## What to cite
Primary checklist (markdown + PDF):
- **`tmp/20260608_help_request.md`**
- **`tmp/20260608_help_request.pdf`**
If a **new** dev session needs a different checklist, create `tmp/YYYYMMDD_help_request.md` + PDF per [docs/20260608_ALPHA_PRIORITIES.md](docs/20260608_ALPHA_PRIORITIES.md) § bottom line — then cite that file instead.
## Format (end of reply — separate block, impossible to miss)
Use when triggers apply (can combine with BE deploy banner if both apply):
```
───────────────────────────────────────────────────────────────
📋 YOUR ACTION — help_request.pdf
───────────────────────────────────────────────────────────────
• Open: tmp/20260608_help_request.pdf (or latest tmp/*_help_request.pdf)
• Unblocks: … (13 bullets — what agent work is waiting on you)
• Reply when done: … (what to paste back — MX, SPF, inbound test, etc.)
───────────────────────────────────────────────────────────────
```
Keep bullets short. Do **not** bury this in prose.
## Agent habit
At the **start** of a turn, if open work still has dev dependencies from the task graph, briefly note that the help checklist exists — do not wait until session end only.
Do **not** show the block when there are zero dev dependencies in that reply.

View File

@@ -147,7 +147,7 @@ Depends on BE deploy (soft). Not alpha blocker.
| ID | Task | Owner | Notes | | ID | Task | Owner | Notes |
|----|------|-------|-------| |----|------|-------|-------|
| **2.1** | DNS: MX + SPF + DKIM + DMARC for `apps.f0xx.org` | **Developer** | Web UI only; examples in `tmp/20260608_help_request.md` | | **2.1** | DNS: MX + SPF for mail — **use apex `f0xx.org`** if `apps.f0xx.org` is CNAME (cannot add MX) | **Developer** | `tmp/20260608_help_request.md` |
| **2.2** | Register forward addresses (`info@`, `admin@`, `root@` → Gmail) | **Developer** | Cloudflare Email Routing or ImprovMX | | **2.2** | Register forward addresses (`info@`, `admin@`, `root@` → Gmail) | **Developer** | Cloudflare Email Routing or ImprovMX |
| **2.3** | Inbound forward verification | **Developer** | Send test mail | | **2.3** | Inbound forward verification | **Developer** | Send test mail |
| **2.4** | Gmail filters / reply-as (optional) | **Developer** | | | **2.4** | Gmail filters / reply-as (optional) | **Developer** | |

View File

@@ -69,9 +69,16 @@ return [
], ],
], ],
// Outbound mail (registration verify, password reset) — see docs/20260607-2FA-email-mobile-auth-flow.md // Outbound mail (registration verify, password reset) — see docs/20260607-2FA-email-mobile-auth-flow.md
'auth' => [
// 32+ char secret for encrypting TOTP seeds + hashing auth attempts
'encryption_key' => '',
'max_attempts' => 8,
'lockout_window_minutes' => 15,
],
'mail' => [ 'mail' => [
'transport' => 'smtp', // smtp | sendmail 'transport' => 'smtp', // smtp | sendmail
'from' => 'Android Cast Issues <noreply@apps.f0xx.org>', // Alpha: use apex if apps.f0xx.org has CNAME (no MX on subdomain) — see tmp/20260608_help_request.md
'from' => 'Android Cast Issues <noreply@f0xx.org>',
'reply_to' => 'info@apps.f0xx.org', 'reply_to' => 'info@apps.f0xx.org',
'smtp' => [ 'smtp' => [
'host' => '127.0.0.1', 'host' => '127.0.0.1',

View File

@@ -55,4 +55,16 @@ if ($action === 'apply_privilege_set') {
json_out($result, empty($result['ok']) ? 400 : 200); json_out($result, empty($result['ok']) ? 400 : 200);
} }
if ($action === 'clear_auth_lockouts') {
if (!Rbac::isRoot() && !Rbac::isGlobalAdmin()) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$userId = (int) ($body['user_id'] ?? 0);
if ($userId <= 0) {
json_out(['ok' => false, 'error' => 'invalid_user'], 400);
}
$deleted = AuthAttempts::clearForUser($userId);
json_out(['ok' => true, 'cleared' => $deleted]);
}
json_out(['ok' => false, 'error' => 'unknown_action'], 400); json_out(['ok' => false, 'error' => 'unknown_action'], 400);

View File

@@ -1141,6 +1141,7 @@ button.report-tag--filter:hover {
[data-theme="light"] .star--on { background: #d97706; } [data-theme="light"] .star--on { background: #d97706; }
.muted { color: var(--muted); } .muted { color: var(--muted); }
.alert { background: rgba(248,113,113,.15); color: var(--danger); padding: 10px; border-radius: 8px; } .alert { background: rgba(248,113,113,.15); color: var(--danger); padding: 10px; border-radius: 8px; }
.alert--ok { background: rgba(52,211,153,.15); color: var(--ok, #34d399); }
.btn-primary { .btn-primary {
background: var(--accent); background: var(--accent);

View File

@@ -116,6 +116,34 @@
"login.register": "Register", "login.register": "Register",
"login.register_soon": "(coming soon)", "login.register_soon": "(coming soon)",
"login.error": "Invalid credentials", "login.error": "Invalid credentials",
"register.title": "Create account",
"register.hint": "We will email a verification link before you can sign in.",
"register.email": "Email",
"register.username": "Username (optional)",
"register.password": "Password",
"register.submit": "Register",
"register.back_login": "Back to sign in",
"verify.title": "Email verification",
"verify.ok": "Your email is verified. You can sign in and enroll two-factor authentication.",
"verify.fail": "Invalid or expired link.",
"verify.sign_in": "Sign in",
"verify.register_again": "Register again",
"twofa.title": "Two-factor authentication",
"twofa.hint": "Enter the 6-digit code from your authenticator app.",
"twofa.code": "Authentication code",
"twofa.submit": "Continue",
"twofa.cancel": "Cancel",
"twofa.error": "Invalid code",
"security.title": "Account security",
"security.totp_intro": "Protect your account with a 6-digit authenticator app (recommended for alpha).",
"security.start_totp": "Set up authenticator",
"security.scan_qr": "Scan this QR code with your authenticator app, then enter a code to confirm.",
"security.manual_secret": "Can't scan?",
"security.confirm_totp": "Confirm enrollment",
"security.totp_enabled": "Authenticator app is enrolled.",
"security.remove_totp": "Remove authenticator",
"security.back_console": "Back to console",
"nav.security": "Security",
"footer.copyright": "© Anton Afanaasyeu, {year}", "footer.copyright": "© Anton Afanaasyeu, {year}",
"tickets.title": "Tickets", "tickets.title": "Tickets",
"tickets.empty": "No tickets yet.", "tickets.empty": "No tickets yet.",

View File

@@ -118,6 +118,34 @@
"login.register": "Регистрация", "login.register": "Регистрация",
"login.register_soon": "(скоро)", "login.register_soon": "(скоро)",
"login.error": "Неверные учётные данные", "login.error": "Неверные учётные данные",
"register.title": "Создать аккаунт",
"register.hint": "Мы отправим ссылку для подтверждения email перед входом.",
"register.email": "Email",
"register.username": "Имя пользователя (необязательно)",
"register.password": "Пароль",
"register.submit": "Зарегистрироваться",
"register.back_login": "Назад ко входу",
"verify.title": "Подтверждение email",
"verify.ok": "Email подтверждён. Войдите и настройте двухфакторную аутентификацию.",
"verify.fail": "Недействительная или просроченная ссылка.",
"verify.sign_in": "Войти",
"verify.register_again": "Зарегистрироваться снова",
"twofa.title": "Двухфакторная аутентификация",
"twofa.hint": "Введите 6-значный код из приложения-аутентификатора.",
"twofa.code": "Код аутентификации",
"twofa.submit": "Продолжить",
"twofa.cancel": "Отмена",
"twofa.error": "Неверный код",
"security.title": "Безопасность аккаунта",
"security.totp_intro": "Защитите аккаунт приложением-аутентификатором (рекомендуется для альфы).",
"security.start_totp": "Настроить аутентификатор",
"security.scan_qr": "Отсканируйте QR-код и введите код для подтверждения.",
"security.manual_secret": "Не удаётся сканировать?",
"security.confirm_totp": "Подтвердить настройку",
"security.totp_enabled": "Приложение-аутентификатор подключено.",
"security.remove_totp": "Отключить аутентификатор",
"security.back_console": "Назад в консоль",
"nav.security": "Безопасность",
"footer.copyright": "© Anton Afanaasyeu, {year}", "footer.copyright": "© Anton Afanaasyeu, {year}",
"nav.tickets": "Тикеты", "nav.tickets": "Тикеты",
"ticket.lifecycle": "Жизненный цикл", "ticket.lifecycle": "Жизненный цикл",

View File

@@ -37,6 +37,7 @@
const companyRoles = data.company_roles || []; const companyRoles = data.company_roles || [];
const globalRoles = data.global_roles || []; const globalRoles = data.global_roles || [];
const sets = [{ key: '', label: '(role defaults only)' }].concat(data.privilege_sets || []); const sets = [{ key: '', label: '(role defaults only)' }].concat(data.privilege_sets || []);
const seenAuth = new Set();
(data.users || []).forEach((u) => { (data.users || []).forEach((u) => {
(u.memberships || []).forEach((m) => { (u.memberships || []).forEach((m) => {
@@ -67,26 +68,47 @@
(m.privilege_set === 'custom' ? '<option value="" disabled>custom grants (edit via API)</option>' : '') + (m.privilege_set === 'custom' ? '<option value="" disabled>custom grants (edit via API)</option>' : '') +
'</select>'; '</select>';
let authCell = '';
if (!seenAuth.has(u.id)) {
seenAuth.add(u.id);
const fails = Number(u.auth_failures || 0);
if (canEditGlobal() && fails > 0) {
authCell =
'<span class="muted">' + fails + ' fail</span> ' +
'<button type="button" class="btn btn--secondary rbac-clear-auth" data-user-id="' + u.id + '">Clear lockouts</button>';
} else if (fails > 0) {
authCell = '<span class="muted">' + fails + ' recent fail</span>';
} else {
authCell = '<span class="muted">—</span>';
}
}
tr.innerHTML = tr.innerHTML =
'<td>' + esc(u.username) + '</td>' + '<td>' + esc(u.username) + '</td>' +
'<td>' + globalSelect + '</td>' + '<td>' + globalSelect + '</td>' +
'<td><code>' + esc(m.slug) + '</code> ' + esc(m.name) + '</td>' + '<td><code>' + esc(m.slug) + '</code> ' + esc(m.name) + '</td>' +
'<td>' + roleSelect + '</td>' + '<td>' + roleSelect + '</td>' +
'<td>' + setSelect + '</td>'; '<td>' + setSelect + '</td>' +
'<td>' + authCell + '</td>';
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
if (!(u.memberships || []).length && canEditGlobal()) { if (!(u.memberships || []).length && canEditGlobal()) {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
const fails = Number(u.auth_failures || 0);
let authCell = fails > 0
? '<button type="button" class="btn btn--secondary rbac-clear-auth" data-user-id="' + u.id + '">Clear lockouts (' + fails + ')</button>'
: '<span class="muted">—</span>';
tr.innerHTML = tr.innerHTML =
'<td>' + esc(u.username) + '</td>' + '<td>' + esc(u.username) + '</td>' +
'<td><code>' + esc(u.global_role) + '</code></td>' + '<td><code>' + esc(u.global_role) + '</code></td>' +
'<td colspan="3" class="muted">No company membership</td>'; '<td colspan="3" class="muted">No company membership</td>' +
'<td>' + authCell + '</td>';
tbody.appendChild(tr); tbody.appendChild(tr);
} }
}); });
if (!tbody.children.length) { if (!tbody.children.length) {
tbody.innerHTML = '<tr><td colspan="5" class="muted">No users in scope.</td></tr>'; tbody.innerHTML = '<tr><td colspan="6" class="muted">No users in scope.</td></tr>';
} }
const hint = document.getElementById('rbac-scope-hint'); const hint = document.getElementById('rbac-scope-hint');
@@ -108,6 +130,19 @@
} }
function bindChanges() { function bindChanges() {
document.addEventListener('click', async (ev) => {
const btn = ev.target && ev.target.closest ? ev.target.closest('.rbac-clear-auth') : null;
if (!btn || !canEditGlobal()) return;
const userId = Number(btn.getAttribute('data-user-id'));
if (!userId) return;
try {
await postAction('clear_auth_lockouts', { user_id: userId });
setStatus('Auth lockouts cleared.');
load();
} catch (e) {
setStatus(String(e.message || e), true);
}
});
document.addEventListener('change', async (ev) => { document.addEventListener('change', async (ev) => {
const t = ev.target; const t = ev.target;
if (!t || !t.classList) return; if (!t || !t.classList) return;

View File

@@ -120,6 +120,11 @@ if ($route === '/api/tag_catalog.php' || str_ends_with($route, '/api/tag_catalog
exit; exit;
} }
if ($route === '/api/auth_register.php' || str_ends_with($route, '/api/auth_register.php')) {
require __DIR__ . '/api/auth_register.php';
exit;
}
if ($route === '/logout') { if ($route === '/logout') {
Auth::logout(); Auth::logout();
header('Location: ' . $base . '/login'); header('Location: ' . $base . '/login');
@@ -129,11 +134,24 @@ if ($route === '/logout') {
if ($route === '/login' && $_SERVER['REQUEST_METHOD'] === 'POST') { if ($route === '/login' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$user = trim($_POST['username'] ?? ''); $user = trim($_POST['username'] ?? '');
$pass = $_POST['password'] ?? ''; $pass = $_POST['password'] ?? '';
if (Auth::login($user, $pass)) { $result = Auth::login($user, $pass);
if ($result === true) {
header('Location: ' . $base . '/'); header('Location: ' . $base . '/');
exit; exit;
} }
if ($result === 'pending_2fa') {
header('Location: ' . $base . '/two-factor');
exit;
}
$loginError = 'Invalid credentials'; $loginError = 'Invalid credentials';
$st = Database::pdo()->prepare('SELECT status FROM users WHERE username = ? LIMIT 1');
$st->execute([$user]);
$row = $st->fetch(PDO::FETCH_ASSOC);
if (is_array($row) && ($row['status'] ?? '') === 'pending') {
$loginError = 'Verify your email before signing in.';
} elseif (AuthAttempts::isRateLimited($user)) {
$loginError = 'Too many attempts — try again later or ask an admin to clear lockouts.';
}
require __DIR__ . '/../views/login.php'; require __DIR__ . '/../views/login.php';
exit; exit;
} }
@@ -143,6 +161,123 @@ if ($route === '/login') {
exit; exit;
} }
if ($route === '/register' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$email = trim($_POST['email'] ?? '');
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
$registerEmail = $email;
$registerUsername = $username;
$out = AuthRegistration::register($email, $password, $username);
if ($out['ok']) {
$registerSuccess = 'Check your email for a verification link.';
require __DIR__ . '/../views/register.php';
exit;
}
$registerError = match ($out['error'] ?? '') {
'invalid_email' => 'Enter a valid email address.',
'weak_password' => 'Password must be at least 10 characters.',
'already_registered' => 'An account with this email or username already exists.',
'rate_limited' => 'Too many attempts — try again later.',
default => 'Registration failed.',
};
require __DIR__ . '/../views/register.php';
exit;
}
if ($route === '/register') {
require __DIR__ . '/../views/register.php';
exit;
}
if ($route === '/verify-email') {
$token = trim($_GET['token'] ?? '');
$result = AuthRegistration::verifyEmailToken($token);
$verifyOk = $result['ok'] ?? false;
if (!$verifyOk) {
$verifyError = match ($result['error'] ?? '') {
'expired_token' => 'This link has expired.',
'invalid_token' => 'Invalid verification link.',
default => 'Verification failed.',
};
}
require __DIR__ . '/../views/verify_email.php';
exit;
}
if ($route === '/two-factor' && $_SERVER['REQUEST_METHOD'] === 'POST') {
if (Auth::pending2faUserId() <= 0) {
header('Location: ' . $base . '/login');
exit;
}
$code = trim($_POST['code'] ?? '');
if (Auth::completeTotpLogin($code)) {
header('Location: ' . $base . '/');
exit;
}
$twofaError = 'Invalid code';
require __DIR__ . '/../views/two_factor_challenge.php';
exit;
}
if ($route === '/two-factor') {
if (Auth::pending2faUserId() <= 0) {
header('Location: ' . $base . '/login');
exit;
}
require __DIR__ . '/../views/two_factor_challenge.php';
exit;
}
if ($route === '/account-security' && $_SERVER['REQUEST_METHOD'] === 'POST') {
Auth::check();
$user = Auth::user();
$uid = (int) ($user['id'] ?? 0);
$action = (string) ($_POST['action'] ?? '');
if ($action === 'start_totp') {
$secret = AuthTotp::generateSecret();
$_SESSION['totp_enroll_secret'] = $secret;
$_SESSION['totp_enroll_exp'] = time() + 900;
} elseif ($action === 'confirm_totp') {
$secret = (string) ($_SESSION['totp_enroll_secret'] ?? '');
$exp = (int) ($_SESSION['totp_enroll_exp'] ?? 0);
unset($_SESSION['totp_enroll_secret'], $_SESSION['totp_enroll_exp']);
$code = trim($_POST['code'] ?? '');
if ($secret === '' || $exp < time() || !AuthTotp::verify($secret, $code)) {
$securityError = 'Invalid code — try setup again.';
} else {
AuthFactors::enrollTotp($uid, $secret);
$securitySuccess = 'Authenticator enrolled.';
}
} elseif ($action === 'remove_totp') {
AuthFactors::removeTotp($uid);
$securitySuccess = 'Authenticator removed.';
}
$totpSecret = '';
if (!AuthFactors::hasTotp($uid)) {
$exp = (int) ($_SESSION['totp_enroll_exp'] ?? 0);
if ($exp >= time()) {
$totpSecret = (string) ($_SESSION['totp_enroll_secret'] ?? '');
}
}
require __DIR__ . '/../views/account_security.php';
exit;
}
if ($route === '/account-security') {
Auth::check();
$user = Auth::user();
$uid = (int) ($user['id'] ?? 0);
$totpSecret = '';
if (!AuthFactors::hasTotp($uid)) {
$exp = (int) ($_SESSION['totp_enroll_exp'] ?? 0);
if ($exp >= time()) {
$totpSecret = (string) ($_SESSION['totp_enroll_secret'] ?? '');
}
}
require __DIR__ . '/../views/account_security.php';
exit;
}
if ($route === '/graphs' || $route === '/graphs/' || str_ends_with($route, '/app/androidcast_project/graphs') || str_ends_with($route, '/app/androidcast_project/graphs/')) { if ($route === '/graphs' || $route === '/graphs/' || str_ends_with($route, '/app/androidcast_project/graphs') || str_ends_with($route, '/app/androidcast_project/graphs/')) {
$_GET['view'] = 'graphs'; $_GET['view'] = 'graphs';
$route = '/'; $route = '/';

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
/** Smoke: TOTP generate + verify round-trip (CLI). */
require_once __DIR__ . '/../src/bootstrap.php';
$secret = AuthTotp::generateSecret();
$slice = (int) floor(time() / 30);
$code = AuthTotp::codeAt($secret, $slice);
$ok = AuthTotp::verify($secret, $code);
echo $ok ? "OK totp verify\n" : "FAIL totp verify\n";
$enc = AuthCrypto::encrypt($secret);
$dec = AuthCrypto::decrypt($enc);
echo ($dec === $secret) ? "OK crypto round-trip\n" : "FAIL crypto\n";
exit($ok && $dec === $secret ? 0 : 1);

View File

@@ -62,19 +62,77 @@ final class Auth {
return $owner !== '' && strcasecmp($owner, (string) ($user['username'] ?? '')) === 0; return $owner !== '' && strcasecmp($owner, (string) ($user['username'] ?? '')) === 0;
} }
public static function login(string $username, string $password): bool { /**
* @return true|'pending_2fa'|false
*/
public static function login(string $username, string $password): bool|string {
if (AuthAttempts::isRateLimited($username)) {
return false;
}
$pdo = Database::pdo(); $pdo = Database::pdo();
AuthEmailSchema::ensure($pdo);
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = ? LIMIT 1'); $stmt = $pdo->prepare('SELECT * FROM users WHERE username = ? LIMIT 1');
$stmt->execute([$username]); $stmt->execute([$username]);
$row = $stmt->fetch(); $row = $stmt->fetch();
if (!$row || !password_verify($password, $row['password_hash'])) { if (!$row || !password_verify($password, $row['password_hash'])) {
AuthAttempts::record('login_fail', $username);
return false;
}
$status = (string) ($row['status'] ?? 'active');
if ($status === 'pending') {
return false;
}
if ($status === 'locked' || $status === 'disabled') {
AuthAttempts::record('login_fail', $username);
return false;
}
$uid = (int) ($row['id'] ?? 0);
if ($uid > 0 && AuthFactors::hasTotp($uid)) {
$_SESSION['pending_2fa_user_id'] = $uid;
$_SESSION['pending_2fa_exp'] = time() + 300;
AuthAttempts::record('login_ok', $username);
return 'pending_2fa';
}
$_SESSION['user'] = Rbac::buildSessionUser($row);
AuthAttempts::record('login_ok', $username);
return true;
}
public static function completeTotpLogin(string $code): bool {
$uid = (int) ($_SESSION['pending_2fa_user_id'] ?? 0);
$exp = (int) ($_SESSION['pending_2fa_exp'] ?? 0);
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_exp']);
if ($uid <= 0 || $exp < time()) {
return false;
}
$secret = AuthFactors::getTotpSecret($uid);
if ($secret === null || !AuthTotp::verify($secret, $code)) {
return false;
}
$stmt = Database::pdo()->prepare('SELECT * FROM users WHERE id = ? LIMIT 1');
$stmt->execute([$uid]);
$row = $stmt->fetch();
if (!$row) {
return false; return false;
} }
$_SESSION['user'] = Rbac::buildSessionUser($row); $_SESSION['user'] = Rbac::buildSessionUser($row);
return true; return true;
} }
public static function pending2faUserId(): int {
$exp = (int) ($_SESSION['pending_2fa_exp'] ?? 0);
if ($exp < time()) {
return 0;
}
return (int) ($_SESSION['pending_2fa_user_id'] ?? 0);
}
public static function clearPending2fa(): void {
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_exp']);
}
public static function logout(): void { public static function logout(): void {
self::clearPending2fa();
unset($_SESSION['user']); unset($_SESSION['user']);
} }

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
/** Hashed login/register attempts + rate limit (task 3.x / A6). */
final class AuthAttempts {
private function __construct() {
}
public static function ensureSchema(): void {
AuthEmailSchema::ensure(Database::pdo());
}
public static function record(string $outcome, ?string $username = null): void {
self::ensureSchema();
$ipHash = self::hashIp(self::clientIp());
$userHash = $username !== null && $username !== '' ? self::hashUsername($username) : null;
Database::pdo()->prepare(
'INSERT INTO auth_attempts (ip_hash, username_hash, outcome, created_at) VALUES (?, ?, ?, ?)'
)->execute([$ipHash, $userHash, $outcome, date('Y-m-d H:i:s')]);
}
public static function isRateLimited(?string $username): bool {
self::ensureSchema();
$max = max(3, (int) cfg('auth.max_attempts', 8));
$windowSec = max(60, (int) cfg('auth.lockout_window_minutes', 15) * 60);
$since = date('Y-m-d H:i:s', time() - $windowSec);
$pdo = Database::pdo();
$ipHash = self::hashIp(self::clientIp());
$st = $pdo->prepare(
"SELECT COUNT(*) FROM auth_attempts WHERE ip_hash = ? AND outcome LIKE '%_fail' AND created_at >= ?"
);
$st->execute([$ipHash, $since]);
if ((int) $st->fetchColumn() >= $max) {
return true;
}
if ($username !== null && $username !== '') {
$userHash = self::hashUsername($username);
$st = $pdo->prepare(
"SELECT COUNT(*) FROM auth_attempts WHERE username_hash = ? AND outcome LIKE '%_fail' AND created_at >= ?"
);
$st->execute([$userHash, $since]);
if ((int) $st->fetchColumn() >= $max) {
return true;
}
}
return false;
}
public static function recentFailureCount(string $username): int {
self::ensureSchema();
$windowSec = max(60, (int) cfg('auth.lockout_window_minutes', 15) * 60);
$since = date('Y-m-d H:i:s', time() - $windowSec);
$st = Database::pdo()->prepare(
"SELECT COUNT(*) FROM auth_attempts WHERE username_hash = ? AND outcome LIKE '%_fail' AND created_at >= ?"
);
$st->execute([self::hashUsername($username), $since]);
return (int) $st->fetchColumn();
}
public static function clearForUser(int $userId): int {
self::ensureSchema();
$st = Database::pdo()->prepare('SELECT username FROM users WHERE id = ? LIMIT 1');
$st->execute([$userId]);
$username = $st->fetchColumn();
if (!is_string($username) || $username === '') {
return 0;
}
return self::clearForUsername($username);
}
public static function clearForUsername(string $username): int {
self::ensureSchema();
$hash = self::hashUsername($username);
$st = Database::pdo()->prepare('DELETE FROM auth_attempts WHERE username_hash = ?');
$st->execute([$hash]);
$deleted = $st->rowCount();
$pdo = Database::pdo();
$pdo->prepare(
"UPDATE users SET status = 'active' WHERE username = ? AND status = 'locked'"
)->execute([$username]);
return $deleted;
}
public static function clientIp(): string {
$xff = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
if (is_string($xff) && $xff !== '') {
$parts = explode(',', $xff);
return trim($parts[0]);
}
return (string) ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
}
public static function hashIp(string $ip): string {
return hash_hmac('sha256', trim($ip), self::pepper());
}
public static function hashUsername(string $username): string {
return hash_hmac('sha256', strtolower(trim($username)), self::pepper());
}
private static function pepper(): string {
$p = (string) cfg('auth.encryption_key', '');
if ($p === '') {
$p = 'dev-change-me-set-auth.encryption_key-in-config.php';
}
return $p;
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/** Encrypt TOTP secrets at rest (libsodium preferred). */
final class AuthCrypto {
private function __construct() {
}
public static function encrypt(string $plain): string {
if ($plain === '') {
return '';
}
$key = self::key();
if (function_exists('sodium_crypto_secretbox')) {
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$cipher = sodium_crypto_secretbox($plain, $nonce, $key);
return 'sbx:' . base64_encode($nonce . $cipher);
}
$iv = random_bytes(16);
$tag = '';
$cipher = openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
if ($cipher === false) {
return 'b64:' . base64_encode($plain);
}
return 'gcm:' . base64_encode($iv . $tag . $cipher);
}
public static function decrypt(string $stored): string {
if ($stored === '') {
return '';
}
if (str_starts_with($stored, 'sbx:')) {
$raw = base64_decode(substr($stored, 4), true);
if ($raw === false || strlen($raw) < SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + SODIUM_CRYPTO_SECRETBOX_MACBYTES) {
return '';
}
$nonce = substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$cipher = substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$plain = sodium_crypto_secretbox_open($cipher, $nonce, self::key());
return $plain === false ? '' : $plain;
}
if (str_starts_with($stored, 'gcm:')) {
$raw = base64_decode(substr($stored, 4), true);
if ($raw === false || strlen($raw) < 32) {
return '';
}
$iv = substr($raw, 0, 16);
$tag = substr($raw, 16, 16);
$cipher = substr($raw, 32);
$plain = openssl_decrypt($cipher, 'aes-256-gcm', self::key(), OPENSSL_RAW_DATA, $iv, $tag);
return is_string($plain) ? $plain : '';
}
if (str_starts_with($stored, 'b64:')) {
$v = base64_decode(substr($stored, 4), true);
return is_string($v) ? $v : '';
}
return '';
}
/** @return string 32-byte key */
private static function key(): string {
$pepper = (string) cfg('auth.encryption_key', '');
if ($pepper === '') {
$pepper = 'dev-change-me-set-auth.encryption_key-in-config.php';
}
return hash('sha256', $pepper, true);
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/** User 2FA factors (TOTP alpha). */
final class AuthFactors {
private function __construct() {
}
public static function ensureSchema(): void {
AuthEmailSchema::ensure(Database::pdo());
}
public static function hasTotp(int $userId): bool {
if ($userId <= 0) {
return false;
}
self::ensureSchema();
$st = Database::pdo()->prepare(
"SELECT id FROM auth_factors WHERE user_id = ? AND type = 'totp' LIMIT 1"
);
$st->execute([$userId]);
return (bool) $st->fetchColumn();
}
public static function getTotpSecret(int $userId): ?string {
if ($userId <= 0) {
return null;
}
self::ensureSchema();
$st = Database::pdo()->prepare(
"SELECT secret_encrypted FROM auth_factors WHERE user_id = ? AND type = 'totp' LIMIT 1"
);
$st->execute([$userId]);
$enc = $st->fetchColumn();
if (!is_string($enc) || $enc === '') {
return null;
}
$plain = AuthCrypto::decrypt($enc);
return $plain !== '' ? $plain : null;
}
public static function enrollTotp(int $userId, string $secret): void {
self::ensureSchema();
$enc = AuthCrypto::encrypt($secret);
$pdo = Database::pdo();
$pdo->prepare("DELETE FROM auth_factors WHERE user_id = ? AND type = 'totp'")->execute([$userId]);
$pdo->prepare(
'INSERT INTO auth_factors (user_id, type, secret_encrypted, label, created_at) VALUES (?, ?, ?, ?, ?)'
)->execute([$userId, 'totp', $enc, 'Authenticator', date('Y-m-d H:i:s')]);
}
public static function removeTotp(int $userId): void {
self::ensureSchema();
Database::pdo()->prepare("DELETE FROM auth_factors WHERE user_id = ? AND type = 'totp'")->execute([$userId]);
}
}

View File

@@ -15,11 +15,16 @@ final class AuthRegistration {
/** @return array{ok:bool,error?:string} */ /** @return array{ok:bool,error?:string} */
public static function register(string $email, string $password, string $username = ''): array { public static function register(string $email, string $password, string $username = ''): array {
self::ensureSchema(); self::ensureSchema();
if (AuthAttempts::isRateLimited($username !== '' ? $username : $email)) {
return ['ok' => false, 'error' => 'rate_limited'];
}
$emailNorm = self::normalizeEmail($email); $emailNorm = self::normalizeEmail($email);
if ($emailNorm === '' || !filter_var($emailNorm, FILTER_VALIDATE_EMAIL)) { if ($emailNorm === '' || !filter_var($emailNorm, FILTER_VALIDATE_EMAIL)) {
AuthAttempts::record('register_fail', $username ?: $email);
return ['ok' => false, 'error' => 'invalid_email']; return ['ok' => false, 'error' => 'invalid_email'];
} }
if (strlen($password) < 10) { if (strlen($password) < 10) {
AuthAttempts::record('register_fail', $username ?: $email);
return ['ok' => false, 'error' => 'weak_password']; return ['ok' => false, 'error' => 'weak_password'];
} }
$username = trim($username); $username = trim($username);
@@ -30,6 +35,7 @@ final class AuthRegistration {
$st = $pdo->prepare('SELECT id FROM users WHERE email_normalized = ? OR username = ? LIMIT 1'); $st = $pdo->prepare('SELECT id FROM users WHERE email_normalized = ? OR username = ? LIMIT 1');
$st->execute([$emailNorm, $username]); $st->execute([$emailNorm, $username]);
if ($st->fetchColumn()) { if ($st->fetchColumn()) {
AuthAttempts::record('register_fail', $username);
return ['ok' => false, 'error' => 'already_registered']; return ['ok' => false, 'error' => 'already_registered'];
} }
$hash = password_hash($password, PASSWORD_DEFAULT); $hash = password_hash($password, PASSWORD_DEFAULT);
@@ -51,10 +57,11 @@ final class AuthRegistration {
VALUES (?, ?, ?, ?, ?, ?)' VALUES (?, ?, ?, ?, ?, ?)'
)->execute([$userId, 'verify_email', $tokenHash, $emailNorm, $expires, date('Y-m-d H:i:s')]); )->execute([$userId, 'verify_email', $tokenHash, $emailNorm, $expires, date('Y-m-d H:i:s')]);
$base = rtrim((string) cfg('base_path', ''), '/'); $base = rtrim((string) cfg('base_path', ''), '/');
$verifyUrl = 'https://apps.f0xx.org' . $base . '/?view=verify_email&token=' . urlencode($token); $verifyUrl = 'https://apps.f0xx.org' . $base . '/verify-email?token=' . urlencode($token);
if (!AuthMailer::sendVerifyEmail($emailNorm, $verifyUrl)) { if (!AuthMailer::sendVerifyEmail($emailNorm, $verifyUrl)) {
error_log('AuthRegistration: verify mail failed for ' . $emailNorm); error_log('AuthRegistration: verify mail failed for ' . $emailNorm);
} }
AuthAttempts::record('register_ok', $username);
return ['ok' => true]; return ['ok' => true];
} }

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
/** RFC 6238 TOTP (6 digits, 30s) — no external deps. */
final class AuthTotp {
private const PERIOD = 30;
private const DIGITS = 6;
private function __construct() {
}
public static function generateSecret(int $bytes = 20): string {
return self::base32Encode(random_bytes($bytes));
}
public static function provisioningUri(string $secret, string $account, string $issuer): string {
$label = rawurlencode($issuer . ':' . $account);
$issuerEnc = rawurlencode($issuer);
return 'otpauth://totp/' . $label . '?secret=' . $secret . '&issuer=' . $issuerEnc . '&period=' . self::PERIOD;
}
public static function qrImageUrl(string $otpauthUri, int $size = 200): string {
return 'https://api.qrserver.com/v1/create-qr-code/?size=' . $size . 'x' . $size
. '&data=' . rawurlencode($otpauthUri);
}
public static function verify(string $secret, string $code, int $window = 1): bool {
$code = preg_replace('/\s+/', '', $code) ?? '';
if (!preg_match('/^\d{6}$/', $code)) {
return false;
}
$slice = (int) floor(time() / self::PERIOD);
for ($i = -$window; $i <= $window; $i++) {
if (hash_equals(self::codeAt($secret, $slice + $i), $code)) {
return true;
}
}
return false;
}
public static function codeAt(string $secret, int $timeSlice): string {
$key = self::base32Decode($secret);
$time = pack('N*', 0, $timeSlice);
$hash = hash_hmac('sha1', $time, $key, true);
$offset = ord($hash[19]) & 0x0f;
$truncated = (
((ord($hash[$offset]) & 0x7f) << 24)
| ((ord($hash[$offset + 1]) & 0xff) << 16)
| ((ord($hash[$offset + 2]) & 0xff) << 8)
| (ord($hash[$offset + 3]) & 0xff)
);
$mod = 10 ** self::DIGITS;
return str_pad((string) ($truncated % $mod), self::DIGITS, '0', STR_PAD_LEFT);
}
private static function base32Encode(string $data): string {
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$bits = '';
foreach (str_split($data) as $ch) {
$bits .= str_pad(decbin(ord($ch)), 8, '0', STR_PAD_LEFT);
}
$out = '';
foreach (str_split($bits, 5) as $chunk) {
if (strlen($chunk) < 5) {
$chunk = str_pad($chunk, 5, '0', STR_PAD_RIGHT);
}
$out .= $alphabet[bindec($chunk)];
}
return $out;
}
private static function base32Decode(string $secret): string {
$secret = strtoupper(preg_replace('/[^A-Z2-7]/', '', $secret) ?? '');
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$bits = '';
$len = strlen($secret);
for ($i = 0; $i < $len; $i++) {
$pos = strpos($alphabet, $secret[$i]);
if ($pos === false) {
continue;
}
$bits .= str_pad(decbin($pos), 5, '0', STR_PAD_LEFT);
}
$out = '';
foreach (str_split($bits, 8) as $chunk) {
if (strlen($chunk) < 8) {
break;
}
$out .= chr(bindec($chunk));
}
return $out;
}
}

View File

@@ -188,6 +188,7 @@ final class RbacAdminRepository {
'username' => (string) $u['username'], 'username' => (string) $u['username'],
'global_role' => Rbac::normalizeGlobalRole((string) ($u['role'] ?? '')), 'global_role' => Rbac::normalizeGlobalRole((string) ($u['role'] ?? '')),
'memberships' => $memberships, 'memberships' => $memberships,
'auth_failures' => AuthAttempts::recentFailureCount((string) $u['username']),
]; ];
} }
return $out; return $out;

View File

@@ -49,6 +49,10 @@ require_once __DIR__ . '/Auth.php';
require_once __DIR__ . '/AuthEmailSchema.php'; require_once __DIR__ . '/AuthEmailSchema.php';
require_once __DIR__ . '/AuthMailer.php'; require_once __DIR__ . '/AuthMailer.php';
require_once __DIR__ . '/AuthRegistration.php'; require_once __DIR__ . '/AuthRegistration.php';
require_once __DIR__ . '/AuthCrypto.php';
require_once __DIR__ . '/AuthTotp.php';
require_once __DIR__ . '/AuthFactors.php';
require_once __DIR__ . '/AuthAttempts.php';
require_once __DIR__ . '/ReportRepository.php'; require_once __DIR__ . '/ReportRepository.php';
require_once __DIR__ . '/TagCatalog.php'; require_once __DIR__ . '/TagCatalog.php';
require_once __DIR__ . '/TicketTagCatalog.php'; require_once __DIR__ . '/TicketTagCatalog.php';

View File

@@ -0,0 +1,69 @@
<?php
$bp = Auth::basePath();
$user = Auth::user();
$hasTotp = AuthFactors::hasTotp((int) ($user['id'] ?? 0));
$issuer = (string) cfg('app_name', 'Android Cast');
$account = (string) ($user['username'] ?? '');
$otpUri = $totpSecret ? AuthTotp::provisioningUri($totpSecret, $account, $issuer) : '';
$qrUrl = $otpUri !== '' ? AuthTotp::qrImageUrl($otpUri) : '';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Account security — <?= h(cfg('app_name')) ?></title>
<script>
(function () {
var l = localStorage.getItem('crash_console_lang');
if (l === 'en' || l === 'ru') document.documentElement.setAttribute('lang', l);
})();
</script>
<link rel="stylesheet" href="<?= h($bp) ?>/assets/css/app.css">
<script src="<?= h($bp) ?>/assets/js/i18n.js" defer></script>
</head>
<body class="login-page" data-base-path="<?= h($bp) ?>">
<div class="login-card" style="max-width:28rem">
<h1 data-i18n="security.title">Account security</h1>
<p class="muted"><?= h($account) ?></p>
<?php if (!empty($securityError)): ?>
<div class="alert"><?= h($securityError) ?></div>
<?php endif; ?>
<?php if (!empty($securitySuccess)): ?>
<div class="alert alert--ok"><?= h($securitySuccess) ?></div>
<?php endif; ?>
<?php if ($hasTotp): ?>
<p data-i18n="security.totp_enabled">Authenticator app is enrolled.</p>
<form method="post" action="<?= h($bp) ?>/account-security" onsubmit="return confirm('Remove authenticator?');">
<input type="hidden" name="action" value="remove_totp">
<button type="submit" class="btn btn--secondary" data-i18n="security.remove_totp">Remove authenticator</button>
</form>
<?php elseif ($totpSecret): ?>
<p data-i18n="security.scan_qr">Scan this QR code with your authenticator app, then enter a code to confirm.</p>
<?php if ($qrUrl): ?>
<p style="text-align:center"><img src="<?= h($qrUrl) ?>" width="200" height="200" alt="TOTP QR" loading="lazy"></p>
<?php endif; ?>
<details class="hint">
<summary data-i18n="security.manual_secret">Cant scan?</summary>
<code style="word-break:break-all"><?= h($totpSecret) ?></code>
</details>
<form method="post" action="<?= h($bp) ?>/account-security">
<input type="hidden" name="action" value="confirm_totp">
<label><span data-i18n="twofa.code">Authentication code</span>
<input name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required></label>
<button type="submit" data-i18n="security.confirm_totp">Confirm enrollment</button>
</form>
<?php else: ?>
<p data-i18n="security.totp_intro">Protect your account with a 6-digit authenticator app (recommended for alpha).</p>
<form method="post" action="<?= h($bp) ?>/account-security">
<input type="hidden" name="action" value="start_totp">
<button type="submit" data-i18n="security.start_totp">Set up authenticator</button>
</form>
<?php endif; ?>
<p class="hint" style="margin-top:1.5rem"><a href="<?= h($bp) ?>/" data-i18n="security.back_console">Back to console</a></p>
</div>
<?php platform_render_footer(); ?>
</body>
</html>

View File

@@ -143,6 +143,14 @@
<span class="nav-icon nav-icon--user" aria-hidden="true"></span> <span class="nav-icon nav-icon--user" aria-hidden="true"></span>
<span class="nav-label"><?= h(Auth::user()['username'] ?? '') ?></span> <span class="nav-label"><?= h(Auth::user()['username'] ?? '') ?></span>
</span> </span>
<a href="<?= h(Auth::basePath()) ?>/account-security"
class="nav-link"
data-i18n-aria="nav.security" data-i18n-title="nav.security"
aria-label="Security"
title="Security">
<span class="nav-icon nav-icon--user" aria-hidden="true"></span>
<span class="nav-label" data-i18n="nav.security">Security</span>
</a>
<a href="<?= h(Auth::basePath()) ?>/logout" <a href="<?= h(Auth::basePath()) ?>/logout"
class="nav-link nav-link--logout" class="nav-link nav-link--logout"
data-i18n-aria="nav.logout" data-i18n-title="nav.logout" data-i18n-aria="nav.logout" data-i18n-title="nav.logout"
@@ -481,6 +489,7 @@
<th scope="col">Company</th> <th scope="col">Company</th>
<th scope="col">Company role</th> <th scope="col">Company role</th>
<th scope="col">Privilege set</th> <th scope="col">Privilege set</th>
<th scope="col">Auth lockouts</th>
</tr> </tr>
</thead> </thead>
<tbody id="rbac-users-tbody"></tbody> <tbody id="rbac-users-tbody"></tbody>

View File

@@ -49,7 +49,7 @@ $bp = Auth::basePath();
<label><span data-i18n="login.password">Password</span><input name="password" type="password" autocomplete="current-password" required></label> <label><span data-i18n="login.password">Password</span><input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit" data-i18n="login.submit">Sign in</button> <button type="submit" data-i18n="login.submit">Sign in</button>
<p class="hint" data-i18n="login.hint_default">Default: admin / admin</p> <p class="hint" data-i18n="login.hint_default">Default: admin / admin</p>
<p class="hint"><span data-i18n="login.register">Register</span> <span data-i18n="login.register_soon">(coming soon)</span></p> <p class="hint"><a href="<?= h($bp) ?>/register" data-i18n="login.register">Register</a></p>
</form> </form>
<?php platform_render_footer(); ?> <?php platform_render_footer(); ?>
</body> </body>

View File

@@ -0,0 +1,51 @@
<?php
$bp = Auth::basePath();
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Register — <?= h(cfg('app_name')) ?></title>
<script>
(function () {
var l = localStorage.getItem('crash_console_lang');
if (l === 'en' || l === 'ru') document.documentElement.setAttribute('lang', l);
})();
</script>
<link rel="stylesheet" href="<?= h($bp) ?>/assets/css/app.css">
<script src="<?= h($bp) ?>/assets/js/i18n.js" defer></script>
<?php AnalyticsHead::render('crashes_register'); ?>
</head>
<body class="login-page" data-base-path="<?= h($bp) ?>">
<div class="login-locale">
<div class="locale-picker" data-i18n-title="lang.label" title="Language">
<span class="locale-flag" aria-hidden="true">🇬🇧</span>
<span class="locale-code" aria-hidden="true">EN</span>
<select class="lang-select locale-picker__select" data-i18n-aria="lang.label" aria-label="Language">
<option value="en">EN</option>
<option value="ru">RU</option>
</select>
</div>
</div>
<form class="login-card" method="post" action="<?= h($bp) ?>/register">
<h1 data-i18n="register.title">Create account</h1>
<p class="muted" data-i18n="register.hint">We will email a verification link before you can sign in.</p>
<?php if (!empty($registerError)): ?>
<div class="alert"><?= h($registerError) ?></div>
<?php endif; ?>
<?php if (!empty($registerSuccess)): ?>
<div class="alert alert--ok"><?= h($registerSuccess) ?></div>
<?php endif; ?>
<label><span data-i18n="register.email">Email</span>
<input name="email" type="email" autocomplete="email" required value="<?= h($registerEmail ?? '') ?>"></label>
<label><span data-i18n="register.username">Username</span>
<input name="username" autocomplete="username" value="<?= h($registerUsername ?? '') ?>"></label>
<label><span data-i18n="register.password">Password</span>
<input name="password" type="password" autocomplete="new-password" minlength="10" required></label>
<button type="submit" data-i18n="register.submit">Register</button>
<p class="hint"><a href="<?= h($bp) ?>/login" data-i18n="register.back_login">Back to sign in</a></p>
</form>
<?php platform_render_footer(); ?>
</body>
</html>

View File

@@ -0,0 +1,33 @@
<?php
$bp = Auth::basePath();
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Two-factor — <?= h(cfg('app_name')) ?></title>
<script>
(function () {
var l = localStorage.getItem('crash_console_lang');
if (l === 'en' || l === 'ru') document.documentElement.setAttribute('lang', l);
})();
</script>
<link rel="stylesheet" href="<?= h($bp) ?>/assets/css/app.css">
<script src="<?= h($bp) ?>/assets/js/i18n.js" defer></script>
</head>
<body class="login-page" data-base-path="<?= h($bp) ?>">
<form class="login-card" method="post" action="<?= h($bp) ?>/two-factor">
<h1 data-i18n="twofa.title">Two-factor authentication</h1>
<p class="muted" data-i18n="twofa.hint">Enter the 6-digit code from your authenticator app.</p>
<?php if (!empty($twofaError)): ?>
<div class="alert" data-i18n="twofa.error"><?= h($twofaError) ?></div>
<?php endif; ?>
<label><span data-i18n="twofa.code">Authentication code</span>
<input name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" required autofocus></label>
<button type="submit" data-i18n="twofa.submit">Continue</button>
<p class="hint"><a href="<?= h($bp) ?>/login" data-i18n="twofa.cancel">Cancel</a></p>
</form>
<?php platform_render_footer(); ?>
</body>
</html>

View File

@@ -0,0 +1,32 @@
<?php
$bp = Auth::basePath();
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Verify email — <?= h(cfg('app_name')) ?></title>
<script>
(function () {
var l = localStorage.getItem('crash_console_lang');
if (l === 'en' || l === 'ru') document.documentElement.setAttribute('lang', l);
})();
</script>
<link rel="stylesheet" href="<?= h($bp) ?>/assets/css/app.css">
<script src="<?= h($bp) ?>/assets/js/i18n.js" defer></script>
</head>
<body class="login-page" data-base-path="<?= h($bp) ?>">
<div class="login-card">
<h1 data-i18n="verify.title">Email verification</h1>
<?php if (!empty($verifyOk)): ?>
<p class="alert alert--ok" data-i18n="verify.ok">Your email is verified. You can sign in and enroll two-factor authentication.</p>
<p><a class="btn" href="<?= h($bp) ?>/login" data-i18n="verify.sign_in">Sign in</a></p>
<?php else: ?>
<p class="alert" data-i18n="verify.fail"><?= h($verifyError ?? 'Invalid or expired link.') ?></p>
<p class="hint"><a href="<?= h($bp) ?>/register" data-i18n="verify.register_again">Register again</a></p>
<?php endif; ?>
</div>
<?php platform_render_footer(); ?>
</body>
</html>