core/UserAPI.php

Summary

Maintainability
D
2 days
Test Coverage
<?php

/*
 * *****************************************************************************
 * Contributions to this work were made on behalf of the GÉANT project, a 
 * project that has received funding from the European Union’s Framework 
 * Programme 7 under Grant Agreements No. 238875 (GN3) and No. 605243 (GN3plus),
 * Horizon 2020 research and innovation programme under Grant Agreements No. 
 * 691567 (GN4-1) and No. 731122 (GN4-2).
 * On behalf of the aforementioned projects, GEANT Association is the sole owner
 * of the copyright in all material which was developed by a member of the GÉANT
 * project. GÉANT Vereniging (Association) is registered with the Chamber of 
 * Commerce in Amsterdam with registration number 40535155 and operates in the 
 * UK as a branch of GÉANT Vereniging.
 * 
 * Registered office: Hoekenrode 3, 1102BR Amsterdam, The Netherlands. 
 * UK branch address: City House, 126-130 Hills Road, Cambridge CB2 1PQ, UK
 *
 * License: see the web/copyright.inc.php file in the file structure or
 *          <base_url>/copyright.php after deploying the software
 */

/**
 * This is the collection of methods dedicated for the user GUI
 * @author Tomasz Wolniewicz <twoln@umk.pl>
 * @author Stefan Winter <stefan.winter@restena.lu>
 * @package UserAPI
 *
 * Parts of this code are based on simpleSAMLPhp discojuice module.
 * This product includes GeoLite data created by MaxMind, available from
 * http://www.maxmind.com
 */

namespace core;

use \Exception;

/**
 * The basic methoods for the user GUI
 * @package UserAPI
 *
 */
class UserAPI extends CAT
{

    /**
     * nothing special to be done here.
     */
    public function __construct()
    {
        parent::__construct();
    }

    /**
     * Prepare the device module environment and send back the link
     * This method creates a device module instance via the {@link DeviceFactory} call, 
     * then sets up the device module environment for the specific profile by calling 
     * {@link DeviceConfig::setup()} method and finally, called the device writeInstaller method
     * passing the returned path name.
     * 
     * @param string $device       identifier as in {@link devices.php}
     * @param int    $profileId    profile identifier
     * @param string $generatedFor which download area does this pertain to
     * @param string $token        for silverbullet: invitation token to consume
     * @param string $password     for silverbull: import PIN for the future certificate
     *
     * @return array|NULL array with the following fields: 
     *  profile - the profile identifier; 
     *  device - the device identifier; 
     *  link - the path name of the resulting installer
     *  mime - the mimetype of the installer
     */
    public function generateInstaller($device, $profileId, $generatedFor = "user", $openRoaming = 0, $token = NULL, $password = NULL)
    {
        $this->loggerInstance->debug(4, "generateInstaller arguments:$device:$profileId:$openRoaming\n");
        $validator = new \web\lib\common\InputValidation();
        $profile = $validator->existingProfile($profileId);
        // test if the profile is production-ready and if not if the authenticated user is an owner
        if ($this->verifyDownloadAccess($profile) === FALSE) {
            return;
        }
        $installerProperties = [];
        $installerProperties['profile'] = $profileId;
        $installerProperties['device'] = $device;
        $cache = $this->getCache($device, $profile, $openRoaming);
        $this->installerPath = $cache['path'];
        if ($this->installerPath !== NULL && $token === NULL && $password === NULL) {
            $this->loggerInstance->debug(4, "Using cached installer for: $device\n");
            $installerProperties['link'] = "user/API.php?action=downloadInstaller&lang=".$this->languageInstance->getLang()."&profile=$profileId&device=$device&generatedfor=$generatedFor&openroaming=$openRoaming";
            $installerProperties['mime'] = $cache['mime'];
        } else {
            $myInstaller = $this->generateNewInstaller($device, $profile, $generatedFor, $openRoaming, $token, $password);
            if ($myInstaller['link'] !== 0) {
                $installerProperties['mime'] = $myInstaller['mime'];
            }
            $installerProperties['link'] = $myInstaller['link'];
        }
        return $installerProperties;
    }

