commit 9c70fcad5fa2d284d7541a456194f35948a3e08c Author: Anton Afanasyeu Date: Tue Jun 23 12:29:30 2026 +0200 initial diff --git a/README.md b/README.md new file mode 100644 index 0000000..2792850 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# ac-ms-identity + +Microservice API. Remote: `git://f0xx.org/ac/ac-ms-identity` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..560246e --- /dev/null +++ b/composer.json @@ -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/" + ] + } +} diff --git a/config/config.example.php b/config/config.example.php new file mode 100644 index 0000000..9a59c16 --- /dev/null +++ b/config/config.example.php @@ -0,0 +1,2 @@ + ['driver' => 'sqlite']]; diff --git a/public/api/auth_register.php b/public/api/auth_register.php new file mode 100644 index 0000000..b8b6e6c --- /dev/null +++ b/public/api/auth_register.php @@ -0,0 +1,26 @@ + 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); diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..922b7c2 --- /dev/null +++ b/public/index.php @@ -0,0 +1,2 @@ + + * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8 + * Contributors: + * - Anton Afanasyeu (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; + } +} diff --git a/src/AuthAttempts.php b/src/AuthAttempts.php new file mode 100644 index 0000000..c8d5e38 --- /dev/null +++ b/src/AuthAttempts.php @@ -0,0 +1,108 @@ +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; + } +} diff --git a/src/AuthCrypto.php b/src/AuthCrypto.php new file mode 100644 index 0000000..8d68d7e --- /dev/null +++ b/src/AuthCrypto.php @@ -0,0 +1,68 @@ + 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')) + )" + ); + } +} diff --git a/src/AuthFactors.php b/src/AuthFactors.php new file mode 100644 index 0000000..ef92783 --- /dev/null +++ b/src/AuthFactors.php @@ -0,0 +1,56 @@ +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]); + } +} diff --git a/src/AuthMailer.php b/src/AuthMailer.php new file mode 100644 index 0000000..c6d3168 --- /dev/null +++ b/src/AuthMailer.php @@ -0,0 +1,142 @@ +'); + $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 $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); + } +} diff --git a/src/AuthRegistration.php b/src/AuthRegistration.php new file mode 100644 index 0000000..8f5e311 --- /dev/null +++ b/src/AuthRegistration.php @@ -0,0 +1,101 @@ + 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)); + } +} diff --git a/src/AuthTotp.php b/src/AuthTotp.php new file mode 100644 index 0000000..8656ed0 --- /dev/null +++ b/src/AuthTotp.php @@ -0,0 +1,93 @@ + $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'] ?? ''), + ]; + } +} diff --git a/src/UserRepository.php b/src/UserRepository.php new file mode 100644 index 0000000..d72d808 --- /dev/null +++ b/src/UserRepository.php @@ -0,0 +1,44 @@ + */ + 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 $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; + } +}