Passed
Push — master ( 2109ce...1a9f82 )
by Andreas
11:34
created

org_openpsa_user_accounthelper::disable_account()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 31
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 4.0961

Importance

Changes 0
Metric Value
cc 4
eloc 20
nc 4
nop 0
dl 0
loc 31
ccs 18
cts 22
cp 0.8182
crap 4.0961
rs 9.6
c 0
b 0
f 0
1
<?php
2
/**
3
 * @author CONTENT CONTROL http://www.contentcontrol-berlin.de/
4
 * @copyright CONTENT CONTROL http://www.contentcontrol-berlin.de/
5
 * @license http://www.gnu.org/licenses/gpl.html GNU General Public License
6
 * @package org.openpsa.user
7
 */
8
9
/**
10
 * Helper class for creating a new account for an existing person
11
 *
12
 * @package org.openpsa.user
13
 */
14
class org_openpsa_user_accounthelper
15
{
16
    use midcom_baseclasses_components_base;
0 ignored issues
show
introduced by
The trait midcom_baseclasses_components_base requires some properties which are not provided by org_openpsa_user_accounthelper: $i18n, $head
Loading history...
17
18
    protected ?midcom_db_person $person = null;
19
20
    private ?midcom_core_account $account = null;
21
22
    public string $errstr;
23
24 16
    public function __construct(?midcom_db_person $person = null)
25
    {
26 16
        $this->_component = 'org.openpsa.user';
27 16
        if (null !== $person) {
28 12
            $this->person = $person;
29
        }
30
    }
31
32 12
    protected function get_account() : midcom_core_account
33
    {
34 12
        return $this->account ??= new midcom_core_account($this->person);
0 ignored issues
show
Bug introduced by
It seems like $this->person can also be of type null; however, parameter $person of midcom_core_account::__construct() does only seem to accept object, 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

34
        return $this->account ??= new midcom_core_account(/** @scrutinizer ignore-type */ $this->person);
Loading history...
35
    }
36
37
    /**
38
     * can be called by various handlers
39
     *
40
     * @param string $password password: leave blank for auto generated
41
     */
42 2
    public function create_account(string $person_guid, string $username, string $usermail, string $password = "", bool $send_welcome_mail = false) : bool
43
    {
44 2
        $this->errstr = ""; // start fresh
45
46
        // quick validation
47 2
        if (empty($person_guid)) {
48 1
            $this->errstr = "Unable to identify user: no guid given";
49 1
            return false;
50
        }
51
52 2
        if (empty($username)) {
53 1
            $this->errstr = "Unable to create account: no username given";
54 1
            return false;
55
        }
56
57 2
        if ($send_welcome_mail && empty($usermail)) {
58 1
            $this->errstr = "Unable to deliver welcome mail: no usermail address given";
59 1
            return false;
60
        }
61
62
        // Check if we get the person
63 2
        $this->person = new midcom_db_person($person_guid);
64 2
        $this->person->require_do('midgard:update');
65
66
        //need to generate password?
67 2
        if (empty($password)) {
68 1
            $generated_password = true;
69 1
            $password = $this->generate_safe_password($this->_config->get("min_password_length"));
0 ignored issues
show
Bug introduced by
It seems like $this->_config->get('min_password_length') can also be of type false; however, parameter $length of org_openpsa_user_account...enerate_safe_password() does only seem to accept integer, 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

69
            $password = $this->generate_safe_password(/** @scrutinizer ignore-type */ $this->_config->get("min_password_length"));
Loading history...
70
        } else {
71 2
            $generated_password = false;
72
        }
73
74 2
        $account = $this->get_account();
75
76
        //an account already existing?
77 2
        if ($account->get_password()) {
78 1
            $this->errstr = "Creating new account for existing account is not possible";
79 1
            return false;
80
        }
81
82
        //try creating
83 2
        if (!$this->set_account($username, $password)) {
84
            $this->errstr = "Could not set account, reason: " . midcom_connection::get_error_string();
85
            return false;
86
        }
87
88
        //send welcome mail?
89 2
        if ($send_welcome_mail) {
90 1
            if (!$this->send_mail($username, $usermail, $password)) {
91
                $this->delete_account();
92 1
                return false;
93
            }
94 1
        } elseif ($generated_password) {
95
            /*
96
             * no welcome mail was sent:
97
             * if the password was auto generated show it in an ui message
98
             */
99 1
            midcom::get()->uimessages->add(
100 1
                $this->_l10n->get($this->_component),
101 1
                sprintf($this->_l10n->get("account_creation_success"), $username, $password), 'ok');
102
        }
103
104 2
        return true;
105
    }
106
107
    /**
108
     * Prepare the welcome mail for the user.
109
     *
110
     * The essential data (recipient, username, password) is already filled in
111
     * at this point. You can override this function in subclasses if you want
112
     * to customize the mail further
113
     */
114 3
    protected function prepare_mail(org_openpsa_mail $mail)
115
    {
116 3
        $mail->from = $this->_config->get('welcome_mail_from_address');
117 3
        $mail->subject = $this->_config->get('welcome_mail_title');
0 ignored issues
show
Documentation Bug introduced by
It seems like $this->_config->get('welcome_mail_title') can also be of type false. However, the property $subject is declared as type string. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
118 3
        $mail->body = $this->_config->get('welcome_mail_body');
119 3
        $mail->parameters["SITE_URL"] = midcom::get()->config->get('midcom_site_url');
120
    }
121
122
    /**
123
     * Send mail
124
     */
125 3
    private function send_mail(string $username, string $usermail, string $password) : bool
126
    {
127 3
        $mail = new org_openpsa_mail();
128 3
        $mail->to = $usermail;
129
130
        // Make replacements to body
131 3
        $mail->parameters = [
132 3
            "USERNAME" => $username,
133 3
            "PASSWORD" => $password,
134 3
        ];
135
136 3
        $this->prepare_mail($mail);
137 3
        if (!$mail->send()) {
138
            $this->errstr = "Unable to deliver welcome mail: " . $mail->get_error_message();
139
            return false;
140
        }
141 3
        return true;
142
    }
143
144
    /**
145
     * Sets username and password for person
146
     *
147
     * @param string $new_password Contains the new password to set
148
     */
149 6
    public function set_account(string $username, $new_password) : bool
150
    {
151 6
        $account = $this->get_account();
152 6
        if (!empty($new_password)) {
153
            //check if the new encrypted password was already used
154 6
            if (   !$this->check_password_reuse($new_password, true)
155 6
                || !$this->check_password_strength($new_password, true)) {
156
                $this->errstr = "password strength too low";
157
                return false;
158
            }
159 6
            $this->save_old_password();
160 6
            $account->set_password($new_password);
161
        }
162
163 6
        $account->set_username($username);
164
165
        // probably username not unique
166 6
        if (!$account->save()) {
167
            $this->errstr = "Failed to save account, reason: " . midcom_connection::get_error_string();
168
            return false;
169
        }
170
171 6
        if (!empty($new_password)) {
172
            // add timestamp of password-change
173 6
            $this->person->set_parameter("org_openpsa_user_password", "last_change", time());
0 ignored issues
show
Bug introduced by
The method set_parameter() does not exist on null. ( Ignorable by Annotation )

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

173
            $this->person->/** @scrutinizer ignore-call */ 
174
                           set_parameter("org_openpsa_user_password", "last_change", time());

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
174
        }
175
        // sets privilege
176 6
        midcom::get()->auth->request_sudo($this->_component);
177 6
        $this->person->set_privilege('midgard:owner', "user:" . $this->person->guid);
178 6
        midcom::get()->auth->drop_sudo();
179
180 6
        return true;
181
    }
182
183
    /**
184
     * Returns an auto generated password which will pass the persons check_password_strength test
185
     */
186 8
    public function generate_safe_password(int $length = 0) : string
187
    {
188
        do {
189 8
            $password = midgard_admin_user_plugin::generate_password($length);
190 8
        } while (!$this->check_password_strength($password));
191 8
        return $password;
192
    }
193
194
    /**
195
     * Function to check if passed password was already used
196
     *
197
     * @return bool returns true if password wasn't used already
198
     */
199 7
    public function check_password_reuse(string $password, bool $show_ui_message = false) : bool
200
    {
201
        // check current password
202 7
        if (midcom_connection::verify_password($password, $this->get_account()->get_password())) {
203 1
            if ($show_ui_message) {
204
                midcom::get()->uimessages->add($this->_l10n->get($this->_component), $this->_l10n->get('password is the same as the current one'), 'error');
205
            }
206 1
            return false;
207
        }
208
209
        // get last passwords
210 7
        $old_passwords = $this->get_old_passwords();
211
212
        // check last passwords
213 7
        foreach ($old_passwords as $old) {
214 1
            if (midcom_connection::verify_password($password, $old)) {
215 1
                if ($show_ui_message) {
216
                    midcom::get()->uimessages->add($this->_l10n->get($this->_component), $this->_l10n->get('password was already used'), 'error');
217
                }
218 1
                return false;
219
            }
220
        }
221 7
        return true;
222
    }
223
224
    /**
225
     * Function to add current password to parameter old passwords - does not update()
226
     */
227 6
    private function save_old_password()
228
    {
229 6
        $max_old_passwords = $this->_config->get('max_old_passwords');
230 6
        if ($max_old_passwords < 1) {
231
            return;
232
        }
233 6
        $old_passwords_array = $this->get_old_passwords();
234 6
        array_unshift($old_passwords_array, $this->get_account()->get_password());
235
236 6
        if (count($old_passwords_array) > $max_old_passwords) {
237
            array_pop($old_passwords_array);
238
        }
239
240 6
        $new_passwords_string = serialize($old_passwords_array);
241
242 6
        $this->person->set_parameter("org_openpsa_user_password", "old_passwords", $new_passwords_string);
243
    }
244
245
    /**
246
     * Function get old passwords
247
     *
248
     * @return array - Array with old passwords - empty if there aren't any old passwords
249
     */
250 7
    private function get_old_passwords() : array
251
    {
252 7
        if ($old_passwords_string = $this->person->get_parameter("org_openpsa_user_password", "old_passwords")) {
253 1
            $old_passwords_array = unserialize($old_passwords_string);
254 1
            $count = count($old_passwords_array);
255 1
            $max = (int) $this->_config->get('max_old_passwords');
256 1
            if ($count > $max) {
257 1
                $old_passwords_array = array_slice($old_passwords_array, 0, $max);
258
            }
259
        } else {
260 7
            $old_passwords_array = [];
261
        }
262 7
        return $old_passwords_array;
263
    }
264
265
    /**
266
     * Function to check strength of passed password
267
     */
268 10
    public function check_password_strength(string $password, bool $show_ui_message = false) : bool
269
    {
270 10
        $password_length = mb_strlen($password);
271
272 10
        if ($password_length < $this->_config->get('min_password_length')) {
273 2
            if ($show_ui_message){
274
                midcom::get()->uimessages->add($this->_l10n->get($this->_component), $this->_l10n->get('password too short'), 'error');
275
            }
276 2
            return false;
277
        }
278
279
        // score for length & repetition
280 9
        $score = $this->count_unique_characters($password) * 4;
281
282
        //check $password with rules
283 9
        $rules = $this->_config->get_array('password_score_rules');
284 9
        foreach ($rules as $rule) {
285 9
            if (preg_match($rule['match'], $password) > 0) {
286 9
                $score += $rule['score'];
287
            }
288
        }
289
290 9
        if ($score < $this->_config->get('min_password_score')) {
291 3
            if ($show_ui_message){
292
                midcom::get()->uimessages->add($this->_l10n->get($this->_component), $this->_l10n->get('password weak'), 'error');
293
            }
294 3
            return false;
295
        }
296 9
        return true;
297
    }
298
299 9
    private function count_unique_characters(string $password) : int
300
    {
301
        // Split into individual (multibyte) characters, flip to filter out duplicates, and then count
302 9
        return count(array_flip(preg_split('//u', $password, -1, PREG_SPLIT_NO_EMPTY)));
303
    }
304
305
    /**
306
     * Function to check password age for this user (age is taken from config)
307
     *
308
     * @return boolean - true indicates password is ok - false password is to old
309
     */
310 1
    public function check_password_age() : bool
311
    {
312 1
        $max_age_days = $this->_config->get('password_max_age_days');
313 1
        if ($max_age_days == 0) {
314
            return true;
315
        }
316
317 1
        if ($last_change = $this->person->get_parameter("org_openpsa_user_password", "last_change")) {
318 1
            $max_timeframe = time() - ($max_age_days * 24 * 60 * 60);
319 1
            return $max_timeframe < $last_change;
320
        }
321 1
        return false;
322
    }
323
324
    /**
325
     * Function sends an email to the user with username and password
326
     *
327
     * @return boolean indicates success
328
     */
329 2
    public function welcome_email() : bool
330
    {
331 2
        $username = $this->get_account()->get_username();
332 2
        $email = $this->person->email;
333
334
        //Resets Password
335 2
        $password = $this->generate_safe_password($this->_config->get("min_password_length"));
0 ignored issues
show
Bug introduced by
It seems like $this->_config->get('min_password_length') can also be of type false; however, parameter $length of org_openpsa_user_account...enerate_safe_password() does only seem to accept integer, 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

335
        $password = $this->generate_safe_password(/** @scrutinizer ignore-type */ $this->_config->get("min_password_length"));
Loading history...
336 2
        $this->set_account($username, $password);
337 2
        return $this->send_mail($username, $email, $password);
338
    }
339
340
    /**
341
     * Function to disable account for time period given in config
342
     *
343
     * @return boolean - indicates success
344
     */
345 2
    public function disable_account() : bool
346
    {
347 2
        $account = $this->get_account();
348
349 2
        $timeframe_minutes = $this->_config->get('password_block_timeframe_min');
350
351 2
        if ($timeframe_minutes == 0) {
352
            return false;
353
        }
354 2
        $release_time = time() + ($timeframe_minutes * 60);
355 2
        $args = [
356 2
            'guid' => $this->person->guid,
357 2
            'parameter_name' => 'org_openpsa_user_blocked_account',
358 2
            'password' => 'account_password',
359 2
        ];
360
361 2
        $qb = midcom_services_at_entry_dba::new_query_builder();
362 2
        $qb->add_constraint('argumentsstore', '=', serialize($args));
363 2
        $qb->add_constraint('status', '=', midcom_services_at_entry_dba::SCHEDULED);
364 2
        if ($entry = $qb->get_result(0)) {
365
            //the account is already blocked, so we just extend the block's duration
366
            $entry->start = $release_time;
0 ignored issues
show
Bug Best Practice introduced by
The property start does not exist on midcom_core_dbaobject. Since you implemented __set, consider adding a @property annotation.
Loading history...
367
            return $entry->update();
368
        }
369
370 2
        if (!midcom_services_at_interface::register($release_time, $this->_component, 'reopen_account', $args)) {
371
            throw new midcom_error("Failed to register interface for re_open the user account, last Midgard error was: " . midcom_connection::get_error_string());
372
        }
373 2
        $this->person->set_parameter("org_openpsa_user_blocked_account", "account_password", $account->get_password());
374 2
        $account->set_password('', false);
375 2
        return $account->save();
376
    }
377
378
    /**
379
     * Function to delete account
380
     *
381
     * @return boolean indicates success
382
     */
383 1
    public function delete_account() : bool
384
    {
385 1
        return $this->get_account()->delete();
386
    }
387
388
    /**
389
     * Permanently disable an user account
390
     *
391
     * @return boolean - indicates success
392
     */
393 2
    public function close_account() : bool
394
    {
395 2
        $account = $this->get_account();
396
397 2
        if (!$account->get_password()) {
398
            // the account is already blocked, so skip the rest
399 1
            return true;
400
        }
401
402 2
        $this->person->set_parameter("org_openpsa_user_blocked_account", "account_password", $account->get_password());
403 2
        $account->set_password('', false);
404 2
        return $account->save();
405
    }
406
407
    /**
408
     * Reopen a blocked account.
409
     */
410 1
    public function reopen_account()
411
    {
412 1
        $account = new midcom_core_account($this->person);
0 ignored issues
show
Bug introduced by
It seems like $this->person can also be of type null; however, parameter $person of midcom_core_account::__construct() does only seem to accept object, 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

412
        $account = new midcom_core_account(/** @scrutinizer ignore-type */ $this->person);
Loading history...
413 1
        if ($account->get_password()) {
414 1
            debug_add('Account for person #' . $this->person->id . ' does have a password already');
415
        } else {
416 1
            $account->set_password($this->person->get_parameter('org_openpsa_user_blocked_account', 'account_password'), false);
0 ignored issues
show
Bug introduced by
It seems like $this->person->get_param...t', 'account_password') can also be of type null; however, parameter $password of midcom_core_account::set_password() 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

416
            $account->set_password(/** @scrutinizer ignore-type */ $this->person->get_parameter('org_openpsa_user_blocked_account', 'account_password'), false);
Loading history...
417 1
            if (!$account->save()) {
418
                throw new midcom_error('Failed to save account: ' . midcom_connection::get_error_string());
419
            }
420
        }
421
422 1
        $this->person->delete_parameter('org_openpsa_user_blocked_account', 'account_password');
423
    }
424
425
    /**
426
     * Determine if an account is blocked
427
     */
428 4
    public function is_blocked() : bool
429
    {
430 4
        return !empty($this->person->get_parameter("org_openpsa_user_blocked_account", "account_password"));
431
    }
432
433 1
    public static function get_person_by_formdata(array $data) : ?midcom_db_person
434
    {
435 1
        if (   empty($data['username'])
436 1
            || empty($data['password'])) {
437 1
            return null;
438
        }
439
440 1
        midcom::get()->auth->request_sudo('org.openpsa.user');
441 1
        $qb = midcom_db_person::new_query_builder();
442 1
        midcom_core_account::add_username_constraint($qb, '=', $data['username']);
443 1
        $results = $qb->execute();
444 1
        midcom::get()->auth->drop_sudo();
445
446 1
        if (count($results) != 1) {
447 1
            return null;
448
        }
449 1
        return $results[0];
450
    }
451
452
    /**
453
     * Record failed login attempts and disable account is necessary
454
     */
455 1
    public function check_login_attempts() : bool
456
    {
457 1
        $stat = true;
458
459
        //max-attempts allowed & timeframe
460 1
        $max_attempts = $this->_config->get('max_password_attempts');
461 1
        $timeframe = $this->_config->get('password_block_timeframe_min');
462
463 1
        if (!$max_attempts || !$timeframe) {
464
            return $stat;
465
        }
466
467 1
        midcom::get()->auth->request_sudo($this->_component);
468
469 1
        if ($attempts = $this->person->get_parameter("org_openpsa_user_password", "attempts")) {
470 1
            $attempts = unserialize($attempts);
471 1
            if (is_array($attempts)) {
472 1
                $attempts = array_slice($attempts, 0, ($max_attempts - 1));
473
            }
474
        }
475 1
        if (!is_array($attempts)) {
476 1
            $attempts = [];
477
        }
478 1
        array_unshift($attempts, time());
479
480
        /*
481
         * If the maximum number of attempts is reached and the oldest attempt
482
         * on the stack is within our defined timeframe, we block the account
483
         */
484 1
        if (   count($attempts) >= $max_attempts
485 1
            && $attempts[$max_attempts - 1] >= (time() - ($timeframe * 60))) {
486 1
            $this->disable_account();
487 1
            $stat = false;
488
        }
489
490 1
        $attempts = serialize($attempts);
491 1
        $this->person->set_parameter("org_openpsa_user_password", "attempts", $attempts);
492 1
        midcom::get()->auth->drop_sudo();
493 1
        return $stat;
494
    }
495
}
496