Subversion Repositories oidplus

Rev

Rev 1267 | Rev 1283 | 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
78
                $tmp = OIDplusAuthContentStoreJWT::getActiveProvider();
79
                if ($tmp) return $tmp;
577 daniel-mar 80
 
1265 daniel-mar 81
                // For REST, we must only allow JWT from Bearer and nothing else! So disable cookies if we are accessing the REST plugin
82
                $rel_url = substr($_SERVER['REQUEST_URI'], strlen(OIDplus::webpath(null, OIDplus::PATH_RELATIVE_TO_ROOT)));
83
                if (!str_starts_with($rel_url, 'rest/')) { // <== TODO: Find a way how to move this into the plugin, since REST does not belong to the core. (Maybe some kind of "stateless mode" that is enabled by the REST plugin)
577 daniel-mar 84
 
1265 daniel-mar 85
                        // Normal login via web-browser
86
                        // Cookie will only be created once content is stored
87
                        $tmp = OIDplusAuthContentStoreSession::getActiveProvider();
88
                        if ($tmp) return $tmp;
89
 
90
                }
91
 
585 daniel-mar 92
                // No active session and no JWT token available. User is not logged in.
93
                return null;
577 daniel-mar 94
        }
95
 
1116 daniel-mar 96
        /**
97
         * @param string $name
98
         * @param mixed|null $default
99
         * @return mixed
100
         * @throws OIDplusException
101
         */
102
        public function getExtendedAttribute(string $name, $default=NULL) {
585 daniel-mar 103
                $acs = $this->getAuthContentStore();
104
                if (is_null($acs)) return $default;
105
                return $acs->getValue($name, $default);
577 daniel-mar 106
        }
107
 
566 daniel-mar 108
        // RA authentication functions
2 daniel-mar 109
 
1116 daniel-mar 110
        /**
111
         * @param string $email
112
         * @return void
113
         * @throws OIDplusException
114
         */
115
        public function raLogin(string $email) {
585 daniel-mar 116
                $acs = $this->getAuthContentStore();
117
                if (is_null($acs)) return;
1116 daniel-mar 118
                $acs->raLogin($email);
566 daniel-mar 119
        }
2 daniel-mar 120
 
1116 daniel-mar 121
        /**
122
         * @param string $email
123
         * @return void
124
         * @throws OIDplusException
125
         */
126
        public function raLogout(string $email) {
585 daniel-mar 127
                $acs = $this->getAuthContentStore();
128
                if (is_null($acs)) return;
1116 daniel-mar 129
                $acs->raLogout($email);
2 daniel-mar 130
        }
131
 
1116 daniel-mar 132
        /**
133
         * @param string $ra_email
134
         * @param string $password
135
         * @return bool
136
         * @throws OIDplusException
137
         */
138
        public function raCheckPassword(string $ra_email, string $password): bool {
329 daniel-mar 139
                $ra = new OIDplusRA($ra_email);
453 daniel-mar 140
 
617 daniel-mar 141
                // Get RA info from RA
459 daniel-mar 142
                $authInfo = $ra->getAuthInfo();
590 daniel-mar 143
                if (!$authInfo) return false; // user not found
622 daniel-mar 144
 
617 daniel-mar 145
                // Ask plugins if they can verify this hash
453 daniel-mar 146
                $plugins = OIDplus::getAuthPlugins();
147
                if (count($plugins) == 0) {
148
                        throw new OIDplusException(_L('No RA authentication plugins found'));
149
                }
150
                foreach ($plugins as $plugin) {
1186 daniel-mar 151
                        if ($plugin->verify($authInfo, $this->raPepperProcessing($password))) return true;
453 daniel-mar 152
                }
153
 
154
                return false;
329 daniel-mar 155
        }
156
 
1116 daniel-mar 157
        /**
158
         * @return int
159
         * @throws OIDplusException
160
         */
161
        public function raNumLoggedIn(): int {
585 daniel-mar 162
                $acs = $this->getAuthContentStore();
163
                if (is_null($acs)) return 0;
164
                return $acs->raNumLoggedIn();
85 daniel-mar 165
        }
166
 
1116 daniel-mar 167
        /**
168
         * @return OIDplusRA[]
169
         * @throws OIDplusException
170
         */
171
        public function loggedInRaList(): array {
585 daniel-mar 172
                if ($this->forceAllLoggedOut()) {
567 daniel-mar 173
                        return array();
174
                } else {
585 daniel-mar 175
                        $acs = $this->getAuthContentStore();
176
                        if (is_null($acs)) return array();
177
                        return $acs->loggedInRaList();
567 daniel-mar 178
                }
2 daniel-mar 179
        }
180
 
1116 daniel-mar 181
        /**
1267 daniel-mar 182
         * @param string|OIDplusRA $ra
1116 daniel-mar 183
         * @return bool
184
         * @throws OIDplusException
185
         */
