1
0
mirror of git://f0xx.org/ac/ac-ms-identity synced 2026-07-30 02:37:39 +03:00
Files
ac-ms-identity/src/Auth.php
Anton Afanasyeu 9c70fcad5f initial
2026-06-23 12:29:30 +02:00

176 lines
5.9 KiB
PHP

<?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;
}
}