mirror of
git://f0xx.org/ac/ac-ms-identity
synced 2026-07-30 02:37:39 +03:00
176 lines
5.9 KiB
PHP
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;
|
|
}
|
|
}
|