Passed
Push — master ( f81b08...a0a105 )
by Andreas
14:23
created

org_openpsa_user_accounthelper::reopen_account()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 13
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 3.0175

Importance

Changes 2
Bugs 1 Features 0
Metric Value
cc 3
eloc 8
c 2
b 1
f 0
nc 3
nop 0
dl 0
loc 13
ccs 7
cts 8
cp 0.875
crap 3.0175
rs 10
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 extends midcom_baseclasses_components_purecode
15
{
16
    /**
17
     * The person we're working on
18
     *
19
     * @var midcom_db_person
20
     */
21
    protected $person;
22
23
    /**
24
     * The account we're working on
25
     *
26
     * @var midcom_core_account
27
     */
28
    private $account;
29
30
    public $errstr;
31
32 14
    public function __construct(midcom_db_person $person = null)
33
    {
34 14
        if (null !== $person) {
35 10
            $this->person = $person;
36
        }
37 14
        parent::__construct();
38 14
    }
39
40 10
    protected function get_account() : midcom_core_account
41
    {
42 10
        if ($this->account === null) {
43 10
            $this->account = new midcom_core_account($this->person);
44
        }
45 10
        return $this->account;
46
    }
47
48
    /**
49
     * can be called by various handlers
50
     *
51
     * @param string $person_guid
52
     * @param string $username
53
     * @param string $usermail
54
     * @param string $password password: leave blank for auto generated
55
     * @param boolean $send_welcome_mail
56
     */
57 2
    public function create_account($person_guid, $username, $usermail, $password = "", $send_welcome_mail = false) : bool
58
    {
59 2
        $this->errstr = ""; // start fresh
60
61
        // quick validation
62 2
        if (empty($person_guid)) {
63 1
            $this->errstr = "Unable to identify user: no guid given";
64 1
            return false;
65
        }
66
67 2
        if (empty($username)) {
68 1
            $this->errstr = "Unable to create account: no username given";
69 1
            return false;
70
        }
71
72 2
        if ($send_welcome_mail && empty($usermail)) {
73 1
            $this->errstr = "Unable to deliver welcome mail: no usermail address given";
74 1
            return false;
75
        }
76
77
        // Check if we get the person
78 2
        $this->person = new midcom_db_person($person_guid);
79 2
        $this->person->require_do('midgard:update');
80
81
        //need to generate password?
82 2
        if (empty($password)) {
83 1
            $generated_password = true;
84 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

84
            $password = $this->generate_safe_password(/** @scrutinizer ignore-type */ $this->_config->get("min_password_length"));
Loading history...
85
        } else {
86 2
            $generated_password = false;
87
        }
88
89 2
        $account = $this->get_account();
90
91
        //an account already existing?
92 2
        if ($account->get_password()) {
93 1
            $this->errstr = "Creating new account for existing account is not possible";
94 1
            return false;
95
        }
96
97
        //try creating
98 2
        if (!$this->set_account($username, $password)) {
99
            $this->errstr = "Could not set account, reason: " . midcom_connection::get_error_string();
100
            return false;
101
        }
102
103
        //send welcome mail?
104 2
        if ($send_welcome_mail) {
105 1
            $mail = new org_openpsa_mail();
106 1
            $mail->to = $usermail;
107
108
            // Make replacements to body
109 1
            $mail->parameters = [
110 1
                "USERNAME" => $username,
111 1
                "PASSWORD" => $password,
112
            ];
113
114 1
            $this->prepare_mail($mail);
115
116 1
            if (!$mail->send()) {
117
                $this->errstr = "Unable to deliver welcome mail: " . $mail->get_error_message();
0 ignored issues
show
Bug introduced by
Are you sure $mail->get_error_message() of type false|mixed|string can be used in concatenation? ( Ignorable by Annotation )

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

117
                $this->errstr = "Unable to deliver welcome mail: " . /** @scrutinizer ignore-type */ $mail->get_error_message();
Loading history...
118
                $this->delete_account();
119 1
                return false;
120
            }
121 1
        } elseif ($generated_password) {
122
            /*
123
             * no welcome mail was sent:
124
             * if the password was auto generated show it in an ui message
125
             */
126 1
            midcom::get()->uimessages->add(
127 1
                $this->_l10n->get('org.openpsa.user'),
128 1
                sprintf($this->_l10n->get("account_creation_success"), $username, $password), 'ok');
129
        }
130
131 2
        if (!empty($this->errstr)) {
132
            throw new midcom_error('Could not create account: ' . $this->errstr);
133
        }
134
135 2
        return true;
136
    }
137
138
    /**
139
     * Prepare the welcome mail for the user.
140
     *
141
     * The essential data (recipient, username, password) is already filled in
142
     * at this point. You can override this function in subclasses if you want
143
     * to customize the mail further
144
     *
145
     * @param org_openpsa_mail $mail
146
     */
147 1
    protected function prepare_mail(org_openpsa_mail $mail)
148
    {
149 1
        $mail->from = $this->_config->get('welcome_mail_from_address');
150 1
        $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...
151 1
        $mail->body = $this->_config->get('welcome_mail_body');
0 ignored issues
show
Documentation Bug introduced by
It seems like $this->_config->get('welcome_mail_body') can also be of type false. However, the property $body 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...
152 1
        $mail->parameters["SITE_URL"] = midcom::get()->config->get('midcom_site_url');
153 1
    }
154
155
    /**
156
     * Sets username and password for person
157
     *
158
     * @param string $username Contains username
159
     * @param string $new_password Contains the new password to set
160
     */
161 3
    public function set_account($username, $new_password) : bool
162
    {
163 3
        $account = $this->get_account();
164 3
        if (!empty($new_password)) {
165
            //check if the new encrypted password was already used
166 3
            if (   !$this->check_password_reuse($new_password, true)
167 3
                || !$this->check_password_strength($new_password, true)) {
168
                $this->errstr = "password strength too low";
169
                return false;
170
            }
171 3
            $this->save_old_password();
172 3
            $account->set_password($new_password);
173
        }
174
175 3
        $account->set_username($username);
176
177
        // probably username not unique
178 3
        if (!$account->save()) {
179
            $this->errstr = "Failed to save account, reason: " . midcom_connection::get_error_string();
180
            return false;
181
        }
182
183 3
        if (!empty($new_password)) {
184
            // add timestamp of password-change
185 3
            $this->person->set_parameter("org_openpsa_user_password", "last_change", time());
186
        }
187
        // sets privilege
188 3
        midcom::get()->auth->request_sudo($this->_component);
189 3
        $this->person->set_privilege('midgard:owner', "user:" . $this->person->guid);
190 3
        midcom::get()->auth->drop_sudo();
191
192 3
        return true;
193
    }
194
195
    /**
196
     * Returns an auto generated password of variable length
197
     *
198
     * @param int $length The number of chars the password will contain
199
     */
200 8
    public static function generate_password($length = 0) : string
201
    {
202
        // Safety
203 8
        if ($length == 0) {
204 7
            $length = 8;
205
        }
206
        // Valid characters for default password (PONDER: make configurable ?)
207 8
        $passwdchars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,-*!:+=()/&%$<>?#@';
208 8
        $first_last_chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
209
210
        //make sure password doesn't begin or end in punctuation character
211 8
        $password = midcom_helper_misc::random_string(1, $first_last_chars);
212 8
        $password .= midcom_helper_misc::random_string($length - 2, $passwdchars);
213 8
        $password .= midcom_helper_misc::random_string(1, $first_last_chars);
214 8
        return $password;
215
    }
216
217
    /**
218
     * Returns an auto generated password which will pass the persons check_password_strength test
219
     *
220
     * @param int $length The number of chars the password will contain
221
     */
222 6
    public function generate_safe_password($length = 0) : string
223
    {
224
        do {
225 6
            $password = self::generate_password($length);
226 6
        } while (!$this->check_password_strength($password));
227 6
        return $password;
228
    }
229
230
    /**
231
     * Function to check if passed password was already used
232
     *
233
     * @param string $password Password to check
234
     * @return bool returns true if password wasn't used already
235
     */
236 5
    public function check_password_reuse($password, $show_ui_message = false) : bool
237
    {
238
        // check current password
239 5
        if (midcom_connection::verify_password($password, $this->get_account()->get_password())) {
240 1
            if ($show_ui_message) {
241
                midcom::get()->uimessages->add($this->_l10n->get('org.openpsa.user'), $this->_l10n->get('password is the same as the current one'), 'error');
242
            }
243 1
            return false;
244
        }
245
246
        // get last passwords
247 5
        $old_passwords = $this->get_old_passwords();
248
249
        // check last passwords
250 5
        foreach ($old_passwords as $old) {
251 1
            if (midcom_connection::verify_password($password, $old)) {
252 1
                if ($show_ui_message) {
253
                    midcom::get()->uimessages->add($this->_l10n->get('org.openpsa.user'), $this->_l10n->get('password was already used'), 'error');
254
                }
255 1
                return false;
256
            }
257
        }
258 5
        return true;
259
    }
260
261
    /**
262
     * Function to add current password to parameter old passwords - does not update()
263
     */
264 3
    private function save_old_password()
265
    {
266 3
        $max_old_passwords = $this->_config->get('max_old_passwords');
267 3
        if ($max_old_passwords < 1) {
268
            return;
269
        }
270 3
        $old_passwords_array = $this->get_old_passwords();
271 3
        array_unshift($old_passwords_array, $this->get_account()->get_password());
272
273 3
        if (count($old_passwords_array) > $max_old_passwords) {
274
            array_pop($old_passwords_array);
275
        }
276
277 3
        $new_passwords_string = serialize($old_passwords_array);
278
279 3
        $this->person->set_parameter("org_openpsa_user_password", "old_passwords", $new_passwords_string);
280 3
    }
281
282
    /**
283
     * Function get old passwords
284
     *
285
     * @return array - Array with old passwords - empty if there aren't any old passwords
286
     */
287 5
    private function get_old_passwords() : array
288
    {
289 5
        $old_passwords_string = $this->person->get_parameter("org_openpsa_user_password", "old_passwords");
290 5
        if (!empty($old_passwords_string)) {
291 1
            $old_passwords_array = unserialize($old_passwords_string);
292 1
            $count = count($old_passwords_array);
293 1
            $max = (int) $this->_config->get('max_old_passwords');
294 1
            if ($count > $max) {
295 1
                $old_passwords_array = array_slice($old_passwords_array, 0, $max);
296
            }
297
        } else {
298 4
            $old_passwords_array = [];
299
        }
300 5
        return $old_passwords_array;
301
    }
302
303
    /**
304
     * Function to check strength of passed password
305
     *
306
     * @param string $password Contains password to check
307
     */
308 8
    public function check_password_strength($password, $show_ui_message = false) : bool
309
    {
310 8
        $password_length = mb_strlen($password);
311
312 8
        if ($password_length < $this->_config->get('min_password_length')) {
313 2
            if ($show_ui_message){
314
                midcom::get()->uimessages->add($this->_l10n->get('org.openpsa.user'), $this->_l10n->get('password too short'), 'error');
315
            }
316 2
            return false;
317
        }
318
319
        // score for length & repetition
320 7
        $score = $this->count_unique_characters($password) * 4;
321
322
        //check $password with rules
323 7
        $rules = $this->_config->get('password_score_rules');
324 7
        foreach ($rules as $rule) {
325 7
            if (preg_match($rule['match'], $password) > 0) {
326 7
                $score += $rule['score'];
327
            }
328
        }
329
330 7
        if ($score < $this->_config->get('min_password_score')) {
331 3
            if ($show_ui_message){
332
                midcom::get()->uimessages->add($this->_l10n->get('org.openpsa.user'), $this->_l10n->get('password weak'), 'error');
333
            }
334 3
            return false;
335
        }
336 7
        return true;
337
    }
338
339 7
    private function count_unique_characters(string $password) : int
340
    {
341
        // Split into individual (multibyte) characters, flip to filter out duplicates, and then count
342 7
        return count(array_flip(preg_split('//u', $password, null, PREG_SPLIT_NO_EMPTY)));
0 ignored issues
show
Bug introduced by
It seems like preg_split('//u', $passw...l, PREG_SPLIT_NO_EMPTY) can also be of type false; however, parameter $array of array_flip() does only seem to accept array, 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

342
        return count(array_flip(/** @scrutinizer ignore-type */ preg_split('//u', $password, null, PREG_SPLIT_NO_EMPTY)));
Loading history...
343
    }
344
345
    /**
346
     * Function to check password age for this user (age is taken from config)
347
     *
348
     * @return boolean - true indicates password is ok - false password is to old
349
     */
350 1
    public function check_password_age() : bool
351
    {
352 1
        $max_age_days = $this->_config->get('password_max_age_days');
353 1
        if ($max_age_days == 0) {
354
            return true;
355
        }
356 1
        $max_timeframe = time() - ($max_age_days * 24 * 60 * 60);
357 1
        $last_change = $this->person->get_parameter("org_openpsa_user_password", "last_change");
358
359 1
        if (empty($last_change)) {
360 1
            return false;
361
        }
362
363 1
        return $max_timeframe < $last_change;
364
    }
365
366
    /**
367
     * Function to disable account for time period given in config
368
     *
369
     * @return boolean - indicates success
370
     */
371 2
    public function disable_account() : bool
372
    {
373 2
        $account = $this->get_account();
374
375 2
        $timeframe_minutes = $this->_config->get('password_block_timeframe_min');
376
377 2
        if ($timeframe_minutes == 0) {
378
            return false;
379
        }
380 2
        $release_time = time() + ($timeframe_minutes * 60);
381
        $args = [
382 2
            'guid' => $this->person->guid,
383 2
            'parameter_name' => 'org_openpsa_user_blocked_account',
384 2
            'password' => 'account_password',
385
        ];
386
387 2
        $qb = midcom_services_at_entry_dba::new_query_builder();
388 2
        $qb->add_constraint('argumentsstore', '=', serialize($args));
389 2
        $qb->add_constraint('status', '=', midcom_services_at_entry_dba::SCHEDULED);
390 2
        if ($entry = $qb->get_result(0)) {
391
            //the account is already blocked, so we just extend the block's duration
392
            $entry->start = $release_time;
393
            return $entry->update();
394
        }
395
396 2
        if (!midcom_services_at_interface::register($release_time, 'org.openpsa.user', 'reopen_account', $args)) {
397
            throw new midcom_error("Failed to register interface for re_open the user account, last Midgard error was: " . midcom_connection::get_error_string());
398
        }
399 2
        $this->person->set_parameter("org_openpsa_user_blocked_account", "account_password", $account->get_password());
400 2
        $account->set_password('', false);
401 2
        return $account->save();
402
    }
403
404
    /**
405
     * Function to delete account
406
     *
407
     * @return boolean indicates success
408
     */
409 1
    public function delete_account() : bool
410
    {
411 1
        return $this->get_account()->delete();
412
    }
413
414
    /**
415
     * Permanently disable an user account
416
     *
417
     * @return boolean - indicates success
418
     */
419 2
    public function close_account() : bool
420
    {
421 2
        $account = $this->get_account();
422
423 2
        if (!$account->get_password()) {
424
            // the account is already blocked, so skip the rest
425 1
            return true;
426
        }
427
428 2
        $this->person->set_parameter("org_openpsa_user_blocked_account", "account_password", $account->get_password());
429 2
        $account->set_password('', false);
430 2
        return $account->save();
431
    }
432
433
    /**
434
     * Reopen a blocked account.
435
     */
436 1
    public function reopen_account()
437
    {
438 1
        $account = new midcom_core_account($this->person);
439 1
        if ($account->get_password()) {
440 1
            debug_add('Account for person #' . $this->person->id . ' does have a password already');
441
        } else {
442 1
            $account->set_password($this->person->get_parameter('org_openpsa_user_blocked_account', 'account_password'), false);
443 1
            if (!$account->save()) {
444
                throw new midcom_error('Failed to save account: ' . midcom_connection::get_error_string());
445
            }
446
        }
447
448 1
        $this->person->delete_parameter('org_openpsa_user_blocked_account', 'account_password');
449 1
    }
450
451
    /**
452
     * Determine if an account is blocked
453
     */
454 4
    public function is_blocked() : bool
455
    {
456 4
        return !empty($this->person->get_parameter("org_openpsa_user_blocked_account", "account_password"));
457
    }
458
459 1
    public static function get_person_by_formdata($data)
460
    {
461 1
        if (   empty($data['username'])
462 1
            || empty($data['password'])) {
463 1
            return false;
464
        }
465
466 1
        midcom::get()->auth->request_sudo('org.openpsa.user');
467 1
        $qb = midcom_db_person::new_query_builder();
468 1
        midcom_core_account::add_username_constraint($qb, '=', $data['username']);
469 1
        $results = $qb->execute();
470 1
        midcom::get()->auth->drop_sudo();
471
472 1
        if (count($results) != 1) {
473 1
            return false;
474
        }
475 1
        return $results[0];
476
    }
477
478
    /**
479
     * Record failed login attempts and disable account is necessary
480
     *
481
     * @param string $component the component we take the config values from
482
     * @return boolean True if further login attempts are allowed, false otherwise
483
     */
484 1
    public function check_login_attempts($component = null) : bool
485
    {
486 1
        $stat = true;
487 1
        $component = $component ?: "org.openpsa.user";
488
489
        //max-attempts allowed & timeframe
490 1
        $max_attempts = midcom_baseclasses_components_configuration::get($component, 'config')->get('max_password_attempts');
491 1
        $timeframe = midcom_baseclasses_components_configuration::get($component, 'config')->get('password_block_timeframe_min');
492
493 1
        if (   $max_attempts == 0
494 1
            || $timeframe == 0) {
495
            return $stat;
496
        }
497
498 1
        midcom::get()->auth->request_sudo('org.openpsa.user');
499
500 1
        if ($attempts = $this->person->get_parameter("org_openpsa_user_password", "attempts")) {
501 1
            $attempts = unserialize($attempts);
502 1
            if (is_array($attempts)) {
503 1
                $attempts = array_slice($attempts, 0, ($max_attempts - 1));
504
            }
505
        }
506 1
        if (!is_array($attempts)) {
507 1
            $attempts = [];
508
        }
509 1
        array_unshift($attempts, time());
510
511
        /*
512
         * If the maximum number of attempts is reached and the oldest attempt
513
         * on the stack is within our defined timeframe, we block the account
514
         */
515 1
        if (   count($attempts) >= $max_attempts
516 1
            && $attempts[$max_attempts - 1] >= (time() - ($timeframe * 60))) {
517 1
            $this->disable_account();
518 1
            $stat = false;
519
        }
520
521 1
        $attempts = serialize($attempts);
522 1
        $this->person->set_parameter("org_openpsa_user_password", "attempts", $attempts);
523 1
        midcom::get()->auth->drop_sudo();
524 1
        return $stat;
525
    }
526
}
527