1267 daniel-mar 186
        public function isRaLoggedIn($ra): bool {
187
                $email = $ra instanceof OIDplusRA ? $ra->raEmail() : $ra;
585 daniel-mar 188
                $acs = $this->getAuthContentStore();
189
                if (is_null($acs)) return false;
190
                return $acs->isRaLoggedIn($email);
2 daniel-mar 191
        }
192
 
585 daniel-mar 193
        // "High level" function including logging and checking for valid JWT alternations
1116 daniel-mar 194
 
195
        /**
196
         * @param string $email
197
         * @param bool $remember_me
198
         * @param string $origin
199
         * @return void
200
         * @throws OIDplusException
201
         */
202
        public function raLoginEx(string $email, bool $remember_me, string $origin='') {
585 daniel-mar 203
                $loginfo = '';
204
                $acs = $this->getAuthContentStore();
205
                if (!is_null($acs)) {
206
                        $acs->raLoginEx($email, $loginfo);
207
                        $acs->activate();
208
                } else {
209
                        if ($remember_me) {
210
                                if (!OIDplus::baseConfig()->getValue('JWT_ALLOW_LOGIN_USER', true)) {
211
                                        throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_LOGIN_USER'));
212
                                }
213
                                $ttl = OIDplus::baseConfig()->getValue('JWT_TTL_LOGIN_USER', 10*365*24*60*60);
214
                                $authSimulation = new OIDplusAuthContentStoreJWT();
215
                                $authSimulation->raLoginEx($email, $loginfo);
216
                                $authSimulation->setValue('oidplus_generator', OIDplusAuthContentStoreJWT::JWT_GENERATOR_LOGIN);
217
                                $authSimulation->setValue('exp', time()+$ttl); // JWT "exp" attribute
218
                                $authSimulation->activate();
219
                        } else {
220
                                $authSimulation = new OIDplusAuthContentStoreSession();
221
                                $authSimulation->raLoginEx($email, $loginfo);
222
                                $authSimulation->activate();
223
                        }
224
                }
225
                $logmsg = "RA '$email' logged in";
226
                if ($origin != '') $logmsg .= " via $origin";
227
                if ($loginfo != '') $logmsg .= " ($loginfo)";
1267 daniel-mar 228
                OIDplus::logger()->log("V2:[OK]RA(%1)", "%2", $email, $logmsg);
585 daniel-mar 229
        }
230
 
1116 daniel-mar 231
        /**
232
         * @param string $email
233
         * @return void
234
         * @throws OIDplusException
235
         */
236
        public function raLogoutEx(string $email) {
585 daniel-mar 237
                $loginfo = '';
238
 
239
                $acs = $this->getAuthContentStore();
240
                if (is_null($acs)) return;
1116 daniel-mar 241
                $acs->raLogoutEx($email, $loginfo);
585 daniel-mar 242
 
1267 daniel-mar 243
                OIDplus::logger()->log("V2:[OK]RA(%1)", "RA '%1' logged out (%2)", $email, $loginfo);
585 daniel-mar 244
 
245
                if (($this->raNumLoggedIn() == 0) && (!$this->isAdminLoggedIn())) {
246
                        // Nobody logged in anymore. Destroy session cookie to make GDPR people happy
247
                        $acs->destroySession();
248
                } else {
249
                        // Get a new token for the remaining users
250
                        $acs->activate();
251
                }
252
        }
253
 
14 daniel-mar 254
        // Admin authentication functions
2 daniel-mar 255
 
1116 daniel-mar 256
        /**
257
         * @return void
258
         * @throws OIDplusException
259
         */
566 daniel-mar 260
        public function adminLogin() {
585 daniel-mar 261
                $acs = $this->getAuthContentStore();
262
                if (is_null($acs)) return;
1116 daniel-mar 263
                $acs->adminLogin();
2 daniel-mar 264
        }
265
 
1116 daniel-mar 266
        /**
267
         * @return void
268
         * @throws OIDplusException
269
         */
566 daniel-mar 270
        public function adminLogout() {
585 daniel-mar 271
                $acs = $this->getAuthContentStore();
272
                if (is_null($acs)) return;
1116 daniel-mar 273
                $acs->adminLogout();
2 daniel-mar 274
        }
275
 
1116 daniel-mar 276
        /**
277
         * @param string $password
278
         * @return bool
279
         * @throws OIDplusException
280
         */
