Test Failed
Branch release_2_0 (0369fe)
by Stefan
08:46
created

UserManagement   A

Complexity

Total Complexity 33

Size/Duplication

Total Lines 275
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 33
eloc 107
dl 0
loc 275
rs 9.76
c 0
b 0
f 0

10 Methods

Rating   Name   Duplication   Size   Complexity  
A listRecentlyExpiredInvitations() 0 15 3
B createTokens() 0 24 7
B createIdPFromToken() 0 64 7
A listInstitutionsByAdmin() 0 8 2
A invalidateToken() 0 3 1
A __construct() 0 3 1
A checkTokenValidity() 0 22 5
A removeAdminFromIdP() 0 3 1
A listPendingInvitations() 0 11 4
A addAdminToIdp() 0 7 2
1
<?php
2
3
/*
4
 * *****************************************************************************
5
 * Contributions to this work were made on behalf of the GÉANT project, a 
6
 * project that has received funding from the European Union’s Framework 
7
 * Programme 7 under Grant Agreements No. 238875 (GN3) and No. 605243 (GN3plus),
8
 * Horizon 2020 research and innovation programme under Grant Agreements No. 
9
 * 691567 (GN4-1) and No. 731122 (GN4-2).
10
 * On behalf of the aforementioned projects, GEANT Association is the sole owner
11
 * of the copyright in all material which was developed by a member of the GÉANT
12
 * project. GÉANT Vereniging (Association) is registered with the Chamber of 
13
 * Commerce in Amsterdam with registration number 40535155 and operates in the 
14
 * UK as a branch of GÉANT Vereniging.
15
 * 
16
 * Registered office: Hoekenrode 3, 1102BR Amsterdam, The Netherlands. 
17
 * UK branch address: City House, 126-130 Hills Road, Cambridge CB2 1PQ, UK
18
 *
19
 * License: see the web/copyright.inc.php file in the file structure or
20
 *          <base_url>/copyright.php after deploying the software
21
 */
22
23
/**
24
 * This file contains the UserManagement class.
25
 *
26
 * @author Stefan Winter <[email protected]>
27
 * @author Tomasz Wolniewicz <[email protected]>
28
 * 
29
 * @license see LICENSE file in root directory
30
 * 
31
 * @package Developer
32
 */
33
/**
34
 * necessary includes
35
 */
36
37
namespace core;
38
39
use \Exception;
40
41
/**
42
 * This class manages user privileges and bindings to institutions
43
 *
44
 * @author Stefan Winter <[email protected]>
45
 * @author Tomasz Wolniewicz <[email protected]>
46
 * 
47
 * @package Developer
48
 */
