mirror of
git://f0xx.org/ac/ac-ms-identity
synced 2026-07-30 02:37:39 +03:00
initial
This commit is contained in:
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# ac-ms-identity
|
||||||
|
|
||||||
|
Microservice API. Remote: `git://f0xx.org/ac/ac-ms-identity`
|
||||||
25
composer.json
Normal file
25
composer.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "androidcast/ms-identity",
|
||||||
|
"description": "Users, login, register, 2FA",
|
||||||
|
"type": "project",
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.1",
|
||||||
|
"androidcast/platform-php": "dev-next",
|
||||||
|
"androidcast/platform-db": "dev-next"
|
||||||
|
},
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"type": "vcs",
|
||||||
|
"url": "git://f0xx.org/ac/ac-platform-php"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "vcs",
|
||||||
|
"url": "git://f0xx.org/ac/ac-platform-db"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"autoload": {
|
||||||
|
"classmap": [
|
||||||
|
"src/"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
2
config/config.example.php
Normal file
2
config/config.example.php
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<?php
|
||||||
|
return ['db' => ['driver' => 'sqlite']];
|
||||||
26
public/api/auth_register.php
Normal file
26
public/api/auth_register.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
json_out(['ok' => false, 'error' => 'method_not_allowed'], 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
$data = json_decode($raw ?: '', true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
json_out(['ok' => false, 'error' => 'invalid_json'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = (string) ($data['action'] ?? 'register');
|
||||||
|
if ($action === 'verify') {
|
||||||
|
$result = AuthRegistration::verifyEmailToken((string) ($data['token'] ?? ''));
|
||||||
|
json_out($result, $result['ok'] ? 200 : 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$email = (string) ($data['email'] ?? '');
|
||||||
|
$password = (string) ($data['password'] ?? '');
|
||||||
|
$username = (string) ($data['username'] ?? '');
|
||||||
|
$result = AuthRegistration::register($email, $password, $username);
|
||||||
|
json_out($result, $result['ok'] ? 200 : 400);
|
||||||
2
public/index.php
Normal file
2
public/index.php
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<?php
|
||||||
|
// API entry — wire nginx to public/
|
||||||
41
sql/migrations/008_auth_email_2fa.sql
Normal file
41
sql/migrations/008_auth_email_2fa.sql
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
-- Email registration + 2FA tables (MariaDB). Run as DB root once; SQLite dev auto-migrates via AuthEmailSchema.
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS email_normalized VARCHAR(254) NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMP NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS status ENUM('pending','active','locked','disabled') NOT NULL DEFAULT 'active',
|
||||||
|
ADD COLUMN IF NOT EXISTS recovery_email_normalized VARCHAR(254) NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_email_normalized ON users (email_normalized);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS auth_tokens (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT UNSIGNED NULL,
|
||||||
|
purpose ENUM('verify_email','reset_password','login_magic') NOT NULL,
|
||||||
|
token_hash CHAR(64) NOT NULL,
|
||||||
|
email_normalized VARCHAR(254) NULL,
|
||||||
|
expires_at TIMESTAMP NOT NULL,
|
||||||
|
used_at TIMESTAMP NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_auth_tokens_hash (token_hash),
|
||||||
|
KEY idx_auth_tokens_email (email_normalized)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS auth_factors (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT UNSIGNED NOT NULL,
|
||||||
|
type ENUM('totp','webauthn','backup_code') NOT NULL,
|
||||||
|
secret_encrypted TEXT NULL,
|
||||||
|
label VARCHAR(64) NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_auth_factors_user (user_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS auth_attempts (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
ip_hash CHAR(64) NOT NULL,
|
||||||
|
username_hash CHAR(64) NULL,
|
||||||
|
outcome VARCHAR(32) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_auth_attempts_ip (ip_hash, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
175
src/Auth.php
Normal file
175
src/Auth.php
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* package examples/crash_reporter/backend/src/Auth.php
|
||||||
|
* Auth.php
|
||||||
|
* Created at: Wed 20 May 2026 14:31:55 +0200
|
||||||
|
* Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
|
||||||
|
* Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
|
||||||
|
* Contributors:
|
||||||
|
* - Anton Afanasyeu <a.afanasieff@gmail.com> (2 commits, 56 lines)
|
||||||
|
* - Cursor Agent (project assistant)
|
||||||
|
* Digest: SHA256 a6e49e8d07788ac2b4ea48f886f9a46ec6f5c1eab5b9723e1554e1fe53765efd
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
final class Auth {
|
||||||
|
public static function user(): ?array {
|
||||||
|
$u = $_SESSION['user'] ?? null;
|
||||||
|
if (!$u || !empty($u['companies'])) {
|
||||||
|
return $u;
|
||||||
|
}
|
||||||
|
$uid = (int) ($u['id'] ?? 0);
|
||||||
|
if ($uid <= 0) {
|
||||||
|
return $u;
|
||||||
|
}
|
||||||
|
$stmt = Database::pdo()->prepare('SELECT * FROM users WHERE id = ? LIMIT 1');
|
||||||
|
$stmt->execute([$uid]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
if (!$row) {
|
||||||
|
return $u;
|
||||||
|
}
|
||||||
|
Database::ensureRbacSchema();
|
||||||
|
Rbac::seedMembershipsForUser($uid, (string) ($row['role'] ?? 'viewer'));
|
||||||
|
$_SESSION['user'] = Rbac::buildSessionUser($row);
|
||||||
|
return $_SESSION['user'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function check(): void {
|
||||||
|
if (!self::user()) {
|
||||||
|
header('Location: ' . self::authUrl('/login'));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function can(string $action): bool {
|
||||||
|
return Rbac::can($action);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canEditTags(?int $companyId = null): bool {
|
||||||
|
return Rbac::can('edit_tags', $companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ticket owner or company admin+ (same gate as tag edit for alpha). */
|
||||||
|
public static function canEditTicket(array $ticket, ?int $companyId = null): bool {
|
||||||
|
if (self::canEditTags($companyId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$user = self::user();
|
||||||
|
if (!$user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$owner = trim((string) ($ticket['owner_username'] ?? ''));
|
||||||
|
return $owner !== '' && strcasecmp($owner, (string) ($user['username'] ?? '')) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return true|'pending_2fa'|false
|
||||||
|
*/
|
||||||
|
public static function login(string $username, string $password): bool|string {
|
||||||
|
if (AuthAttempts::isRateLimited($username)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$pdo = Database::pdo();
|
||||||
|
AuthEmailSchema::ensure($pdo);
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = ? LIMIT 1');
|
||||||
|
$stmt->execute([$username]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
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);
|
||||||
|
if ($uid <= 0 || $exp < time()) {
|
||||||
|
self::clearPending2fa();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$secret = AuthFactors::getTotpSecret($uid);
|
||||||
|
if ($secret === null || !AuthTotp::verify($secret, $code)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self::clearPending2fa();
|
||||||
|
$stmt = Database::pdo()->prepare('SELECT * FROM users WHERE id = ? LIMIT 1');
|
||||||
|
$stmt->execute([$uid]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
if (!$row) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$_SESSION['user'] = Rbac::buildSessionUser($row);
|
||||||
|
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 {
|
||||||
|
self::clearPending2fa();
|
||||||
|
unset($_SESSION['user']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function basePath(): string {
|
||||||
|
$bp = rtrim((string) cfg('base_path', ''), '/');
|
||||||
|
if ($bp === '') {
|
||||||
|
return $bp;
|
||||||
|
}
|
||||||
|
// If nginx serves /issues/ but config.php still has legacy /crashes, asset URLs must match nginx.
|
||||||
|
$uri = (string) ($_SERVER['REQUEST_URI'] ?? '');
|
||||||
|
if (str_contains($uri, '/issues') && str_ends_with($bp, '/crashes')) {
|
||||||
|
return substr($bp, 0, -strlen('/crashes')) . '/issues';
|
||||||
|
}
|
||||||
|
return $bp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared project prefix, e.g. /app/androidcast_project */
|
||||||
|
public static function projectBasePath(): string {
|
||||||
|
$p = trim((string) cfg('project_base_path', ''));
|
||||||
|
if ($p !== '') {
|
||||||
|
return rtrim($p, '/');
|
||||||
|
}
|
||||||
|
$bp = self::basePath();
|
||||||
|
if (preg_match('#^(.+)/(?:crashes|issues|graphs|build)$#', $bp, $m)) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
return '/app/androidcast_project';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Login/logout/register/2FA live at project root, not under /issues/. */
|
||||||
|
public static function authUrl(string $path = ''): string {
|
||||||
|
$root = self::projectBasePath();
|
||||||
|
$path = '/' . ltrim($path, '/');
|
||||||
|
if ($path === '/' || $path === '') {
|
||||||
|
return $root;
|
||||||
|
}
|
||||||
|
return $root . $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
108
src/AuthAttempts.php
Normal file
108
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
src/AuthCrypto.php
Normal file
68
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
140
src/AuthEmailSchema.php
Normal file
140
src/AuthEmailSchema.php
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/** Migration 008 — email registration + 2FA tables (SQLite dev + MariaDB ALTER). */
|
||||||
|
final class AuthEmailSchema {
|
||||||
|
private static bool $ensured = false;
|
||||||
|
|
||||||
|
private function __construct() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function ensure(PDO $pdo): void {
|
||||||
|
if (self::$ensured) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Database::tableExists($pdo, 'users')) {
|
||||||
|
self::$ensured = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Database::withMigrationLock($pdo, 'auth_email_schema', static function () use ($pdo): void {
|
||||||
|
self::ensureUserColumns($pdo);
|
||||||
|
self::ensureAuthTokens($pdo);
|
||||||
|
self::ensureAuthFactors($pdo);
|
||||||
|
self::ensureAuthAttempts($pdo);
|
||||||
|
});
|
||||||
|
self::$ensured = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function ensureUserColumns(PDO $pdo): void {
|
||||||
|
$existing = Database::columnNames($pdo, 'users');
|
||||||
|
$cols = [
|
||||||
|
'email_normalized' => Database::isMysql() ? 'VARCHAR(254) NULL' : 'TEXT NULL',
|
||||||
|
'email_verified_at' => Database::isMysql() ? 'TIMESTAMP NULL' : 'TEXT NULL',
|
||||||
|
'status' => Database::isMysql()
|
||||||
|
? "ENUM('pending','active','locked','disabled') NOT NULL DEFAULT 'active'"
|
||||||
|
: "TEXT NOT NULL DEFAULT 'active'",
|
||||||
|
'recovery_email_normalized' => Database::isMysql() ? 'VARCHAR(254) NULL' : 'TEXT NULL',
|
||||||
|
];
|
||||||
|
foreach ($cols as $name => $ddl) {
|
||||||
|
if (in_array($name, $existing, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$pdo->exec('ALTER TABLE users ADD COLUMN ' . $name . ' ' . $ddl);
|
||||||
|
$existing[] = $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function ensureAuthTokens(PDO $pdo): void {
|
||||||
|
if (Database::tableExists($pdo, 'auth_tokens')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Database::isMysql()) {
|
||||||
|
$pdo->exec(
|
||||||
|
"CREATE TABLE auth_tokens (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT UNSIGNED NULL,
|
||||||
|
purpose ENUM('verify_email','reset_password','login_magic') NOT NULL,
|
||||||
|
token_hash CHAR(64) NOT NULL,
|
||||||
|
email_normalized VARCHAR(254) NULL,
|
||||||
|
expires_at TIMESTAMP NOT NULL,
|
||||||
|
used_at TIMESTAMP NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_auth_tokens_hash (token_hash),
|
||||||
|
KEY idx_auth_tokens_email (email_normalized)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$pdo->exec(
|
||||||
|
"CREATE TABLE auth_tokens (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NULL,
|
||||||
|
purpose TEXT NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
email_normalized TEXT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
used_at TEXT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)"
|
||||||
|
);
|
||||||
|
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_auth_tokens_hash ON auth_tokens (token_hash)');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function ensureAuthFactors(PDO $pdo): void {
|
||||||
|
if (Database::tableExists($pdo, 'auth_factors')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Database::isMysql()) {
|
||||||
|
$pdo->exec(
|
||||||
|
"CREATE TABLE auth_factors (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT UNSIGNED NOT NULL,
|
||||||
|
type ENUM('totp','webauthn','backup_code') NOT NULL,
|
||||||
|
secret_encrypted TEXT NULL,
|
||||||
|
label VARCHAR(64) NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_auth_factors_user (user_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$pdo->exec(
|
||||||
|
"CREATE TABLE auth_factors (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
secret_encrypted TEXT NULL,
|
||||||
|
label TEXT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function ensureAuthAttempts(PDO $pdo): void {
|
||||||
|
if (Database::tableExists($pdo, 'auth_attempts')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Database::isMysql()) {
|
||||||
|
$pdo->exec(
|
||||||
|
"CREATE TABLE auth_attempts (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
ip_hash CHAR(64) NOT NULL,
|
||||||
|
username_hash CHAR(64) NULL,
|
||||||
|
outcome VARCHAR(32) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_auth_attempts_ip (ip_hash, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$pdo->exec(
|
||||||
|
"CREATE TABLE auth_attempts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ip_hash TEXT NOT NULL,
|
||||||
|
username_hash TEXT NULL,
|
||||||
|
outcome TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/AuthFactors.php
Normal file
56
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
142
src/AuthMailer.php
Normal file
142
src/AuthMailer.php
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/** Outbound mail (registration verify, password reset). Task 2.5 — uses config mail.* */
|
||||||
|
final class AuthMailer {
|
||||||
|
private function __construct() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function send(string $to, string $subject, string $bodyText): bool {
|
||||||
|
$from = (string) cfg('mail.from', 'Android Cast <noreply@apps.f0xx.org>');
|
||||||
|
$replyTo = (string) cfg('mail.reply_to', '');
|
||||||
|
$transport = strtolower((string) cfg('mail.transport', 'smtp'));
|
||||||
|
if ($transport === 'sendmail') {
|
||||||
|
return self::sendMail($to, $subject, $bodyText, $from, $replyTo);
|
||||||
|
}
|
||||||
|
return self::sendSmtp($to, $subject, $bodyText, $from, $replyTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function sendVerifyEmail(string $to, string $verifyUrl): bool {
|
||||||
|
$subject = 'Verify your Android Cast account';
|
||||||
|
$body = "Open this link to verify your email (valid 24h):\n\n" . $verifyUrl . "\n";
|
||||||
|
return self::send($to, $subject, $body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function sendMail(string $to, string $subject, string $body, string $from, string $replyTo): bool {
|
||||||
|
$headers = "From: {$from}\r\n";
|
||||||
|
if ($replyTo !== '') {
|
||||||
|
$headers .= "Reply-To: {$replyTo}\r\n";
|
||||||
|
}
|
||||||
|
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
||||||
|
return @mail($to, $subject, $body, $headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function sendSmtp(string $to, string $subject, string $body, string $from, string $replyTo): bool {
|
||||||
|
$host = (string) cfg('mail.smtp.host', '127.0.0.1');
|
||||||
|
$port = (int) cfg('mail.smtp.port', 587);
|
||||||
|
$enc = strtolower((string) cfg('mail.smtp.encryption', 'tls'));
|
||||||
|
$user = (string) cfg('mail.smtp.username', '');
|
||||||
|
$pass = (string) cfg('mail.smtp.password', '');
|
||||||
|
$remote = ($enc === 'ssl' ? 'ssl://' : '') . $host;
|
||||||
|
$errno = 0;
|
||||||
|
$errstr = '';
|
||||||
|
$fp = @stream_socket_client($remote . ':' . $port, $errno, $errstr, 15);
|
||||||
|
if ($fp === false) {
|
||||||
|
error_log('AuthMailer SMTP connect failed: ' . $errstr);
|
||||||
|
return self::sendMail($to, $subject, $body, $from, $replyTo);
|
||||||
|
}
|
||||||
|
stream_set_timeout($fp, 15);
|
||||||
|
if (!self::smtpExpect($fp, [220])) {
|
||||||
|
fclose($fp);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$ehloHost = 'localhost';
|
||||||
|
fwrite($fp, "EHLO {$ehloHost}\r\n");
|
||||||
|
if (!self::smtpExpect($fp, [250])) {
|
||||||
|
fclose($fp);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($enc === 'tls') {
|
||||||
|
fwrite($fp, "STARTTLS\r\n");
|
||||||
|
if (!self::smtpExpect($fp, [220])) {
|
||||||
|
fclose($fp);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
||||||
|
fclose($fp);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
fwrite($fp, "EHLO {$ehloHost}\r\n");
|
||||||
|
if (!self::smtpExpect($fp, [250])) {
|
||||||
|
fclose($fp);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($user !== '') {
|
||||||
|
fwrite($fp, "AUTH LOGIN\r\n");
|
||||||
|
if (!self::smtpExpect($fp, [334])) {
|
||||||
|
fclose($fp);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
fwrite($fp, base64_encode($user) . "\r\n");
|
||||||
|
if (!self::smtpExpect($fp, [334])) {
|
||||||
|
fclose($fp);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
fwrite($fp, base64_encode($pass) . "\r\n");
|
||||||
|
if (!self::smtpExpect($fp, [235])) {
|
||||||
|
fclose($fp);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$fromAddr = self::extractAddress($from);
|
||||||
|
fwrite($fp, "MAIL FROM:<{$fromAddr}>\r\n");
|
||||||
|
if (!self::smtpExpect($fp, [250])) {
|
||||||
|
fclose($fp);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
fwrite($fp, "RCPT TO:<{$to}>\r\n");
|
||||||
|
if (!self::smtpExpect($fp, [250, 251])) {
|
||||||
|
fclose($fp);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
fwrite($fp, "DATA\r\n");
|
||||||
|
if (!self::smtpExpect($fp, [354])) {
|
||||||
|
fclose($fp);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$msg = "From: {$from}\r\n";
|
||||||
|
if ($replyTo !== '') {
|
||||||
|
$msg .= "Reply-To: {$replyTo}\r\n";
|
||||||
|
}
|
||||||
|
$msg .= "To: {$to}\r\nSubject: {$subject}\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n{$body}\r\n.\r\n";
|
||||||
|
fwrite($fp, $msg);
|
||||||
|
if (!self::smtpExpect($fp, [250])) {
|
||||||
|
fclose($fp);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
fwrite($fp, "QUIT\r\n");
|
||||||
|
fclose($fp);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param resource $fp @param list<int> $codes */
|
||||||
|
private static function smtpExpect($fp, array $codes): bool {
|
||||||
|
$line = '';
|
||||||
|
while (($chunk = fgets($fp, 512)) !== false) {
|
||||||
|
$line .= $chunk;
|
||||||
|
if (strlen($chunk) >= 4 && $chunk[3] === ' ') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$code = (int) substr(trim($line), 0, 3);
|
||||||
|
return in_array($code, $codes, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function extractAddress(string $from): string {
|
||||||
|
if (preg_match('/<([^>]+)>/', $from, $m)) {
|
||||||
|
return trim($m[1]);
|
||||||
|
}
|
||||||
|
return trim($from);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/AuthRegistration.php
Normal file
101
src/AuthRegistration.php
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/** Email self-registration (verify link). TOTP enrollment follows login — task 3.x. */
|
||||||
|
final class AuthRegistration {
|
||||||
|
private const TOKEN_TTL_S = 86400;
|
||||||
|
|
||||||
|
private function __construct() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function ensureSchema(): void {
|
||||||
|
AuthEmailSchema::ensure(Database::pdo());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{ok:bool,error?:string} */
|
||||||
|
public static function register(string $email, string $password, string $username = ''): array {
|
||||||
|
self::ensureSchema();
|
||||||
|
if (AuthAttempts::isRateLimited($username !== '' ? $username : $email)) {
|
||||||
|
return ['ok' => false, 'error' => 'rate_limited'];
|
||||||
|
}
|
||||||
|
$emailNorm = self::normalizeEmail($email);
|
||||||
|
if ($emailNorm === '' || !filter_var($emailNorm, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
AuthAttempts::record('register_fail', $username ?: $email);
|
||||||
|
return ['ok' => false, 'error' => 'invalid_email'];
|
||||||
|
}
|
||||||
|
if (strlen($password) < 10) {
|
||||||
|
AuthAttempts::record('register_fail', $username ?: $email);
|
||||||
|
return ['ok' => false, 'error' => 'weak_password'];
|
||||||
|
}
|
||||||
|
$username = trim($username);
|
||||||
|
if ($username === '') {
|
||||||
|
$username = strstr($emailNorm, '@', true) ?: $emailNorm;
|
||||||
|
}
|
||||||
|
$pdo = Database::pdo();
|
||||||
|
$st = $pdo->prepare('SELECT id FROM users WHERE email_normalized = ? OR username = ? LIMIT 1');
|
||||||
|
$st->execute([$emailNorm, $username]);
|
||||||
|
if ($st->fetchColumn()) {
|
||||||
|
AuthAttempts::record('register_fail', $username);
|
||||||
|
return ['ok' => false, 'error' => 'already_registered'];
|
||||||
|
}
|
||||||
|
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||||
|
$pdo->prepare(
|
||||||
|
'INSERT INTO users (username, password_hash, role, email_normalized, status) VALUES (?, ?, ?, ?, ?)'
|
||||||
|
)->execute([
|
||||||
|
$username,
|
||||||
|
$hash,
|
||||||
|
'viewer',
|
||||||
|
$emailNorm,
|
||||||
|
'pending',
|
||||||
|
]);
|
||||||
|
$userId = (int) $pdo->lastInsertId();
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
$tokenHash = hash('sha256', $token);
|
||||||
|
$expires = date('Y-m-d H:i:s', time() + self::TOKEN_TTL_S);
|
||||||
|
$pdo->prepare(
|
||||||
|
'INSERT INTO auth_tokens (user_id, purpose, token_hash, email_normalized, expires_at, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)'
|
||||||
|
)->execute([$userId, 'verify_email', $tokenHash, $emailNorm, $expires, date('Y-m-d H:i:s')]);
|
||||||
|
$base = rtrim((string) cfg('base_path', ''), '/');
|
||||||
|
$verifyUrl = 'https://apps.f0xx.org' . $base . '/verify-email?token=' . urlencode($token);
|
||||||
|
if (!AuthMailer::sendVerifyEmail($emailNorm, $verifyUrl)) {
|
||||||
|
error_log('AuthRegistration: verify mail failed for ' . $emailNorm);
|
||||||
|
}
|
||||||
|
AuthAttempts::record('register_ok', $username);
|
||||||
|
return ['ok' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{ok:bool,error?:string} */
|
||||||
|
public static function verifyEmailToken(string $token): array {
|
||||||
|
self::ensureSchema();
|
||||||
|
$token = trim($token);
|
||||||
|
if ($token === '') {
|
||||||
|
return ['ok' => false, 'error' => 'missing_token'];
|
||||||
|
}
|
||||||
|
$hash = hash('sha256', $token);
|
||||||
|
$pdo = Database::pdo();
|
||||||
|
$st = $pdo->prepare(
|
||||||
|
"SELECT * FROM auth_tokens WHERE token_hash = ? AND purpose = 'verify_email' AND used_at IS NULL LIMIT 1"
|
||||||
|
);
|
||||||
|
$st->execute([$hash]);
|
||||||
|
$row = $st->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if (!$row) {
|
||||||
|
return ['ok' => false, 'error' => 'invalid_token'];
|
||||||
|
}
|
||||||
|
$exp = strtotime((string) ($row['expires_at'] ?? ''));
|
||||||
|
if ($exp !== false && $exp < time()) {
|
||||||
|
return ['ok' => false, 'error' => 'expired_token'];
|
||||||
|
}
|
||||||
|
$userId = (int) ($row['user_id'] ?? 0);
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$pdo->prepare('UPDATE auth_tokens SET used_at = ? WHERE id = ?')->execute([$now, (int) $row['id']]);
|
||||||
|
$pdo->prepare(
|
||||||
|
"UPDATE users SET email_verified_at = ?, status = 'active' WHERE id = ?"
|
||||||
|
)->execute([$now, $userId]);
|
||||||
|
return ['ok' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalizeEmail(string $email): string {
|
||||||
|
return strtolower(trim($email));
|
||||||
|
}
|
||||||
|
}
|
||||||
93
src/AuthTotp.php
Normal file
93
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 = 2): 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/AuthTwoFactorPage.php
Normal file
35
src/AuthTwoFactorPage.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/** 2FA challenge page helpers (QR short link for mobile handoff). */
|
||||||
|
final class AuthTwoFactorPage {
|
||||||
|
/** @return array{short_url?:string,qr_url?:string,long_url:string}|null */
|
||||||
|
public static function qrPayload(): ?array {
|
||||||
|
$uid = Auth::pending2faUserId();
|
||||||
|
if ($uid <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$project = Auth::projectBasePath();
|
||||||
|
$longUrl = 'https://apps.f0xx.org' . $project . '/?ref=2fa&uid=' . $uid;
|
||||||
|
if (!UrlShortenerDatabase::enabled() || !UrlShortenerDatabase::ping()) {
|
||||||
|
return ['long_url' => $longUrl];
|
||||||
|
}
|
||||||
|
$bearers = ShortLinksRepository::listBearers();
|
||||||
|
if ($bearers === []) {
|
||||||
|
return ['long_url' => $longUrl];
|
||||||
|
}
|
||||||
|
$bearerId = (int) ($bearers[0]['id'] ?? 0);
|
||||||
|
if ($bearerId <= 0) {
|
||||||
|
return ['long_url' => $longUrl];
|
||||||
|
}
|
||||||
|
$mint = ShortLinksRepository::createLink($bearerId, $longUrl, 900);
|
||||||
|
if (empty($mint['ok'])) {
|
||||||
|
return ['long_url' => $longUrl];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'long_url' => $longUrl,
|
||||||
|
'short_url' => (string) ($mint['short_url'] ?? ''),
|
||||||
|
'qr_url' => (string) ($mint['qr_url'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/UserRepository.php
Normal file
44
src/UserRepository.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
final class UserRepository {
|
||||||
|
/** @return list<array{username:string,role:string}> */
|
||||||
|
public static function listForAssignees(): array {
|
||||||
|
$pdo = Database::pdo();
|
||||||
|
$stmt = $pdo->query('SELECT username, role FROM users ORDER BY username ASC');
|
||||||
|
$out = [];
|
||||||
|
foreach ($stmt->fetchAll() as $row) {
|
||||||
|
$out[] = [
|
||||||
|
'username' => (string) $row['username'],
|
||||||
|
'role' => (string) ($row['role'] ?? 'viewer'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $usernames */
|
||||||
|
public static function validateUsernames(array $usernames): ?string {
|
||||||
|
$want = [];
|
||||||
|
foreach ($usernames as $u) {
|
||||||
|
$u = trim((string) $u);
|
||||||
|
if ($u !== '') {
|
||||||
|
$want[strtolower($u)] = $u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($want === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$pdo = Database::pdo();
|
||||||
|
$stmt = $pdo->query('SELECT username FROM users');
|
||||||
|
$have = [];
|
||||||
|
foreach ($stmt->fetchAll() as $row) {
|
||||||
|
$have[strtolower((string) $row['username'])] = true;
|
||||||
|
}
|
||||||
|
foreach ($want as $lower => $orig) {
|
||||||
|
if (!isset($have[$lower])) {
|
||||||
|
return 'unknown user: ' . $orig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user