Newer
Older
smart-home-server / server / SHServ / Controllers / AuthController.php
<?php

declare(strict_types=1);

namespace SHServ\Controllers;

use GNexus\GAuth\DTO\TokenSet;
use SHServ\Integrations\GAuth\AuthControllerTrait;
use SHServ\Integrations\GAuth\AuthService;
use SHServ\Integrations\GAuth\PermissionResolver;
use SHServ\Integrations\GAuth\RateLimiter;
use SHServ\Integrations\GAuth\Store\DbTokenStore;
use SHServ\Integrations\GAuth\UserResolver;

class AuthController extends \SHServ\Middleware\Controller
{
    use AuthControllerTrait;

    /**
     * GET /auth/login
     * Redirect user to gnexus-auth authorization page.
     */
    public function login()
    {
        $rateLimit = $this->checkAuthRateLimit('login');
        if ($rateLimit !== null) {
            return $rateLimit;
        }

        $service = new AuthService();
        $returnTo = $_GET['return_to'] ?? '/';
        $returnTo = $this->sanitizeReturnTo($returnTo);
        $url = $service->buildLoginUrl($returnTo);

        logging()->info('php:Auth', 'Login attempt', [
            'client_ip' => $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0',
            'return_to' => $returnTo,
        ]);

        return $this->utils()->redirect($url);
    }

    /**
     * GET /auth/callback
     * Handle OAuth callback from gnexus-auth.
     */
    public function callback()
    {
        $rateLimit = $this->checkAuthRateLimit('callback');
        if ($rateLimit !== null) {
            return $rateLimit;
        }

        if (session_status() === PHP_SESSION_NONE) {
            @session_start();
        }

        $code = isset($_GET['code']) ? (string) $_GET['code'] : '';
        $state = isset($_GET['state']) ? (string) $_GET['state'] : '';

        if ($code === '' || $state === '') {
            return $this->utils()->response_error('invalid_callback');
        }

        // Capture returnTo BEFORE handleCallback wipes the state from session
        $context = $_SESSION['gauth_state'][$state]['context'] ?? [];

        $service = new AuthService();

        try {
            $user = $service->handleCallback($code, $state);
        } catch (\GNexus\GAuth\Exception\GAuthException $e) {
            logging()->warn('php:Auth', 'OAuth callback failed', [
                'message' => $e->getMessage(),
                'client_ip' => $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0',
            ]);
            return $this->utils()->response_error('auth_failed', [], ['message' => $e->getMessage()]);
        }

        $resolver = new UserResolver();
        $localUserId = $resolver->resolve($user);

        logging()->info('php:Auth', 'OAuth login successful', [
            'user_id' => $localUserId,
            'email' => $user->email,
            'gauth_user_id' => $user->userId,
        ]);

        $_SESSION['shserv_user_id'] = $localUserId;

        // Persist tokens to DB now that we have the local user ID
        $sessionToken = $_SESSION['shserv_auth_token'] ?? null;
        $tokenData = $_SESSION['shserv_last_token_set'] ?? null;
        if ($sessionToken && $tokenData) {
            $expiresAt = $tokenData['expires_at'] ? new \DateTimeImmutable($tokenData['expires_at']) : null;
            $tokenSet = new TokenSet(
                accessToken: $tokenData['access_token'],
                refreshToken: $tokenData['refresh_token'] ?? null,
                tokenType: $tokenData['token_type'] ?? 'Bearer',
                expiresIn: $expiresAt ? (int) $expiresAt->format('U') - time() : 0,
                expiresAt: $expiresAt,
            );
            $dbStore = new DbTokenStore(app()->thin_builder);
            $dbStore->put($sessionToken, $tokenSet, $localUserId);
            unset($_SESSION['shserv_last_token_set']);
        }

        // Redirect back to app
        $returnTo = $context['return_to'] ?? '/';
        $returnTo = $this->sanitizeReturnTo($returnTo);

        return $this->utils()->redirect($returnTo);
    }

    /**
     * Prevent open redirect: allow only same-origin relative paths or absolute URLs.
     */
    private function sanitizeReturnTo(string $returnTo): string
    {
        if (str_starts_with($returnTo, '/')) {
            return $returnTo;
        }

        if (str_starts_with($returnTo, 'http://') || str_starts_with($returnTo, 'https://')) {
            $host = parse_url($returnTo, PHP_URL_HOST);
            $allowedHost = $_SERVER['HTTP_HOST'] ?? '';
            if ($host && $host === $allowedHost) {
                return $returnTo;
            }
        }

        return '/';
    }