    /**
     * checks whether the requested profile data is public, XOR was requested by
     * its own admin.
     * @param \core\AbstractProfile $profile the profile in question
     * @return boolean
     */
    private function verifyDownloadAccess($profile)
    {
        $attribs = $profile->getCollapsedAttributes();
        if (\core\common\Entity::getAttributeValue($attribs, 'profile:production', 0) !== 'on') {
            $this->loggerInstance->debug(4, "Attempt to download a non-production ready installer for profile: $profile->identifier\n");
            $auth = new \web\lib\admin\Authentication();
            if (!$auth->isAuthenticated()) {
                $this->loggerInstance->debug(2, "User NOT authenticated, rejecting request for a non-production installer\n");
                header("HTTP/1.0 403 Not Authorized");
                return FALSE;
            }
            $auth->authenticate();
            $userObject = new User($_SESSION['user']);
            if (!$userObject->isIdPOwner($profile->institution)) {
                $this->loggerInstance->debug(2, "User not an owner of a non-production profile - access forbidden\n");
                header("HTTP/1.0 403 Not Authorized");
                return FALSE;
            }
            $this->loggerInstance->debug(4, "User is the owner - allowing access\n");
        }
        return TRUE;
    }

    /**
     * This function tries to find a cached copy of an installer for a given
     * combination of Profile and device
     * 
     * @param string          $device  the device for which the installer is searched in cache
     * @param AbstractProfile $profile the profile for which the installer is searched in cache
     * @return array containing path to the installer and mime type of the file, the path is set to NULL if no cache can be returned
     */
    private function getCache($device, $profile, $openRoaming)
    {
        $deviceConfig = \devices\Devices::listDevices()[$device];
        $noCache = (isset(\devices\Devices::$Options['no_cache']) && \devices\Devices::$Options['no_cache']) ? 1 : 0;
        if (isset($deviceConfig['options']['no_cache'])) {
            $noCache = $deviceConfig['options']['no_cache'] ? 1 : 0;
        }
        if ($noCache) {
            $this->loggerInstance->debug(5, "getCache: the no_cache option set for this device\n");
            return ['path' => NULL, 'mime' => NULL];
        }
        $this->loggerInstance->debug(5, "getCache: caching option set for this device\n");
        $cache = $profile->testCache($device, $openRoaming);
        $iPath = $cache['cache'];
        if ($iPath && is_file($iPath)) {
            return ['path' => $iPath, 'mime' => $cache['mime']];
        }
        return ['path' => NULL, 'mime' => NULL];
    }

    /**
     * Generates a new installer for the given combination of device and Profile
     * 
     * @param string          $device       the device for which we want an installer
     * @param AbstractProfile $profile      the profile for which we want an installer
     * @param string          $generatedFor type of download requested (admin/user/silverbullet)
     * @param int             $openRoaming values 0 o 1 to indicate support for open roaming in the installer
     * @param string          $token        in case of silverbullet, the token that was used to trigger the generation
     * @param string          $password     in case of silverbullet, the import PIN for the future client certificate
     * @return array info about the new installer (mime and link)
     */
    private function generateNewInstaller($device, $profile, $generatedFor, $openRoaming, $token, $password)
    {
        $this->loggerInstance->debug(5, "generateNewInstaller() - Enter");
        $this->loggerInstance->debug(5, "generateNewInstaller:openRoaming:$openRoaming\n");
        $factory = new DeviceFactory($device);
        $this->loggerInstance->debug(5, "generateNewInstaller() - created Device");
        $dev = $factory->device;
        $out = [];
        if (isset($dev)) {
            $dev->setup($profile, $token, $password, $openRoaming);
            $this->loggerInstance->debug(5, "generateNewInstaller() - Device setup done");
            $installer = $dev->writeInstaller();
            $this->loggerInstance->debug(5, "generateNewInstaller() - writeInstaller complete");
            $iPath = $dev->FPATH.'/tmp/'.$installer;
            if ($iPath && is_file($iPath)) {
                if (isset($dev->options['mime'])) {
                    $out['mime'] = $dev->options['mime'];
                } else {
                    $info = new \finfo();
                    $out['mime'] = $info->file($iPath, FILEINFO_MIME_TYPE);
                }
                $this->installerPath = $dev->FPATH.'/'.$installer;
                rename($iPath, $this->installerPath);
                $integerEap = (new \core\common\EAP($dev->selectedEap))->getIntegerRep();
                $profile->updateCache($device, $this->installerPath, $out['mime'], $integerEap, $openRoaming);
                if (\config\Master::DEBUG_LEVEL < 4) {
                    \core\common\Entity::rrmdir($dev->FPATH.'/tmp');
                }
                $this->loggerInstance->debug(4, "Generated installer: ".$this->installerPath.": for: $device, EAP:".$integerEap.", openRoaming: $openRoaming\n");
                $out['link'] = "user/API.php?action=downloadInstaller&lang=".$this->languageInstance->getLang()."&profile=".$profile->identifier."&device=$device&generatedfor=$generatedFor&openroaming=$openRoaming";
            } else {
                $this->loggerInstance->debug(2, "Installer generation failed for: ".$profile->identifier.":$device:".$this->languageInstance->getLang()."openRoaming: $openRoaming\n");
                $out['link'] = 0;
            }
        }
        return $out;
    }

