mirror of
git://f0xx.org/android_cast
synced 2026-07-29 05:58:14 +03:00
sync BE
This commit is contained in:
49
.cursor/rules/dev-help-request-reminder.mdc
Normal file
49
.cursor/rules/dev-help-request-reminder.mdc
Normal 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.1–2.4**, **E1–E5** 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: … (1–3 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.
|
||||||
@@ -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** | |
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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": "Жизненный цикл",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 = '/';
|
||||||
|
|||||||
14
examples/crash_reporter/backend/scripts/verify_auth_totp.php
Normal file
14
examples/crash_reporter/backend/scripts/verify_auth_totp.php
Normal 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);
|
||||||
@@ -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']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
108
examples/crash_reporter/backend/src/AuthAttempts.php
Normal file
108
examples/crash_reporter/backend/src/AuthAttempts.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
68
examples/crash_reporter/backend/src/AuthCrypto.php
Normal file
68
examples/crash_reporter/backend/src/AuthCrypto.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
examples/crash_reporter/backend/src/AuthFactors.php
Normal file
56
examples/crash_reporter/backend/src/AuthFactors.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
93
examples/crash_reporter/backend/src/AuthTotp.php
Normal file
93
examples/crash_reporter/backend/src/AuthTotp.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
69
examples/crash_reporter/backend/views/account_security.php
Normal file
69
examples/crash_reporter/backend/views/account_security.php
Normal 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">Can’t 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
51
examples/crash_reporter/backend/views/register.php
Normal file
51
examples/crash_reporter/backend/views/register.php
Normal 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>
|
||||||
@@ -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>
|
||||||
32
examples/crash_reporter/backend/views/verify_email.php
Normal file
32
examples/crash_reporter/backend/views/verify_email.php
Normal 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>
|
||||||
Reference in New Issue
Block a user