<?php

declare(strict_types=1);

/*
 * eduVPN - End-user friendly VPN.
 *
 * Copyright: 2014-2023, The Commons Conservancy eduVPN Programme
 * SPDX-License-Identifier: AGPL-3.0+
 */

namespace Vpn\Portal\Http\Auth;

use fkooman\Radius\Exception\AuthException;
use fkooman\Radius\RadiusClient;
use RuntimeException;
use Vpn\Portal\Cfg\RadiusAuthConfig;
use Vpn\Portal\Http\Auth\Exception\CredentialValidatorException;
use Vpn\Portal\Http\Auth\Exception\RadiusException;
use Vpn\Portal\Http\UserInfo;
use Vpn\Portal\LoggerInterface;

class RadiusCredentialValidator implements CredentialValidatorInterface
{
    private LoggerInterface $logger;
    private RadiusAuthConfig $radiusAuthConfig;

    public function __construct(LoggerInterface $logger, RadiusAuthConfig $radiusAuthConfig)
    {
        $this->logger = $logger;
        $this->radiusAuthConfig = $radiusAuthConfig;
    }

    /**
     * @throws \Vpn\Portal\Http\Auth\Exception\CredentialValidatorException
     */
    public function validate(string $authUser, string $authPass): UserInfo
    {
        // make sure authUser does not contain "\x00" string
        if (false !== strpos($authUser, "\x00")) {
            throw new CredentialValidatorException('"authUser" contains "\x00"');
        }

        // add realm when requested
        if (null !== $radiusRealm = $this->radiusAuthConfig->radiusRealm()) {
            $authUser = sprintf('%s@%s', $authUser, $radiusRealm);
        }

        if (extension_loaded('radius')) {
            return $this->peclRadius($authUser, $authPass);
        }

        return $this->fkoomanRadius($authUser, $authPass);
    }

    /**
     * @psalm-suppress UndefinedConstant
     */
    private function peclRadius(string $authUser, string $authPass): UserInfo
    {
        if (0 === count($this->radiusAuthConfig->serverList())) {
            throw new RuntimeException('no RADIUS servers configured');
        }
        $radiusAuth = radius_auth_open();
        foreach ($this->radiusAuthConfig->serverList() as $radiusServer) {
            if (false === radius_add_server(
                $radiusAuth,
                $radiusServer['radiusHost'],
                $radiusServer['radiusPort'],
                $radiusServer['radiusSecret'],
                5,  // timeout
                3   // max_tries
            )) {
                $errorMsg = sprintf('RADIUS error: %s', radius_strerror($radiusAuth));
                $this->logger->error($errorMsg);

                throw new RadiusException($errorMsg);
            }
        }

        if (false === radius_create_request($radiusAuth, RADIUS_ACCESS_REQUEST)) {
            $errorMsg = sprintf('RADIUS error: %s', radius_strerror($radiusAuth));
            $this->logger->error($errorMsg);

            throw new RadiusException($errorMsg);
        }

        radius_put_attr($radiusAuth, RADIUS_USER_NAME, $authUser);
        radius_put_attr($radiusAuth, RADIUS_USER_PASSWORD, $authPass);
        if (null !== $nasIdentifier = $this->radiusAuthConfig->nasIdentifier()) {
            radius_put_attr($radiusAuth, RADIUS_NAS_IDENTIFIER, $nasIdentifier);
        }

        $radiusResponse = radius_send_request($radiusAuth);
        if (false === $radiusResponse) {
            $errorMsg = sprintf('RADIUS error: %s', radius_strerror($radiusAuth));
            $this->logger->error($errorMsg);

            throw new RadiusException($errorMsg);
        }

        if (RADIUS_ACCESS_ACCEPT !== $radiusResponse) {
            // most likely wrong authUser/authPass, not necessarily an error
            throw new CredentialValidatorException('authentication not accepted');
        }

        $permissionList = [];
        if (null !== $permissionAttribute = $this->radiusAuthConfig->permissionAttribute()) {
            // find the authorization attribute and use its value
            while ($radiusAttribute = radius_get_attr($radiusAuth)) {
                if (!\is_array($radiusAttribute)) {
                    continue;
                }
                if ($permissionAttribute !== $radiusAttribute['attr']) {
                    continue;
                }
                $permissionList[] = $radiusAttribute['data'];
            }
        }

        return new UserInfo($authUser, $permissionList);
    }

    private function fkoomanRadius(string $authUser, string $authPass): UserInfo
    {
        if (0 === count($this->radiusAuthConfig->serverList())) {
            throw new RuntimeException('no (valid) RADIUS servers configured');
        }

        $nasIdentifier = $this->radiusAuthConfig->nasIdentifier() ?? 'vpn';
        $radiusAuth = new RadiusClient($nasIdentifier);
        foreach ($this->radiusAuthConfig->serverList() as $radiusServer) {
            $radiusAuth->addServer($radiusServer['radiusHost'], $radiusServer['radiusSecret'], $radiusServer['radiusPort']);
        }

        try {
            $accessResponse = $radiusAuth->accessRequest($authUser, $authPass);
            $permissionList = [];

            // if config asked for a permission attribute, we check whether it
            // is available
            if (null !== $permissionAttribute = $this->radiusAuthConfig->permissionAttribute()) {
                if (null !== $attributeValue = $accessResponse->attributeByType($permissionAttribute)) {
                    $permissionList[] = $attributeValue;
                }
            }

            // if the server provides us with a normalized User-Name, we use
            // it. Might be relevant for LDAP backends which might be case
            // insenstive
            if (null !== $userName = $accessResponse->attributeByName('User-Name')) {
                $authUser = $userName;
            }

            return new UserInfo($authUser, $permissionList);
        } catch (AuthException $e) {
            throw new CredentialValidatorException('authentication not accepted');
        }
    }
}