    /**
     * interface to Devices::listDevices() 
     * 
     * @param int $showHidden whether or not hidden devices should be shown
     * @return array the list of devices
     * @throws Exception
     */
    public function listDevices($showHidden = 0)
    {
        $returnList = [];
        $count = 0;
        if ($showHidden !== 0 && $showHidden != 1) {
            throw new Exception("show_hidden is only be allowed to be 0 or 1, but it is $showHidden!");
        }
        foreach (\devices\Devices::listDevices() as $device => $deviceProperties) {
            $hidden = \core\common\Entity::getAttributeValue($deviceProperties, 'options', 'hidden');
            if (($hidden === 1 || $hidden === 2) && $showHidden === 0) {
                continue;
            }
            $count++;
            $deviceProperties['device'] = $device;
            $group = isset($deviceProperties['group']) ? $deviceProperties['group'] : 'other';
            if (!isset($returnList[$group])) {
                $returnList[$group] = [];
            }
            $returnList[$group][$device] = $deviceProperties;
        }
        return $returnList;
    }

    /**
     * 
     * @param string $device    identifier of the device
     * @param int    $profileId identifier of the profile
     * @return void
     */
    public function deviceInfo($device, $profileId)
    {
        $validator = new \web\lib\common\InputValidation();
        $out = 0;
        $profile = $validator->existingProfile($profileId);
        $factory = new DeviceFactory($device);
        $dev = $factory->device;
        if (isset($dev)) {
            $dev->setup($profile);
            $out = $dev->writeDeviceInfo();
        }
        echo $out;
    }

    /**
     * Prepare the support data for a given profile
     *
     * @param int $profId profile identifier
     * @return array
     * array with the following fields:
     * - local_email
     * - local_phone
     * - local_url
     * - description
     * - devices - an array of device names and their statuses (for a given profile)
     * - last_changed
     */
    public function profileAttributes($profId)
    {
        $validator = new \web\lib\common\InputValidation();
        $profile = $validator->existingProfile($profId);
        $attribs = $profile->getCollapsedAttributes();
        $returnArray = [];
        $returnArray['silverbullet'] = $profile instanceof ProfileSilverbullet ? 1 : 0;
        if (isset($attribs['support:email'])) {
            $returnArray['local_email'] = $attribs['support:email'][0];
        }
        if (isset($attribs['support:phone'])) {
            $returnArray['local_phone'] = $attribs['support:phone'][0];
        }
        if (isset($attribs['support:url'])) {
            $returnArray['local_url'] = $attribs['support:url'][0];
        }
        if (isset($attribs['profile:description'])) {
            $returnArray['description'] = $attribs['profile:description'][0];
        }
        if (isset($attribs['media:openroaming'])) {
            $returnArray['openroaming'] = $attribs['media:openroaming'][0];
        } else {
            $returnArray['openroaming'] = 'none';
        }
        $returnArray['devices'] = $profile->listDevices();
        $returnArray['last_changed'] = $profile->getFreshness();
        return $returnArray;
    }

