Passed
Push — release_2_0 ( c99898...ea195d )
by Stefan
08:44
created

AbstractProfile::significantChanges()   B

Complexity

Conditions 8
Paths 48

Size

Total Lines 35
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 21
c 0
b 0
f 0
dl 0
loc 35
rs 8.4444
cc 8
nc 48
nop 2
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 AbstractProfile class. It contains common methods for
24
 * both RADIUS/EAP profiles and SilverBullet profiles
25
 *
26
 * @author Stefan Winter <[email protected]>
27
 * @author Tomasz Wolniewicz <[email protected]>
28
 *
29
 * @package Developer
30
 *
31
 */
32
33
namespace core;
34
35
use \Exception;
36
37
/**
38
 * This class represents an EAP Profile.
39
 * Profiles can inherit attributes from their IdP, if the IdP has some. Otherwise,
40
 * one can set attribute in the Profile directly. If there is a conflict between
41
 * IdP-wide and Profile-wide attributes, the more specific ones (i.e. Profile) win.
42
 * 
43
 * @author Stefan Winter <[email protected]>
44
 * @author Tomasz Wolniewicz <[email protected]>
45
 *
46
 * @license see LICENSE file in root directory
47
 *
48
 * @package Developer
49
 */
50
abstract class AbstractProfile extends EntityWithDBProperties {
51
52
    const HIDDEN = -1;
53
    const AVAILABLE = 0;
54
    const UNAVAILABLE = 1;
55
    const INCOMPLETE = 2;
56
    const NOTCONFIGURED = 3;
57
58
    const PROFILETYPE_RADIUS = "RADIUS";
59
    const PROFILETYPE_SILVERBULLET = "SILVERBULLET";
60
    
61
    public const SERVERNAME_ADDED = 2;
62
    public const CA_ADDED = 3;
63
    public const CA_CLASH_ADDED = 4;
64
65
    /**
66
     * DB identifier of the parent institution of this profile
67
     * @var integer
68
     */
69
    public $institution;
70
71
    /**
72
     * name of the parent institution of this profile in the current language
73
     * @var string
74
     */
75
    public $instName;
76
77
    /**
78
     * realm of this profile (empty string if unset)
79
     * @var string
80
     */
81
    public $realm;
82
83
    /**
84
     * This array holds the supported EAP types (in object representation). 
85
     * 
86
     * They are not synced against the DB after instantiation.
87
     * 
88
     * @var array
89
     */
90
    protected $privEaptypes;
91
92
    /**
93
     * number of profiles of the IdP this profile is attached to
94
     * 
95
     * @var integer
96
     */
97
    protected $idpNumberOfProfiles;
98
99
    /**
100
     * IdP-wide attributes of the IdP this profile is attached to
101
     * 
102
     * @var array
103
     */
104
    protected $idpAttributes;
105
106
    /**
107
     * Federation level attributes that this profile is attached to via its IdP
108
     * 
109
     * @var array
110
     */
111
    protected $fedAttributes;
112
113
    /**
114
     * This class also needs to handle frontend operations, so needs its own
115
     * access to the FRONTEND datbase. This member stores the corresponding 
116
     * handle.
117
     * 
118
     * @var DBConnection
119
     */
120
    protected $frontendHandle;
121
122
    /**
123
     *  generates a detailed log of which installer was downloaded
124
     * 
125
     * @param int    $idpIdentifier the IdP identifier
126
     * @param int    $profileId     the Profile identifier
127
     * @param string $deviceId      the Device identifier
128
     * @param string $area          the download area (user, silverbullet, admin)
129
     * @param string $lang          the language of the installer
130
     * @param int    $eapType       the EAP type of the installer
131
     * @return void
132
     * @throws Exception
133
     */
134
    protected function saveDownloadDetails($idpIdentifier, $profileId, $deviceId, $area, $lang, $eapType) {
135
        if (CONFIG['PATHS']['logdir']) {
136
            $file = fopen(CONFIG['PATHS']['logdir'] . "/download_details.log", "a");
137
            if ($file === FALSE) {
138
                throw new Exception("Unable to open file for append: $file");
139
            }
140
            fprintf($file, "%-015s;%d;%d;%s;%s;%s;%d\n", microtime(TRUE), $idpIdentifier, $profileId, $deviceId, $area, $lang, $eapType);
141
            fclose($file);
142
        }
143
    }
144
145
    /**
146
     * checks if security-relevant parameters have changed
147
     * 
148
     * @param AbstractProfile $old old instantiation of a profile to compare against
149
     * @param AbstractProfile $new new instantiation of a profile 
150
     * @return array there are never any user-induced changes in SB
151
     */
152
    public static function significantChanges($old, $new) {
153
        $retval = [];
154
        // check if a CA was added
155
        $x509 = new common\X509();
156
        $baselineCA = [];
157
        foreach ($old->getAttributes("eap:ca_file") as $oldCA) {
158
            $ca = $x509->processCertificate($oldCA['value']);
159
            $baselineCA[$ca['sha1']] = $ca['subject'];
160
        }
161
        // remove the new ones that are identical to the baseline
162
        foreach ($new->getAttributes("eap:ca_file") as $newCA) {
163
            $ca = $x509->processCertificate($newCA['value']);
164
            if (array_key_exists($ca['sha1'], $baselineCA)) {
165
                // do nothing; we assume here that SHA1 doesn't clash
166
                continue;
167
            }
168
            // check if a CA with identical DN was added - alert NRO if so
169
            if (array_search($ca['subject'], $baselineCA) !== FALSE) {
170
                $retval[AbstractProfile::CA_CLASH_ADDED] .= "#SHA1 for CA with DN '".print_r($ca['subject'], TRUE)."' has SHA1 fingerprints (pre-existing) "./** @scrutinizer ignore-type */ array_search($ca['subject'], $baselineCA)." and (added) ".$ca['sha1'];
171
            } else {
172
                $retval[AbstractProfile::CA_ADDED] .= "#CA with DN '".print_r($ca['subject'], TRUE)."' and SHA1 fingerprint ".$ca['sha1']." was added as trust anchor";
173
            }
174
            
175
        }
176
        // check if a servername was added
177
        $baselineNames = [];
178
        foreach ($old->getAttributes("eap:server_name") as $oldName) {
179
            $baselineNames[] = $oldName['value'];
180
        }
181
        foreach ($new->getAttributes("eap:server_name") as $newName) {
182
            if (!in_array($newName['value'], $baselineNames)) {
183
                $retval[AbstractProfile::SERVERNAME_ADDED] .= "#New server name '".$newName['value']."' added";
184
            }
185
        }
186
        return $retval;
187
    }
188
    
189
    /**
190
     * each profile has supported EAP methods, so get this from DB, Silver Bullet has one
191
     * static EAP method.
192
     * 
193
     * @return array list of supported EAP methods
194
     */
195
    protected function fetchEAPMethods() {
196
        $eapMethod = $this->databaseHandle->exec("SELECT eap_method_id 
197
                                                        FROM supported_eap supp 
198
                                                        WHERE supp.profile_id = $this->identifier 
199
                                                        ORDER by preference");
200
        $eapTypeArray = [];
201
        // SELECTs never return a boolean, it's always a resource
202
        while ($eapQuery = (mysqli_fetch_object(/** @scrutinizer ignore-type */ $eapMethod))) {
203
            $eaptype = new common\EAP($eapQuery->eap_method_id);
204
            $eapTypeArray[] = $eaptype;
205
        }
206
        $this->loggerInstance->debug(4, "This profile supports the following EAP types:\n" . print_r($eapTypeArray, true));
207
        return $eapTypeArray;
208
    }
209
210
    /**
211
     * Class constructor for existing profiles (use IdP::newProfile() to actually create one). Retrieves all attributes and 
212
     * supported EAP types from the DB and stores them in the priv_ arrays.
213
     * 
214
     * sub-classes need to set the property $realm, $name themselves!
215
     * 
216
     * @param int $profileIdRaw identifier of the profile in the DB
217
     * @param IdP $idpObject    optionally, the institution to which this Profile belongs. Saves the construction of the IdP instance. If omitted, an extra query and instantiation is executed to find out.
218
     */
0 ignored issues
show
Coding Style Documentation introduced by
Missing @throws tag in function comment
Loading history...
219
    public function __construct($profileIdRaw, $idpObject = NULL) {
220
        $this->databaseType = "INST";
221
        parent::__construct(); // we now have access to our INST database handle and logging
222
        $handle = DBConnection::handle("FRONTEND");
223
        if ($handle instanceof DBConnection) {
224
            $this->frontendHandle = $handle;
225
        } else {
226
            throw new Exception("This database type is never an array!");
227
        }
228
        $profile = $this->databaseHandle->exec("SELECT inst_id FROM profile WHERE profile_id = ?", "i", $profileIdRaw);
229
        // SELECT always yields a resource, never a boolean
230
        if ($profile->num_rows == 0) {
231
            $this->loggerInstance->debug(2, "Profile $profileIdRaw not found in database!\n");
232
            throw new Exception("Profile $profileIdRaw not found in database!");
233
        }
234
        $this->identifier = $profileIdRaw;
235
        $profileQuery = mysqli_fetch_object(/** @scrutinizer ignore-type */ $profile);
236
        if (!($idpObject instanceof IdP)) {
237
            $this->institution = $profileQuery->inst_id;
238
            $idp = new IdP($this->institution);
239
        } else {
240
            $idp = $idpObject;
241
            $this->institution = $idp->identifier;
242
        }
243
244
        $this->instName = $idp->name;
245
        $this->idpNumberOfProfiles = $idp->profileCount();
246
        $this->idpAttributes = $idp->getAttributes();
247
        $fedObject = new Federation($idp->federation);
248
        $this->fedAttributes = $fedObject->getAttributes();
249
        $this->loggerInstance->debug(3, "--- END Constructing new AbstractProfile object ... ---\n");
250
    }
251
252
    /**
253
     * join new attributes to existing ones, but only if not already defined on
254
     * a different level in the existing set
255
     * 
256
     * @param array  $existing the already existing attributes
257
     * @param array  $new      the new set of attributes
258
     * @param string $newlevel the level of the new attributes
259
     * @return array the new set of attributes
260
     */
261
    protected function levelPrecedenceAttributeJoin($existing, $new, $newlevel) {
262
        foreach ($new as $attrib) {
263
            $ignore = "";
264
            foreach ($existing as $approvedAttrib) {
265
                if (($attrib["name"] == $approvedAttrib["name"] && $approvedAttrib["level"] != $newlevel) && ($approvedAttrib["name"] != "device-specific:redirect") ){
266
                    $ignore = "YES";
267
                }
268
            }
269
            if ($ignore != "YES") {
270
                $existing[] = $attrib;
271
            }
272
        }
273
        return $existing;
274
    }
275
276
    /**
277
     * find a profile, given its realm
278
     * 
279
     * @param string $realm the realm for which we are trying to find a profile
280
     * @return int|false the profile identifier, if any
281
     */
282
    public static function profileFromRealm($realm) {
283
        // static, need to create our own handle
284
        $handle = DBConnection::handle("INST");
285
        $execQuery = $handle->exec("SELECT profile_id FROM profile WHERE realm LIKE '%@$realm'");
286
        // a SELECT query always yields a resource, not a boolean
287
        if ($profileIdQuery = mysqli_fetch_object(/** @scrutinizer ignore-type */ $execQuery)) {
288
            return $profileIdQuery->profile_id;
289
        }
290
        return FALSE;
291
    }
292
293
    /**
294
     * Constructs the outer ID which should be used during realm tests. Obviously
295
     * can only do something useful if the realm is known to the system.
296
     * 
297
     * @return string the outer ID to use for realm check operations
298
     * @throws Exception
299
     */
300
    public function getRealmCheckOuterUsername() {
301
        $realm = $this->getAttributes("internal:realm")[0]['value'] ?? FALSE;
302
        if ($realm == FALSE) { // we can't really return anything useful here
303
            throw new Exception("Unable to construct a realmcheck username if the admin did not tell us the realm. You shouldn't have called this function in this context.");
304
        }
305
        if (count($this->getAttributes("internal:checkuser_outer")) > 0) {
306
            // we are supposed to use a specific outer username for checks, 
307
            // which is different from the outer username we put into installers
308
            return $this->getAttributes("internal:checkuser_value")[0]['value'] . "@" . $realm;
309
        }
310
        if (count($this->getAttributes("internal:use_anon_outer")) > 0) {
311
            // no special check username, but there is an anon outer ID for
312
            // installers - so let's use that one
313
            return $this->getAttributes("internal:anon_local_value")[0]['value'] . "@" . $realm;
314
        }
315
        // okay, no guidance on outer IDs at all - but we need *something* to
316
        // test with for the RealmChecks. So:
317
        return "@" . $realm;
318
    }
319
320
    /**
321
     * update the last_changed timestamp for this profile
322
     * 
323
     * @return void
324
     */
325
    public function updateFreshness() {
326
        $this->databaseHandle->exec("UPDATE profile SET last_change = CURRENT_TIMESTAMP WHERE profile_id = $this->identifier");
327
    }
328
329
    /**
330
     * gets the last-modified timestamp (useful for caching "dirty" check)
331
     * 
332
     * @return string the date in string form, as returned by SQL
333
     */
334
    public function getFreshness() {
335
        $execLastChange = $this->databaseHandle->exec("SELECT last_change FROM profile WHERE profile_id = $this->identifier");
336
        // SELECT always returns a resource, never a boolean
337
        if ($freshnessQuery = mysqli_fetch_object(/** @scrutinizer ignore-type */ $execLastChange)) {
338
            return $freshnessQuery->last_change;
339
        }
340
    }
341
342
    /**
343
     * tests if the configurator needs to be regenerated
344
     * returns the configurator path or NULL if regeneration is required
345
     */
346
    /**
347
     * This function tests if the configurator needs to be regenerated 
348
     * (properties of the Profile may have changed since the last configurator 
349
     * generation).
350
     * SilverBullet will always return NULL here because all installers are new!
351
     * 
352
     * @param string $device device ID to check
353
     * @return mixed a string with the path to the configurator download, or NULL if it needs to be regenerated
354
     */
355
356
    /**
357
     * This function tests if the configurator needs to be regenerated (properties of the Profile may have changed since the last configurator generation).
358
     * 
359
     * @param string $device device ID to check
360
     * @return mixed a string with the path to the configurator download, or NULL if it needs to be regenerated
361
     */
362
    public function testCache($device) {
363
        $returnValue = NULL;
364
        $lang = $this->languageInstance->getLang();
365
        $result = $this->frontendHandle->exec("SELECT download_path, mime, UNIX_TIMESTAMP(installer_time) AS tm FROM downloads WHERE profile_id = ? AND device_id = ? AND lang = ?", "iss", $this->identifier, $device, $lang);
366
        // SELECT queries always return a resource, not a boolean
367
        if ($result && $cache = mysqli_fetch_object(/** @scrutinizer ignore-type */ $result)) {
368
            $execUpdate = $this->databaseHandle->exec("SELECT UNIX_TIMESTAMP(last_change) AS last_change FROM profile WHERE profile_id = $this->identifier");
369
            // SELECT queries always return a resource, not a boolean
370
            if ($lastChange = mysqli_fetch_object(/** @scrutinizer ignore-type */ $execUpdate)->last_change) {
371
                if ($lastChange < $cache->tm) {
372
                    $this->loggerInstance->debug(4, "Installer cached:$cache->download_path\n");
373
                    $returnValue = ['cache' => $cache->download_path, 'mime' => $cache->mime];
374
                }
375
            }
376
        }
377
        return $returnValue;
378
    }
379
380
    /**
381
     * Updates database with new installer location. Actually does stuff when
382
     * caching is possible; is a noop if not
383
     * 
384
     * @param string $device         the device identifier string
385
     * @param string $path           the path where the new installer can be found
386
     * @param string $mime           the mime type of the new installer
387
     * @param int    $integerEapType the inter-representation of the EAP type that is configured in this installer
388
     * @return void
389
     */
390
    abstract public function updateCache($device, $path, $mime, $integerEapType);
391
392
    /** Toggle anonymous outer ID support.
393
     *
394
     * @param boolean $shallwe TRUE to enable outer identities (needs valid $realm), FALSE to disable
395
     * @return void
396
     */
397
    abstract public function setAnonymousIDSupport($shallwe) ;
398
    
399
    /**
400
     * Log a new download for our stats
401
     * 
402
     * @param string $device the device id string
403
     * @param string $area   either admin or user
404
     * @return boolean TRUE if incrementing worked, FALSE if not
405
     */
406
    public function incrementDownloadStats($device, $area) {
407
        if ($area == "admin" || $area == "user" || $area == "silverbullet") {
408
            $lang = $this->languageInstance->getLang();
409
            $this->frontendHandle->exec("INSERT INTO downloads (profile_id, device_id, lang, downloads_$area) VALUES (? ,?, ?, 1) ON DUPLICATE KEY UPDATE downloads_$area = downloads_$area + 1", "iss", $this->identifier, $device, $lang);
410
            // get eap_type from the downloads table
411
            $eapTypeQuery = $this->frontendHandle->exec("SELECT eap_type FROM downloads WHERE profile_id = ? AND device_id = ? AND lang = ?", "iss", $this->identifier, $device, $lang);
412
            // SELECT queries always return a resource, not a boolean
413
            if (!$eapTypeQuery || !$eapO = mysqli_fetch_object(/** @scrutinizer ignore-type */ $eapTypeQuery)) {
414
                $this->loggerInstance->debug(2, "Error getting EAP_type from the database\n");
415
            } else {
416
                if ($eapO->eap_type == NULL) {
417
                    $this->loggerInstance->debug(2, "EAP_type not set in the database\n");
418
                } else {
419
                    $this->saveDownloadDetails($this->institution, $this->identifier, $device, $area, $this->languageInstance->getLang(), $eapO->eap_type);
420
                }
421
            }
422
            return TRUE;
423
        }
424
        return FALSE;
425
    }
426
427
    /**
428
     * Retrieve current download stats from database, either for one specific device or for all devices
429
     * 
430
     * @param string $device the device id string
431
     * @return mixed user downloads of this profile; if device is given, returns the counter as int, otherwise an array with devicename => counter
432
     */
433
    public function getUserDownloadStats($device = NULL) {
434
        $columnName = "downloads_user";
435
        if ($this instanceof \core\ProfileSilverbullet) {
436
            $columnName = "downloads_silverbullet";
437
        }
438
        $returnarray = [];
439
        $numbers = $this->frontendHandle->exec("SELECT device_id, SUM($columnName) AS downloads_user FROM downloads WHERE profile_id = ? GROUP BY device_id", "i", $this->identifier);
440
        // SELECT queries always return a resource, not a boolean
441
        while ($statsQuery = mysqli_fetch_object(/** @scrutinizer ignore-type */ $numbers)) {
442
            $returnarray[$statsQuery->device_id] = $statsQuery->downloads_user;
443
        }
444
        if ($device !== NULL) {
445
            if (isset($returnarray[$device])) {
446
                return $returnarray[$device];
447
            }
448
            return 0;
449
        }
450
        // we should pretty-print the device names
451
        $finalarray = [];
452
        $devlist = \devices\Devices::listDevices();
453
        foreach ($returnarray as $devId => $count) {
454
            if (isset($devlist[$devId])) {
455
                $finalarray[$devlist[$devId]['display']] = $count;
456
            }
457
        }
458
        return $finalarray;
459
    }
460
461
    /**
462
     * Deletes the profile from database and uninstantiates itself.
463
     * Works fine also for Silver Bullet; the first query will simply do nothing
464
     * because there are no stored options
465
     * 
466
     * @return void
467
     */
468
    public function destroy() {
469
        $this->databaseHandle->exec("DELETE FROM profile_option WHERE profile_id = $this->identifier");
470
        $this->databaseHandle->exec("DELETE FROM supported_eap WHERE profile_id = $this->identifier");
471
        $this->databaseHandle->exec("DELETE FROM profile WHERE profile_id = $this->identifier");
472
    }
473
474
    /**
475
     * Specifies the realm of this profile.
476
     * 
477
     * Forcefully type-hinting $realm parameter to string - Scrutinizer seems to
478
     * think that it can alternatively be an array<integer,?> which looks like a
479
     * false positive. If there really is an issue, let PHP complain about it at
480
     * runtime.
481
     * 
482
     * @param string $realm the realm (potentially with the local@ part that should be used for anonymous identities)
483
     * @return void
484
     */
485
    public function setRealm(string $realm) {
486
        $this->databaseHandle->exec("UPDATE profile SET realm = ? WHERE profile_id = ?", "si", $realm, $this->identifier);
487
        $this->realm = $realm;
488
    }
489
490
    /**
491
     * register new supported EAP method for this profile
492
     *
493
     * @param \core\common\EAP $type       The EAP Type, as defined in class EAP
494
     * @param int              $preference preference of this EAP Type. If a preference value is re-used, the order of EAP types of the same preference level is undefined.
495
     * @return void
496
     */
497
    public function addSupportedEapMethod(\core\common\EAP $type, $preference) {
498
        $eapInt = $type->getIntegerRep();
499
        $this->databaseHandle->exec("INSERT INTO supported_eap (profile_id, eap_method_id, preference) VALUES (?, ?, ?)", "iii", $this->identifier, $eapInt, $preference);
500
        $this->updateFreshness();
501
    }
502
503
    /**
504
     * Produces an array of EAP methods supported by this profile, ordered by preference
505
     * 
506
     * @param int $completeOnly if set and non-zero limits the output to methods with complete information
507
     * @return array list of EAP methods, (in object representation)
508
     */
509
    public function getEapMethodsinOrderOfPreference($completeOnly = 0) {
510
        $temparray = [];
511
512
        if ($completeOnly == 0) {
513
            return $this->privEaptypes;
514
        }
515
        foreach ($this->privEaptypes as $type) {
516
            if ($this->isEapTypeDefinitionComplete($type) === true) {
517
                $temparray[] = $type;
518
            }
519
        }
520
        return($temparray);
521
    }
522
523
    /**
524
     * Performs a sanity check for a given EAP type - did the admin submit enough information to create installers for him?
525
     * 
526
     * @param common\EAP $eaptype the EAP type
527
     * @return mixed TRUE if the EAP type is complete; an array of missing attribues if it's incomplete; FALSE if it's incomplete for other reasons
528
     */
529
    public function isEapTypeDefinitionComplete($eaptype) {
530
        if ($eaptype->needsServerCACert() && $eaptype->needsServerName()) {
531
            $missing = [];
532
            // silverbullet needs a support email address configured
533
            if ($eaptype->getIntegerRep() == common\EAP::INTEGER_SILVERBULLET && count($this->getAttributes("support:email")) == 0) {
534
                return ["support:email"];
535
            }
536
            $cnOption = $this->getAttributes("eap:server_name"); // cannot be set per device or eap type
537
            $caOption = $this->getAttributes("eap:ca_file"); // cannot be set per device or eap type
538
539
            if (count($caOption) > 0 && count($cnOption) > 0) {// see if we have at least one root CA cert
540
                foreach ($caOption as $oneCa) {
541
                    $x509 = new \core\common\X509();
542
                    $caParsed = $x509->processCertificate($oneCa['value']);
543
                    if ($caParsed['root'] == 1) {
544
                        return TRUE;
545
                    }
546
                }
547
                $missing[] = "eap:ca_file";
548
            }
549
            if (count($caOption) == 0) {
550
                $missing[] = "eap:ca_file";
551
            }
552
            if (count($cnOption) == 0) {
553
                $missing[] = "eap:server_name";
554
            }
555
            if (count($missing) == 0) {
556
                return TRUE;
557
            }
558
            return $missing;
559
        }
560
        return TRUE;
561
    }
562
563
    /**
564
     * list all devices marking their availabiblity and possible redirects
565
     *
566
     * @return array of device ids display names and their status
567
     */
568
    public function listDevices() {
569
        $returnarray = [];
570
        $redirect = $this->getAttributes("device-specific:redirect"); // this might return per-device ones or the general one
571
        // if it was a general one, we are done. Find out if there is one such
572
        // which has device = NULL
573
        $generalRedirect = NULL;
574
        foreach ($redirect as $index => $oneRedirect) {
575
            if ($oneRedirect["level"] == "Profile") {
576
                $generalRedirect = $index;
577
            }
578
        }
579
        if ($generalRedirect !== NULL) { // could be index 0
580
            return [['id' => '0', 'redirect' => $redirect[$generalRedirect]['value']]];
581
        }
582
        $preferredEap = $this->getEapMethodsinOrderOfPreference(1);
583
        $eAPOptions = [];
584
        foreach (\devices\Devices::listDevices() as $deviceIndex => $deviceProperties) {
585
            $factory = new DeviceFactory($deviceIndex);
586
            $dev = $factory->device;
587
            // find the attribute pertaining to the specific device
588
            $redirectUrl = 0;
589
            $redirects = [];
590
            foreach ($redirect as $index => $oneRedirect) {
591
                if ($oneRedirect["device"] == $deviceIndex) {
592
                    $redirects[] = $oneRedirect;
593
                }
594
            }
595
            if (count($redirects) > 0) {
596
                $redirectUrl = $this->languageInstance->getLocalisedValue($redirects);
597
            }
598
            $devStatus = self::AVAILABLE;
599
            $message = 0;
600
            if (isset($deviceProperties['options']) && isset($deviceProperties['options']['message']) && $deviceProperties['options']['message']) {
601
                $message = $deviceProperties['options']['message'];
602
            }
603
            $eapCustomtext = 0;
604
            $deviceCustomtext = 0;
605
            if ($redirectUrl === 0) {
606
                if (isset($deviceProperties['options']) && isset($deviceProperties['options']['redirect']) && $deviceProperties['options']['redirect']) {
607
                    $devStatus = self::HIDDEN;
608
                } else {
609
                    $dev->calculatePreferredEapType($preferredEap);
610
                    $eap = $dev->selectedEap;
611
                    if (count($eap) > 0) {
612
                        if (isset($eAPOptions["eap-specific:customtext"][serialize($eap)])) {
613
                            $eapCustomtext = $eAPOptions["eap-specific:customtext"][serialize($eap)];
614
                        } else {
615
                            // fetch customtexts from method-level attributes
616
                            $eapCustomtext = 0;
617
                            $customTextAttributes = [];
618
                            $attributeList = $this->getAttributes("eap-specific:customtext"); // eap-specific attributes always have the array index 'eapmethod' set
619
                            foreach ($attributeList as $oneAttribute) {
620
                                if ($oneAttribute["eapmethod"] == $eap) {
621
                                    $customTextAttributes[] = $oneAttribute;
622
                                }
623
                            }
624
                            if (count($customTextAttributes) > 0) {
625
                                $eapCustomtext = $this->languageInstance->getLocalisedValue($customTextAttributes);
626
                            }
627
                            $eAPOptions["eap-specific:customtext"][serialize($eap)] = $eapCustomtext;
628
                        }
629
                        // fetch customtexts for device
630
                        $customTextAttributes = [];
631
                        $attributeList = $this->getAttributes("device-specific:customtext");
632
                        foreach ($attributeList as $oneAttribute) {
633
                            if ($oneAttribute["device"] == $deviceIndex) { // device-specific attributes always have the array index "device" set
634
                                $customTextAttributes[] = $oneAttribute;
635
                            }
636
                        }
637
                        $deviceCustomtext = $this->languageInstance->getLocalisedValue($customTextAttributes);
638
                    } else {
639
                        $devStatus = self::UNAVAILABLE;
640
                    }
641
                }
642
            }
643
            $returnarray[] = ['id' => $deviceIndex, 'display' => $deviceProperties['display'], 'status' => $devStatus, 'redirect' => $redirectUrl, 'eap_customtext' => $eapCustomtext, 'device_customtext' => $deviceCustomtext, 'message' => $message, 'options' => $deviceProperties['options']];
644
        }
645
        return $returnarray;
646
    }
647
648
    /**
649
     * prepare profile attributes for device modules
650
     * Gets profile attributes taking into account the most specific level on which they may be defined
651
     * as wel as the chosen language.
652
     * can be called with an optional $eap argument
653
     * 
654
     * @param array $eap if specified, retrieves all attributes except those not pertaining to the given EAP type
655
     * @return array list of attributes in collapsed style (index is the attrib name, value is an array of different values)
656
     */
657
    public function getCollapsedAttributes($eap = []) {
658
        $collapsedList = [];
659
        foreach ($this->getAttributes() as $attribute) {
660
            // filter out eap-level attributes not pertaining to EAP type $eap
661
            if (count($eap) > 0 && isset($attribute['eapmethod']) && $attribute['eapmethod'] != 0 && $attribute['eapmethod'] != $eap) {
662
                continue;
663
            }
664
            // create new array indexed by attribute name
665
            $collapsedList[$attribute['name']][] = $attribute['value'];
666
            // and keep all language-variant names in a separate sub-array
667
            if ($attribute['flag'] == "ML") {
668
                $collapsedList[$attribute['name']]['langs'][$attribute['lang']] = $attribute['value'];
669
            }
670
        }
671
        // once we have the final list, populate the respective "best-match"
672
        // language to choose for the ML attributes
673
        foreach ($collapsedList as $attribName => $valueArray) {
674
            if (isset($valueArray['langs'])) { // we have at least one language-dependent name in this attribute
675
                // for printed names on screen:
676
                // assign to exact match language, fallback to "default" language, fallback to English, fallback to whatever comes first in the list
677
                $collapsedList[$attribName][0] = $valueArray['langs'][$this->languageInstance->getLang()] ?? $valueArray['langs']['C'] ?? $valueArray['langs']['en'] ?? array_shift($valueArray['langs']);
678
                // for names usable in filesystems (closer to good old ASCII...)
679
                // prefer English, otherwise the "default" language, otherwise the same that we got above
680
                $collapsedList[$attribName][1] = $valueArray['langs']['en'] ?? $valueArray['langs']['C'] ?? $collapsedList[$attribName][0];
681
            }
682
        }
683
684
        return $collapsedList;
685
    }
686
687
    const READINESS_LEVEL_NOTREADY = 0;
688
    const READINESS_LEVEL_SUFFICIENTCONFIG = 1;
689
    const READINESS_LEVEL_SHOWTIME = 2;
690
691
    /**
692
     * Does the profile contain enough information to generate installers with
693
     * it? Silverbullet will always return TRUE; RADIUS profiles need to do some
694
     * heavy lifting here.
695
     * 
696
     * @return int one of the constants above which tell if enough info is set to enable installers
697
     */
698
    public function readinessLevel() {
699
        $result = $this->databaseHandle->exec("SELECT sufficient_config, showtime FROM profile WHERE profile_id = ?", "i", $this->identifier);
700
        // SELECT queries always return a resource, not a boolean
701
        $configQuery = mysqli_fetch_row(/** @scrutinizer ignore-type */ $result);
702
        if ($configQuery[0] == "0") {
703
            return self::READINESS_LEVEL_NOTREADY;
704
        }
705
        // at least fully configured, if not showtime!
706
        if ($configQuery[1] == "0") {
707
            return self::READINESS_LEVEL_SUFFICIENTCONFIG;
708
        }
709
        return self::READINESS_LEVEL_SHOWTIME;
710
    }
711
712
    /**
713
     * Checks if the profile has enough information to have something to show to end users. This does not necessarily mean
714
     * that there's a fully configured EAP type - it is sufficient if a redirect has been set for at least one device.
715
     * 
716
     * @return boolean TRUE if enough information for showtime is set; FALSE if not
717
     */
718
    private function readyForShowtime() {
719
        $properConfig = FALSE;
720
        $attribs = $this->getCollapsedAttributes();
721
        // do we have enough to go live? Check if any of the configured EAP methods is completely configured ...
722
        if (sizeof($this->getEapMethodsinOrderOfPreference(1)) > 0) {
723
            $properConfig = TRUE;
724
        }
725
        // if not, it could still be that general redirect has been set
726
        if (!$properConfig) {
727
            if (isset($attribs['device-specific:redirect'])) {
728
                $properConfig = TRUE;
729
            }
730
            // just a per-device redirect? would be good enough... but this is not actually possible:
731
            // per-device redirects can only be set on the "fine-tuning" page, which is only accessible
732
            // if at least one EAP type is fully configured - which is caught above and makes readyForShowtime TRUE already
733
        }
734
        // do we know at least one SSID to configure, or work with wired? If not, it's not ready...
735
        if (!isset($attribs['media:SSID']) &&
736
                !isset($attribs['media:SSID_with_legacy']) &&
737
                (!isset(CONFIG_CONFASSISTANT['CONSORTIUM']['ssid']) || count(CONFIG_CONFASSISTANT['CONSORTIUM']['ssid']) == 0) &&
738
                !isset($attribs['media:wired'])) {
739
            $properConfig = FALSE;
740
        }
741
        return $properConfig;
742
    }
743
744
    /**
745
     * set the showtime property if prepShowTime says that there is enough info *and* the admin flagged the profile for showing
746
     * 
747
     * @return void
748
     */
749
    public function prepShowtime() {
750
        $properConfig = $this->readyForShowtime();
751
        $this->databaseHandle->exec("UPDATE profile SET sufficient_config = " . ($properConfig ? "TRUE" : "FALSE") . " WHERE profile_id = " . $this->identifier);
752
753
        $attribs = $this->getCollapsedAttributes();
754
        // if not enough info to go live, set FALSE
755
        // even if enough info is there, admin has the ultimate say: 
756
        //   if he doesn't want to go live, no further checks are needed, set FALSE as well
757
        if (!$properConfig || !isset($attribs['profile:production']) || (isset($attribs['profile:production']) && $attribs['profile:production'][0] != "on")) {
758
            $this->databaseHandle->exec("UPDATE profile SET showtime = FALSE WHERE profile_id = ?", "i", $this->identifier);
759
            return;
760
        }
761
        $this->databaseHandle->exec("UPDATE profile SET showtime = TRUE WHERE profile_id = ?", "i", $this->identifier);
762
    }
763
764
    /**
765
     * internal helper - some attributes are added by the constructor "ex officio"
766
     * without actual input from the admin. We can streamline their addition in
767
     * this function to avoid duplication.
768
     * 
769
     * @param array $internalAttributes - only names and value
770
     * @return array full attributes with all properties set
771
     */
772
    protected function addInternalAttributes($internalAttributes) {
773
        // internal attributes share many attribute properties, so condense the generation
774
        $retArray = [];
775
        foreach ($internalAttributes as $attName => $attValue) {
776
            $retArray[] = ["name" => $attName,
777
                "lang" => NULL,
778
                "value" => $attValue,
779
                "level" => "Profile",
780
                "row" => 0,
781
                "flag" => NULL,
782
            ];
783
        }
784
        return $retArray;
785
    }
786
787
    /**
788
     * Retrieves profile attributes stored in the database
789
     * 
790
     * @return array The attributes in one array
791
     */
792
    protected function addDatabaseAttributes() {
793
        $databaseAttributes = $this->retrieveOptionsFromDatabase("SELECT DISTINCT option_name, option_lang, option_value, row
794
                FROM $this->entityOptionTable
795
                WHERE $this->entityIdColumn = ?
796
                AND device_id IS NULL AND eap_method_id = 0
797
                ORDER BY option_name", "Profile");
798
        return $databaseAttributes;
799
    }
800
801
}
802