Subversion Repositories oidplus

Rev

Rev 1283 | Rev 1301 | Go to most recent revision | Details | Compare with Previous | Last modification | View Log | RSS feed

Rev Author Line No. Line
2 daniel-mar 1
<?php
2
 
3
/*
4
 * OIDplus 2.0
1086 daniel-mar 5
 * Copyright 2019 - 2023 Daniel Marschall, ViaThinkSoft
2 daniel-mar 6
 *
7
 * Licensed under the Apache License, Version 2.0 (the "License");
8
 * you may not use this file except in compliance with the License.
9
 * You may obtain a copy of the License at
10
 *
11
 *     http://www.apache.org/licenses/LICENSE-2.0
12
 *
13
 * Unless required by applicable law or agreed to in writing, software
14
 * distributed under the License is distributed on an "AS IS" BASIS,
15
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
 * See the License for the specific language governing permissions and
17
 * limitations under the License.
18
 */
19
 
1050 daniel-mar 20
namespace ViaThinkSoft\OIDplus;
511 daniel-mar 21
 
1086 daniel-mar 22
// phpcs:disable PSR1.Files.SideEffects
23
\defined('INSIDE_OIDPLUS') or die;
24
// phpcs:enable PSR1.Files.SideEffects
25
 
730 daniel-mar 26
class OIDplusAuthUtils extends OIDplusBaseClass {
2 daniel-mar 27
 
566 daniel-mar 28
        // Useful functions
29
 
1116 daniel-mar 30
        /**
31
         * @param string $password
32
         * @return string
33
         * @throws OIDplusException
34
         */
1186 daniel-mar 35
        private function raPepperProcessing(string $password): string {
617 daniel-mar 36
                // Additional feature: Pepper
37
                // The pepper is stored inside the base configuration file
38
                // It prevents that an attacker with SQL write rights can
39
                // create accounts.
40
                // ATTENTION!!! If a pepper is used, then the
41
                // hashes are bound to that pepper. If you change the pepper,
42
                // then ALL passwords of RAs become INVALID!
43
                $pepper = OIDplus::baseConfig()->getValue('RA_PASSWORD_PEPPER','');
44
                if ($pepper !== '') {
711 daniel-mar 45
                        $algo = OIDplus::baseConfig()->getValue('RA_PASSWORD_PEPPER_ALGO','sha512'); // sha512 works with PHP 7.0
46
                        if (strtolower($algo) === 'sha3-512') {
47
                                $hmac = sha3_512_hmac($password, $pepper);
48
                        } else {
49
                                $hmac = hash_hmac($algo, $password, $pepper);
50
                        }
1116 daniel-mar 51
                        if ($hmac === "") throw new OIDplusException(_L('HMAC failed'));
617 daniel-mar 52
                        return $hmac;
53
                } else {
54
                        return $password;
55
                }
56
        }
57
 
585 daniel-mar 58
        // Content provider
577 daniel-mar 59
 
1116 daniel-mar 60
        /**
61
         * @return string
62
         * @throws OIDplusException
63
         */
1130 daniel-mar 64
        public function getAuthMethod(): string {
585 daniel-mar 65
                $acs = $this->getAuthContentStore();
66
                if (is_null($acs)) return 'null';
67
                return get_class($acs);
577 daniel-mar 68
        }
69
 
1116 daniel-mar 70
        /**
71
         * @return OIDplusAuthContentStore|null
72
         * @throws OIDplusException
73
         */
74
        protected function getAuthContentStore()/*: ?OIDplusAuthContentStore*/ {
1265 daniel-mar 75
                // TODO: Should we implement these AuthContentStore as plugin type, so that there can be more than just JWT and PHP session?
76
 
585 daniel-mar 77
                // Logged in via JWT
1300 daniel-mar 78
                // (The JWT can come from a REST Authentication Bearer, an AJAX Cookie, or an Automated AJAX Call GET/POST token.)
585 daniel-mar 79
                $tmp = OIDplusAuthContentStoreJWT::getActiveProvider();
80
                if ($tmp) return $tmp;
577 daniel-mar 81
 
1300 daniel-mar 82
                // Normal login via web-browser
83
                // Cookie will only be created once content is stored
84
                $tmp = OIDplusAuthContentStoreSession::getActiveProvider();
85
                if ($tmp) return $tmp;
577 daniel-mar 86
 
585 daniel-mar 87
                // No active session and no JWT token available. User is not logged in.
88
                return null;
577 daniel-mar 89
        }
90
 
1116 daniel-mar 91
        /**
92
         * @param string $name
93
         * @param mixed|null $default
94
         * @return mixed
95
         * @throws OIDplusException
96
         */
97
        public function getExtendedAttribute(string $name, $default=NULL) {
585 daniel-mar 98
                $acs = $this->getAuthContentStore();
99
                if (is_null($acs)) return $default;
100
                return $acs->getValue($name, $default);
577 daniel-mar 101
        }
102
 
566 daniel-mar 103
        // RA authentication functions
2 daniel-mar 104
 
1116 daniel-mar 105
        /**
106
         * @param string $email
107
         * @return void
108
         * @throws OIDplusException
109
         */
110
        public function raLogin(string $email) {
585 daniel-mar 111
                $acs = $this->getAuthContentStore();
112
                if (is_null($acs)) return;
1116 daniel-mar 113
                $acs->raLogin($email);
566 daniel-mar 114
        }
2 daniel-mar 115
 
1116 daniel-mar 116
        /**
117
         * @param string $email
118
         * @return void
119
         * @throws OIDplusException
120
         */
121
        public function raLogout(string $email) {
585 daniel-mar 122
                $acs = $this->getAuthContentStore();
123
                if (is_null($acs)) return;
1116 daniel-mar 124
                $acs->raLogout($email);
2 daniel-mar 125
        }
126
 
1116 daniel-mar 127
        /**
128
         * @param string $ra_email
129
         * @param string $password
130
         * @return bool
131
         * @throws OIDplusException
132
         */
133
        public function raCheckPassword(string $ra_email, string $password): bool {
329 daniel-mar 134
                $ra = new OIDplusRA($ra_email);
453 daniel-mar 135
 
617 daniel-mar 136
                // Get RA info from RA
459 daniel-mar 137
                $authInfo = $ra->getAuthInfo();
590 daniel-mar 138
                if (!$authInfo) return false; // user not found
622 daniel-mar 139
 
617 daniel-mar 140
                // Ask plugins if they can verify this hash
453 daniel-mar 141
                $plugins = OIDplus::getAuthPlugins();
142
                if (count($plugins) == 0) {
143
                        throw new OIDplusException(_L('No RA authentication plugins found'));
144
                }
145
                foreach ($plugins as $plugin) {
1186 daniel-mar 146
                        if ($plugin->verify($authInfo, $this->raPepperProcessing($password))) return true;
453 daniel-mar 147
                }
148
 
149
                return false;
329 daniel-mar 150
        }
151
 
1116 daniel-mar 152
        /**
153
         * @return int
154
         * @throws OIDplusException
155
         */
156
        public function raNumLoggedIn(): int {
585 daniel-mar 157
                $acs = $this->getAuthContentStore();
158
                if (is_null($acs)) return 0;
159
                return $acs->raNumLoggedIn();
85 daniel-mar 160
        }
161
 
1116 daniel-mar 162
        /**
163
         * @return OIDplusRA[]
164
         * @throws OIDplusException
165
         */
166
        public function loggedInRaList(): array {
585 daniel-mar 167
                if ($this->forceAllLoggedOut()) {
567 daniel-mar 168
                        return array();
169
                } else {
585 daniel-mar 170
                        $acs = $this->getAuthContentStore();
171
                        if (is_null($acs)) return array();
172
                        return $acs->loggedInRaList();
567 daniel-mar 173
                }
2 daniel-mar 174
        }
175
 
1116 daniel-mar 176
        /**
1267 daniel-mar 177
         * @param string|OIDplusRA $ra
1116 daniel-mar 178
         * @return bool
179
         * @throws OIDplusException
180
         */
1267 daniel-mar 181
        public function isRaLoggedIn($ra): bool {
182
                $email = $ra instanceof OIDplusRA ? $ra->raEmail() : $ra;
585 daniel-mar 183
                $acs = $this->getAuthContentStore();
184
                if (is_null($acs)) return false;
185
                return $acs->isRaLoggedIn($email);
2 daniel-mar 186
        }
187
 
585 daniel-mar 188
        // "High level" function including logging and checking for valid JWT alternations
1116 daniel-mar 189
 
190
        /**
191
         * @param string $email
192
         * @param bool $remember_me
193
         * @param string $origin
194
         * @return void
195
         * @throws OIDplusException
196
         */
197
        public function raLoginEx(string $email, bool $remember_me, string $origin='') {
585 daniel-mar 198
                $loginfo = '';
199
                $acs = $this->getAuthContentStore();
200
                if (!is_null($acs)) {
201
                        $acs->raLoginEx($email, $loginfo);
202
                        $acs->activate();
203
                } else {
204
                        if ($remember_me) {
205
                                if (!OIDplus::baseConfig()->getValue('JWT_ALLOW_LOGIN_USER', true)) {
206
                                        throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_LOGIN_USER'));
207
                                }
208
                                $ttl = OIDplus::baseConfig()->getValue('JWT_TTL_LOGIN_USER', 10*365*24*60*60);
209
                                $authSimulation = new OIDplusAuthContentStoreJWT();
210
                                $authSimulation->raLoginEx($email, $loginfo);
211
                                $authSimulation->setValue('oidplus_generator', OIDplusAuthContentStoreJWT::JWT_GENERATOR_LOGIN);
212
                                $authSimulation->setValue('exp', time()+$ttl); // JWT "exp" attribute
213
                                $authSimulation->activate();
214
                        } else {
215
                                $authSimulation = new OIDplusAuthContentStoreSession();
216
                                $authSimulation->raLoginEx($email, $loginfo);
217
                                $authSimulation->activate();
218
                        }
219
                }
220
                $logmsg = "RA '$email' logged in";
221
                if ($origin != '') $logmsg .= " via $origin";
222
                if ($loginfo != '') $logmsg .= " ($loginfo)";
1267 daniel-mar 223
                OIDplus::logger()->log("V2:[OK]RA(%1)", "%2", $email, $logmsg);
585 daniel-mar 224
        }
225
 
1116 daniel-mar 226
        /**
227
         * @param string $email
228
         * @return void
229
         * @throws OIDplusException
230
         */
231
        public function raLogoutEx(string $email) {
585 daniel-mar 232
                $loginfo = '';
233
 
234
                $acs = $this->getAuthContentStore();
235
                if (is_null($acs)) return;
1116 daniel-mar 236
                $acs->raLogoutEx($email, $loginfo);
585 daniel-mar 237
 
1267 daniel-mar 238
                OIDplus::logger()->log("V2:[OK]RA(%1)", "RA '%1' logged out (%2)", $email, $loginfo);
585 daniel-mar 239
 
240
                if (($this->raNumLoggedIn() == 0) && (!$this->isAdminLoggedIn())) {
241
                        // Nobody logged in anymore. Destroy session cookie to make GDPR people happy
242
                        $acs->destroySession();
243
                } else {
244
                        // Get a new token for the remaining users
245
                        $acs->activate();
246
                }
247
        }
248
 
14 daniel-mar 249
        // Admin authentication functions
2 daniel-mar 250
 
1116 daniel-mar 251
        /**
252
         * @return void
253
         * @throws OIDplusException
254
         */
566 daniel-mar 255
        public function adminLogin() {
585 daniel-mar 256
                $acs = $this->getAuthContentStore();
257
                if (is_null($acs)) return;
1116 daniel-mar 258
                $acs->adminLogin();
2 daniel-mar 259
        }
260
 
1116 daniel-mar 261
        /**
262
         * @return void
263
         * @throws OIDplusException
264
         */
566 daniel-mar 265
        public function adminLogout() {
585 daniel-mar 266
                $acs = $this->getAuthContentStore();
267
                if (is_null($acs)) return;
1116 daniel-mar 268
                $acs->adminLogout();
2 daniel-mar 269
        }
270
 
1116 daniel-mar 271
        /**
272
         * @param string $password
273
         * @return bool
274
         * @throws OIDplusException
275
         */
276
        public function adminCheckPassword(string $password): bool {
609 daniel-mar 277
                $cfgData = OIDplus::baseConfig()->getValue('ADMIN_PASSWORD', '');
278
                if (empty($cfgData)) {
360 daniel-mar 279
                        throw new OIDplusException(_L('No admin password set in %1','userdata/baseconfig/config.inc.php'));
261 daniel-mar 280
                }
622 daniel-mar 281
 
609 daniel-mar 282
                if (!is_array($cfgData)) {
283
                        $passwordDataArray = array($cfgData);
284
                } else {
1106 daniel-mar 285
                        $passwordDataArray = $cfgData; // Multiple Administrator passwords
609 daniel-mar 286
                }
622 daniel-mar 287
 
609 daniel-mar 288
                foreach ($passwordDataArray as $passwordData) {
1107 daniel-mar 289
                        if (str_starts_with($passwordData, '$')) {
290
                                // Version 3: BCrypt (or any other crypt)
291
                                $ok = password_verify($password, $passwordData);
292
                        } else if (strpos($passwordData, '$') !== false) {
293
                                // Version 2: SHA3-512 with salt
294
                                list($salt, $hash) = explode('$', $passwordData, 2);
295
                                $ok = hash_equals(sha3_512($salt.$password, true), base64_decode($hash));
456 daniel-mar 296
                        } else {
609 daniel-mar 297
                                // Version 1: SHA3-512 without salt
1107 daniel-mar 298
                                $ok = hash_equals(sha3_512($password, true), base64_decode($passwordData));
456 daniel-mar 299
                        }
1107 daniel-mar 300
                        if ($ok) return true;
421 daniel-mar 301
                }
609 daniel-mar 302
 
303
                return false;
2 daniel-mar 304
        }
305
 
1116 daniel-mar 306
        /**
307
         * @return bool
308
         * @throws OIDplusException
309
         */
310
        public function isAdminLoggedIn(): bool {
585 daniel-mar 311
                if ($this->forceAllLoggedOut()) {
567 daniel-mar 312
                        return false;
313
                } else {
585 daniel-mar 314
                        $acs = $this->getAuthContentStore();
315
                        if (is_null($acs)) return false;
316
                        return $acs->isAdminLoggedIn();
567 daniel-mar 317
                }
2 daniel-mar 318
        }
319
 
1116 daniel-mar 320
        /**
321
         * "High level" function including logging and checking for valid JWT alternations
322
         * @param bool $remember_me
323
         * @param string $origin
324
         * @return void
325
         * @throws OIDplusException
326
         */
327
        public function adminLoginEx(bool $remember_me, string $origin='') {
585 daniel-mar 328
                $loginfo = '';
329
                $acs = $this->getAuthContentStore();
330
                if (!is_null($acs)) {
331
                        $acs->adminLoginEx($loginfo);
332
                        $acs->activate();
333
                } else {
334
                        if ($remember_me) {
335
                                if (!OIDplus::baseConfig()->getValue('JWT_ALLOW_LOGIN_ADMIN', true)) {
336
                                        throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_LOGIN_ADMIN'));
337
                                }
338
                                $ttl = OIDplus::baseConfig()->getValue('JWT_TTL_LOGIN_ADMIN', 10*365*24*60*60);
339
                                $authSimulation = new OIDplusAuthContentStoreJWT();
340
                                $authSimulation->adminLoginEx($loginfo);
341
                                $authSimulation->setValue('oidplus_generator', OIDplusAuthContentStoreJWT::JWT_GENERATOR_LOGIN);
342
                                $authSimulation->setValue('exp', time()+$ttl); // JWT "exp" attribute
343
                                $authSimulation->activate();
344
                        } else {
345
                                $authSimulation = new OIDplusAuthContentStoreSession();
346
                                $authSimulation->adminLoginEx($loginfo);
347
                                $authSimulation->activate();
348
                        }
349
                }
350
                $logmsg = "Admin logged in";
351
                if ($origin != '') $logmsg .= " via $origin";
352
                if ($loginfo != '') $logmsg .= " ($loginfo)";
1267 daniel-mar 353
                OIDplus::logger()->log("V2:[OK]A", "%1", $logmsg);
585 daniel-mar 354
        }
355
 
1116 daniel-mar 356
        /**
357
         * @return void
358
         * @throws OIDplusException
359
         */
585 daniel-mar 360
        public function adminLogoutEx() {
361
                $loginfo = '';
362
 
363
                $acs = $this->getAuthContentStore();
364
                if (is_null($acs)) return;
1116 daniel-mar 365
                $acs->adminLogoutEx($loginfo);
585 daniel-mar 366
 
367
                if ($this->raNumLoggedIn() == 0) {
368
                        // Nobody here anymore. Destroy the cookie to make GDPR people happy
369
                        $acs->destroySession();
370
                } else {
371
                        // Get a new token for the remaining users
372
                        $acs->activate();
373
                }
374
 
1267 daniel-mar 375
                OIDplus::logger()->log("V2:[OK]A", "Admin logged out (%1)", $loginfo);
585 daniel-mar 376
        }
377
 
1282 daniel-mar 378
        // Authentication keys for generating secrets or validating arguments (e.g. sent by mail)
2 daniel-mar 379
 
1116 daniel-mar 380
        /**
1283 daniel-mar 381
         * @param array|string $data
1116 daniel-mar 382
         * @return string
383
         * @throws OIDplusException
384
         */
1283 daniel-mar 385
        public function makeSecret($data): string {
386
                if (!is_array($data)) $data = [$data];
387
                $data = json_encode($data);
1282 daniel-mar 388
                return sha3_512_hmac($data, 'OIDplus:'.OIDplus::baseConfig()->getValue('SERVER_SECRET'), false);
389
        }
390
 
391
        /**
1283 daniel-mar 392
         * @param array|string $data Arbitary data to be validated later
393
         * @return string A string that need to be validated with validateAuthKey
1282 daniel-mar 394
         * @throws OIDplusException
395
         */
1283 daniel-mar 396
        public function makeAuthKey($data): string {
397
                if (!is_array($data)) $data = [$data];
398
                $ts = time();
399
                $data_ext = [$ts, $data];
400
                $secret = $this->makeSecret($data_ext);
401
                return $ts.'.'.$secret;
2 daniel-mar 402
        }
403
 
1116 daniel-mar 404
        /**
1283 daniel-mar 405
         * @param array|string $data The original data that had been passed to makeAuthKey()
406
         * @param string $auth_key The result from makeAuthKey()
407
         * @param int $valid_secs How many seconds is the auth key valid? (-1 for infinite)
408
         * @return bool True if the key is valid and not expired.
1116 daniel-mar 409
         * @throws OIDplusException
410
         */
1283 daniel-mar 411
        public function validateAuthKey($data, string $auth_key, int $valid_secs=-1): bool {
412
                $auth_key_ary = explode('.', $auth_key, 2);
413
                if (count($auth_key_ary) != 2) return false; // invalid auth key syntax
414
                list($ts, $secret) = $auth_key_ary;
415
                if (!is_numeric($ts)) return false; // invalid auth key syntax
416
                if ($valid_secs >= 0) {
417
                        if (time() > ($ts+$valid_secs)) return false; // expired auth key
418
                }
419
                if (!is_array($data)) $data = [$data];
420
                $data_ext = [(int)$ts, $data];
421
                return hash_equals($this->makeSecret($data_ext), $secret);
2 daniel-mar 422
        }
423
 
329 daniel-mar 424
        // "Veto" functions to force logout state
425
 
1116 daniel-mar 426
        /**
427
         * @return bool
428
         */
429
        protected function forceAllLoggedOut(): bool {
329 daniel-mar 430
                if (isset($_SERVER['SCRIPT_FILENAME']) && (basename($_SERVER['SCRIPT_FILENAME']) == 'sitemap.php')) {
431
                        // The sitemap may not contain any confidential information,
432
                        // even if the user is logged in, because the admin could
433
                        // accidentally copy-paste the sitemap to a
434
                        // search engine control panel while they are logged in
435
                        return true;
436
                } else {
437
                        return false;
438
                }
439
        }
427 daniel-mar 440
 
424 daniel-mar 441
        // CSRF functions
427 daniel-mar 442
 
424 daniel-mar 443
        private $enable_csrf = true;
427 daniel-mar 444
 
1116 daniel-mar 445
        /**
446
         * @return void
447
         */
424 daniel-mar 448
        public function enableCSRF() {
449
                $this->enable_csrf = true;
450
        }
427 daniel-mar 451
 
1116 daniel-mar 452
        /**
453
         * @return void
454
         */
424 daniel-mar 455
        public function disableCSRF() {
456
                $this->enable_csrf = false;
457
        }
427 daniel-mar 458
 
1116 daniel-mar 459
        /**
460
         * @return string
461
         * @throws \Random\RandomException
462
         */
463
        public function genCSRFToken(): string {
1098 daniel-mar 464
                return random_bytes_ex(64, false, false);
424 daniel-mar 465
        }
427 daniel-mar 466
 
1116 daniel-mar 467
        /**
468
         * @return void
469
         * @throws OIDplusException
470
         */
424 daniel-mar 471
        public function checkCSRF() {
427 daniel-mar 472
                if (!$this->enable_csrf) return;
866 daniel-mar 473
 
1130 daniel-mar 474
                $request_token = $_REQUEST['csrf_token'] ?? '';
475
                $cookie_token = $_COOKIE['csrf_token'] ?? '';
866 daniel-mar 476
 
477
                if (empty($request_token) || empty($cookie_token) || ($request_token !== $cookie_token)) {
478
                        if (OIDplus::baseConfig()->getValue('DEBUG')) {
479
                                throw new OIDplusException(_L('Missing or wrong CSRF Token: Request %1 vs Cookie %2',
480
                                        isset($_REQUEST['csrf_token']) ? '"'.$_REQUEST['csrf_token'].'"' : 'NULL',
1130 daniel-mar 481
                                        $_COOKIE['csrf_token'] ?? 'NULL'
866 daniel-mar 482
                                ));
483
                        } else {
484
                                throw new OIDplusException(_L('Missing or wrong "CSRF Token". To fix the issue, try clearing your browser cache and reload the page. If you visited the page via HTTPS before, try HTTPS in case you are currently connected via HTTP.'));
485
                        }
424 daniel-mar 486
                }
487
        }
329 daniel-mar 488
 
421 daniel-mar 489
        // Generate RA passwords
622 daniel-mar 490
 
1116 daniel-mar 491
        /**
492
         * @param string $password
493
         * @return OIDplusRAAuthInfo
494
         * @throws OIDplusException
495
         */
1186 daniel-mar 496
        public function raGeneratePassword(string $password): OIDplusRAAuthInfo {
1099 daniel-mar 497
                $plugin = OIDplus::getDefaultRaAuthPlugin(true);
1186 daniel-mar 498
                return $plugin->generate($this->raPepperProcessing($password));
421 daniel-mar 499
        }
500
 
501
        // Generate admin password
502
 
585 daniel-mar 503
        /* Nothing here; the admin password will be generated in setup_base.js , purely in the web-browser */
421 daniel-mar 504
 
505
}