    /**
     * Generate and send the installer
     *
     * @param string $device        identifier as in {@link devices.php}
     * @param int    $prof_id       profile identifier
     * @param string $generated_for which download area does this pertain to
     * @param string $token         for silverbullet: invitation token to consume
     * @param string $password      for silverbull: import PIN for the future certificate
     * @return string binary stream: installerFile
     */
    public function downloadInstaller($device, $prof_id, $generated_for = 'user', $openRoaming = 0, $token = NULL, $password = NULL)
    {
        $this->loggerInstance->debug(4, "downloadInstaller arguments: $device,$prof_id,$generated_for, $openRoaming\n");
        $output = $this->generateInstaller($device, $prof_id, $generated_for, $openRoaming, $token, $password);
        $this->loggerInstance->debug(4, "output from GUI::generateInstaller:");
        $this->loggerInstance->debug(4, print_r($output, true));
        if (empty($output['link']) || $output['link'] === 0) {
            header("HTTP/1.0 404 Not Found");
            return;
        }
        $validator = new \web\lib\common\InputValidation();
        $profile = $validator->existingProfile($prof_id);
        $profile->incrementDownloadStats($device, $generated_for, $openRoaming);
        $file = $this->installerPath;
        $filetype = $output['mime'];
        $this->loggerInstance->debug(4, "installer MIME type:$filetype\n");
        header("Content-type: ".$filetype);
        if ($filetype !== "application/x-wifi-config") { // for those installers to work on Android, Content-Disposition MUST NOT be set
            header('Content-Disposition: inline; filename="'.basename($file).'"');
        } else {
            header('Content-Transfer-Encoding: base64');
        }
        header('Content-Length: '.filesize($file));
        ob_clean();
        flush();
        readfile($file);
    }

    /**
     * resizes image files
     * 
     * @param string $inputImage the image we want to process
     * @param string $destFile   the output file for the processed image
     * @param int    $width      if resizing, the target width
     * @param int    $height     if resizing, the target height
     * @param bool   $resize     shall we do resizing? width and height are ignored otherwise
     * @return array
     */
    private function processImage($inputImage, $destFile, $width, $height, $resize)
    {
        $info = new \finfo();
        $filetype = $info->buffer($inputImage, FILEINFO_MIME_TYPE);
        $expiresString = $this->logoExpireTime();
        $blob = $inputImage;

        if ($resize === TRUE) {
            if (class_exists('\\Gmagick')) { 
                $image = new \Gmagick(); 
            } else {
                $image = new \Imagick();
            }
            $image->readImageBlob($inputImage);
            $image->setImageFormat('PNG');
            $image->thumbnailImage($width, $height, 1);
            $blob = $image->getImageBlob();
            $this->loggerInstance->debug(4, "Writing cached logo $destFile for IdP/Federation.\n");
            file_put_contents($destFile, $blob);
        }

        return ["filetype" => $filetype, "expires" => $expiresString, "blob" => $blob];
    }

