1
0
mirror of git://f0xx.org/ac/ac-ms-identity synced 2026-07-30 02:37:39 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-23 12:29:30 +02:00
commit 9c70fcad5f
16 changed files with 1061 additions and 0 deletions

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# ac-ms-identity
Microservice API. Remote: `git://f0xx.org/ac/ac-ms-identity`

25
composer.json Normal file
View 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/"
]
}
}

View File

@@ -0,0 +1,2 @@
<?php
return ['db' => ['driver' => 'sqlite']];

View 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
View File

@@ -0,0 +1,2 @@
<?php
// API entry — wire nginx to public/

View 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
View 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
View File

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

68
src/AuthCrypto.php Normal file
View File

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

140
src/AuthEmailSchema.php Normal file
View 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
View File

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

142
src/AuthMailer.php Normal file
View 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
View 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
View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
/** RFC 6238 TOTP (6 digits, 30s) — no external deps. */
final class AuthTotp {
private const PERIOD = 30;
private const DIGITS = 6;
private function __construct() {
}
public static function generateSecret(int $bytes = 20): string {
return self::base32Encode(random_bytes($bytes));
}
public static function provisioningUri(string $secret, string $account, string $issuer): string {
$label = rawurlencode($issuer . ':' . $account);
$issuerEnc = rawurlencode($issuer);
return 'otpauth://totp/' . $label . '?secret=' . $secret . '&issuer=' . $issuerEnc . '&period=' . self::PERIOD;
}
public static function qrImageUrl(string $otpauthUri, int $size = 200): string {
return 'https://api.qrserver.com/v1/create-qr-code/?size=' . $size . 'x' . $size
. '&data=' . rawurlencode($otpauthUri);
}
public static function verify(string $secret, string $code, int $window = 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
View 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
View 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;
}
}