Test Failed
Branch release_2_0 (f52346)
by Stefan
05:57
created

UserManagement::listRecentlyExpiredInvitations()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 15
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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