Passed
Push — master ( dacdde...62e46e )
by Andreas
25:19
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
     * Administrator, false otherwise.
50
     *
51
     * @var boolean
52
     */
53
    public $admin = false;
54
55
    /**
56
     * @var midcom_services_auth_acl
57
     */
58
    public $acl;
59
60
    /**
61
     * Internal cache of all loaded groups, indexed by their identifiers.
62
     *
63
     * @var array
64
     */
65
    private $_group_cache = [];
66
67
    /**
68
     * This flag indicates if sudo mode is active during execution. This will only be the
69
     * case if the sudo system actually grants this privileges, and only until components
70
     * release the rights again. This does override the full access control system at this time
71
     * and essentially give you full admin privileges (though this might change in the future).
72
     *
73
     * Note, that this is no boolean but an int, otherwise it would be impossible to trace nested
74
     * sudo invocations, which are quite possible with multiple components calling each others
75
     * callback. A value of 0 indicates that sudo is inactive. A value greater than zero indicates
76
     * sudo mode is active, with the count being equal to the depth of the sudo callers.
77
     *
78
     * It is thus still safely possible to evaluate this member in a boolean context to check
79
     * for an enabled sudo mode.
80
     *
81
     * @var int
82
     * @see request_sudo()
83
     * @see drop_sudo()
84
     */
85
    private $_component_sudo = 0;
86
87
    /**
88
     * @var midcom_services_auth_backend
89
     */
90
    private $backend;
91
92
    /**
93
     * @var midcom_services_auth_frontend
94
     */
95
    private $frontend;
96
97
    /**
98
     * Loads all configured authentication drivers.
99
     */
100
    public function __construct(midcom_services_auth_acl $acl, midcom_services_auth_backend $backend, midcom_services_auth_frontend $frontend)
101
    {
102
        $this->acl = $acl;
103
        $this->backend = $backend;
104
        $this->frontend = $frontend;
105
    }
106
107
    /**
108
     * Checks if the current authentication fronted has new credentials
109
     * ready. If yes, it processes the login accordingly. Otherwise look for existing session
110
     */
111 1
    public function check_for_login_session(Request $request)
112
    {
113
        // Try to start up a new session, this will authenticate as well.
114 1
        if ($credentials = $this->frontend->read_login_data($request)) {
115
            if (!$this->login($credentials['username'], $credentials['password'], $request->getClientIp())) {
116
                return;
117
            }
118
            debug_add('Authentication was successful, we have a new login session now. Updating timestamps');
119
120
            $person_class = midcom::get()->config->get('person_class');
121
            $person = new $person_class($this->user->guid);
122
123
            if (!$person->get_parameter('midcom', 'first_login')) {
124
                $person->set_parameter('midcom', 'first_login', time());
125
            } elseif (midcom::get()->config->get('auth_save_prev_login')) {
126
                $person->set_parameter('midcom', 'prev_login', $person->get_parameter('midcom', 'last_login'));
127
            }
128
            $person->set_parameter('midcom', 'last_login', time());
129
130
            // Now we check whether there is a success-relocate URL given somewhere.
131
            if ($request->get('midcom_services_auth_login_success_url')) {
132
                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

132
                return new midcom_response_relocate(/** @scrutinizer ignore-type */ $request->get('midcom_services_auth_login_success_url'));
Loading history...
133
            }
134
        }
135
        // No new login detected, so we check if there is a running session.
136 1
        elseif ($user = $this->backend->check_for_active_login_session($request)) {
137
            $this->set_user($user);
138
        }
139
    }
140
141 64
    private function set_user(midcom_core_user $user)
142
    {
143 64
        $this->user = $user;
144 64
        $this->admin = $user->is_admin();
145
    }
146
147
    /**
148
     * Checks whether a user has a certain privilege on the given content object.
149
     * Works on the currently authenticated user by default, but can take another
150
     * user as an optional argument.
151
     *
152
     * @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...
153
     * @param midcom_core_user $user The user against which to check the privilege, defaults to the currently authenticated user.
154
     *     You may specify "EVERYONE" instead of an object to check what an anonymous user can do.
155
     */
156 463
    public function can_do(string $privilege, object $content_object, $user = null) : bool
