Subversion Repositories oidplus

Rev

Rev 1300 | Rev 1303 | 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)) {
1301 daniel-mar 201
                        // User is already logged in (a session or JWT exists), so we modify their login status
585 daniel-mar 202
                        $acs->raLoginEx($email, $loginfo);
203
                        $acs->activate();
204
                } else {
1301 daniel-mar 205
                        // No user is logged in (no session or JWT exists). We now create a auth content store and activate it (cookies will be set etc.)
585 daniel-mar 206
                        if ($remember_me) {
207
                                if (!OIDplus::baseConfig()->getValue('JWT_ALLOW_LOGIN_USER', true)) {
208
                                        throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_LOGIN_USER'));
209
                                }
210
                                $ttl = OIDplus::baseConfig()->getValue('JWT_TTL_LOGIN_USER', 10*365*24*60*60);
211
                                $authSimulation = new OIDplusAuthContentStoreJWT();
212
                                $authSimulation->raLoginEx($email, $loginfo);
213
                                $authSimulation->setValue('oidplus_generator', OIDplusAuthContentStoreJWT::JWT_GENERATOR_LOGIN);
214
                                $authSimulation->setValue('exp', time()+$ttl); // JWT "exp" attribute
215
                                $authSimulation->activate();
216
                        } else {
217
                                $authSimulation = new OIDplusAuthContentStoreSession();
218
                                $authSimulation->raLoginEx($email, $loginfo);
219
                                $authSimulation->activate();
220
                        }
221
                }
222
                $logmsg = "RA '$email' logged in";
223
                if ($origin != '') $logmsg .= " via $origin";
224
                if ($loginfo != '') $logmsg .= " ($loginfo)";
1267 daniel-mar 225
                OIDplus::logger()->log("V2:[OK]RA(%1)", "%2", $email, $logmsg);
585 daniel-mar 226
        }
227
 
1116 daniel-mar 228
        /**
229
         * @param string $email
230
         * @return void
231
         * @throws OIDplusException
232
         */
233
        public function raLogoutEx(string $email) {
585 daniel-mar 234
                $loginfo = '';
235
 
236
                $acs = $this->getAuthContentStore();
237
                if (is_null($acs)) return;
1116 daniel-mar 238
                $acs->raLogoutEx($email, $loginfo);
585 daniel-mar 239
 
1267 daniel-mar 240
                OIDplus::logger()->log("V2:[OK]RA(%1)", "RA '%1' logged out (%2)", $email, $loginfo);
585 daniel-mar 241
 
242
                if (($this->raNumLoggedIn() == 0) && (!$this->isAdminLoggedIn())) {
243
                        // Nobody logged in anymore. Destroy session cookie to make GDPR people happy
244
                        $acs->destroySession();
245
                } else {
246
                        // Get a new token for the remaining users
247
                        $acs->activate();
248
                }
249
        }
250
 
14 daniel-mar 251
        // Admin authentication functions
2 daniel-mar 252
 
1116 daniel-mar 253
        /**
254
         * @return void
255
         * @throws OIDplusException
256
         */
566 daniel-mar 257
        public function adminLogin() {
585 daniel-mar 258
                $acs = $this->getAuthContentStore();
259
                if (is_null($acs)) return;
1116 daniel-mar 260
                $acs->adminLogin();
2 daniel-mar 261
        }
262
 
1116 daniel-mar 263
        /**
264
         * @return void
265
         * @throws OIDplusException
266
         */
566 daniel-mar 267
        public function adminLogout() {
585 daniel-mar 268
                $acs = $this->getAuthContentStore();
269
                if (is_null($acs)) return;
1116 daniel-mar 270
                $acs->adminLogout();
2 daniel-mar 271
        }
272
 
1116 daniel-mar 273
        /**
274
         * @param string $password
275
         * @return bool
276
         * @throws OIDplusException
277
         */
278
        public function adminCheckPassword(string $password): bool {
609 daniel-mar 279
                $cfgData = OIDplus::baseConfig()->getValue('ADMIN_PASSWORD', '');
280
                if (empty($cfgData)) {
360 daniel-mar 281
                        throw new OIDplusException(_L('No admin password set in %1','userdata/baseconfig/config.inc.php'));
261 daniel-mar 282
                }
622 daniel-mar 283
 
609 daniel-mar 284
                if (!is_array($cfgData)) {
285
                        $passwordDataArray = array($cfgData);
286
                } else {
1106 daniel-mar 287
                        $passwordDataArray = $cfgData; // Multiple Administrator passwords
609 daniel-mar 288
                }
622 daniel-mar 289
 
609 daniel-mar 290
                foreach ($passwordDataArray as $passwordData) {
1107 daniel-mar 291
                        if (str_starts_with($passwordData, '$')) {
292
                                // Version 3: BCrypt (or any other crypt)
293
                                $ok = password_verify($password, $passwordData);
294
                        } else if (strpos($passwordData, '$') !== false) {
295
                                // Version 2: SHA3-512 with salt
296
                                list($salt, $hash) = explode('$', $passwordData, 2);
297
                                $ok = hash_equals(sha3_512($salt.$password, true), base64_decode($hash));
456 daniel-mar 298
                        } else {
609 daniel-mar 299
                                // Version 1: SHA3-512 without salt
1107 daniel-mar 300
                                $ok = hash_equals(sha3_512($password, true), base64_decode($passwordData));
456 daniel-mar 301
                        }
1107 daniel-mar 302
                        if ($ok) return true;
421 daniel-mar 303
                }
609 daniel-mar 304
 
305
                return false;
2 daniel-mar 306
        }