    /**
     * POST /auth/logout
     */
    public function logout()
    {
        $sessionToken = $_SESSION['shserv_auth_token'] ?? null;
        $currentUser = $this->get_current_user();
        $userId = $currentUser['id'] ?? null;

        if (!$sessionToken) {
            $bearer = $this->get_bearer_token();
            if ($bearer) {
                $tb = app()->thin_builder;
                $result = $tb->select('shserv_sessions', ['session_token'], [['access_token', '=', $bearer]]);
                if ($result) {
                    $sessionToken = $result[0]['session_token'];
                }
            }
        }

        if ($sessionToken) {
            $service = new AuthService();
            $dbStore = new DbTokenStore(app()->thin_builder);
            $tokenSet = $dbStore->get($sessionToken);
            if ($tokenSet && $tokenSet->refreshToken) {
                try {
                    $service->getClient()->revokeToken($tokenSet->refreshToken, 'refresh_token');
                } catch (\Throwable $e) {
                    logging()->warn('php:Auth', 'Token revoke failed during logout', [
                        'user_id' => $userId,
                        'message' => $e->getMessage(),
                    ]);
                }
            }
            $dbStore->forget($sessionToken);
        }

        if (session_status() === PHP_SESSION_NONE) {
            @session_start();
        }

        unset(
            $_SESSION['shserv_auth_token'],
            $_SESSION['shserv_access_token'],
            $_SESSION['shserv_user_id'],
            $_SESSION['gauth_state'],
            $_SESSION['gauth_pkce']
        );

        // Destroy session cookie so the browser stops sending it
        if (ini_get('session.use_cookies')) {
            $params = session_get_cookie_params();
            setcookie(
                session_name(),
                '',
                time() - 42000,
                $params['path'],
                $params['domain'],
                $params['secure'],
                $params['httponly']
            );
        }

        session_destroy();

        logging()->info('php:Auth', 'Logout', ['user_id' => $userId]);

        return $this->utils()->response_success();
    }

    /**
     * GET /auth/me
     * Return current authenticated user + effective permissions.
     */
    public function me()
    {
        $user = $this->get_current_user();
        if (!$user) {
            return $this->utils()->response_error('not_found_any_sessions', [], [], 401);
        }

        logging()->trace('php:Auth', 'Auth me request', ['user_id' => $user['id']]);

        $permissions = $this->get_current_permissions();

        $gauthBase = FCONF['gauth']['base_url'] ?? '';

        return $this->utils()->response_success([
            'user' => [
                'id' => $user['id'],
                'gauth_user_id' => $user['gauth_user_id'],
                'email' => $user['email'],
                'display_name' => $user['display_name'],
                'avatar_url' => $user['avatar_url'],
                'system_role' => $user['system_role'],
                'status' => $user['status'],
                'gauth_profile_url' => $gauthBase ? rtrim($gauthBase, '/') . '/account/profile' : '',
            ],
            'permissions' => $permissions,
        ]);
    }

    /**
     * Check rate limit for auth endpoints. Returns error response if exceeded.
     */
    private function checkAuthRateLimit(string $action): ?string
    {
        $limiter = new RateLimiter('shserv_auth_', 10, 60);
        $clientIp = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
        if (!$limiter->check($action . ':' . $clientIp)) {
            logging()->warn('php:Auth', 'Auth rate limit exceeded', [
                'action' => $action,
                'client_ip' => $clientIp,
            ]);
            return $this->utils()->response_error('rate_limit', [], [], 429);
        }
        return null;
    }

    /**
     * POST /auth/refresh
     * Refresh access token using stored refresh token.
     */
    public function refresh()
    {
        $sessionToken = $_SESSION['shserv_auth_token'] ?? null;

        if (!$sessionToken) {
            $bearer = $this->get_bearer_token();
            if ($bearer) {
                $tb = app()->thin_builder;
                $result = $tb->select('shserv_sessions', ['session_token'], [['access_token', '=', $bearer]]);
                if ($result) {
                    $sessionToken = $result[0]['session_token'];
                }
            }
        }

        if (!$sessionToken) {
            logging()->warn('php:Auth', 'Token refresh failed: no session', [
                'client_ip' => $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0',
            ]);
            return $this->utils()->response_error('not_found_any_sessions', [], [], 401);
        }

        $service = new AuthService();
        $tokenSet = $service->refreshAccessToken($sessionToken);

        if (!$tokenSet) {
            logging()->warn('php:Auth', 'Token refresh failed: session expired', [
                'session_token' => substr($sessionToken, 0, 8) . '...',
            ]);
            return $this->utils()->response_error('session_expired', [], [], 401);
        }

        logging()->info('php:Auth', 'Token refresh successful', [
            'expires_in' => $tokenSet->expiresIn,
        ]);

        return $this->utils()->response_success([
            'access_token' => $tokenSet->accessToken,
            'expires_in' => $tokenSet->expiresIn,
        ]);
    }