157
    {
158 463
        if ($this->is_admin($user)) {
159
            // Administrators always have access.
160 2
            return true;
161
        }
162
163 463
        $user_id = $this->acl->get_user_id($user);
164
165
        //if we're handed the correct object type, we use its class right away
166 463
        if (midcom::get()->dbclassloader->is_midcom_db_object($content_object)) {
167 463
            $content_object_class = get_class($content_object);
168
        }
169
        //otherwise, we assume (hope) that it's a midgard object
170
        else {
171
            $content_object_class = midcom::get()->dbclassloader->get_midcom_class_name_for_mgdschema_object($content_object);
172
        }
173
174 463
        return $this->acl->can_do_byguid($privilege, $content_object->guid, $content_object_class, $user_id);
0 ignored issues
show
Bug introduced by
It seems like $content_object_class can also be of type null; however, parameter $object_class of midcom_services_auth_acl::can_do_byguid() 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

174
        return $this->acl->can_do_byguid($privilege, $content_object->guid, /** @scrutinizer ignore-type */ $content_object_class, $user_id);
Loading history...
175
    }
176
177 473
    private function is_admin($user) : bool
178
    {
179 473
        if ($user === null) {
180 473
            return $this->user && $this->admin;
181
        }
182 5
        if ($user instanceof midcom_core_user) {
183 2
            return $user->is_admin();
184
        }
185 3
        return false;
186
    }
187
188
    /**
189
     * Checks, whether the given user have the privilege assigned to him in general.
190
     * Be aware, that this does not take any permissions overridden by content objects
191
     * into account. Whenever possible, you should user the can_do() variant of this
192
     * call therefore. can_user_do is only of interest in cases where you do not have
193
     * any content object available, for example when creating root topics.
194
     *
195
     * @param midcom_core_user $user The user against which to check the privilege, defaults to the currently authenticated user,
196
     *     you may specify 'EVERYONE' here to check what an anonymous user can do.
197
     * @param string $class Optional parameter to set if the check should take type specific permissions into account. The class must be default constructible.
198
     */
199 339
    public function can_user_do(string $privilege, $user = null, $class = null) : bool
200
    {
201 339
        if ($this->is_admin($user)) {
202
            // Administrators always have access.
203 2
            return true;
204
        }
205 339
        if ($this->_component_sudo) {
206 335
            return true;
207
        }
208 7
        if ($user === null) {
209 7
            $user =& $this->user;
210
        }
211
212 7
        if ($user == 'EVERYONE') {
0 ignored issues
show
introduced by
The condition $user == 'EVERYONE' is always false.
Loading history...
213
            $user = null;
214
        }
215
216 7
        return $this->acl->can_do_byclass($privilege, $user, $class);
217
    }
218
219
    /**
220
     * Request superuser privileges for the domain passed.
221
     *
222
     * STUB IMPLEMENTATION ONLY, WILL ALWAYS GRANT SUDO.
223
     *
224
     * You have to call midcom_services_auth::drop_sudo() as soon as you no longer
225
     * need the elevated privileges, which will reset the authentication data to the
226
     * initial credentials.
227
     *
228
     * @param string $domain The domain to request sudo for. This is a component name.
229
     */
230 685
    public function request_sudo(string $domain = null) : bool
231
    {
232 685
        if (!midcom::get()->config->get('auth_allow_sudo')) {
233 1
            debug_add("SUDO is not allowed on this website.", MIDCOM_LOG_ERROR);
234 1
            return false;
235
        }
236
237 685
        if ($domain === null) {
238 1
            $domain = midcom_core_context::get()->get_key(MIDCOM_CONTEXT_COMPONENT);
239 1
            debug_add("Domain was not supplied, falling back to '{$domain}' which we got from the current component context.");
240
        }
241
242 685
        if ($domain == '') {
243 1
            debug_add("SUDO request for an empty domain, this should not happen. Denying sudo.", MIDCOM_LOG_INFO);
244 1
            return false;
245
        }
246
247 685
        $this->_component_sudo++;
248
249 685
        debug_add("Entered SUDO mode for domain {$domain}.", MIDCOM_LOG_INFO);
250
251 685
        return true;
252
    }
253
254
    /**
255
     * Drops previously acquired superuser privileges.
256
     *
257
     * @see request_sudo()
258
     */
259 685
    public function drop_sudo()
