core/UserManagement.php

Summary

Maintainability
C
7 hrs
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 file contains the UserManagement class.
 *
 * @author Stefan Winter <stefan.winter@restena.lu>
 * @author Tomasz Wolniewicz <twoln@umk.pl>
 * 
 * @license see LICENSE file in root directory
 * 
 * @package Developer
 */
/**
 * necessary includes
 */

namespace core;

use \Exception;

/**
 * This class manages user privileges and bindings to institutions
 *
 * @author Stefan Winter <stefan.winter@restena.lu>
 * @author Tomasz Wolniewicz <twoln@umk.pl>
 * 
 * @package Developer
 */
class UserManagement extends \core\common\Entity
{

    /**
     * our handle to the INST database
     * 
     * @var DBConnection
     */
    private $databaseHandle;

    /**
     * Class constructor. Nothing special to be done when constructing.
     * 
     * @throws Exception
     */
    public function __construct()
    {
        parent::__construct();
        $handle = DBConnection::handle(self::$databaseType);
        if ($handle instanceof DBConnection) {
            $this->databaseHandle = $handle;
        } else {
            throw new Exception("This database type is never an array!");
        }
    }

    /**
     * database which this class queries by default
     * 
     * @var string
     */
    private static $databaseType = "INST";

    const TOKENSTATUS_OK_NEW = 1;
    const TOKENSTATUS_OK_EXISTING = 2;
    const TOKENSTATUS_FAIL_ALREADYCONSUMED = -1;
    const TOKENSTATUS_FAIL_EXPIRED = -2;
    const TOKENSTATUS_FAIL_NONEXISTING = -3;

