core/AbstractProfile.php
<?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 AbstractProfile class. It contains common methods for
* both RADIUS/EAP profiles and SilverBullet profiles
*
* @author Stefan Winter <stefan.winter@restena.lu>
* @author Tomasz Wolniewicz <twoln@umk.pl>
*
* @package Developer
*
*/
namespace core;
use \Exception;
/**
* This class represents an EAP Profile.
* Profiles can inherit attributes from their IdP, if the IdP has some. Otherwise,
* one can set attribute in the Profile directly. If there is a conflict between
* IdP-wide and Profile-wide attributes, the more specific ones (i.e. Profile) win.
*
* @author Stefan Winter <stefan.winter@restena.lu>
* @author Tomasz Wolniewicz <twoln@umk.pl>
*
* @license see LICENSE file in root directory
*
* @package Developer
*/
abstract class AbstractProfile extends EntityWithDBProperties
{
const HIDDEN = -1;
const AVAILABLE = 0;
const UNAVAILABLE = 1;
const INCOMPLETE = 2;
const NOTCONFIGURED = 3;
const PROFILETYPE_RADIUS = "RADIUS";
const PROFILETYPE_SILVERBULLET = "SILVERBULLET";
public const SERVERNAME_ADDED = 2;
public const CA_ADDED = 3;
public const CA_CLASH_ADDED = 4;
public const SERVER_CERT_ADDED = 5;
public const CA_ROOT_NO_EXT = 6;
/**
* DB identifier of the parent institution of this profile
* @var integer
*/
public $institution;
/**
* name of the parent institution of this profile in the current language
* @var string
*/
public $instName;
/**
* realm of this profile (empty string if unset)
* @var string
*/
public $realm;
/**
* This array holds the supported EAP types (in object representation).
*
* They are not synced against the DB after instantiation.
*
* @var array
*/
protected $privEaptypes;
/**
* number of profiles of the IdP this profile is attached to
*
* @var integer
*/
protected $idpNumberOfProfiles;
/**
* IdP-wide attributes of the IdP this profile is attached to
*
* @var array
*/
protected $idpAttributes;
/**
* Federation level attributes that this profile is attached to via its IdP
*
* @var array
*/
protected $fedAttributes;
/**
* This class also needs to handle frontend operations, so needs its own
* access to the FRONTEND database. This member stores the corresponding
* handle.
*
* @var DBConnection
*/
protected $frontendHandle;
/**
* readiness levels for OpenRoaming column in profiles)
*/
const OVERALL_OPENROAMING_LEVEL_NO = 4;
const OVERALL_OPENROAMING_LEVEL_GOOD = 3;
const OVERALL_OPENROAMING_LEVEL_NOTE = 2;
const OVERALL_OPENROAMING_LEVEL_WARN = 1;
const OVERALL_OPENROAMING_LEVEL_ERROR = 0;
/**
* constants used for displaying messages
*/
const OPENROAMING_ALL_GOOD = 24;
const OPENROAMING_NO_REALM = 17; //none
const OPENROAMING_BAD_SRV = 16; //none
const OPENROAMING_BAD_NAPTR = 10; // warning
const OPENROAMING_SOME_BAD_CONNECTIONS = 8; //warning
const OPENROAMING_NO_DNSSEC = 8; //warning
const OPENROAMING_NO_NAPTR = 3; //error
const OPENROAMING_BAD_NAPTR_RESOLVE = 2; //error
const OPENROAMING_BAD_SRV_RESOLVE = 1; //error
const OPENROAMING_BAD_CONNECTION = 0; //error
const READINESS_LEVEL_NOTREADY = 0;
const READINESS_LEVEL_SUFFICIENTCONFIG = 1;
const READINESS_LEVEL_SHOWTIME = 2;
const CERT_STATUS_NONE = -1;
const CERT_STATUS_OK = 0;
const CERT_STATUS_WARN = 1;
const CERT_STATUS_ERROR = 2;
const OVERALL_OPENROAMING_INDEX = [
self::OVERALL_OPENROAMING_LEVEL_NO => 'OVERALL_OPENROAMING_LEVEL_NO',
self::OVERALL_OPENROAMING_LEVEL_GOOD => 'OVERALL_OPENROAMING_LEVEL_GOOD',
self::OVERALL_OPENROAMING_LEVEL_NOTE => 'OVERALL_OPENROAMING_LEVEL_NOTE',
self::OVERALL_OPENROAMING_LEVEL_WARN => 'OVERALL_OPENROAMING_LEVEL_WARN',
self::OVERALL_OPENROAMING_LEVEL_ERROR => 'OVERALL_OPENROAMING_LEVEL_ERROR',
];
const OPENROAMING_INDEX = [
self::OVERALL_OPENROAMING_LEVEL_NO => 'OVERALL_OPENROAMING_LEVEL_NO',
];
const CERT_STATUS_INDEX = [
self::CERT_STATUS_OK => 'CERT_STATUS_OK',
self::CERT_STATUS_WARN => 'CERT_STATUS_WARN',
self::CERT_STATUS_ERROR => 'CERT_STATUS_ERROR',
];
/**
* generates a detailed log of which installer was downloaded
*
* @param int $idpIdentifier the IdP identifier
* @param int $profileId the Profile identifier
* @param string $deviceId the Device identifier
* @param string $area the download area (user, silverbullet, admin)
* @param string $lang the language of the installer
* @param int $eapType the EAP type of the installer
* @return void
* @throws Exception
*/
protected function saveDownloadDetails($idpIdentifier, $profileId, $deviceId, $area, $lang, $eapType, $openRoaming)
{
if (\config\Master::PATHS['logdir']) {
$file = fopen(\config\Master::PATHS['logdir']."/download_details.log", "a");
if ($file === FALSE) {
throw new Exception("Unable to open file for append: $file");
}
fprintf($file, "%-015s;%d;%d;%s;%s;%s;%d;%d\n", microtime(TRUE), $idpIdentifier, $profileId, $deviceId, $area, $lang, $eapType, $openRoaming);
fclose($file);
}
}
/**
* checks if security-relevant parameters have changed
*
* @param AbstractProfile $old old instantiation of a profile to compare against
* @param AbstractProfile $new new instantiation of a profile
* @return array there are never any user-induced changes in SB
*/
public static function significantChanges($old, $new)
{
$retval = [];
// check if a CA was added
$x509 = new common\X509();
$baselineCA = [];
$baselineCApublicKey = [];
foreach ($old->getAttributes("eap:ca_file") as $oldCA) {
$ca = $x509->processCertificate($oldCA['value']);
$baselineCA[$ca['sha1']] = $ca['name'];
$baselineCApublicKey[$ca['sha1']] = $ca['full_details']['public_key'];
}
// remove the new ones that are identical to the baseline
foreach ($new->getAttributes("eap:ca_file") as $newCA) {
$ca = $x509->processCertificate($newCA['value']);
if (array_key_exists($ca['sha1'], $baselineCA) || $ca['root'] != 1) {
// do nothing; we assume here that SHA1 doesn't clash
continue;
}
// check if a CA with identical DN was added - alert NRO if so
$foundSHA1 = array_search($ca['name'], $baselineCA);
if ($foundSHA1 !== FALSE) {
// but only if the public key does not match
if ($baselineCApublicKey[$foundSHA1] === $ca['full_details']['public_key']) {
continue;
}
$retval[AbstractProfile::CA_CLASH_ADDED] .= "#SHA1 for CA with DN '".$ca['name']."' has SHA1 fingerprints (pre-existing) "./** @scrutinizer ignore-type */ array_search($ca['name'], $baselineCA)." and (added) ".$ca['sha1'];
} else {
$retval[AbstractProfile::CA_ADDED] .= "#CA with DN '"./** @scrutinizer ignore-type */ print_r($ca['name'], TRUE)."' and SHA1 fingerprint ".$ca['sha1']." was added as trust anchor";
}
}
// check if a servername was added
$baselineNames = [];
foreach ($old->getAttributes("eap:server_name") as $oldName) {
$baselineNames[] = $oldName['value'];
}
foreach ($new->getAttributes("eap:server_name") as $newName) {
if (!in_array($newName['value'], $baselineNames)) {
$retval[AbstractProfile::SERVERNAME_ADDED] .= "#New server name '".$newName['value']."' added";
}
}
return $retval;
}
/**
* Tests OpenRoaming aspects of the profile like DNS settings and server reachibility
*
* @return array of arrays of the form [['level' => $level, 'explanation' => $explanation, 'reason' => $reason]];
*/
public function openroamingRedinessTest() {
// do OpenRoaming initial diagnostic checks
// numbers correspond to RFC7585Tests::OVERALL_LEVEL
$results = [];
$resultLevel = $this::OVERALL_OPENROAMING_LEVEL_GOOD; // assume all is well, degrade if we have concrete findings to suggest otherwise
$tag = "aaa+auth:radius.tls.tcp";
// do we know the realm at all? Notice if not.
if (!isset($this->getAttributes("internal:realm")[0]['value'])) {
$explanation = _("The profile information does not include the realm, so no DNS checks for OpenRoaming can be executed.");
$level = $this::OVERALL_OPENROAMING_LEVEL_NOTE;
$reason = $this::OPENROAMING_NO_REALM;
$results[] = ['level' => $level, 'explanation' => $explanation, 'reason' => $reason];
$resultLevel = min([$resultLevel, $level]);
} else {
$dnsChecks = new \core\diag\RFC7585Tests($this->getAttributes("internal:realm")[0]['value'], $tag);
$relevantNaptrRecords = $dnsChecks->relevantNAPTR();
if ($relevantNaptrRecords <= 0) {
$explanation = _("There is no relevant DNS NAPTR record ($tag) for this realm. OpenRoaming will not work.");
$reason = $this::OPENROAMING_NO_NAPTR;
$level = $this::OVERALL_OPENROAMING_LEVEL_ERROR;
$results[] = ['level' => $level, 'explanation' => $explanation, 'reason' => $reason];
$resultLevel = min([$resultLevel, $level]);
} else {
$recordCompliance = $dnsChecks->relevantNAPTRcompliance();
if ($recordCompliance != \core\diag\AbstractTest::RETVAL_OK) {
$explanation = _("The DNS NAPTR record ($tag) for this realm is not syntax conform. OpenRoaming will likely not work.");
$reason = $this::OPENROAMING_BAD_NAPTR;
$level = $this::OVERALL_OPENROAMING_LEVEL_WARN;
$results[] = ['level' => $level, 'explanation' => $explanation, 'reason' => $reason];
$resultLevel = min([$resultLevel, $level]);
}
// check if target is the expected one, if set by NRO
foreach ($this->fedAttributes as $attr) {
if ($attr['name'] === 'fed:openroaming_customtarget') {
$customText = $attr['value'];
} else {
$customText = '';
}
}
if ($customText !== '') {
foreach ($dnsChecks->NAPTR_records as $orpointer) {
if ($orpointer["replacement"] != $customText) {
$explanation = _("The SRV target of an OpenRoaming NAPTR record is unexpected.");
$reason = $this::OPENROAMING_BAD_SRV;
$level = $this::OVERALL_OPENROAMING_LEVEL_NOTE;
$results[] = ['level' => $level, 'explanation' => $explanation, 'reason' => $reason];
$resultLevel = min([$resultLevel, $level]);
}
}
}
$srvResolution = $dnsChecks->relevantNAPTRsrvResolution();
$hostnameResolution = $dnsChecks->relevantNAPTRhostnameResolution();
if ($srvResolution <= 0) {
$explanation = _("The DNS SRV target for NAPTR $tag does not resolve. OpenRoaming will not work.");
$level = $this::OVERALL_OPENROAMING_LEVEL_ERROR;
$reason = $this::OPENROAMING_BAD_NAPTR_RESOLVE;
$results[] = ['level' => $level, 'explanation' => $explanation, 'reason' => $reason];
$resultLevel = min([$resultLevel, $this::OVERALL_OPENROAMING_LEVEL_ERROR]);
} elseif ($hostnameResolution <= 0) {
$explanation = _("The DNS hostnames in the SRV records do not resolve to actual host IPs. OpenRoaming will not work.");
$level = $this::OVERALL_OPENROAMING_LEVEL_ERROR;
$reason = $this::OPENROAMING_BAD_SRV_RESOLVE;
$results[] = ['level' => $level, 'explanation' => $explanation, 'reason' => $reason];
$resultLevel = min([$resultLevel, $level]);
}
// connect to all IPs we found and see if they are really an OpenRoaming server
$allHostsOkay = TRUE;
$oneHostOkay = FALSE;
$testCandidates = [];
foreach ($dnsChecks->NAPTR_hostname_records as $oneServer) {
$testCandidates[$oneServer['hostname']][] = ($oneServer['family'] == "IPv4" ? $oneServer['IP'] : "[".$oneServer['IP']."]").":".$oneServer['port'];
}
foreach ($testCandidates as $oneHost => $listOfIPs) {
$connectionTests = new \core\diag\RFC6614Tests(array_values($listOfIPs), $oneHost, "openroaming");
// for now (no OpenRoaming client certs available) only run server-side tests
foreach ($listOfIPs as $oneIP) {
$connectionResult = $connectionTests->cApathCheck($oneIP);
if ($connectionResult != \core\diag\AbstractTest::RETVAL_OK || ( isset($connectionTests->TLS_CA_checks_result['cert_oddity']) && count($connectionTests->TLS_CA_checks_result['cert_oddity']) > 0)) {
$allHostsOkay = FALSE;
} else {
$oneHostOkay = TRUE;
}
}
}
if (!$allHostsOkay) {
if (!$oneHostOkay) {
$explanation = _("When connecting to the discovered OpenRoaming endpoints, they all had errors. OpenRoaming will likely not work.");
$level = $this::OVERALL_OPENROAMING_LEVEL_ERROR;
$reason = $this::OPENROAMING_BAD_CONNECTION;
$results[] = ['level' => $level, 'explanation' => $explanation, 'reason' => $reason];
$resultLevel = min([$resultLevel, $level]);
} else {
$explanation = _("When connecting to the discovered OpenRoaming endpoints, only a subset of endpoints had no errors.");
$level = $this::OVERALL_OPENROAMING_LEVEL_WARN;
$reason = $this::OPENROAMING_SOME_BAD_CONNECTIONS;
$results[] = ['level' => $level, 'explanation' => $explanation, 'reason' => $reason];
$resultLevel = min([$resultLevel, $level]);
}
}
}
}
if (!$dnsChecks->allResponsesSecure) {
$explanation = _("At least one DNS response was NOT secured using DNSSEC. OpenRoaming ANPs may refuse to connect to the endpoint.");
$level = $this::OVERALL_OPENROAMING_LEVEL_WARN;
$reason = $this::OPENROAMING_NO_DNSSEC;
$results[] = ['level' => $level, 'explanation' => $explanation, 'reason' => $reason];
$resultLevel = min([$resultLevel, $level]);
}
if ($resultLevel == $this::OVERALL_OPENROAMING_LEVEL_GOOD) {
$explanation = _("Initial diagnostics regarding the DNS part of OpenRoaming (including DNSSEC) were successful.");
$level = $this::OVERALL_OPENROAMING_LEVEL_GOOD;
$reason = $this::OPENROAMING_ALL_GOOD;
$results = [['level' => $level, 'explanation' => $explanation, 'reason' => $reason]];
}
$this->setOpenRoamingReadinessInfo($resultLevel);
return $results;
}
/**
* Takes note of the OpenRoaming participation and conformance level
*
* @param int $level the readiness level, as determined by RFC7585Tests
* @return void
*/
public function setOpenRoamingReadinessInfo(int $level)
{
$this->databaseHandle->exec("UPDATE profile SET openroaming = ? WHERE profile_id = ?", "ii", $level, $this->identifier);
}
/**
* each profile has supported EAP methods, so get this from DB, Silver Bullet has one
* static EAP method.
*
* @return array list of supported EAP methods
*/
protected function fetchEAPMethods()
{
$eapMethod = $this->databaseHandle->exec("SELECT eap_method_id
FROM supported_eap supp
WHERE supp.profile_id = $this->identifier
ORDER by preference");
$eapTypeArray = [];
// SELECTs never return a boolean, it's always a resource
while ($eapQuery = (mysqli_fetch_object(/** @scrutinizer ignore-type */ $eapMethod))) {
$eaptype = new common\EAP($eapQuery->eap_method_id);
$eapTypeArray[] = $eaptype;
}
$this->loggerInstance->debug(4, "This profile supports the following EAP types:\n"./** @scrutinizer ignore-type */ print_r($eapTypeArray, true));
return $eapTypeArray;
}
/**
* Class constructor for existing profiles (use IdP::newProfile() to actually create one). Retrieves all attributes and
* supported EAP types from the DB and stores them in the priv_ arrays.
*
* sub-classes need to set the property $realm, $name themselves!
*
* @param int $profileIdRaw identifier of the profile in the DB
* @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.
* @throws Exception
*/
public function __construct($profileIdRaw, $idpObject = NULL)
{
$this->databaseType = "INST";
parent::__construct(); // we now have access to our INST database handle and logging
$handle = DBConnection::handle("FRONTEND");
if ($handle instanceof DBConnection) {
$this->frontendHandle = $handle;
} else {
throw new Exception("This database type is never an array!");
}
$profile = $this->databaseHandle->exec("SELECT inst_id FROM profile WHERE profile_id = ?", "i", $profileIdRaw);
// SELECT always yields a resource, never a boolean
if ($profile->num_rows == 0) {
$this->loggerInstance->debug(2, "Profile $profileIdRaw not found in database!\n");
throw new Exception("Profile $profileIdRaw not found in database!");
}
$this->identifier = $profileIdRaw;
$profileQuery = mysqli_fetch_object(/** @scrutinizer ignore-type */ $profile);
if (!($idpObject instanceof IdP)) {
$this->institution = $profileQuery->inst_id;
$idp = new IdP($this->institution);
} else {
$idp = $idpObject;
$this->institution = $idp->identifier;
}
$this->instName = $idp->name;
$this->idpNumberOfProfiles = $idp->profileCount();
$this->idpAttributes = $idp->getAttributes();
$fedObject = new Federation($idp->federation);
$this->fedAttributes = $fedObject->getAttributes();
$this->loggerInstance->debug(4, "--- END Constructing new AbstractProfile object ... ---\n");
}
/**
* find a profile, given its realm
*
* @param string $realm the realm for which we are trying to find a profile
* @return int|false the profile identifier, if any
*/
public static function profileFromRealm($realm)
{
// static, need to create our own handle
$handle = DBConnection::handle("INST");
$execQuery = $handle->exec("SELECT profile_id FROM profile WHERE realm LIKE '%@$realm'");
// a SELECT query always yields a resource, not a boolean
if ($profileIdQuery = mysqli_fetch_object(/** @scrutinizer ignore-type */ $execQuery)) {
return $profileIdQuery->profile_id;
}
return FALSE;
}
/**
* Constructs the outer ID which should be used during realm tests. Obviously
* can only do something useful if the realm is known to the system.
*
* @return string the outer ID to use for realm check operations
* @throws Exception
*/
public function getRealmCheckOuterUsername()
{
$realm = $this->getAttributes("internal:realm")[0]['value'] ?? FALSE;
if ($realm == FALSE) { // we can't really return anything useful here
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.");
}
if (count($this->getAttributes("internal:checkuser_outer")) > 0) {
// we are supposed to use a specific outer username for checks,
// which is different from the outer username we put into installers
return $this->getAttributes("internal:checkuser_value")[0]['value']."@".$realm;
}
if (count($this->getAttributes("internal:use_anon_outer")) > 0) {
// no special check username, but there is an anon outer ID for
// installers - so let's use that one
return $this->getAttributes("internal:anon_local_value")[0]['value']."@".$realm;
}
// okay, no guidance on outer IDs at all - but we need *something* to
// test with for the RealmChecks. So:
return "@".$realm;
}
/**
* update the last_changed timestamp for this profile
*
* @return void
*/
public function updateFreshness()
{
$this->databaseHandle->exec("UPDATE profile SET last_change = CURRENT_TIMESTAMP WHERE profile_id = $this->identifier");
}
/**
* gets the last-modified timestamp (useful for caching "dirty" check)
*
* @return string the date in string form, as returned by SQL
*/
public function getFreshness()
{
$execLastChange = $this->databaseHandle->exec("SELECT last_change FROM profile WHERE profile_id = $this->identifier");
// SELECT always returns a resource, never a boolean
if ($freshnessQuery = mysqli_fetch_object(/** @scrutinizer ignore-type */ $execLastChange)) {
return $freshnessQuery->last_change;
}
}
/**
* tests if the configurator needs to be regenerated
* returns the configurator path or NULL if regeneration is required
*/
/**
* This function tests if the configurator needs to be regenerated
* (properties of the Profile may have changed since the last configurator
* generation).
* SilverBullet will always return NULL here because all installers are new!
*
* @param string $device device ID to check
* @return mixed a string with the path to the configurator download, or NULL if it needs to be regenerated
*/
/**
* This function tests if the configurator needs to be regenerated (properties of the Profile may have changed since the last configurator generation).
*
* @param string $device device ID to check
* @return mixed a string with the path to the configurator download, or NULL if it needs to be regenerated
*/
public function testCache($device, $openRoaming)
{
$returnValue = ['cache' => NULL, 'mime' => NULL];
$lang = $this->languageInstance->getLang();
$result = $this->frontendHandle->exec("SELECT download_path, mime, UNIX_TIMESTAMP(installer_time) AS tm FROM downloads WHERE profile_id = ? AND device_id = ? AND lang = ? AND openroaming = ?", "issi", $this->identifier, $device, $lang, $openRoaming);
// SELECT queries always return a resource, not a boolean
if ($result && $cache = mysqli_fetch_object(/** @scrutinizer ignore-type */ $result)) {
$execUpdate = $this->databaseHandle->exec("SELECT UNIX_TIMESTAMP(last_change) AS last_change FROM profile WHERE profile_id = $this->identifier");
// SELECT queries always return a resource, not a boolean
if ($lastChange = mysqli_fetch_object(/** @scrutinizer ignore-type */ $execUpdate)->last_change) {
if ($lastChange < $cache->tm) {
$this->loggerInstance->debug(4, "Installer cached:$cache->download_path\n");
$returnValue = ['cache' => $cache->download_path, 'mime' => $cache->mime];
}
}
}
return $returnValue;
}
/**
* Updates database with new installer location. Actually does stuff when
* caching is possible; is a noop if not
*
* @param string $device the device identifier string
* @param string $path the path where the new installer can be found
* @param string $mime the mime type of the new installer
* @param int $integerEapType the inter-representation of the EAP type that is configured in this installer
* @return void
*/
abstract public function updateCache($device, $path, $mime, $integerEapType, $openRoaming);
/** Toggle anonymous outer ID support.
*
* @param boolean $shallwe TRUE to enable outer identities (needs valid $realm), FALSE to disable
* @return void
*/
abstract public function setAnonymousIDSupport($shallwe);
/**
* Log a new download for our stats
*
* @param string $device the device id string
* @param string $area either admin or user
* @return boolean TRUE if incrementing worked, FALSE if not
*/
public function incrementDownloadStats($device, $area, $openRoaming)
{
if ($area == "admin" || $area == "user" || $area == "silverbullet") {
$lang = $this->languageInstance->getLang();
$this->frontendHandle->exec("INSERT INTO downloads (profile_id, device_id, lang, openroaming, downloads_$area) VALUES (? ,?, ?, ?, 1) ON DUPLICATE KEY UPDATE downloads_$area = downloads_$area + 1", "issi", $this->identifier, $device, $lang, $openRoaming);
// get eap_type from the downloads table
$eapTypeQuery = $this->frontendHandle->exec("SELECT eap_type FROM downloads WHERE profile_id = ? AND device_id = ? AND lang = ?", "iss", $this->identifier, $device, $lang);
// SELECT queries always return a resource, not a boolean
if (!$eapTypeQuery || !$eapO = mysqli_fetch_object(/** @scrutinizer ignore-type */ $eapTypeQuery)) {
$this->loggerInstance->debug(2, "Error getting EAP_type from the database\n");
} else {
if ($eapO->eap_type == NULL) {
$this->loggerInstance->debug(2, "EAP_type not set in the database\n");
} else {
$this->saveDownloadDetails($this->institution, $this->identifier, $device, $area, $this->languageInstance->getLang(), $eapO->eap_type, $openRoaming);
}
}
return TRUE;
}
return FALSE;
}
/**
* Retrieve current download stats from database, either for one specific device or for all devices
*
* @param string $device the device id string
* @return mixed user downloads of this profile; if device is given, returns the counter as int, otherwise an array with devicename => counter
*/
public function getUserDownloadStats($device = NULL)
{
$columnName = "downloads_user";
if ($this instanceof \core\ProfileSilverbullet) {
$columnName = "downloads_silverbullet";
}
$returnarray = [];
$numbers = $this->frontendHandle->exec("SELECT device_id, SUM($columnName) AS downloads_user FROM downloads WHERE profile_id = ? GROUP BY device_id", "i", $this->identifier);
// SELECT queries always return a resource, not a boolean
while ($statsQuery = mysqli_fetch_object(/** @scrutinizer ignore-type */ $numbers)) {
$returnarray[$statsQuery->device_id] = $statsQuery->downloads_user;
}
if ($device !== NULL) {
if (isset($returnarray[$device])) {
return $returnarray[$device];
}
return 0;
}
// we should pretty-print the device names
$finalarray = [];
$devlist = \devices\Devices::listDevices($this->identifier);
foreach ($returnarray as $devId => $count) {
if (isset($devlist[$devId])) {
$finalarray[$devlist[$devId]['display']] = $count;
}
}
return $finalarray;
}
/**
* Deletes the profile from database and uninstantiates itself.
* Works fine also for Silver Bullet; the first query will simply do nothing
* because there are no stored options
*
* @return void
*/
public function destroy()
{
$this->databaseHandle->exec("DELETE FROM profile_option WHERE profile_id = $this->identifier");
$this->databaseHandle->exec("DELETE FROM supported_eap WHERE profile_id = $this->identifier");
$this->databaseHandle->exec("DELETE FROM profile WHERE profile_id = $this->identifier");
}
/**
* Specifies the realm of this profile.
*
* Forcefully type-hinting $realm parameter to string - Scrutinizer seems to
* think that it can alternatively be an array<integer,?> which looks like a
* false positive. If there really is an issue, let PHP complain about it at
* runtime.
*
* @param string $realm the realm (potentially with the local@ part that should be used for anonymous identities)
* @return void
*/
public function setRealm(string $realm)
{
$this->databaseHandle->exec("UPDATE profile SET realm = ? WHERE profile_id = ?", "si", $realm, $this->identifier);
$this->realm = $realm;
}
/**
* register new supported EAP method for this profile
*
* @param \core\common\EAP $type The EAP Type, as defined in class EAP
* @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.
* @return void
*/
public function addSupportedEapMethod(\core\common\EAP $type, $preference)
{
$eapInt = $type->getIntegerRep();
$this->databaseHandle->exec("INSERT INTO supported_eap (profile_id, eap_method_id, preference) VALUES (?, ?, ?)", "iii", $this->identifier, $eapInt, $preference);
$this->updateFreshness();
}
/**
* Produces an array of EAP methods supported by this profile, ordered by preference
*
* @param int $completeOnly if set and non-zero limits the output to methods with complete information
* @return array list of EAP methods, (in object representation)
*/
public function getEapMethodsinOrderOfPreference($completeOnly = 0)
{
$temparray = [];
if ($completeOnly == 0) {
return $this->privEaptypes;
}
foreach ($this->privEaptypes as $type) {
if ($this->isEapTypeDefinitionComplete($type) === true) {
$temparray[] = $type;
}
}
return($temparray);
}
/**
* Performs a sanity check for a given EAP type - did the admin submit enough information to create installers for him?
*
* @param common\EAP $eaptype the EAP type
* @return mixed TRUE if the EAP type is complete; an array of missing attributes if it's incomplete; FALSE if it's incomplete for other reasons
*/
public function isEapTypeDefinitionComplete($eaptype)
{
if ($eaptype->needsServerCACert() && $eaptype->needsServerName()) {
$missing = [];
// silverbullet needs a support email address configured
if ($eaptype->getIntegerRep() == common\EAP::INTEGER_SILVERBULLET && count($this->getAttributes("support:email")) == 0) {
return ["support:email"];
}
$cnOption = $this->getAttributes("eap:server_name"); // cannot be set per device or eap type
$caOption = $this->getAttributes("eap:ca_file"); // cannot be set per device or eap type
if (count($caOption) > 0 && count($cnOption) > 0) {// see if we have at least one root CA cert
foreach ($caOption as $oneCa) {
$x509 = new \core\common\X509();
$caParsed = $x509->processCertificate($oneCa['value']);
if ($caParsed['root'] == 1) {
return TRUE;
}
}
$missing[] = "eap:ca_file";
}
if (count($caOption) == 0) {
$missing[] = "eap:ca_file";
}
if (count($cnOption) == 0) {
$missing[] = "eap:server_name";
}
if (count($missing) == 0) {
return TRUE;
}
return $missing;
}
return TRUE;
}
/**
* list all devices marking their availabiblity and possible redirects
*
* @return array of device ids display names and their status
*/
public function listDevices()
{
$returnarray = [];
$redirect = $this->getAttributes("device-specific:redirect"); // this might return per-device ones or the general one
// if it was a general one, we are done. Find out if there is one such
// which has device = NULL
$generalRedirect = NULL;
foreach ($redirect as $index => $oneRedirect) {
if ($oneRedirect["level"] == Options::LEVEL_PROFILE) {
$generalRedirect = $index;
}
}
if ($generalRedirect !== NULL) { // could be index 0
return [['id' => '0', 'redirect' => $redirect[$generalRedirect]['value']]];
}
$preferredEap = $this->getEapMethodsinOrderOfPreference(1);
$eAPOptions = [];
if (count($this->getAttributes("media:openroaming")) == 1 && $this->getAttributes("media:openroaming")[0]['value'] == 'always-preagreed') {
$orAlways = 1;
} else {
$orAlways = 0;
}
foreach (\devices\Devices::listDevices($this->identifier, $orAlways) as $deviceIndex => $deviceProperties) {
$factory = new DeviceFactory($deviceIndex);
$dev = $factory->device;
// find the attribute pertaining to the specific device
$group = '';
$redirectUrl = 0;
$redirects = [];
foreach ($redirect as $index => $oneRedirect) {
if ($oneRedirect["device"] == $deviceIndex) {
$redirects[] = $oneRedirect;
}
}
if (count($redirects) > 0) {
$redirectUrl = $this->languageInstance->getLocalisedValue($redirects);
}
$devStatus = self::AVAILABLE;
$message = 0;
if (isset($deviceProperties['options']) && isset($deviceProperties['options']['message']) && $deviceProperties['options']['message']) {
$message = $deviceProperties['options']['message'];
}
if (isset($deviceProperties['group'])) {
$group = $deviceProperties['group'];
}
$eapCustomtext = 0;
$deviceCustomtext = 0;
$geteduroam = 0;
if ($redirectUrl === 0) {
if (isset($deviceProperties['options']) && isset($deviceProperties['options']['redirect']) && $deviceProperties['options']['redirect']) {
$devStatus = self::HIDDEN;
} else {
$dev->calculatePreferredEapType($preferredEap);
$eap = $dev->selectedEap;
if (count($eap) > 0) {
if (isset($eAPOptions["eap-specific:customtext"][serialize($eap)])) {
$eapCustomtext = $eAPOptions["eap-specific:customtext"][serialize($eap)];
} else {
// fetch customtexts from method-level attributes
$eapCustomtext = 0;
$customTextAttributes = [];
$attributeList = $this->getAttributes("eap-specific:customtext"); // eap-specific attributes always have the array index 'eapmethod' set
foreach ($attributeList as $oneAttribute) {
if ($oneAttribute["eapmethod"] == $eap) {
$customTextAttributes[] = $oneAttribute;
}
}
if (count($customTextAttributes) > 0) {
$eapCustomtext = $this->languageInstance->getLocalisedValue($customTextAttributes);
}
$eAPOptions["eap-specific:customtext"][serialize($eap)] = $eapCustomtext;
}
// fetch customtexts for device
$customTextAttributes = [];
$attributeList = $this->getAttributes("device-specific:customtext");
foreach ($attributeList as $oneAttribute) {
if ($oneAttribute["device"] == $deviceIndex) { // device-specific attributes always have the array index "device" set
$customTextAttributes[] = $oneAttribute;
}
}
$deviceCustomtext = $this->languageInstance->getLocalisedValue($customTextAttributes);
} else {
$devStatus = self::UNAVAILABLE;
}
$geteduroamOpts = $this->getAttributes("device-specific:geteduroam");
foreach ($geteduroamOpts as $dev) {
if ($dev['device'] == $deviceIndex) {
$geteduroam = $dev['value'] == 'on' ? 1 : 0;
}
}
}
}
$returnarray[] = ['id' => $deviceIndex, 'display' => $deviceProperties['display'], 'status' => $devStatus, 'redirect' => $redirectUrl, 'eap_customtext' => $eapCustomtext, 'device_customtext' => $deviceCustomtext, 'message' => $message, 'options' => $deviceProperties['options'], 'group' => $group, 'geteduroam' => $geteduroam];
}
return $returnarray;
}
/**
* prepare profile attributes for device modules
* Gets profile attributes taking into account the most specific level on which they may be defined
* as well as the chosen language.
* can be called with an optional $eap argument
*
* @param array $eap if specified, retrieves all attributes except those not pertaining to the given EAP type
* @return array list of attributes in collapsed style (index is the attrib name, value is an array of different values)
*/
public function getCollapsedAttributes($eap = [])
{
$collapsedList = [];
foreach ($this->getAttributes() as $attribute) {
// filter out eap-level attributes not pertaining to EAP type $eap
if (count($eap) > 0 && isset($attribute['eapmethod']) && $attribute['eapmethod'] != 0 && $attribute['eapmethod'] != $eap) {
continue;
}
// create new array indexed by attribute name
if (isset($attribute['device'])) {
$collapsedList[$attribute['name']][$attribute['device']][] = $attribute['value'];
} else {
$collapsedList[$attribute['name']][] = $attribute['value'];
}
// and keep all language-variant names in a separate sub-array
if ($attribute['flag'] == "ML") {
$collapsedList[$attribute['name']]['langs'][$attribute['lang']] = $attribute['value'];
}
}
// once we have the final list, populate the respective "best-match"
// language to choose for the ML attributes
foreach ($collapsedList as $attribName => $valueArray) {
if (isset($valueArray['langs'])) { // we have at least one language-dependent name in this attribute
// for printed names on screen:
// assign to exact match language, fallback to "default" language, fallback to English, fallback to whatever comes first in the list
$collapsedList[$attribName][0] = $valueArray['langs'][$this->languageInstance->getLang()] ?? $valueArray['langs']['C'] ?? $valueArray['langs']['en'] ?? array_shift($valueArray['langs']);
// for names usable in filesystems (closer to good old ASCII...)
// prefer English, otherwise the "default" language, otherwise the same that we got above
$collapsedList[$attribName][1] = $valueArray['langs']['en'] ?? $valueArray['langs']['C'] ?? $collapsedList[$attribName][0];
}
}
return $collapsedList;
}
/**
* Is the profile global redirection set?
*
* @return bool
*/
public function isRedirected() {
$result = $this->databaseHandle->exec("SELECT profile_id FROM profile_option WHERE profile_id = ? AND option_name='device-specific:redirect' AND device_id IS NULL", "i", $this->identifier);
if ($result->num_rows == 0) {
return false;
}
return true;
}
/**
* Does the profile contain enough information to generate installers with
* it? Silverbullet will always return TRUE; RADIUS profiles need to do some
* heavy lifting here.
*
* @return int one of the constants above which tell if enough info is set to enable installers
*/
public function readinessLevel()
{
$result = $this->databaseHandle->exec("SELECT sufficient_config, showtime FROM profile WHERE profile_id = ?", "i", $this->identifier);
// SELECT queries always return a resource, not a boolean
$configQuery = mysqli_fetch_row(/** @scrutinizer ignore-type */ $result);
if ($configQuery[0] == "0") {
return self::READINESS_LEVEL_NOTREADY;
}
// at least fully configured, if not showtime!
if ($configQuery[1] == "0") {
return self::READINESS_LEVEL_SUFFICIENTCONFIG;
}
return self::READINESS_LEVEL_SHOWTIME;
}
/**
* Checks all profile certificates validity periods comparing to the pre-defined
* thresholds and returns the most critical status.
*
* @return int - one of constants defined in this profile
*/
public function certificateStatus()
{
$query = "SELECT option_value AS cert FROM profile_option WHERE option_name='eap:ca_file' AND profile_id = ?";
$result = $this->databaseHandle->exec($query, "i", $this->identifier);
$rows = $result->fetch_all();
$x509 = new \core\common\X509();
$profileStatus = self::CERT_STATUS_NONE;
foreach ($rows as $row) {
$encodedCert = $row[0];
$tm = $x509->processCertificate(base64_decode($encodedCert))['full_details']['validTo_time_t']- time();
if ($tm < \config\ConfAssistant::CERT_WARNINGS['expiry_critical']) {
$certStatus = self::CERT_STATUS_ERROR;
} elseif ($tm < \config\ConfAssistant::CERT_WARNINGS['expiry_warning']) {
$certStatus = self::CERT_STATUS_WARN;
} else {
$certStatus = self::CERT_STATUS_OK;
}
$profileStatus = max($profileStatus, $certStatus);
}
return $profileStatus;
}
/**
* Checks if the profile has enough information to have something to show to end users. This does not necessarily mean
* that there's a fully configured EAP type - it is sufficient if a redirect has been set for at least one device.
*
* @return boolean TRUE if enough information for showtime is set; FALSE if not
*/
private function readyForShowtime()
{
$properConfig = FALSE;
$attribs = $this->getCollapsedAttributes();
// do we have enough to go live? Check if any of the configured EAP methods is completely configured ...
if (sizeof($this->getEapMethodsinOrderOfPreference(1)) > 0) {
$properConfig = TRUE;
}
// if not, it could still be that general redirect has been set
if (!$properConfig) {
if (isset($attribs['device-specific:redirect'])) {
$properConfig = TRUE;
}
// just a per-device redirect? would be good enough... but this is not actually possible:
// per-device redirects can only be set on the "fine-tuning" page, which is only accessible
// if at least one EAP type is fully configured - which is caught above and makes readyForShowtime TRUE already
}
// do we know at least one SSID to configure, or work with wired? If not, it's not ready...
if (!isset($attribs['media:SSID']) &&
(!isset(\config\ConfAssistant::CONSORTIUM['ssid']) || count(\config\ConfAssistant::CONSORTIUM['ssid']) == 0) &&
!isset($attribs['media:wired'])) {
$properConfig = FALSE;
}
// institutions without a name are not really a corner case we should support
if (!isset($attribs['general:instname'])) {
$properConfig = FALSE;
}
return $properConfig;
}
/**
* set the showtime property if prepShowTime says that there is enough info *and* the admin flagged the profile for showing
*
* @return void
*/
public function prepShowtime()
{
$properConfig = $this->readyForShowtime();
$this->databaseHandle->exec("UPDATE profile SET sufficient_config = ".($properConfig ? "TRUE" : "FALSE")." WHERE profile_id = ".$this->identifier);
$attribs = $this->getCollapsedAttributes();
// if not enough info to go live, set FALSE
// even if enough info is there, admin has the ultimate say:
// if he doesn't want to go live, no further checks are needed, set FALSE as well
if (!$properConfig || !isset($attribs['profile:production']) || (isset($attribs['profile:production']) && $attribs['profile:production'][0] != "on")) {
$this->databaseHandle->exec("UPDATE profile SET showtime = FALSE WHERE profile_id = ?", "i", $this->identifier);
return;
}
$this->databaseHandle->exec("UPDATE profile SET showtime = TRUE WHERE profile_id = ?", "i", $this->identifier);
}
/**
* internal helper - some attributes are added by the constructor "ex officio"
* without actual input from the admin. We can streamline their addition in
* this function to avoid duplication.
*
* @param array $internalAttributes - only names and value
* @return array full attributes with all properties set
*/
protected function addInternalAttributes($internalAttributes)
{
// internal attributes share many attribute properties, so condense the generation
$retArray = [];
foreach ($internalAttributes as $attName => $attValue) {
$retArray[] = ["name" => $attName,
"lang" => NULL,
"value" => $attValue,
"level" => Options::LEVEL_PROFILE,
"row_id" => 0,
"flag" => NULL,
];
}
return $retArray;
}
/**
* Retrieves profile attributes stored in the database
*
* @return array The attributes in one array
*/
protected function addDatabaseAttributes()
{
$databaseAttributes = $this->retrieveOptionsFromDatabase("SELECT DISTINCT option_name, option_lang, option_value, row_id
FROM $this->entityOptionTable
WHERE $this->entityIdColumn = ?
AND device_id IS NULL AND eap_method_id = 0
ORDER BY option_name", "Profile");
return $databaseAttributes;
}
}