| Current Path : /var/www/v3.cesa.co.za/src/UserBundle/Service/ |
| 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' => ''];
}
}