260
    {
261 685
        if ($this->_component_sudo > 0) {
262 685
            debug_add('Leaving SUDO mode.');
263 685
            $this->_component_sudo--;
264
        } else {
265 1
            debug_add('Requested to leave SUDO mode, but sudo was already disabled. Ignoring request.', MIDCOM_LOG_INFO);
266
        }
267
    }
268
269 662
    public function is_component_sudo() : bool
270
    {
271 662
        return $this->_component_sudo > 0;
272
    }
273
274
    /**
275
     * Check, whether a user is member of a given group. By default, the query is run
276
     * against the currently authenticated user.
277
     *
278
     * It always returns true for administrative users.
279
     *
280
     * @param mixed $group Group to check against, this can be either a midcom_core_group object or a group string identifier.
281
     * @param midcom_core_user $user The user which should be checked, defaults to the current user.
282
     */
283
    public function is_group_member($group, $user = null) : bool
284
    {
285
        if ($this->is_admin($user)) {
286
            // Administrators always have access.
287
            return true;
288
        }
289
        // Default parameter
290
        if ($user === null) {
291
            if ($this->user === null) {
292
                // not authenticated
293
                return false;
294
            }
295
            $user = $this->user;
296
        }
297
298
        return $user->is_in_group($group);
299
    }
300
301
    /**
302
     * Returns true if there is an authenticated user, false otherwise.
303
     */
304 155
    public function is_valid_user() : bool
305
    {
306 155
        return $this->user !== null;
307
    }
308
309
    /**
310
     * Validates that the current user has the given privilege granted on the
311
     * content object passed to the function.
312
     *
313
     * If this is not the case, an Access Denied error is generated, the message
314
     * defaulting to the string 'access denied: privilege %s not granted' of the
315
     * MidCOM main L10n table.
316
     *
317
     * The check is always done against the currently authenticated user. If the
318
     * check is successful, the function returns silently.
319
     *
320
     * @param MidgardObject $content_object A Midgard Content Object
321
     */
322 193
    public function require_do(string $privilege, object $content_object, string $message = null)
323
    {
324 193
        if (!$this->can_do($privilege, $content_object)) {
325
            throw $this->access_denied($message, 'privilege %s not granted', $privilege);
326
        }
327
    }
328
329
    /**
330
     * Validates, whether the given user have the privilege assigned to him in general.
331
     * Be aware, that this does not take any permissions overridden by content objects
332
     * into account. Whenever possible, you should user the require_do() variant of this
333
     * call therefore. require_user_do is only of interest in cases where you do not have
334
     * any content object available, for example when creating root topics.
335
     *
336
     * If this is not the case, an Access Denied error is generated, the message
337
     * defaulting to the string 'access denied: privilege %s not granted' of the
338
     * MidCOM main L10n table.
339
     *
340
     * The check is always done against the currently authenticated user. If the
341
     * check is successful, the function returns silently.
342
     *
343
     * @param string $class Optional parameter to set if the check should take type specific permissions into account. The class must be default constructible.
344
     */
345 77
    public function require_user_do(string $privilege, string $message = null, string $class = null)
346
    {
347 77
        if (!$this->can_user_do($privilege, null, $class)) {
348
            throw $this->access_denied($message, 'privilege %s not granted', $privilege);
349
        }
350
    }
351
352
    /**
353
     * Validates that the current user is a member of the given group.
354
     *
355
     * If this is not the case, an Access Denied error is generated, the message
356
     * defaulting to the string 'access denied: user is not member of the group %s' of the
357
     * MidCOM main L10n table.
358
     *
359
     * The check is always done against the currently authenticated user. If the
360
     * check is successful, the function returns silently.
361
     *
362
     * @param mixed $group Group to check against, this can be either a midcom_core_group object or a group string identifier.
363
     * @param string $message The message to show if the user is not member of the given group.
364
     */
365
    function require_group_member($group, $message = null)
0 ignored issues
show
Best Practice introduced by
It is generally recommended to explicitly declare the visibility for methods.

Adding explicit visibility (private, protected, or public) is generally recommend to communicate to other developers how, and from where this method is intended to be used.

Loading history...
366
    {
367
        if (!$this->is_group_member($group)) {
368
            if (is_object($group)) {
369
                $group = $group->name;
370
            }
371
            throw $this->access_denied($message, 'user is not member of the group %s', $group);
372
        }
373
    }
