<?php

declare(strict_types=1);

/*
 * Copyright (c) 2023-2024 François Kooman <fkooman@tuxed.net>
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

require_once __DIR__ . '/autoload.php';

use fkooman\Radius\MessageAuthenticator;
use fkooman\Radius\Password;
use fkooman\Radius\RadiusPacket;
use fkooman\Radius\ResponseAuthenticator;
use fkooman\Radius\Utils;

$userDb = [
    // hash the passwords on server start, which introduces a (slight) delay
    // when verifying passwords, which is more realistic
    'alice' => [password_hash('alic3', PASSWORD_DEFAULT, ['cost' => 12]), null],
    'bob' => [password_hash('b0b', PASSWORD_DEFAULT, ['cost' => 12]), '123456'],
];
$sharedSecret = 's3cr3t';

$enableMA = true;
$requireMA = true;
$listenAddress = '127.0.0.1:1812';

for ($i = 1; $i < $argc; $i++) {
    if ('-n' === $argv[$i]) {
        // do not set Message-Authenticator for responses to client
        $enableMA = false;
    }
    if ('-r' === $argv[$i]) {
        // do not require the client to set Message-Authenticator
        $requireMA = false;
    }
    if ('-l' === $argv[$i] || '--listen' === $argv[$i]) {
        if ($i + 1 < $argc) {
            $listenAddress = $argv[++$i];
        }
    }
}

error_log('Listen Address:                          ' . $listenAddress);
error_log('Set Response "Message-Authenticator":    ' . ($enableMA ? 'Yes' : 'No'));
error_log('Require Request "Message-Authenticator": ' . ($requireMA ? 'Yes' : 'No'));

$socket = stream_socket_server(sprintf('udp://%s', $listenAddress), $errno, $errstr, STREAM_SERVER_BIND);
if (!$socket) {
    die('unable to start socket');
}

error_log('*** READY ***');

/**
 * @param resource $socket
 */
function sendPacket($socket, string $peerAddress, RadiusPacket $r, string $sharedSecret, bool $enableMA): void
{
    if ($enableMA) {
        $r->attributeCollection()->set('Message-Authenticator', MessageAuthenticator::calculate($r, $sharedSecret));
    }
    $r->setPacketAuthenticator(ResponseAuthenticator::calculate($r, $sharedSecret, $r->packetAuthenticator()));
    if (false === stream_socket_sendto($socket, $r->toBytes(), 0, $peerAddress)) {
        error_log('unable to send packet');

        return;
    }
}

while (true) {
    if (false === $packetData = stream_socket_recvfrom($socket, 20, STREAM_PEEK, $peerAddress)) {
        continue;
    }
    $packetLength = Utils::bytesToShort(Utils::safeSubstr($packetData, 2, 2));
    if (false === $packetData = stream_socket_recvfrom($socket, $packetLength, 0, $peerAddress)) {
        continue;
    }
    $radiusPacket = RadiusPacket::fromBytes($packetData);

    if (false === $maResponse = MessageAuthenticator::verify($radiusPacket, $sharedSecret)) {
        error_log('Message-Authenticator from client did not verify');

        continue;
    }
    if (null === $maResponse && $requireMA) {
        error_log('Message-Authenticator not set by client, but required');

        continue;
    }

    if (!$radiusPacket->isAccessRequest()) {
        error_log('not an Access-Request');

        continue;
    }

    $userName = $radiusPacket->attributeCollection()->requireOne('User-Name');
    $encryptedUserPass = $radiusPacket->attributeCollection()->requireOne('User-Password');
    $userPass = Password::decrypt($encryptedUserPass, $radiusPacket->packetAuthenticator(), $sharedSecret);

    if (!array_key_exists($userName, $userDb)) {
        $r = new RadiusPacket(RadiusPacket::ACCESS_REJECT, $radiusPacket->packetId(), $radiusPacket->packetAuthenticator());
        $r->attributeCollection()->set('Reply-Message', 'No Such User');
        sendPacket($socket, $peerAddress, $r, $sharedSecret, $enableMA);

        continue;
    }

    if (null !== $stateValue = $radiusPacket->attributeCollection()->getOne('State')) {
        // resuming existing Accept-Challenge
        $stateFile = sprintf('%s/%s.state', sys_get_temp_dir(), bin2hex($stateValue));
        if (false === $stateUserName = file_get_contents($stateFile)) {
            $r = new RadiusPacket(RadiusPacket::ACCESS_REJECT, $radiusPacket->packetId(), $radiusPacket->packetAuthenticator());
            $r->attributeCollection()->set('Reply-Message', 'No Such State :-(');
            sendPacket($socket, $peerAddress, $r, $sharedSecret, $enableMA);

            continue;
        }

        if ($userName !== $stateUserName) {
            $r = new RadiusPacket(RadiusPacket::ACCESS_REJECT, $radiusPacket->packetId(), $radiusPacket->packetAuthenticator());
            $r->attributeCollection()->set('Reply-Message', 'Not Our State :-(');
            sendPacket($socket, $peerAddress, $r, $sharedSecret, $enableMA);
            unlink($stateFile);

            continue;
        }

        $userOtp = $userDb[$userName][1];
        if ($userPass !== $userOtp) {
            $r = new RadiusPacket(RadiusPacket::ACCESS_REJECT, $radiusPacket->packetId(), $radiusPacket->packetAuthenticator());
            $r->attributeCollection()->set('Reply-Message', 'Invalid OTP :-(');
            sendPacket($socket, $peerAddress, $r, $sharedSecret, $enableMA);
            unlink($stateFile);

            continue;
        }

        $r = new RadiusPacket(RadiusPacket::ACCESS_ACCEPT, $radiusPacket->packetId(), $radiusPacket->packetAuthenticator());
        $r->attributeCollection()->set('Reply-Message', sprintf('Welcome >>> %s <<< !', $userName));
        $r->attributeCollection()->add('Reply-Message', 'OTP Accepted!');
        sendPacket($socket, $peerAddress, $r, $sharedSecret, $enableMA);
        unlink($stateFile);

        continue;
    }

    if (!password_verify($userPass, $userDb[$userName][0])) {
        $r = new RadiusPacket(RadiusPacket::ACCESS_REJECT, $radiusPacket->packetId(), $radiusPacket->packetAuthenticator());
        $r->attributeCollection()->set('Reply-Message', 'Invalid Password');
        sendPacket($socket, $peerAddress, $r, $sharedSecret, $enableMA);

        continue;
    }

    if (null !== $userDb[$userName][1]) {
        $r = new RadiusPacket(RadiusPacket::ACCESS_CHALLENGE, $radiusPacket->packetId(), $radiusPacket->packetAuthenticator());
        $r->attributeCollection()->set('Reply-Message', 'Provide your OTP');
        $stateValue = random_bytes(16);
        $r->attributeCollection()->set('State', $stateValue);
        $stateFile = sprintf('%s/%s.state', sys_get_temp_dir(), bin2hex($stateValue));
        file_put_contents($stateFile, $userName);
        sendPacket($socket, $peerAddress, $r, $sharedSecret, $enableMA);

        continue;
    }

    $r = new RadiusPacket(RadiusPacket::ACCESS_ACCEPT, $radiusPacket->packetId(), $radiusPacket->packetAuthenticator());
    $r->attributeCollection()->set('Reply-Message', sprintf('Welcome >>> %s <<< !', $userName));
    sendPacket($socket, $peerAddress, $r, $sharedSecret, $enableMA);
}