281
        public function adminCheckPassword(string $password): bool {
609 daniel-mar 282
                $cfgData = OIDplus::baseConfig()->getValue('ADMIN_PASSWORD', '');
283
                if (empty($cfgData)) {
360 daniel-mar 284
                        throw new OIDplusException(_L('No admin password set in %1','userdata/baseconfig/config.inc.php'));
261 daniel-mar 285
                }
622 daniel-mar 286
 
609 daniel-mar 287
                if (!is_array($cfgData)) {
288
                        $passwordDataArray = array($cfgData);
289
                } else {
1106 daniel-mar 290
                        $passwordDataArray = $cfgData; // Multiple Administrator passwords
609 daniel-mar 291
                }
622 daniel-mar 292
 
609 daniel-mar 293
                foreach ($passwordDataArray as $passwordData) {
1107 daniel-mar 294
                        if (str_starts_with($passwordData, '$')) {
295
                                // Version 3: BCrypt (or any other crypt)
296
                                $ok = password_verify($password, $passwordData);
297
                        } else if (strpos($passwordData, '$') !== false) {
298
                                // Version 2: SHA3-512 with salt
299
                                list($salt, $hash) = explode('$', $passwordData, 2);
300
                                $ok = hash_equals(sha3_512($salt.$password, true), base64_decode($hash));
456 daniel-mar 301
                        } else {
609 daniel-mar 302
                                // Version 1: SHA3-512 without salt
1107 daniel-mar 303
                                $ok = hash_equals(sha3_512($password, true), base64_decode($passwordData));
456 daniel-mar 304
                        }
1107 daniel-mar 305
                        if ($ok) return true;
421 daniel-mar 306
                }
609 daniel-mar 307
 
308
                return false;
2 daniel-mar 309
        }
310
 
1116 daniel-mar 311
        /**
312
         * @return bool
313
         * @throws OIDplusException
314
         */
315
        public function isAdminLoggedIn(): bool {
585 daniel-mar 316
                if ($this->forceAllLoggedOut()) {
567 daniel-mar 317
                        return false;
318
                } else {
585 daniel-mar 319
                        $acs = $this->getAuthContentStore();
320
                        if (is_null($acs)) return false;
321
                        return $acs->isAdminLoggedIn();
567 daniel-mar 322
                }
2 daniel-mar 323
        }
324
 
1116 daniel-mar 325
        /**
326
         * "High level" function including logging and checking for valid JWT alternations
327
         * @param bool $remember_me
328
         * @param string $origin
329
         * @return void
330
         * @throws OIDplusException
331
         */
332
        public function adminLoginEx(bool $remember_me, string $origin='') {
585 daniel-mar 333
                $loginfo = '';
334
                $acs = $this->getAuthContentStore();
335
                if (!is_null($acs)) {
336
                        $acs->adminLoginEx($loginfo);
337
                        $acs->activate();
338
                } else {
339
                        if ($remember_me) {
340
                                if (!OIDplus::baseConfig()->getValue('JWT_ALLOW_LOGIN_ADMIN', true)) {
341
                                        throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_LOGIN_ADMIN'));
342
                                }
343
                                $ttl = OIDplus::baseConfig()->getValue('JWT_TTL_LOGIN_ADMIN', 10*365*24*60*60);
344
                                $authSimulation = new OIDplusAuthContentStoreJWT();
345
                                $authSimulation->adminLoginEx($loginfo);
346
                                $authSimulation->setValue('oidplus_generator', OIDplusAuthContentStoreJWT::JWT_GENERATOR_LOGIN);
347
                                $authSimulation->setValue('exp', time()+$ttl); // JWT "exp" attribute
348
                                $authSimulation->activate();
349
                        } else {
350
                                $authSimulation = new OIDplusAuthContentStoreSession();
351
                                $authSimulation->adminLoginEx($loginfo);
352
                                $authSimulation->activate();
353
                        }
354
                }
355
                $logmsg = "Admin logged in";
356
                if ($origin != '') $logmsg .= " via $origin";
357
                if ($loginfo != '') $logmsg .= " ($loginfo)";
1267 daniel-mar 358
                OIDplus::logger()->log("V2:[OK]A", "%1", $logmsg);
585 daniel-mar 359
        }
360
 
1116 daniel-mar 361
        /**
362
         * @return void
363
         * @throws OIDplusException
364
         */
585 daniel-mar 365
        public function adminLogoutEx() {
366
                $loginfo = '';
367
 
368
                $acs = $this->getAuthContentStore();
369
                if (is_null($acs)) return;
1116 daniel-mar 370
                $acs->adminLogoutEx($loginfo);
585 daniel-mar 371
 
372
                if ($this->raNumLoggedIn() == 0) {
373
                        // Nobody here anymore. Destroy the cookie to make GDPR people happy
374
                        $acs->destroySession();
375
                } else {
376
                        // Get a new token for the remaining users
377
                        $acs->activate();
378
                }
379
 
1267 daniel-mar 380
                OIDplus::logger()->log("V2:[OK]A", "Admin logged out (%1)", $loginfo);
585 daniel-mar 381
        }
382
 