    /**
     * Checks if a given invitation token exists and is valid in the invitations database
     * returns a string with the following values:
     * 
     * OK-NEW valid token exists, and is not attached to an existing institution. When consuming the token, a new inst will be created
     * OK-EXISTING valid token exists, and is attached to an existing institution. When consuming the token, user will be added as an admin
     * FAIL-NONEXISTINGTOKEN this token does not exist at all in the database
     * FAIL-ALREADYCONSUMED the token exists, but has been used before
     * FAIL-EXPIRED the token exists, but has expired
     * 
     * @param string $token the invitation token
     * @return int
     */
    public function checkTokenValidity($token)
    {
        $check = $this->databaseHandle->exec("SELECT invite_token, cat_institution_id 
                           FROM invitations 
                           WHERE invite_token = ? AND invite_created >= TIMESTAMPADD(DAY, -1, NOW()) AND used = 0", "s", $token);
        // SELECT -> resource, not boolean
        if ($tokenCheck = mysqli_fetch_object(/** @scrutinizer ignore-type */ $check)) {
            if ($tokenCheck->cat_institution_id === NULL) {
                return self::TOKENSTATUS_OK_NEW;
            }
            return self::TOKENSTATUS_OK_EXISTING;
        }
        // if we haven't returned from the function yet, it is an invalid token... 
        // be a little verbose what's wrong with it
        $checkReason = $this->databaseHandle->exec("SELECT invite_token, used FROM invitations WHERE invite_token = ?", "s", $token);
        // SELECT -> resource, not boolean
        if ($invalidTokenCheck = mysqli_fetch_object(/** @scrutinizer ignore-type */ $checkReason)) {
            if ($invalidTokenCheck->used == 1) {
                return self::TOKENSTATUS_FAIL_ALREADYCONSUMED;
            }
            return self::TOKENSTATUS_FAIL_EXPIRED;
        }
        return self::TOKENSTATUS_FAIL_NONEXISTING;
    }

    /**
     * This function creates a new IdP in the database based on a valid invitation token - or adds a new administrator
     * to an existing one. The institution is created for the logged-in user (second argument) who presents the token (first 
     * argument). The tokens are created via createToken().
     * 
     * @param string $token The invitation token (must exist in the database and be valid). 
     * @param string $owner Persistent User ID who becomes the administrator of the institution
     * @return IdP 
     */
    public function createIdPFromToken(string $token, string $owner)
    {
        new CAT(); // be sure that Entity's static members are initialised
        common\Entity::intoThePotatoes();
        // the token either has cat_institution_id set -> new admin for existing inst
        // or contains a number of parameters from external DB -> set up new inst
        $instinfo = $this->databaseHandle->exec("SELECT cat_institution_id, country, name, invite_issuer_level, invite_dest_mail, external_db_uniquehandle, invite_fortype 
                             FROM invitations 
                             WHERE invite_token = ? AND invite_created >= TIMESTAMPADD(DAY, -1, NOW()) AND used = 0", "s", $token);
        // SELECT -> resource, no boolean
        if ($invitationDetails = mysqli_fetch_object(/** @scrutinizer ignore-type */ $instinfo)) {
            if ($invitationDetails->cat_institution_id !== NULL) { // add new admin to existing IdP
                // we can't rely on a unique key on this table (user IDs 
                // possibly too long), so run a query to find there's an
                // tuple already; and act accordingly
                $catId = $invitationDetails->cat_institution_id;
                $level = $invitationDetails->invite_issuer_level;
                $destMail = $invitationDetails->invite_dest_mail;
                $existing = $this->databaseHandle->exec("SELECT user_id FROM ownership WHERE user_id = ? AND institution_id = ?", "si", $owner, $catId);
                // SELECT -> resource, not boolean
                if (mysqli_num_rows(/** @scrutinizer ignore-type */ $existing) > 0) {
                    $this->databaseHandle->exec("UPDATE ownership SET blesslevel = ?, orig_mail = ? WHERE user_id = ? AND institution_id = ?", "sssi", $level, $destMail, $owner, $catId);
                } else {
                    $this->databaseHandle->exec("INSERT INTO ownership (user_id, institution_id, blesslevel, orig_mail) VALUES(?, ?, ?, ?)", "siss", $owner, $catId, $level, $destMail);
                }
                $this->loggerInstance->writeAudit((string) $owner, "OWN", "IdP " . $invitationDetails->cat_institution_id . " - added user as owner");
                common\Entity::outOfThePotatoes();
                return new IdP($invitationDetails->cat_institution_id);
            }
            // create new IdP
            $fed = new Federation($invitationDetails->country);
            // find the best name for the entity: C if specified, otherwise English, otherwise whatever
            if ($invitationDetails->external_db_uniquehandle != NULL) {
                // see if we had a C language, and if not, pick a good candidate 
                $cat = new CAT();
                $externalinfo = $cat->getExternalDBEntityDetails($invitationDetails->external_db_uniquehandle);
                $bestnameguess = $externalinfo['names']['C'] ?? $externalinfo['names']['en'] ?? reset($externalinfo['names']);
                $idp = new IdP($fed->newIdP($invitationDetails->invite_fortype, $owner, $invitationDetails->invite_issuer_level, $invitationDetails->invite_dest_mail, $bestnameguess));
                foreach ($externalinfo['names'] as $instlang => $instname) {
                    $idp->addAttribute("general:instname", $instlang, $instname);
                }
                $idp->setExternalDBId($invitationDetails->external_db_uniquehandle);
            } else {
                $bestnameguess = $invitationDetails->name;
                $idp = new IdP($fed->newIdP($invitationDetails->invite_fortype, $owner, $invitationDetails->invite_issuer_level, $invitationDetails->invite_dest_mail, $bestnameguess));
            }
            $idp->addAttribute("general:instname", 'C', $bestnameguess);
            $this->loggerInstance->writeAudit($owner, "NEW", "IdP " . $idp->identifier . " - created from invitation");

            // in case we have more admins in the queue which were invited to 
            // administer the same inst but haven't redeemed their invitations 
            // yet, then we will have to rewrite the invitations to point to the
            // newly created actual IdP rather than the placeholder entry in the
            // invitations table
            // which other pending invites do we have?

            $otherPending = $this->databaseHandle->exec("SELECT id
                             FROM invitations 
                             WHERE invite_created >= TIMESTAMPADD(DAY, -1, NOW()) AND used = 0 AND name = ? AND country = ? AND ( cat_institution_id IS NULL OR external_db_uniquehandle IS NULL ) ", "ss", $invitationDetails->name, $invitationDetails->country);
            // SELECT -> resource, no boolean
            while ($pendingDetail = mysqli_fetch_object(/** @scrutinizer ignore-type */ $otherPending)) {
                $this->databaseHandle->exec("UPDATE invitations SET cat_institution_id = " . $idp->identifier . " WHERE id = " . $pendingDetail->id);
            }

            common\Entity::outOfThePotatoes();
            return $idp;
        }
    }

    /**
     * Adds a new administrator to an existing IdP
     * @param IdP    $idp  institution to which the admin is to be added.
     * @param string $user persistent user ID that is to be added as an admin.
     * @return boolean This function always returns TRUE.
     */
    public function addAdminToIdp($idp, $user)
    {
        $existing = $this->databaseHandle->exec("SELECT user_id FROM ownership WHERE user_id = ? AND institution_id = ?", "si", $user, $idp->identifier);
        // SELECT -> resource, not boolean
        if (mysqli_num_rows(/** @scrutinizer ignore-type */ $existing) == 0) {
            $this->databaseHandle->exec("INSERT INTO ownership (institution_id,user_id,blesslevel,orig_mail) VALUES(?, ?, 'FED', 'SELF-APPOINTED')", "is", $idp->identifier, $user);
        }
        return TRUE;
    }

    /**
     * Deletes an administrator from the IdP. If the IdP and user combination doesn't match, nothing happens.
     * @param IdP    $idp  institution from which the admin is to be deleted.
     * @param string $user persistent user ID that is to be deleted as an admin.
     * @return boolean This function always returns TRUE.
     */
    public function removeAdminFromIdP($idp, $user)
    {
        $this->databaseHandle->exec("DELETE from ownership WHERE institution_id = $idp->identifier AND user_id = ?", "s", $user);
        return TRUE;
    }

    /**
     * Invalidates a token so that it can't be used any more. Tokens automatically expire after 24h, but can be invalidated
     * earlier, e.g. after having been used to create an institution. If the token doesn't exist in the DB or is already invalidated,
     * nothing happens.
     * 
     * @param string $token the token to invalidate
     * @return boolean This function always returns TRUE.
     */
    public function invalidateToken($token)
    {
        $this->databaseHandle->exec("UPDATE invitations SET used = 1 WHERE invite_token = ?", "s", $token);
        return TRUE;
    }

    /**
     * Creates a new invitation token. The token's main purpose is to be sent out by mail. The function either can generate a token for a new 
     * administrator of an existing institution, or for a new institution. In the latter case, the institution only actually gets 
     * created in the DB if the token is actually consumed via createIdPFromToken().
     * 
     * @param boolean $isByFedadmin   is the invitation token created for a federation admin (TRUE) or from an existing inst admin (FALSE)
     * @param array   $for            identifiers (typically email addresses) for which the invitation is created
     * @param mixed   $instIdentifier either an instance of the IdP class (for existing institutions to invite new admins) or a string (new institution - this is the inst name then)
     * @param string  $externalId     if the IdP to be created is related to an external DB entity, this parameter contains that ID
     * @param string  $country        if the institution is new (i.e. $inst is a string) this parameter needs to specify the federation of the new inst
     * @param string  $partType       the type of participant
     * @return mixed The function returns either the token (as string) or FALSE if something went wrong
     * @throws Exception
     */
    public function createTokens($isByFedadmin, $for, $instIdentifier, $externalId = 0, $country = 0, $partType = 0)
    {
        $level = ($isByFedadmin ? "FED" : "INST");
        $tokenList = [];
        foreach ($for as $oneDest) {
            $token = bin2hex(random_bytes(40));
            if ($instIdentifier instanceof IdP) {
                $this->databaseHandle->exec("INSERT INTO invitations (invite_fortype, invite_issuer_level, invite_dest_mail, invite_token,cat_institution_id) VALUES(?, ?, ?, ?, ?)", "ssssi", $instIdentifier->type, $level, $oneDest, $token, $instIdentifier->identifier);
                $tokenList[$token] = $oneDest;
            } else if (func_num_args() == 4) { // string name, but no country - new IdP with link to external DB
                // what country are we talking about?
                $cat = new CAT();
                $extinfo = $cat->getExternalDBEntityDetails($externalId);
                $extCountry = $extinfo['country'];
                $extType = $extinfo['type'];
                $this->databaseHandle->exec("INSERT INTO invitations (invite_fortype, invite_issuer_level, invite_dest_mail, invite_token,name,country, external_db_uniquehandle) VALUES(?, ?, ?, ?, ?, ?, ?)", "sssssss", $extType, $level, $oneDest, $token, $instIdentifier, $extCountry, $externalId);
                $tokenList[$token] = $oneDest;
            } else if (func_num_args() == 6) { // string name, and country set - whole new IdP
                $this->databaseHandle->exec("INSERT INTO invitations (invite_fortype, invite_issuer_level, invite_dest_mail, invite_token,name,country) VALUES(?, ?, ?, ?, ?, ?)", "ssssss", $partType, $level, $oneDest, $token, $instIdentifier, $country);
                $tokenList[$token] = $oneDest;
            } else {
                throw new Exception("The invitation is somehow ... wrong.");
            }
        }
        if (count($for) != count($tokenList)) {
            throw new Exception("Creation of a new token failed!");
        }
        return $tokenList;
    }

    /**
     * Retrieves all pending invitations for an institution or for a federation.
     * 
     * @param int $idpIdentifier the identifier of the institution. If not set, returns invitations for not-yet-created insts
     * @return array if idp_identifier is set: an array of strings (mail addresses); otherwise an array of tuples (country;name;mail)
     */
    public function listPendingInvitations($idpIdentifier = 0)
    {
        $retval = [];
        $invitations = $this->databaseHandle->exec("SELECT cat_institution_id, country, name, invite_issuer_level, invite_dest_mail, invite_token , TIMESTAMPADD(DAY, 1, invite_created) as expiry
                                        FROM invitations 
                                        WHERE cat_institution_id " . ( $idpIdentifier != 0 ? "= $idpIdentifier" : "IS NULL") . " AND invite_created >= TIMESTAMPADD(DAY, -1, NOW()) AND used = 0");
        // SELECT -> resource, not boolean
        $this->loggerInstance->debug(4, "Retrieving pending invitations for " . ($idpIdentifier != 0 ? "IdP $idpIdentifier" : "IdPs awaiting initial creation" ) . ".\n");
        while ($invitationQuery = mysqli_fetch_object(/** @scrutinizer ignore-type */ $invitations)) {
            $retval[] = ["country" => $invitationQuery->country, "name" => $invitationQuery->name, "mail" => $invitationQuery->invite_dest_mail, "token" => $invitationQuery->invite_token, "expiry" => $invitationQuery->expiry];
        }
        return $retval;
    }

    /** Retrieves all invitations which have expired in the last hour.
     * 
     * @return array of expired invitations
     */
    public function listRecentlyExpiredInvitations()
    {
        $retval = [];
        $invitations = $this->databaseHandle->exec("SELECT cat_institution_id, country, name, invite_issuer_level, invite_dest_mail, invite_token 
                                        FROM invitations 
                                        WHERE invite_created >= TIMESTAMPADD(HOUR, -25, NOW()) AND invite_created < TIMESTAMPADD(HOUR, -24, NOW()) AND used = 0");
        // SELECT -> resource, not boolean
        while ($expInvitationQuery = mysqli_fetch_object(/** @scrutinizer ignore-type */ $invitations)) {
            $this->loggerInstance->debug(4, "Retrieving recently expired invitations (expired in last hour)\n");
            if ($expInvitationQuery->cat_institution_id == NULL) {
                $retval[] = ["country" => $expInvitationQuery->country, "level" => $expInvitationQuery->invite_issuer_level, "name" => $expInvitationQuery->name, "mail" => $expInvitationQuery->invite_dest_mail];
            } else {
                $retval[] = ["country" => $expInvitationQuery->country, "level" => $expInvitationQuery->invite_issuer_level, "name" => "Existing IdP", "mail" => $expInvitationQuery->invite_dest_mail];
            }
        }
        return $retval;
    }

    /**
     * For a given persistent user identifier, returns an array of institution identifiers (not the actual objects!) for which this
     * user is the/a administrator.
     * 
     * @param string $userid persistent user identifier
     * @return array array of institution IDs
     */
    public function listInstitutionsByAdmin($userid)
    {
        $returnarray = [];
        $institutions = $this->databaseHandle->exec("SELECT institution_id FROM ownership WHERE user_id = ? ORDER BY institution_id", "s", $userid);
        // SELECT -> resource, not boolean
        while ($instQuery = mysqli_fetch_object(/** @scrutinizer ignore-type */ $institutions)) {
            $returnarray[] = $instQuery->institution_id;
        }
        return $returnarray;
    }
}