    protected function logoExpireTime()
    {
        $offset = 60 * 60 * 24 * 30;
        // gmdate cannot fail here - time() is its default argument (and integer), and we are adding an integer to it
        return("Expires: "./** @scrutinizer ignore-type */ gmdate("D, d M Y H:i:s", time() + $offset)." GMT");
    }
    /**
     * Get and prepare logo file 
     *
     * When called for DiscoJuice, first check if file cache exists
     * If not then generate the file and save it in the cache
     * @param int|string $identifier IdP or Federation identifier
     * @param string     $type       either 'idp' or 'federation' is allowed 
     * @param integer    $widthIn    maximum width of the generated image - if 0 then it is treated as no upper bound
     * @param integer    $heightIn   maximum height of the generated image - if 0 then it is treated as no upper bound
     * @return array|null array with image information or NULL if there is no logo
     * @throws Exception
     */
    protected function getLogo($identifier, $type, $widthIn, $heightIn)
    {
        $expiresString = '';
        $attributeName = [
            'federation' => "fed:logo_file",
            'federation_from_idp' => "fed:logo_file",
            'idp' => "general:logo_file",
        ];

        $logoFile = "";
        $validator = new \web\lib\common\InputValidation();
        switch ($type) {
            case "federation":
                $entity = $validator->existingFederation($identifier);
                break;
            case "idp":
                $entity = $validator->existingIdP($identifier);
                break;
            case "federation_from_idp":
                $idp = $validator->existingIdP($identifier);
                $entity = $validator->existingFederation($idp->federation);
                break;
            default:
                throw new Exception("Unknown type of logo requested!");
        }
        $filetype = 'image/png'; // default, only one code path where it can become different
        list($width, $height, $resize) = $this->testForResize($widthIn, $heightIn);
        if ($resize) {
            $logoFile = ROOT.'/web/downloads/logos/'.$identifier.'_'.$width.'_'.$height.'.png';
        }
        if (is_file($logoFile)) { // $logoFile could be an empty string but then we will get a FALSE
            $this->loggerInstance->debug(4, "Using cached logo $logoFile for: $identifier\n");
            $blob = file_get_contents($logoFile);
        } else {
            $logoAttribute = $entity->getAttributes($attributeName[$type]);
            if (count($logoAttribute) == 0) {
                $blob = file_get_contents(ROOT.'/web/resources/images/empty.png');
                $expiresString = $this->logoExpireTime();
            } else {
                $this->loggerInstance->debug(4, "RESIZE:$width:$height\n");
                $meta = $this->processImage($logoAttribute[0]['value'], $logoFile, $width, $height, $resize);
                $filetype = $meta['filetype'];
                $expiresString = $meta['expires'];
                $blob = $meta['blob'];
            }
        }
        return ["filetype" => $filetype, "expires" => $expiresString, "blob" => $blob];
    }

    /**
     * see if we have to resize an image
     * 
     * @param integer $width  the desired max width (0 = unbounded)
     * @param integer $height the desired max height (0 = unbounded)
     * @return array
     */
    private function testForResize($width, $height)
    {
        if (is_numeric($width) && is_numeric($height) && ($width > 0 || $height > 0)) {
            if ($height == 0) {
                $height = 10000;
            }
            if ($width == 0) {
                $width = 10000;
            }
            return [$width, $height, TRUE];
        }
        return [0, 0, FALSE];
    }

    /**
     * find out where the device is currently located
     * @return array
     */
    public function locateDevice()
    {
        return \core\DeviceLocation::locateDevice();
    }

    /**
     * Lists all identity providers in the database
     * adding information required by DiscoJuice.
     * 
     * @param int    $activeOnly if set to non-zero will cause listing of only those institutions which have some valid profiles defined.
     * @param string $country    if set, only list IdPs in a specific country
     * @return array the list of identity providers
     *
     */
    public function listAllIdentityProviders($activeOnly = 0, $country = "")
    {
        return IdPlist::listAllIdentityProviders($activeOnly, $country);
    }

    /**
     * Order active identity providers according to their distance and name
     * @param string $country         NRO to work with
     * @param array  $currentLocation current location
     *
     * @return array $IdPs -  list of arrays ('id', 'name');
     */
    public function orderIdentityProviders($country, $currentLocation)
    {
        return IdPlist::orderIdentityProviders($country, $currentLocation);
    }
    
    /**
     * outputs a full list of IdPs containing the fllowing data:
     * institution_is, institution name in all available languages,
     * list of production profiles.
     * For eache profile the profile identifier, profile name in all languages
     * and redirect values (empty rediret value means that no redirect has been
     * set).
     * 
     * @return array of identity providers with attributes
     */
    public function listIdentityProvidersWithProfiles() {
        return IdPlist::listIdentityProvidersWithProfiles();
    }
    