307
 
1116 daniel-mar 308
        /**
309
         * @return bool
310
         * @throws OIDplusException
311
         */
312
        public function isAdminLoggedIn(): bool {
585 daniel-mar 313
                if ($this->forceAllLoggedOut()) {
567 daniel-mar 314
                        return false;
315
                } else {
585 daniel-mar 316
                        $acs = $this->getAuthContentStore();
317
                        if (is_null($acs)) return false;
318
                        return $acs->isAdminLoggedIn();
567 daniel-mar 319
                }
2 daniel-mar 320
        }
321
 
1116 daniel-mar 322
        /**
323
         * "High level" function including logging and checking for valid JWT alternations
324
         * @param bool $remember_me
325
         * @param string $origin
326
         * @return void
327
         * @throws OIDplusException
328
         */
329
        public function adminLoginEx(bool $remember_me, string $origin='') {
585 daniel-mar 330
                $loginfo = '';
331
                $acs = $this->getAuthContentStore();
332
                if (!is_null($acs)) {
1301 daniel-mar 333
                        // User is already logged in (a session or JWT exists), so we modify their login status
585 daniel-mar 334
                        $acs->adminLoginEx($loginfo);
335
                        $acs->activate();
336
                } else {
1301 daniel-mar 337
                        // No user is logged in (no session or JWT exists). We now create a auth content store and activate it (cookies will be set etc.)
585 daniel-mar 338
                        if ($remember_me) {
339
                                if (!OIDplus::baseConfig()->getValue('JWT_ALLOW_LOGIN_ADMIN', true)) {
340
                                        throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_LOGIN_ADMIN'));
341
                                }
342
                                $ttl = OIDplus::baseConfig()->getValue('JWT_TTL_LOGIN_ADMIN', 10*365*24*60*60);
343
                                $authSimulation = new OIDplusAuthContentStoreJWT();
344
                                $authSimulation->adminLoginEx($loginfo);
345
                                $authSimulation->setValue('oidplus_generator', OIDplusAuthContentStoreJWT::JWT_GENERATOR_LOGIN);
346
                                $authSimulation->setValue('exp', time()+$ttl); // JWT "exp" attribute
347
                                $authSimulation->activate();
348
                        } else {
349
                                $authSimulation = new OIDplusAuthContentStoreSession();
350
                                $authSimulation->adminLoginEx($loginfo);
351
                                $authSimulation->activate();
352
                        }
353
                }
354
                $logmsg = "Admin logged in";
355
                if ($origin != '') $logmsg .= " via $origin";
356
                if ($loginfo != '') $logmsg .= " ($loginfo)";
1267 daniel-mar 357
                OIDplus::logger()->log("V2:[OK]A", "%1", $logmsg);
585 daniel-mar 358
        }
359
 
1116 daniel-mar 360
        /**
361
         * @return void
362
         * @throws OIDplusException
363
         */
585 daniel-mar 364
        public function adminLogoutEx() {
365
                $loginfo = '';
366
 
367
                $acs = $this->getAuthContentStore();
368
                if (is_null($acs)) return;
1116 daniel-mar 369
                $acs->adminLogoutEx($loginfo);
585 daniel-mar 370
 
371
                if ($this->raNumLoggedIn() == 0) {
372
                        // Nobody here anymore. Destroy the cookie to make GDPR people happy
373
                        $acs->destroySession();
374
                } else {
375
                        // Get a new token for the remaining users
376
                        $acs->activate();
377
                }
378
 
1267 daniel-mar 379
                OIDplus::logger()->log("V2:[OK]A", "Admin logged out (%1)", $loginfo);
585 daniel-mar 380
        }
381
 
1282 daniel-mar 382
        // Authentication keys for generating secrets or validating arguments (e.g. sent by mail)
2 daniel-mar 383
 
1116 daniel-mar 384
        /**
1283 daniel-mar 385
         * @param array|string $data
1116 daniel-mar 386
         * @return string
387
         * @throws OIDplusException
388
         */
1283 daniel-mar 389
        public function makeSecret($data): string {
390
                if (!is_array($data)) $data = [$data];
391
                $data = json_encode($data);
1282 daniel-mar 392
                return sha3_512_hmac($data, 'OIDplus:'.OIDplus::baseConfig()->getValue('SERVER_SECRET'), false);
393
        }
394
 
395
        /**
1283 daniel-mar 396
         * @param array|string $data Arbitary data to be validated later
397
         * @return string A string that need to be validated with validateAuthKey
1282 daniel-mar 398
         * @throws OIDplusException
399
         */
