Your IP : 216.73.217.79


Current Path : /var/www/v3.cesa.co.za/src/UserBundle/Service/
Upload File :
Current File : /var/www/v3.cesa.co.za/src/UserBundle/Service/Echasl23SsoTicketService.php

<?php

namespace App\UserBundle\Service;

use App\UserBundle\Entity\UserOld;
use App\UserBundle\Repository\UserOldRepository;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;

/**
 * Short-lived HMAC tickets for echasl23 → cesa-v3 SSO (server-side proxy).
 */
final class Echasl23SsoTicketService
{
    private const DEFAULT_TTL_SECONDS = 120;

    public function __construct(
        private readonly UserOldRepository $userOldRepository,
        private readonly Echasl23UserService $echasl23UserService,
        private readonly TokenStorageInterface $tokenStorage,
        private readonly string $sharedSecret = '',
    ) {
    }

    public static function resolveSharedSecret(): string
    {
        return (string) (
            $_ENV['ECHASL23_SSO_SECRET']
            ?? $_SERVER['ECHASL23_SSO_SECRET']
            ?? getenv('ECHASL23_SSO_SECRET')
            ?? ''
        );
    }

    /**
     * Mint a ticket for an echasl23 username (used by cesa-main proxy; also usable in tests).
     */
    public function mint(string $username, ?int $ttlSeconds = null): string
    {
        $secret = $this->sharedSecret !== '' ? $this->sharedSecret : self::resolveSharedSecret();
        if ($secret === '') {
            throw new \RuntimeException('ECHASL23_SSO_SECRET is not configured.');
        }

        $username = trim($username);
        if ($username === '') {
            throw new \InvalidArgumentException('Username is required to mint an SSO ticket.');
        }

        $ttl = $ttlSeconds ?? self::DEFAULT_TTL_SECONDS;
        if ($ttl <= 0) {
            $ttl = self::DEFAULT_TTL_SECONDS;
        }

        $payload = [
            'u' => $username,
            'exp' => time() + $ttl,
            'n' => bin2hex(random_bytes(8)),
        ];
        $payloadJson = json_encode($payload, JSON_UNESCAPED_SLASHES);
        if ($payloadJson === false) {
            throw new \RuntimeException('Could not encode SSO ticket payload.');
        }

        $payloadB64 = rtrim(strtr(base64_encode($payloadJson), '+/', '-_'), '=');
        $sig = hash_hmac('sha256', $payloadB64, $secret);

        return $payloadB64 . '.' . $sig;
    }

    /**
     * @return array{username: string, exp: int, nonce: string}|null
     */
    public function parseAndVerify(string $ticket): ?array
    {
        $secret = $this->sharedSecret !== '' ? $this->sharedSecret : self::resolveSharedSecret();
        if ($secret === '' || $ticket === '' || !str_contains($ticket, '.')) {
            return null;
        }

        [$payloadB64, $sig] = explode('.', $ticket, 2);
        if ($payloadB64 === '' || $sig === '') {
            return null;
        }

        $expected = hash_hmac('sha256', $payloadB64, $secret);
        if (!hash_equals($expected, $sig)) {
            return null;
        }

        $json = base64_decode(strtr($payloadB64, '-_', '+/'), true);
        if ($json === false) {
            return null;
        }

        $payload = json_decode($json, true);
        if (!is_array($payload)) {
            return null;
        }

        $username = isset($payload['u']) ? trim((string) $payload['u']) : '';
        $exp = isset($payload['exp']) ? (int) $payload['exp'] : 0;
        $nonce = isset($payload['n']) ? (string) $payload['n'] : '';

        if ($username === '' || $exp < time()) {
            return null;
        }

        return [
            'username' => $username,
            'exp' => $exp,
            'nonce' => $nonce,
        ];
    }

    public function authenticateFromTicket(string $ticket): ?UserOld
    {
        $result = $this->authenticateFromTicketDetailed($ticket);

        return $result['user'];
    }

    /**
     * @return array{user: ?UserOld, error: string}
     */
    public function authenticateFromTicketDetailed(string $ticket): array
    {
        $secret = $this->sharedSecret !== '' ? $this->sharedSecret : self::resolveSharedSecret();
        if ($secret === '') {
            return ['user' => null, 'error' => 'ECHASL23_SSO_SECRET is not configured on the application server.'];
        }

        $parsed = $this->parseAndVerify($ticket);
        if ($parsed === null) {
            if ($ticket === '' || !str_contains($ticket, '.')) {
                return ['user' => null, 'error' => 'Malformed SSO ticket.'];
            }

            [$payloadB64, $sig] = explode('.', $ticket, 2);
            $expected = hash_hmac('sha256', $payloadB64, $secret);
            if ($payloadB64 === '' || $sig === '' || !hash_equals($expected, $sig)) {
                return ['user' => null, 'error' => 'Invalid SSO ticket signature (shared secret mismatch between cesa-main and cesa-v3).'];
            }

            $json = base64_decode(strtr($payloadB64, '-_', '+/'), true);
            $payload = is_string($json) ? json_decode($json, true) : null;
            $exp = is_array($payload) && isset($payload['exp']) ? (int) $payload['exp'] : 0;
            if ($exp > 0 && $exp < time()) {
                return ['user' => null, 'error' => 'SSO ticket expired.'];
            }

            return ['user' => null, 'error' => 'Invalid or unreadable SSO ticket.'];
        }

        $user = $this->userOldRepository->findOneBy(['username' => $parsed['username']]);
        if (!$user instanceof UserOld) {
            return [
                'user' => null,
                'error' => 'SSO user "' . $parsed['username'] . '" was not found in cesa-v3 (UserOld / echasl23 users).',
            ];
        }
        if (!$user->isActivated()) {
            return [
                'user' => null,
                'error' => 'SSO user "' . $parsed['username'] . '" exists in cesa-v3 but is not activated.',
            ];
        }

        $roles = $this->echasl23UserService->resolveRoles($user);
        if ($roles === ['NONE']) {
            return [
                'user' => null,
                'error' => 'SSO user "' . $parsed['username'] . '" has no usable roles in cesa-v3.',
            ];
        }

        $user->setResolvedRoles($roles);
        $token = new UsernamePasswordToken($user, 'main', $roles);
        $this->tokenStorage->setToken($token);

        return ['user' => $user, 'error' => ''];
    }
}