    /**
     * Detect the best device driver form the browser
     * Detects the operating system and returns its id 
     * display name and group membership (as in devices.php)
     * @return array|boolean OS information, indexed by 'id', 'display', 'group'
     */
    public function detectOS()
    {
        $Dev = \devices\Devices::listDevices();
        $devId = $this->deviceFromRequest();
        if ($devId !== NULL) {
            $ret = $this->returnDevice($devId, $Dev[$devId]);
            if ($ret !== FALSE) {
                return $ret;
            }
        }
// the device has not been specified or not specified correctly, try to detect if from the browser ID
        $browser = htmlspecialchars(strip_tags($_SERVER['HTTP_USER_AGENT']), ENT_QUOTES);
        $this->loggerInstance->debug(4, "HTTP_USER_AGENT=$browser\n");
        foreach ($Dev as $devId => $device) {
            if (!isset($device['match'])) {
                continue;
            }
            if (preg_match('/'.$device['match'].'/', $browser)) {
                $this->loggerInstance->debug(5, "Matched: $devId\n".$device['match']."\n".$browser."\n");
                return $this->returnDevice($devId, $device);
            }
        }
        $this->loggerInstance->debug(2, "Unrecognised system: $browser\n");
        return FALSE;
    }

    /**
     * test if devise is defined and is not hidden. If all is fine return extracted information.
     * 
     * @param string $devId  device id as defined as index in Devices.php
     * @param array  $device device info as defined in Devices.php
     * @return array|FALSE if the device has not been correctly specified
     */
    private function returnDevice($devId, $device)
    {
        $hidden = \core\common\Entity::getAttributeValue($device, 'options', 'hidden');
        if ($hidden !== 1 && $hidden !== 2) {
            $this->loggerInstance->debug(4, "Browser_id: $devId\n");
            if (isset($device['options']['hs20']) && $device['options']['hs20'] === 1) {
                $hs20 = 1;
            } else {
                $hs20 = 0;
            }
            return ['device' => $devId, 'display' => $device['display'], 'group' => $device['group'], 'hs20' => $hs20];
        }
        return FALSE;
    }

    /**
     * This method checks if the device has been specified as the HTTP parameters
     * 
     * @return device id|NULL if correctly specified or FALSE otherwise
     */
    private function deviceFromRequest()
    {
        $devId = NULL;
        if (isset($_GET['device'])) {
            $devId = htmlspecialchars(strip_tags($_GET['device']));
        } elseif (isset($_POST['device'])) {
            $devId = htmlspecialchars(strip_tags($_POST['device']));
        } 
        if ($devId === NULL || $devId === FALSE) {
            $this->loggerInstance->debug(4, "Invalid device id provided\n");
            return NULL;
        }
        if (!isset(\devices\Devices::listDevices()[$devId])) {
            $this->loggerInstance->debug(2, "Unrecognised system: $devId\n");
            return NULL;
        }
        return $devId;
    }

    /**
     * finds all the user certificates that originated in a given token
     * 
     * @param string $token the token for which we are fetching all associated user certs
     * @return array|boolean returns FALSE if a token is invalid, otherwise array of certs
     */
    public function getUserCerts($token)
    {
        $validator = new \web\lib\common\InputValidation();
        $cleanToken = $validator->token($token);
        if ($cleanToken) {
            // check status of this silverbullet token according to info in DB:
            // it can be VALID (exists and not redeemed, EXPIRED, REDEEMED or INVALID (non existent)
            $invitationObject = new \core\SilverbulletInvitation($cleanToken);
        } else {
            return false;
        }
        $profile = new \core\ProfileSilverbullet($invitationObject->profile, NULL);
        $userdata = $profile->userStatus($invitationObject->userId);
        $allcerts = [];
        foreach ($userdata as $content) {
            $allcerts = array_merge($allcerts, $content->associatedCertificates);
        }
        return $allcerts;
    }

    /**
     * device name
     * 
     * @var string
     */
    public $device;

    /**
     * path to installer
     * 
     * @var string
     */
    private $installerPath;

    /**
     * helper function to sort profiles by their name
     * @param \core\AbstractProfile $profile1 the first profile's information
     * @param \core\AbstractProfile $profile2 the second profile's information
     * @return int
     */
    private static function profileSort($profile1, $profile2)
    {
        return strcasecmp($profile1->name, $profile2->name);
    }
}