Passed
Push — master ( c4a4cd...90d26e )
by Andreas
18:42
created

midcom_services_auth::get_user()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
rs 10
c 1
b 0
f 0
1
<?php
2
/**
3
 * @package midcom.services
4
 * @author The Midgard Project, http://www.midgard-project.org
5
 * @copyright The Midgard Project, http://www.midgard-project.org
6
 * @license http://www.gnu.org/licenses/lgpl.html GNU Lesser General Public License
7
 */
8
9
use Symfony\Component\HttpFoundation\Request;
10
11
/**
12
 * Main Authentication/Authorization service class, it provides means to authenticate
13
 * users and to check for permissions.
14
 *
15
 * <b>Authentication</b>
16
 *
17
 * Whenever the system successfully creates a new login session (during auth service startup),
18
 * it checks whether the key <i>midcom_services_auth_login_success_url</i> is present in the HTTP
19
 * Request data. If this is the case, it relocates to the URL given in it. This member isn't set
20
 * by default in the MidCOM core, it is intended for custom authentication forms. The MidCOM
21
 * relocate function is used to for relocation, thus you can take full advantage of the
22
 * convenience functions in there. See midcom_application::relocate() for details.
23
 *
24
 * <b>Checking Privileges</b>
25
 *
26
 * This class offers various methods to verify the privilege state of a user, all of them prefixed
27
 * with can_* for privileges and is_* for membership checks.
28
 *
29
 * Each function is available in a simple check version, which returns true or false, and a
30
 * require_* prefixed variant, which has no return value. The require variants of these calls
31
 * instead check if the given condition is met, if yes, they return silently, otherwise they
32
 * throw an access denied error.
33
 *
34
 * @todo Fully document authentication.
35
 * @package midcom.services
36
 */