374
375
    /**
376
     * Validates that we currently have admin level privileges, which can either
377
     * come from the current user, or from the sudo service.
378
     *
379
     * If the check is successful, the function returns silently.
380
     */
381 4
    public function require_admin_user(string $message = null)
382
    {
383 4
        if (!$this->admin && !$this->_component_sudo) {
384
            throw $this->access_denied($message, 'admin level privileges required');
385
        }
386
    }
387
388
    private function access_denied(?string $message, string $fallback, string $data = null) : midcom_error_forbidden
389
    {
390
        if ($message === null) {
391
            $message = midcom::get()->i18n->get_string('access denied: ' . $fallback, 'midcom');
392
            if ($data !== null) {
393
                $message = sprintf($message, $data);
394
            }
395
        }
396
        debug_print_function_stack("access_denied was called from here:");
397
        return new midcom_error_forbidden($message);
398
    }
399
400
    /**
401
     * Require either a configured IP address or admin credentials
402
     */
403
    public function require_admin_or_ip(string $domain) : bool
404
    {
405
        $ips = midcom::get()->config->get_array('indexer_reindex_allowed_ips');
406
        if (in_array($_SERVER['REMOTE_ADDR'], $ips)) {
407
            if (!$this->request_sudo($domain)) {
408
                throw new midcom_error('Failed to acquire SUDO rights. Aborting.');
409
            }
410
            return true;
411
        }
412
413
        // Require user to Basic-authenticate for security reasons
414
        $this->require_valid_user('basic');
415
        $this->require_admin_user();
416
        return false;
417
    }
418
419
    /**
420
     * Validates that there is an authenticated user.
421
     *
422
     * If this is not the case, midcom_error_forbidden is thrown, or a
423
     * basic auth challenge is triggered
424
     *
425
     * If the check is successful, the function returns silently.
426
     *
427
     * @param string $method Preferred authentication method: form or basic
428
     */
429 153
    public function require_valid_user(string $method = 'form')
430
    {
431 153
        if ($method === 'basic') {
432 4
            $this->_http_basic_auth();
433
        }
434 153
        if (!$this->is_valid_user()) {
435
            throw new midcom_error_forbidden(null, MIDCOM_ERRAUTH, $method);
436
        }
437
    }
438
439
    /**
440
     * Handles HTTP Basic authentication
441
     */
442 4
    private function _http_basic_auth()