    /**
     * GET /auth/mobile-bridge
     * Renders a page that extracts the token from session and redirects back to the mobile app.
     * Used by the Capacitor mobile app after OAuth login.
     */
    public function mobileBridge()
    {
        if (session_status() === PHP_SESSION_NONE) {
            @session_start();
        }

        $sessionToken = $_SESSION['shserv_auth_token'] ?? null;
        if (!$sessionToken) {
            return $this->utils()->redirect('/auth/login');
        }

        $service = new AuthService();
        $tokenSet = $service->refreshAccessToken($sessionToken);

        if (!$tokenSet) {
            return $this->utils()->redirect('/auth/login');
        }

        $token = $tokenSet->accessToken;
        $expiresIn = $tokenSet->expiresIn;

        $tokenEsc = htmlspecialchars($token, ENT_QUOTES, 'UTF-8');

        $intentUrl = 'intent://auth/callback?token=' . $tokenEsc . '&expires_in=' . (int)$expiresIn . '#Intent;scheme=shserv;package=com.gnexus.shserv;end';

        return '<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Smart Home — Authentication Complete</title>
    <style>
        :root {
            /* gnexus-ui-kit palette (extracted from kit.css) */
            --gn-page: #16161e;
            --gn-card: #1f2335;
            --gn-primary: #c0caf5;
            --gn-text: #c0caf5;
            --gn-text-medium: #a9b1d6;
            --gn-text-muted: #787c99;
            --gn-success: #9ece6a;
            --gn-danger: #f7768e;
            --gn-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
        }
        * { box-sizing: border-box; }
        body {
            font-family: var(--gn-font);
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            min-height: 100vh;
            margin: 0;
            background: var(--gn-page);
            color: var(--gn-text);
        }
        .card {
            position: relative;
            max-width: 340px;
            width: 90%;
            overflow: hidden;
            background: var(--gn-card);
            border: 2px solid var(--gn-primary);
        }
        .card .card-title {
            color: var(--gn-page);
            background: var(--gn-primary);
            padding: 8px 12px;
            font-weight: 700;
            text-transform: uppercase;
            font-size: 14px;
            letter-spacing: 0.05em;
            margin: 0;
        }
        .card .card-content {
            padding: 22px 18px;
            text-align: center;
        }
        .card .card-content p {
            color: var(--gn-text-medium);
            font-size: 13px;
            margin: 0;
            line-height: 1.5;
            min-height: 42px;
        }
        .card .card-footer {
            padding: 0 18px 18px;
            text-align: center;
        }
        /* gnexus-ui-kit .btn-primary */
        .btn {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            min-height: 40px;
            font-family: var(--gn-font);
            font-size: 14px;
            font-weight: 600;
            text-transform: uppercase;
            letter-spacing: 0.05em;
            color: var(--gn-primary);
            text-decoration: none;
            padding: 8px 18px;
            border: 2px solid var(--gn-primary);
            background: transparent;
            cursor: pointer;
            transition: background-color .15s, color .15s;
        }
        .btn:hover, .btn:active {
            background-color: var(--gn-primary);
            color: var(--gn-page);
        }
        .hidden { display: none; }
        .spinner {
            width: 18px;
            height: 18px;
            border: 2px solid var(--gn-text-muted);
            border-top-color: var(--gn-primary);
            border-radius: 50%;
            animation: spin 1s linear infinite;
            margin: 0 auto 10px;
            display: inline-block;
            vertical-align: middle;
        }
        @keyframes spin { to { transform: rotate(360deg); } }
    </style>
</head>
<body>
    <div class="card">
        <div class="card-title">Authentication complete</div>
        <div class="card-content">
            <p id="status"><span class="spinner"></span>Opening Smart Home app…</p>
        </div>
        <div class="card-footer">
            <a id="btn" class="btn hidden" href="' . $intentUrl . '">Open App</a>
        </div>
    </div>
    <script>
        (function() {
            var appUrl = "intent://auth/callback?token=" + encodeURIComponent("' . $tokenEsc . '") + "&expires_in=' . (int)$expiresIn . '#Intent;scheme=shserv;package=com.gnexus.shserv;end";
            window.location.href = appUrl;
            setTimeout(function() {
                document.getElementById("status").innerHTML = "Tap below to continue back to the app";
                document.getElementById("btn").classList.remove("hidden");
            }, 1500);
        })();
    </script>
</body>
</html>';
    }
}