1283 daniel-mar 400
        public function makeAuthKey($data): string {
401
                if (!is_array($data)) $data = [$data];
402
                $ts = time();
403
                $data_ext = [$ts, $data];
404
                $secret = $this->makeSecret($data_ext);
405
                return $ts.'.'.$secret;
2 daniel-mar 406
        }
407
 
1116 daniel-mar 408
        /**
1283 daniel-mar 409
         * @param array|string $data The original data that had been passed to makeAuthKey()
410
         * @param string $auth_key The result from makeAuthKey()
411
         * @param int $valid_secs How many seconds is the auth key valid? (-1 for infinite)
412
         * @return bool True if the key is valid and not expired.
1116 daniel-mar 413
         * @throws OIDplusException
414
         */
1283 daniel-mar 415
        public function validateAuthKey($data, string $auth_key, int $valid_secs=-1): bool {
416
                $auth_key_ary = explode('.', $auth_key, 2);
417
                if (count($auth_key_ary) != 2) return false; // invalid auth key syntax
418
                list($ts, $secret) = $auth_key_ary;
419
                if (!is_numeric($ts)) return false; // invalid auth key syntax
420
                if ($valid_secs >= 0) {
421
                        if (time() > ($ts+$valid_secs)) return false; // expired auth key
422
                }
423
                if (!is_array($data)) $data = [$data];
424
                $data_ext = [(int)$ts, $data];
425
                return hash_equals($this->makeSecret($data_ext), $secret);
2 daniel-mar 426
        }
427
 
329 daniel-mar 428
        // "Veto" functions to force logout state
429
 
1116 daniel-mar 430
        /**
431
         * @return bool
432
         */
433
        protected function forceAllLoggedOut(): bool {
329 daniel-mar 434
                if (isset($_SERVER['SCRIPT_FILENAME']) && (basename($_SERVER['SCRIPT_FILENAME']) == 'sitemap.php')) {
435
                        // The sitemap may not contain any confidential information,
436
                        // even if the user is logged in, because the admin could
437
                        // accidentally copy-paste the sitemap to a
438
                        // search engine control panel while they are logged in
439
                        return true;
440
                } else {
441
                        return false;
442
                }
443
        }
427 daniel-mar 444
 
424 daniel-mar 445
        // CSRF functions
427 daniel-mar 446
 
424 daniel-mar 447
        private $enable_csrf = true;
427 daniel-mar 448
 
1116 daniel-mar 449
        /**
450
         * @return void
451
         */
424 daniel-mar 452
        public function enableCSRF() {
453
                $this->enable_csrf = true;
454
        }
427 daniel-mar 455
 
1116 daniel-mar 456
        /**
457
         * @return void
458
         */
424 daniel-mar 459
        public function disableCSRF() {
460
                $this->enable_csrf = false;
461
        }
427 daniel-mar 462
 
1116 daniel-mar 463
        /**
464
         * @return string
465
         * @throws \Random\RandomException
466
         */
467
        public function genCSRFToken(): string {
1098 daniel-mar 468
                return random_bytes_ex(64, false, false);
424 daniel-mar 469
        }
427 daniel-mar 470
 
1116 daniel-mar 471
        /**
472
         * @return void
473
         * @throws OIDplusException
474
         */
424 daniel-mar 475
        public function checkCSRF() {
427 daniel-mar 476
                if (!$this->enable_csrf) return;
866 daniel-mar 477
 
1130 daniel-mar 478
                $request_token = $_REQUEST['csrf_token'] ?? '';
479
                $cookie_token = $_COOKIE['csrf_token'] ?? '';
866 daniel-mar 480
 
481
                if (empty($request_token) || empty($cookie_token) || ($request_token !== $cookie_token)) {
482
                        if (OIDplus::baseConfig()->getValue('DEBUG')) {
483
                                throw new OIDplusException(_L('Missing or wrong CSRF Token: Request %1 vs Cookie %2',
484
                                        isset($_REQUEST['csrf_token']) ? '"'.$_REQUEST['csrf_token'].'"' : 'NULL',
1130 daniel-mar 485
                                        $_COOKIE['csrf_token'] ?? 'NULL'
866 daniel-mar 486
                                ));
487
                        } else {
488
                                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.'));
489
                        }
424 daniel-mar 490
                }
491
        }
329 daniel-mar 492
 
421 daniel-mar 493
        // Generate RA passwords
622 daniel-mar 494
 
1116 daniel-mar 495
        /**
496
         * @param string $password
497
         * @return OIDplusRAAuthInfo
498
         * @throws OIDplusException
499
         */
1186 daniel-mar 500
        public function raGeneratePassword(string $password): OIDplusRAAuthInfo {
1099 daniel-mar 501
                $plugin = OIDplus::getDefaultRaAuthPlugin(true);
1186 daniel-mar 502
                return $plugin->generate($this->raPepperProcessing($password));
421 daniel-mar 503
        }
504
 
505
        // Generate admin password
506
 
585 daniel-mar 507
        /* Nothing here; the admin password will be generated in setup_base.js , purely in the web-browser */
421 daniel-mar 508
 
509
}