37
class midcom_services_auth
38
{
39
    /**
40
     * The currently authenticated user or null in case of anonymous access.
41
     * It is to be considered read-only.
42
     *
43
     * @var midcom_core_user
44
     */
45
    public $user;
46
47
    /**
48
     * Admin user level state. This is true if the currently authenticated user is an
49
     * Midgard Administrator, false otherwise.
50
     *
51
     * This effectively maps to midcom_connection::is_admin(); but it is suggested to use the auth class
52
     * for consistency reasons nevertheless.
53
     *
54
     * @var boolean
55
     */
56
    public $admin = false;
57
58
    /**
59
     * The ACL management system.
60
     *
61
     * @var midcom_services_auth_acl
62
     */
63
    public $acl;
64
65
    /**
66
     * Internal cache of all loaded groups, indexed by their identifiers.
67
     *
68
     * @var Array
69
     */
70
    private $_group_cache = [];
71
72
    /**
73
     * This flag indicates if sudo mode is active during execution. This will only be the
74
     * case if the sudo system actually grants this privileges, and only until components
75
     * release the rights again. This does override the full access control system at this time
76
     * and essentially give you full admin privileges (though this might change in the future).
77
     *
78
     * Note, that this is no boolean but an int, otherwise it would be impossible to trace nested
79
     * sudo invocations, which are quite possible with multiple components calling each others
80
     * callback. A value of 0 indicates that sudo is inactive. A value greater than zero indicates
81
     * sudo mode is active, with the count being equal to the depth of the sudo callers.
82
     *
83
     * It is thus still safely possible to evaluate this member in a boolean context to check
84
     * for an enabled sudo mode.
85
     *
86
     * @var int
87
     * @see request_sudo()
88
     * @see drop_sudo()
89
     */
90
    private $_component_sudo = 0;
91
92
    /**
93
     * The authentication backend we should use by default.
94
     *
95
     * @var midcom_services_auth_backend
96
     */
97
    private $backend;
98
99
    /**
100
     * The authentication frontend we should use by default.
101
     *
102
     * @var midcom_services_auth_frontend
103
     */
104
    private $frontend;
105
106
    /**
107
     * Loads all configured authentication drivers.
108
     */
109
    public function __construct(midcom_services_auth_acl $acl)
110
    {
111
        $this->acl = $acl;
112
113
        $classname = midcom::get()->config->get('auth_backend');
114
        if (!str_contains($classname, "_")) {
0 ignored issues
show
Bug introduced by
It seems like $classname can also be of type null; however, parameter $haystack of str_contains() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

114
        if (!str_contains(/** @scrutinizer ignore-type */ $classname, "_")) {
Loading history...
115
            $classname = 'midcom_services_auth_backend_' . $classname;
116
        }
117
        $this->backend = new $classname();
118
119
        $classname = midcom::get()->config->get('auth_frontend');
120
        if (!str_contains($classname, "_")) {
121
            $classname = 'midcom_services_auth_frontend_' . $classname;
122
        }
123
        $this->frontend = new $classname();
124
    }
125
126
    /**
127
     * Checks if the current authentication fronted has new credentials
128
     * ready. If yes, it processes the login accordingly. Otherwise look for existing session
129
     *
130
     * @param Request $request The request object
131
     */
132 1
    public function check_for_login_session(Request $request)
133
    {
134
        // Try to start up a new session, this will authenticate as well.
135 1
        if ($credentials = $this->frontend->read_login_data($request)) {
136
            if (!$this->login($credentials['username'], $credentials['password'], $request->getClientIp())) {
137
                return;
138
            }
139
            debug_add('Authentication was successful, we have a new login session now. Updating timestamps');
140
141
            $person_class = midcom::get()->config->get('person_class');
142
            $person = new $person_class($this->user->guid);
143
144
            if (!$person->get_parameter('midcom', 'first_login')) {
145
                $person->set_parameter('midcom', 'first_login', time());
146
            } elseif (midcom::get()->config->get('auth_save_prev_login')) {
147
                $person->set_parameter('midcom', 'prev_login', $person->get_parameter('midcom', 'last_login'));
148
            }
149
            $person->set_parameter('midcom', 'last_login', time());
150
151
            // Now we check whether there is a success-relocate URL given somewhere.
152
            if ($request->get('midcom_services_auth_login_success_url')) {
153
                return new midcom_response_relocate($request->get('midcom_services_auth_login_success_url'));
0 ignored issues
show
Bug introduced by
It seems like $request->get('midcom_se...uth_login_success_url') can also be of type null; however, parameter $url of midcom_response_relocate::__construct() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

153
                return new midcom_response_relocate(/** @scrutinizer ignore-type */ $request->get('midcom_services_auth_login_success_url'));
Loading history...
154
            }
155
        }
156
        // No new login detected, so we check if there is a running session.
157 1
        elseif ($user = $this->backend->check_for_active_login_session($request)) {
158
            $this->set_user($user);
159
        }
160 1
    }
161
162
    /**
163
     * @param midcom_core_user $user
164
     */
165 62
    private function set_user(midcom_core_user $user)
166
    {
167 62
        $this->user = $user;
168 62
        $this->admin = $user->is_admin();
169 62
    }
170
171
    /**
172
     * Checks whether a user has a certain privilege on the given content object.
173
     * Works on the currently authenticated user by default, but can take another
174
     * user as an optional argument.
175
     *
176
     * @param string $privilege The privilege to check for
177
     * @param MidgardObject $content_object A Midgard Content Object
0 ignored issues
show
Bug introduced by
The type MidgardObject was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
178
     * @param midcom_core_user $user The user against which to check the privilege, defaults to the currently authenticated user.
179
     *     You may specify "EVERYONE" instead of an object to check what an anonymous user can do.
180
     */
181 440
    public function can_do(string $privilege, object $content_object, $user = null) : bool
182
    {
183 440
        if ($this->is_admin($user)) {
184
            // Administrators always have access.
185 2
            return true;
186
        }
187
188 440
        $user_id = $this->acl->get_user_id($user);
189
190
        //if we're handed the correct object type, we use its class right away
191 440
        if (midcom::get()->dbclassloader->is_midcom_db_object($content_object)) {
192 440
            $content_object_class = get_class($content_object);
193
        }
194
        //otherwise, we assume (hope) that it's a midgard object
195
        else {
196
            $content_object_class = midcom::get()->dbclassloader->get_midcom_class_name_for_mgdschema_object($content_object);
197
        }
198
199 440
        return $this->acl->can_do_byguid($privilege, $content_object->guid, $content_object_class, $user_id);
200
    }
201
202 450
    private function is_admin($user) : bool
203
    {
204 450
        if ($user === null) {
205 450
            return $this->user && $this->admin;
206
        }
207 5
        if (is_a($user, midcom_core_user::class)) {
208 2
            return $user->is_admin();
209
        }
210 3
        return false;
211
    }
212
213
    /**
214
     * Checks, whether the given user have the privilege assigned to him in general.
215
     * Be aware, that this does not take any permissions overridden by content objects
216
     * into account. Whenever possible, you should user the can_do() variant of this
217
     * call therefore. can_user_do is only of interest in cases where you do not have
218
     * any content object available, for example when creating root topics.
219
     *
220
     * @param midcom_core_user $user The user against which to check the privilege, defaults to the currently authenticated user,
221
     *     you may specify 'EVERYONE' here to check what an anonymous user can do.
222
     * @param string $class Optional parameter to set if the check should take type specific permissions into account. The class must be default constructible.
223
     */
224 334
    public function can_user_do(string $privilege, $user = null, $class = null) : bool
225
    {
226 334
        if ($this->is_admin($user)) {
227
            // Administrators always have access.
228 2
            return true;
229
        }
230 334
        if ($this->_component_sudo) {
231 331
            return true;
232
        }
233 7
        if ($user === null) {
234 7
            $user =& $this->user;
235
        }
236
237 7
        if ($user == 'EVERYONE') {
0 ignored issues
show
introduced by
The condition $user == 'EVERYONE' is always false.
Loading history...
238
            $user = null;
239
        }
240
241 7
        return $this->acl->can_do_byclass($privilege, $user, $class);
242
    }
243
244
    /**
245
     * Request superuser privileges for the domain passed.
246
     *
247
     * STUB IMPLEMENTATION ONLY, WILL ALWAYS GRANT SUDO.
248
     *
249
     * You have to call midcom_services_auth::drop_sudo() as soon as you no longer
250
     * need the elevated privileges, which will reset the authentication data to the
251
     * initial credentials.
252
     *
253
     * @param string $domain The domain to request sudo for. This is a component name.
254
     */
255 603
    public function request_sudo(string $domain = null) : bool
256
    {
257 603
        if (!midcom::get()->config->get('auth_allow_sudo')) {
258 1
            debug_add("SUDO is not allowed on this website.", MIDCOM_LOG_ERROR);
259 1
            return false;
260
        }
261
262 603
        if ($domain === null) {
263 1
            $domain = midcom_core_context::get()->get_key(MIDCOM_CONTEXT_COMPONENT);
264 1
            debug_add("Domain was not supplied, falling back to '{$domain}' which we got from the current component context.");
265
        }
266
267 603
        if ($domain == '') {
268 1
            debug_add("SUDO request for an empty domain, this should not happen. Denying sudo.", MIDCOM_LOG_INFO);
269 1
            return false;
270
        }
271
272 603
        $this->_component_sudo++;
273
274 603
        debug_add("Entered SUDO mode for domain {$domain}.", MIDCOM_LOG_INFO);
275
276 603
        return true;
277
    }
278
279
    /**
280
     * Drops previously acquired superuser privileges.
281
     *
282
     * @see request_sudo()
283
     */
284 603
    public function drop_sudo()
285
    {
286 603
        if ($this->_component_sudo > 0) {
287 603
            debug_add('Leaving SUDO mode.');
288 603
            $this->_component_sudo--;
289
        } else {
290 1
            debug_add('Requested to leave SUDO mode, but sudo was already disabled. Ignoring request.', MIDCOM_LOG_INFO);
291
        }
292 603
    }
293
294 580
    public function is_component_sudo() : bool
295
    {
296 580
        return $this->_component_sudo > 0;
297
    }
298
299
    /**
300
     * Check, whether a user is member of a given group. By default, the query is run
301
     * against the currently authenticated user.
302
     *
303
     * It always returns true for administrative users.
304
     *
305
     * @param mixed $group Group to check against, this can be either a midcom_core_group object or a group string identifier.
306
     * @param midcom_core_user $user The user which should be checked, defaults to the current user.
307
     */
308
    public function is_group_member($group, $user = null) : bool
309
    {
310
        if ($this->is_admin($user)) {
311
            // Administrators always have access.
312
            return true;
313
        }
314
        // Default parameter
315
        if ($user === null) {
316
            if ($this->user === null) {
317
                // not authenticated
318
                return false;
319
            }
320
            $user = $this->user;
321
        }
322
323
        return $user->is_in_group($group);
324
    }
325
326
    /**
327
     * Returns true if there is an authenticated user, false otherwise.
328
     */
329 149
    public function is_valid_user() : bool
330
    {
331 149
        return $this->user !== null;
332
    }
333
334
    /**
335
     * Validates that the current user has the given privilege granted on the
336
     * content object passed to the function.
337
     *
338
     * If this is not the case, an Access Denied error is generated, the message
339
     * defaulting to the string 'access denied: privilege %s not granted' of the
340
     * MidCOM main L10n table.
341
     *
342
     * The check is always done against the currently authenticated user. If the
343
     * check is successful, the function returns silently.
344
     *
345
     * @param MidgardObject $content_object A Midgard Content Object
346
     */
347 185
    public function require_do(string $privilege, object $content_object, string $message = null)
348
    {
349 185
        if (!$this->can_do($privilege, $content_object)) {
350
            throw $this->access_denied($message, 'privilege %s not granted', $privilege);
351
        }
352 185
    }
353
354
    /**
355
     * Validates, whether the given user have the privilege assigned to him in general.
356
     * Be aware, that this does not take any permissions overridden by content objects
357
     * into account. Whenever possible, you should user the require_do() variant of this
358
     * call therefore. require_user_do is only of interest in cases where you do not have
359
     * any content object available, for example when creating root topics.
360
     *
361
     * If this is not the case, an Access Denied error is generated, the message
362
     * defaulting to the string 'access denied: privilege %s not granted' of the
363
     * MidCOM main L10n table.
364
     *
365
     * The check is always done against the currently authenticated user. If the
366
     * check is successful, the function returns silently.
367
     *
368
     * @param string $class Optional parameter to set if the check should take type specific permissions into account. The class must be default constructible.
369
     */
370 77
    public function require_user_do(string $privilege, string $message = null, string $class = null)
371
    {
372 77
        if (!$this->can_user_do($privilege, null, $class)) {
373
            throw $this->access_denied($message, 'privilege %s not granted', $privilege);
374
        }
375 77
    }
376
377
    /**
378
     * Validates that the current user is a member of the given group.
379
     *
380
     * If this is not the case, an Access Denied error is generated, the message
381
     * defaulting to the string 'access denied: user is not member of the group %s' of the
382
     * MidCOM main L10n table.
383
     *
384
     * The check is always done against the currently authenticated user. If the
385
     * check is successful, the function returns silently.
386
     *
387
     * @param mixed $group Group to check against, this can be either a midcom_core_group object or a group string identifier.
388
     * @param string $message The message to show if the user is not member of the given group.
389
     */
390
    function require_group_member($group, $message = null)
391
    {
392
        if (!$this->is_group_member($group)) {
393
            if (is_object($group)) {
394
                $group = $group->name;
395
            }
396
            throw $this->access_denied($message, 'user is not member of the group %s', $group);
397
        }
398
    }
399
400
    /**
401
     * Validates that we currently have admin level privileges, which can either
402
     * come from the current user, or from the sudo service.
403
     *
404
     * If the check is successful, the function returns silently.
405
     */
406 4
    public function require_admin_user(string $message = null)
407
    {
408 4
        if (!$this->admin && !$this->_component_sudo) {
409
            throw $this->access_denied($message, 'admin level privileges required');
410
        }
411 4
    }
412
413
    private function access_denied(?string $message, string $fallback, string $data = null) : midcom_error_forbidden
414
    {
415
        if ($message === null) {
416
            $message = midcom::get()->i18n->get_string('access denied: ' . $fallback, 'midcom');
417
            if ($data !== null) {
418
                $message = sprintf($message, $data);
419
            }
420
        }
421
        debug_print_function_stack("access_denied was called from here:");
422
        return new midcom_error_forbidden($message);
423
    }
424
425
    /**
426
     * Require either a configured IP address or admin credentials
427
     */
428
    public function require_admin_or_ip(string $domain) : bool
429
    {
430
        $ips = midcom::get()->config->get('indexer_reindex_allowed_ips');
431
        if (   $ips
432
            && in_array($_SERVER['REMOTE_ADDR'], $ips)) {
433
            if (!$this->request_sudo($domain)) {
434
                throw new midcom_error('Failed to acquire SUDO rights. Aborting.');
435
            }
436
            return true;
437
        }
438
439
        // Require user to Basic-authenticate for security reasons
440
        $this->require_valid_user('basic');
441
        $this->require_admin_user();
442
        return false;
443
    }
444
445
    /**
446
     * Validates that there is an authenticated user.
447
     *
448
     * If this is not the case, midcom_error_forbidden is thrown, or a
449
     * basic auth challenge is triggered
450
     *
451
     * If the check is successful, the function returns silently.
452
     *
453
     * @param string $method Preferred authentication method: form or basic
454
     */
455 147
    public function require_valid_user(string $method = 'form')
456
    {
457 147
        if ($method === 'basic') {
458 3
            $this->_http_basic_auth();
459
        }
460 147
        if (!$this->is_valid_user()) {
461
            throw new midcom_error_forbidden(null, MIDCOM_ERRAUTH, $method);
462
        }
463 147
    }
464
465
    /**
466
     * Handles HTTP Basic authentication
467
     */
468 3
    private function _http_basic_auth()
469
    {
470 3
        if (isset($_SERVER['PHP_AUTH_USER'])) {
471
            if ($user = $this->backend->authenticate($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'])) {
472
                $this->set_user($user);
473
            } else {
474
                // Wrong password
475
                unset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW']);
476
            }
477
        }
478 3
    }
479
480
    /**
481
     * Resolve any assignee identifier known by the system into an appropriate user/group object.
482
     *
483
     * @param string $id A valid user or group identifier usable as assignee (e.g. the $id member
484
     *     of any midcom_core_user or midcom_core_group object).
485
     * @return object|null corresponding object or false on failure.
486
     */
487 125
    public function get_assignee(string $id) : ?object
488
    {
489 125
        $parts = explode(':', $id);
490
491 125
        if ($parts[0] == 'user') {
492 124
            return $this->get_user($id);
493
        }
494 4
        if ($parts[0] == 'group') {
495 3
            return $this->get_group($id);
496
        }
497 1
        debug_add("The identifier {$id} cannot be resolved into an assignee, it cannot be mapped to a type.", MIDCOM_LOG_WARN);
498
499 1
        return null;
500
    }
501
502
    /**
503
     * This is a wrapper for get_user, which allows user retrieval by its name.
504
     * If the username is unknown, false is returned.
505
     */
506 3
    public function get_user_by_name(string $name) : ?midcom_core_user
507
    {
508 3
        $mc = new midgard_collector('midgard_user', 'login', $name);
509 3
        $mc->set_key_property('person');
510 3
        $mc->add_constraint('authtype', '=', midcom::get()->config->get('auth_type'));
511 3
        $mc->execute();
512 3
        $keys = $mc->list_keys();
513 3
        if (count($keys) != 1) {
514
            return null;
515
        }
516
517 3
        $person_class = midcom::get()->config->get('person_class');
518 3
        $person = new $person_class(key($keys));
519
520 3
        return $this->get_user($person);
521
    }
522
523
    /**
524
     * This is a wrapper for get_group, which allows Midgard Group retrieval by its name.
525
     * If the group name is unknown, false is returned.
526
     *
527
     * In the case that more than one group matches the given name, the first one is returned.
528
     * Note, that this should not happen as midgard group names should be unique according to the specs.
529
     */
530
    public function get_midgard_group_by_name(string $name) : ?midcom_core_group
531
    {
532
        $qb = new midgard_query_builder('midgard_group');
533
        $qb->add_constraint('name', '=', $name);
534
535
        $result = $qb->execute();
536
        if (empty($result)) {
537
            return null;
538
        }
539
        return $this->get_group($result[0]);
540
    }
541
542
    /**
543
     * Load a user from the database and returns an object instance.
544
     *
545
     * @param mixed $id A valid identifier for a MidgardPerson: An existing midgard_person class
546
     *     or subclass thereof, a Person ID or GUID or a midcom_core_user identifier.
547
     */
548 174
    public function get_user($id) : ?midcom_core_user
549
    {
550 174
        return $this->backend->get_user($id);
551
    }
552
553
    /**
554
     * Returns a midcom_core_group instance. Valid arguments are either a valid group identifier
555
     * (group:...), any valid identifier for the midcom_core_group
556
     * constructor or a valid object of that type.
557
     *
558
     * @param mixed $id The identifier of the group as outlined above.
559
     */
560 3
    public function get_group($id) : ?midcom_core_group
561
    {
562 3
        $param = $id;
563
564 3
        if (isset($param->id)) {
565
            $id = $param->id;
566 3
        } elseif (!is_string($id) && !is_int($id)) {
567
            debug_add('The group identifier is of an unsupported type: ' . gettype($param), MIDCOM_LOG_WARN);
568
            debug_print_r('Complete dump:', $param);
569
            return null;
570
        }
571
572 3
        if (!array_key_exists($id, $this->_group_cache)) {
573
            try {
574 3
                if (is_a($param, midcom_core_dbaobject::class)) {
575
                    $param = $param->__object;
576
                }
577 3
                $this->_group_cache[$id] = new midcom_core_group($param);
578
            } catch (midcom_error $e) {
579
                debug_add("Group with identifier {$id} could not be loaded: " . $e->getMessage(), MIDCOM_LOG_WARN);
580
                $this->_group_cache[$id] = null;
581
            }
582
        }
583 3
        return $this->_group_cache[$id];
584
    }
585
586
    /**
587
     * This call tells the backend to log in.
588
     */
589 62
    public function login(string $username, string $password, string $clientip = null) : bool
590
    {
591 62
        if ($user = $this->backend->login($username, $password, $clientip)) {
592 62
            $this->set_user($user);
593 62
            return true;
594
        }
595
        debug_add('The login information for ' . $username . ' was invalid.', MIDCOM_LOG_WARN);
596
        return false;
597
    }
598
599
    public function trusted_login(string $username) : bool
600
    {
601
        if (midcom::get()->config->get('auth_allow_trusted') !== true) {
602
            debug_add("Trusted logins are prohibited", MIDCOM_LOG_ERROR);
603
            return false;
604
        }
605
606
        if ($user = $this->backend->login($username, '', null, true)) {
607
            $this->set_user($user);
608
            return true;
609
        }
610
        return false;
611
    }
612
613
    /**
614
     * This call clears any authentication state
615
     */
616 1
    public function logout()
617
    {
618 1
        if ($this->user === null) {
619
            debug_add('The backend has no authenticated user set, so we should be fine');
620
        } else {
621 1
            $this->backend->logout($this->user);
622 1
            $this->user = null;
623
        }
624 1
        $this->admin = false;
625 1
    }
626
627
    /**
628
     * Render the main login form.
629
     * This only includes the form, no heading or whatsoever.
630
     *
631
     * It is recommended to call this function only as long as the headers are not yet sent (which
632
     * is usually given thanks to MidCOMs output buffering).
633
     *
634
     * What gets rendered depends on the authentication frontend, but will usually be some kind
635
     * of form.
636
     */
637
    public function show_login_form()
638
    {
639
        $this->frontend->show_login_form();
640
    }
641
642
    public function has_login_data() : bool
643
    {
644
        return $this->frontend->has_login_data();
645
    }
646
}
647