49
class UserManagement extends \core\common\Entity {
50
51
    /**
52
     * our handle to the INST database
53
     * 
54
     * @var DBConnection
55
     */
56
    private $databaseHandle;
57
58
    /**
59
     * Class constructor. Nothing special to be done when constructing.
60
     */
61
    public function __construct() {
62
        parent::__construct();
63
        $this->databaseHandle = DBConnection::handle(self::$databaseType);
64
    }
65
66
    /**
67
     * database which this class queries by default
68
     * 
69
     * @var string
70
     */
71
    private static $databaseType = "INST";
72
73
    const TOKENSTATUS_OK_NEW = 1;
74
    const TOKENSTATUS_OK_EXISTING = 2;
75
    const TOKENSTATUS_FAIL_ALREADYCONSUMED = -1;
76
    const TOKENSTATUS_FAIL_EXPIRED = -2;
77
    const TOKENSTATUS_FAIL_NONEXISTING = -3;
78
79
    /**
80
     * Checks if a given invitation token exists and is valid in the invitations database
81
     * returns a string with the following values:
82
     * 
83
     * OK-NEW valid token exists, and is not attached to an existing institution. When consuming the token, a new inst will be created
84
     * OK-EXISTING valid token exists, and is attached to an existing institution. When consuming the token, user will be added as an admin
85
     * FAIL-NONEXISTINGTOKEN this token does not exist at all in the database
86
     * FAIL-ALREADYCONSUMED the token exists, but has been used before
87
     * FAIL-EXPIRED the token exists, but has expired
88
     * 
89
     * @param string $token the invitation token
90
     * @return int
91
     */
92
    public function checkTokenValidity($token) {
93
        $check = $this->databaseHandle->exec("SELECT invite_token, cat_institution_id 
94
                           FROM invitations 
95
                           WHERE invite_token = ? AND invite_created >= TIMESTAMPADD(DAY, -1, NOW()) AND used = 0", "s", $token);
96
        // SELECT -> resource, not boolean
97
        if ($tokenCheck = mysqli_fetch_object(/** @scrutinizer ignore-type */ $check)) {
98
            if ($tokenCheck->cat_institution_id === NULL) {
99
                return self::TOKENSTATUS_OK_NEW;
100
            }
101
            return self::TOKENSTATUS_OK_EXISTING;
102
        }
103
        // if we haven't returned from the function yet, it is an invalid token... 
104
        // be a little verbose what's wrong with it
105
        $checkReason = $this->databaseHandle->exec("SELECT invite_token, used FROM invitations WHERE invite_token = ?", "s", $token);
106
        // SELECT -> resource, not boolean
107
        if ($invalidTokenCheck = mysqli_fetch_object(/** @scrutinizer ignore-type */ $checkReason)) {
108
            if ($invalidTokenCheck->used == 1) {
109
                return self::TOKENSTATUS_FAIL_ALREADYCONSUMED;
110
            }
111
            return self::TOKENSTATUS_FAIL_EXPIRED;
112
        }
113
        return self::TOKENSTATUS_FAIL_NONEXISTING;
114
    }
115
116
    /**
117
     * This function creates a new IdP in the database based on a valid invitation token - or adds a new administrator
118
     * to an existing one. The institution is created for the logged-in user (second argument) who presents the token (first 
119
     * argument). The tokens are created via createToken().
120
     * 
121
     * @param string $token The invitation token (must exist in the database and be valid). 
122
     * @param string $owner Persistent User ID who becomes the administrator of the institution
123
     * @return IdP 
124
     */
125
    public function createIdPFromToken(string $token, string $owner) {
126
        new CAT(); // be sure that Entity's static members are initialised
127
        common\Entity::intoThePotatoes();
128
        // the token either has cat_institution_id set -> new admin for existing inst
129
        // or contains a number of parameters from external DB -> set up new inst
130
        $instinfo = $this->databaseHandle->exec("SELECT cat_institution_id, country, name, invite_issuer_level, invite_dest_mail, external_db_uniquehandle 
131
                             FROM invitations 
132
                             WHERE invite_token = ? AND invite_created >= TIMESTAMPADD(DAY, -1, NOW()) AND used = 0", "s", $token);
133
        // SELECT -> resource, no boolean
134
        if ($invitationDetails = mysqli_fetch_object(/** @scrutinizer ignore-type */ $instinfo)) {
135
            if ($invitationDetails->cat_institution_id !== NULL) { // add new admin to existing IdP
136
                // we can't rely on a unique key on this table (user IDs 
137
                // possibly too long), so run a query to find there's an
138
                // tuple already; and act accordingly
139
                $catId = $invitationDetails->cat_institution_id;
140
                $level = $invitationDetails->invite_issuer_level;
141
                $destMail = $invitationDetails->invite_dest_mail;
142
                $existing = $this->databaseHandle->exec("SELECT user_id FROM ownership WHERE user_id = ? AND institution_id = ?", "si", $owner, $catId);
143
                // SELECT -> resource, not boolean
144
                if (mysqli_num_rows(/** @scrutinizer ignore-type */ $existing) > 0) {
145
                    $this->databaseHandle->exec("UPDATE ownership SET blesslevel = ?, orig_mail = ? WHERE user_id = ? AND institution_id = ?", "sssi", $level, $destMail, $owner, $catId);
146
                } else {
147
                    $this->databaseHandle->exec("INSERT INTO ownership (user_id, institution_id, blesslevel, orig_mail) VALUES(?, ?, ?, ?)", "siss", $owner, $catId, $level, $destMail);
148
                }
149
                $this->loggerInstance->writeAudit((string) $owner, "OWN", "IdP " . $invitationDetails->cat_institution_id . " - added user as owner");
150
                common\Entity::outOfThePotatoes();
151
                return new IdP($invitationDetails->cat_institution_id);
152
            }
153
            // create new IdP
154
            $fed = new Federation($invitationDetails->country);
155
            // find the best name for the entity: C if specified, otherwise English, otherwise whatever
156
            if ($invitationDetails->external_db_uniquehandle != NULL) {
157
                // see if we had a C language, and if not, pick a good candidate 
158
                $cat = new CAT();
159
                $externalinfo = $cat->getExternalDBEntityDetails($invitationDetails->external_db_uniquehandle);
160
                $bestnameguess = $externalinfo['names']['C'] ?? $externalinfo['names']['en'] ?? reset($externalinfo['names']);
161
                $idp = new IdP($fed->newIdP($owner, $invitationDetails->invite_issuer_level, $invitationDetails->invite_dest_mail, $bestnameguess));
162
                foreach ($externalinfo['names'] as $instlang => $instname) {
163
                    $idp->addAttribute("general:instname", $instlang, $instname);
164
                }
165
                $idp->setExternalDBId($invitationDetails->external_db_uniquehandle);
166
            } else {
167
                $bestnameguess = $invitationDetails->name;
168
                $idp = new IdP($fed->newIdP($owner, $invitationDetails->invite_issuer_level, $invitationDetails->invite_dest_mail, $bestnameguess));
169
            }
170
            $idp->addAttribute("general:instname", 'C', $bestnameguess);
171
            $this->loggerInstance->writeAudit($owner, "NEW", "IdP " . $idp->identifier . " - created from invitation");
172
173
            // in case we have more admins in the queue which were invited to 
174
            // administer the same inst but haven't redeemed their invitations 
175
            // yet, then we will have to rewrite the invitations to point to the
176
            // newly created actual IdP rather than the placeholder entry in the
177
            // invitations table
178
            // which other pending invites do we have?
179
180
            $otherPending = $this->databaseHandle->exec("SELECT id
181
                             FROM invitations 
182
                             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);
183
            // SELECT -> resource, no boolean
184
            while ($pendingDetail = mysqli_fetch_object(/** @scrutinizer ignore-type */ $otherPending)) {
185
                $this->databaseHandle->exec("UPDATE invitations SET cat_institution_id = " . $idp->identifier . " WHERE id = " . $pendingDetail->id);
186
            }
187
            common\Entity::outOfThePotatoes();
188
            return $idp;
189
        }
190
    }
191
192
    /**
193
     * Adds a new administrator to an existing IdP
194
     * @param IdP    $idp  institution to which the admin is to be added.
195
     * @param string $user persistent user ID that is to be added as an admin.
196
     * @return boolean This function always returns TRUE.
197
     */
198
    public function addAdminToIdp($idp, $user) {
199
        $existing = $this->databaseHandle->exec("SELECT user_id FROM ownership WHERE user_id = ? AND institution_id = ?", "si", $user, $idp->identifier);
200
        // SELECT -> resource, not boolean
201
        if (mysqli_num_rows(/** @scrutinizer ignore-type */ $existing) == 0) {
202
            $this->databaseHandle->exec("INSERT INTO ownership (institution_id,user_id,blesslevel,orig_mail) VALUES(?, ?, 'FED', 'SELF-APPOINTED')", "is", $idp->identifier, $user);
203
        }
204
        return TRUE;
205
    }
206
207
    /**
208
     * Deletes an administrator from the IdP. If the IdP and user combination doesn't match, nothing happens.
209
     * @param IdP    $idp  institution from which the admin is to be deleted.
210
     * @param string $user persistent user ID that is to be deleted as an admin.
211
     * @return boolean This function always returns TRUE.
212
     */
213
    public function removeAdminFromIdP($idp, $user) {
214
        $this->databaseHandle->exec("DELETE from ownership WHERE institution_id = $idp->identifier AND user_id = ?", "s", $user);
215
        return TRUE;
216
    }
217
218
    /**
219
     * Invalidates a token so that it can't be used any more. Tokens automatically expire after 24h, but can be invalidated
220
     * earlier, e.g. after having been used to create an institution. If the token doesn't exist in the DB or is already invalidated,
221
     * nothing happens.
222
     * 
223
     * @param string $token the token to invalidate
224
     * @return boolean This function always returns TRUE.
225
     */
226
    public function invalidateToken($token) {
227
        $this->databaseHandle->exec("UPDATE invitations SET used = 1 WHERE invite_token = ?", "s", $token);
228
        return TRUE;
229
    }
230
231
    /**
232
     * 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 
233
     * administrator of an existing institution, or for a new institution. In the latter case, the institution only actually gets 
234
     * created in the DB if the token is actually consumed via createIdPFromToken().
235
     * 
236
     * @param boolean $isByFedadmin   is the invitation token created for a federation admin (TRUE) or from an existing inst admin (FALSE)
237
     * @param array   $for            identifiers (typically email addresses) for which the invitation is created
238
     * @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)
239
     * @param string  $externalId     if the IdP to be created is related to an external DB entity, this parameter contains that ID
240
     * @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
241
     * @return mixed The function returns either the token (as string) or FALSE if something went wrong
242
     */
243
    public function createTokens($isByFedadmin, $for, $instIdentifier, $externalId = 0, $country = 0) {
244
        $level = ($isByFedadmin ? "FED" : "INST");
245
        $tokenList = [];
246
        foreach ($for as $oneDest) {
247
            $token = bin2hex(random_bytes(40));
248
            if ($instIdentifier instanceof IdP) {
249
                $this->databaseHandle->exec("INSERT INTO invitations (invite_issuer_level, invite_dest_mail, invite_token,cat_institution_id) VALUES(?, ?, ?, ?)", "sssi", $level, $oneDest, $token, $instIdentifier->identifier);
250
                $tokenList[$token] = $oneDest;
251
            } else if (func_num_args() == 4) { // string name, but no country - new IdP with link to external DB
252
                // what country are we talking about?
253
                $cat = new CAT();
254
                $extinfo = $cat->getExternalDBEntityDetails($externalId);
255
                $extCountry = $extinfo['country'];
256
                $this->databaseHandle->exec("INSERT INTO invitations (invite_issuer_level, invite_dest_mail, invite_token,name,country, external_db_uniquehandle) VALUES(?, ?, ?, ?, ?, ?)", "ssssss", $level, $oneDest, $token, $instIdentifier, $extCountry, $externalId);
257
                $tokenList[$token] = $oneDest;
258
            } else if (func_num_args() == 5) { // string name, and country set - whole new IdP
259
                $this->databaseHandle->exec("INSERT INTO invitations (invite_issuer_level, invite_dest_mail, invite_token,name,country) VALUES(?, ?, ?, ?, ?)", "sssss", $level, $oneDest, $token, $instIdentifier, $country);
260
                $tokenList[$token] = $oneDest;
261
            }
262
        }
263
        if (count($for) != count($tokenList)) {
264
            throw new Exception("Creation of a new token failed!");
265
        }
266
        return $tokenList;
267
    }
268
269
    /**
270
     * Retrieves all pending invitations for an institution or for a federation.
271
     * 
272
     * @param int $idpIdentifier the identifier of the institution. If not set, returns invitations for not-yet-created insts
273
     * @return array if idp_identifier is set: an array of strings (mail addresses); otherwise an array of tuples (country;name;mail)
274
     */
275
    public function listPendingInvitations($idpIdentifier = 0) {
276
        $retval = [];
277
        $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
278
                                        FROM invitations 
279
                                        WHERE cat_institution_id " . ( $idpIdentifier != 0 ? "= $idpIdentifier" : "IS NULL") . " AND invite_created >= TIMESTAMPADD(DAY, -1, NOW()) AND used = 0");
280
        // SELECT -> resource, not boolean
281
        $this->loggerInstance->debug(4, "Retrieving pending invitations for " . ($idpIdentifier != 0 ? "IdP $idpIdentifier" : "IdPs awaiting initial creation" ) . ".\n");
282
        while ($invitationQuery = mysqli_fetch_object(/** @scrutinizer ignore-type */ $invitations)) {
283
            $retval[] = ["country" => $invitationQuery->country, "name" => $invitationQuery->name, "mail" => $invitationQuery->invite_dest_mail, "token" => $invitationQuery->invite_token, "expiry" => $invitationQuery->expiry];
284
        }
285
        return $retval;
286
    }
287
288
    /** Retrieves all invitations which have expired in the last hour.
289
     * 
290
     * @return array of expired invitations
291
     */
292
    public function listRecentlyExpiredInvitations() {
293
        $retval = [];
294
        $invitations = $this->databaseHandle->exec("SELECT cat_institution_id, country, name, invite_issuer_level, invite_dest_mail, invite_token 
295
                                        FROM invitations 
296
                                        WHERE invite_created >= TIMESTAMPADD(HOUR, -25, NOW()) AND invite_created < TIMESTAMPADD(HOUR, -24, NOW()) AND used = 0");
297
        // SELECT -> resource, not boolean
298
        while ($expInvitationQuery = mysqli_fetch_object(/** @scrutinizer ignore-type */ $invitations)) {
299
            $this->loggerInstance->debug(4, "Retrieving recently expired invitations (expired in last hour)\n");
300
            if ($expInvitationQuery->cat_institution_id == NULL) {
301
                $retval[] = ["country" => $expInvitationQuery->country, "level" => $expInvitationQuery->invite_issuer_level, "name" => $expInvitationQuery->name, "mail" => $expInvitationQuery->invite_dest_mail];
302
            } else {
303
                $retval[] = ["country" => $expInvitationQuery->country, "level" => $expInvitationQuery->invite_issuer_level, "name" => "Existing IdP", "mail" => $expInvitationQuery->invite_dest_mail];
304
            }
305
        }
306
        return $retval;
307
    }
308
309
    /**
310
     * For a given persistent user identifier, returns an array of institution identifiers (not the actual objects!) for which this
311
     * user is the/a administrator.
312
     * 
313
     * @param string $userid persistent user identifier
314
     * @return array array of institution IDs
315
     */
316
    public function listInstitutionsByAdmin($userid) {
317
        $returnarray = [];
318
        $institutions = $this->databaseHandle->exec("SELECT institution_id FROM ownership WHERE user_id = ? ORDER BY institution_id", "s", $userid);
319
        // SELECT -> resource, not boolean
320
        while ($instQuery = mysqli_fetch_object(/** @scrutinizer ignore-type */ $institutions)) {
321
            $returnarray[] = $instQuery->institution_id;
322
        }
323
        return $returnarray;
324
    }
325
326
}
327