443
    {
444 4
        if (isset($_SERVER['PHP_AUTH_USER'])) {
445
            if ($user = $this->backend->authenticate($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'])) {
446
                $this->set_user($user);
447
            } else {
448
                // Wrong password
449
                unset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW']);
450
            }
451
        }
452
    }
453
454
    /**
455
     * Resolve any assignee identifier known by the system into an appropriate user/group object.
456
     *
457
     * @param string $id A valid user or group identifier usable as assignee (e.g. the $id member
458
     *     of any midcom_core_user or midcom_core_group object).
459
     * @return object|null corresponding object or false on failure.
460
     */
461 131
    public function get_assignee(string $id) : ?object
462
    {
463 131
        $parts = explode(':', $id);
464
465 131
        if ($parts[0] == 'user') {
466 130
            return $this->get_user($id);
467
        }
468 4
        if ($parts[0] == 'group') {
469 3
            return $this->get_group($id);
470
        }
471 1
        debug_add("The identifier {$id} cannot be resolved into an assignee, it cannot be mapped to a type.", MIDCOM_LOG_WARN);
472
473 1
        return null;
474
    }
475
476
    /**
477
     * This is a wrapper for get_user, which allows user retrieval by its name.
478
     * If the username is unknown, false is returned.
479
     */
480 3
    public function get_user_by_name(string $name) : ?midcom_core_user
481
    {
482 3
        $mc = new midgard_collector('midgard_user', 'login', $name);
483 3
        $mc->set_key_property('person');
484 3
        $mc->add_constraint('authtype', '=', midcom::get()->config->get('auth_type'));
485 3
        $mc->execute();
486 3
        $keys = $mc->list_keys();
487 3
        if (count($keys) != 1) {
488
            return null;
489
        }
490
491 3
        return $this->get_user(key($keys));
492
    }
493
494
    /**
495
     * This is a wrapper for get_group, which allows Midgard Group retrieval by its name.
496
     * If the group name is unknown, false is returned.
497
     *
498
     * In the case that more than one group matches the given name, the first one is returned.
499
     * Note, that this should not happen as midgard group names should be unique according to the specs.
500
     */
501
    public function get_midgard_group_by_name(string $name) : ?midcom_core_group
502
    {
503
        $qb = new midgard_query_builder('midgard_group');
504
        $qb->add_constraint('name', '=', $name);
505
506
        if ($result = $qb->execute()) {
507
            return $this->get_group($result[0]);
508
        }
509
        return null;
510
    }
511
512
    /**
513
     * Load a user from the database and returns an object instance.
514
     *
515
     * @param mixed $id A valid identifier for a MidgardPerson: An existing midgard_person class
516
     *     or subclass thereof, a Person ID or GUID or a midcom_core_user identifier.
517
     */
518 189
    public function get_user($id) : ?midcom_core_user
519
    {
520 189
        return $this->backend->get_user($id);
521
    }
522
523
    /**
524
     * Returns a midcom_core_group instance. Valid arguments are either a valid group identifier
525
     * (group:...), any valid identifier for the midcom_core_group
526
     * constructor or a valid object of that type.
527
     *
528
     * @param mixed $id The identifier of the group as outlined above.
529
     */
530 3
    public function get_group($id) : ?midcom_core_group
531
    {
532 3
        $param = $id;
533
534 3
        if (isset($param->id)) {
535
            $id = $param->id;
536 3
        } elseif (!is_string($id) && !is_int($id)) {
537
            debug_add('The group identifier is of an unsupported type: ' . gettype($param), MIDCOM_LOG_WARN);
538
            debug_print_r('Complete dump:', $param);
539
            return null;
540
        }
541
542 3
        if (!array_key_exists($id, $this->_group_cache)) {
543
            try {
544 3
                if ($param instanceof midcom_core_dbaobject) {
545
                    $param = $param->__object;
546
                }
547 3
                $this->_group_cache[$id] = new midcom_core_group($param);
548
            } catch (midcom_error $e) {
549
                debug_add("Group with identifier {$id} could not be loaded: " . $e->getMessage(), MIDCOM_LOG_WARN);
550
                $this->_group_cache[$id] = null;
551
            }
552
        }
553 3
        return $this->_group_cache[$id];
554
    }
555
556
    /**
557
     * This call tells the backend to log in.
558
     */
559 64
    public function login(string $username, string $password, string $clientip = null) : bool
560
    {
561 64
        if ($user = $this->backend->login($username, $password, $clientip)) {
562 64
            $this->set_user($user);
563 64
            return true;
564
        }
565
        debug_add('The login information for ' . $username . ' was invalid.', MIDCOM_LOG_WARN);
566
        return false;
567
    }
568
569
    public function trusted_login(string $username) : bool
570
    {
571
        if (midcom::get()->config->get('auth_allow_trusted') !== true) {
572
            debug_add("Trusted logins are prohibited", MIDCOM_LOG_ERROR);
573
            return false;
574
        }
575
576
        if ($user = $this->backend->login($username, '', null, true)) {
577
            $this->set_user($user);
578
            return true;
579
        }
580
        return false;
581
    }
582
583
    /**
584
     * This call clears any authentication state
585
     */
586 1
    public function logout()
587
    {
588 1
        if ($this->user === null) {
589
            debug_add('The backend has no authenticated user set, so we should be fine');
590
        } else {
591 1
            $this->backend->logout($this->user);
592 1
            $this->user = null;
593
        }
594 1
        $this->admin = false;
595
    }
596
597
    /**
598
     * Render the main login form.
599
     * This only includes the form, no heading or whatsoever.
600
     *
601
     * It is recommended to call this function only as long as the headers are not yet sent (which
602
     * is usually given thanks to MidCOMs output buffering).
603
     *
604
     * What gets rendered depends on the authentication frontend, but will usually be some kind
605
     * of form.
606
     */
607
    public function show_login_form()
608
    {
609
        $this->frontend->show_login_form();
610
    }
611
612
    public function has_login_data() : bool
613
    {
614
        return $this->frontend->has_login_data();
615
    }
616
}
617