1282 daniel-mar 383
        // Authentication keys for generating secrets or validating arguments (e.g. sent by mail)
2 daniel-mar 384
 
1116 daniel-mar 385
        /**
386
         * @param string $data
387
         * @return string
388
         * @throws OIDplusException
389
         */
1282 daniel-mar 390
        public function makeSecret(string $data): string {
391
                return sha3_512_hmac($data, 'OIDplus:'.OIDplus::baseConfig()->getValue('SERVER_SECRET'), false);
392
        }
393
 
394
        /**
395
         * @param string $data
396
         * @return string
397
         * @throws OIDplusException
398
         */
1186 daniel-mar 399
        public function makeAuthKey(string $data): string {
1282 daniel-mar 400
                return $this->makeSecret($data);
2 daniel-mar 401
        }
402
 
1116 daniel-mar 403
        /**
404
         * @param string $data
405
         * @param string $auth_key
406
         * @return bool
407
         * @throws OIDplusException
408
         */
1186 daniel-mar 409
        public function validateAuthKey(string $data, string $auth_key): bool {
410
                return hash_equals($this->makeAuthKey($data), $auth_key);
2 daniel-mar 411
        }
412
 
329 daniel-mar 413
        // "Veto" functions to force logout state
414
 
1116 daniel-mar 415
        /**
416
         * @return bool
417
         */
418
        protected function forceAllLoggedOut(): bool {
329 daniel-mar 419
                if (isset($_SERVER['SCRIPT_FILENAME']) && (basename($_SERVER['SCRIPT_FILENAME']) == 'sitemap.php')) {
420
                        // The sitemap may not contain any confidential information,
421
                        // even if the user is logged in, because the admin could
422
                        // accidentally copy-paste the sitemap to a
423
                        // search engine control panel while they are logged in
424
                        return true;
425
                } else {
426
                        return false;
427
                }
428
        }
427 daniel-mar 429
 
424 daniel-mar 430
        // CSRF functions
427 daniel-mar 431
 
424 daniel-mar 432
        private $enable_csrf = true;
427 daniel-mar 433
 
1116 daniel-mar 434
        /**
435
         * @return void
436
         */
424 daniel-mar 437
        public function enableCSRF() {
438
                $this->enable_csrf = true;
439
        }
427 daniel-mar 440
 
1116 daniel-mar 441
        /**
442
         * @return void
443
         */
424 daniel-mar 444
        public function disableCSRF() {
445
                $this->enable_csrf = false;
446
        }
427 daniel-mar 447
 
1116 daniel-mar 448
        /**
449
         * @return string
450
         * @throws \Random\RandomException
451
         */
452
        public function genCSRFToken(): string {
1098 daniel-mar 453
                return random_bytes_ex(64, false, false);
424 daniel-mar 454
        }
427 daniel-mar 455
 
1116 daniel-mar 456
        /**
457
         * @return void
458
         * @throws OIDplusException
459
         */
424 daniel-mar 460
        public function checkCSRF() {
427 daniel-mar 461
                if (!$this->enable_csrf) return;
866 daniel-mar 462
 
1130 daniel-mar 463
                $request_token = $_REQUEST['csrf_token'] ?? '';
464
                $cookie_token = $_COOKIE['csrf_token'] ?? '';
866 daniel-mar 465
 
466
                if (empty($request_token) || empty($cookie_token) || ($request_token !== $cookie_token)) {
467
                        if (OIDplus::baseConfig()->getValue('DEBUG')) {
468
                                throw new OIDplusException(_L('Missing or wrong CSRF Token: Request %1 vs Cookie %2',
469
                                        isset($_REQUEST['csrf_token']) ? '"'.$_REQUEST['csrf_token'].'"' : 'NULL',
1130 daniel-mar 470
                                        $_COOKIE['csrf_token'] ?? 'NULL'
866 daniel-mar 471
                                ));
472
                        } else {
473
                                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.'));
474
                        }
424 daniel-mar 475
                }
476
        }
329 daniel-mar 477
 
421 daniel-mar 478
        // Generate RA passwords
622 daniel-mar 479
 
1116 daniel-mar 480
        /**
481
         * @param string $password
482
         * @return OIDplusRAAuthInfo
483
         * @throws OIDplusException
484
         */
1186 daniel-mar 485
        public function raGeneratePassword(string $password): OIDplusRAAuthInfo {
1099 daniel-mar 486
                $plugin = OIDplus::getDefaultRaAuthPlugin(true);
1186 daniel-mar 487
                return $plugin->generate($this->raPepperProcessing($password));
421 daniel-mar 488
        }
489
 
490
        // Generate admin password
491
 
585 daniel-mar 492
        /* Nothing here; the admin password will be generated in setup_base.js , purely in the web-browser */
421 daniel-mar 493
 
494
}