Completed
Push — master ( 6a1094...c4becb )
by Andreas
27:56
created

check_login_attempts()   B

Complexity

Conditions 9
Paths 13

Size

Total Lines 41
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 23
CRAP Score 9.0058

Importance

Changes 0
Metric Value
cc 9
eloc 23
nc 13
nop 1
dl 0
loc 41
ccs 23
cts 24
cp 0.9583
crap 9.0058
rs 8.0555
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 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 $password password: leave blank for auto generated
52
     */
53 2
    public function create_account(string $person_guid, string $username, string $usermail, string $password = "", bool $send_welcome_mail = false) : bool
54
    {
55 2
        $this->errstr = ""; // start fresh
56
57
        // quick validation
58 2
        if (empty($person_guid)) {
59 1
            $this->errstr = "Unable to identify user: no guid given";
60 1
            return false;
61
        }
62
63 2
        if (empty($username)) {
64 1
            $this->errstr = "Unable to create account: no username given";
65 1
            return false;
66
        }
67
68 2
        if ($send_welcome_mail && empty($usermail)) {
69 1
            $this->errstr = "Unable to deliver welcome mail: no usermail address given";
70 1
            return false;
71
        }
72
73
        // Check if we get the person
74 2
        $this->person = new midcom_db_person($person_guid);
75 2
        $this->person->require_do('midgard:update');
76
77
        //need to generate password?
78 2
        if (empty($password)) {
79 1
            $generated_password = true;
80 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

80
            $password = $this->generate_safe_password(/** @scrutinizer ignore-type */ $this->_config->get("min_password_length"));
Loading history...
81
        } else {
82 2
            $generated_password = false;
83
        }
84
85 2
        $account = $this->get_account();
86
87
        //an account already existing?
88 2
        if ($account->get_password()) {
89 1
            $this->errstr = "Creating new account for existing account is not possible";
90 1
            return false;
91
        }
92
93
        //try creating
94 2
        if (!$this->set_account($username, $password)) {
95
            $this->errstr = "Could not set account, reason: " . midcom_connection::get_error_string();
96
            return false;
97
        }
98
99
        //send welcome mail?
100 2
        if ($send_welcome_mail) {
101 1
            $mail = new org_openpsa_mail();
102 1
            $mail->to = $usermail;
103
104
            // Make replacements to body
105 1
            $mail->parameters = [
106 1
                "USERNAME" => $username,
107 1
                "PASSWORD" => $password,
108
            ];
109
110 1
            $this->prepare_mail($mail);
111
112 1
            if (!$mail->send()) {
113
                $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

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

305
        return count(array_flip(/** @scrutinizer ignore-type */ preg_split('//u', $password, null, PREG_SPLIT_NO_EMPTY)));
Loading history...
306
    }
307
308
    /**
309
     * Function to check password age for this user (age is taken from config)
310
     *
311
     * @return boolean - true indicates password is ok - false password is to old
312
     */
313 1
    public function check_password_age() : bool
314
    {
315 1
        $max_age_days = $this->_config->get('password_max_age_days');
316 1
        if ($max_age_days == 0) {
317
            return true;
318
        }
319
320 1
        if ($last_change = $this->person->get_parameter("org_openpsa_user_password", "last_change")) {
321 1
            $max_timeframe = time() - ($max_age_days * 24 * 60 * 60);
322 1
            return $max_timeframe < $last_change;
323
        }
324 1
        return false;
325
    }
326
327
    /**
328
     * Function to disable account for time period given in config
329
     *
330
     * @return boolean - indicates success
331
     */
332 2
    public function disable_account() : bool
333
    {
334 2
        $account = $this->get_account();
335
336 2
        $timeframe_minutes = $this->_config->get('password_block_timeframe_min');
337
338 2
        if ($timeframe_minutes == 0) {
339
            return false;
340
        }
341 2
        $release_time = time() + ($timeframe_minutes * 60);
342
        $args = [
343 2
            'guid' => $this->person->guid,
344 2
            'parameter_name' => 'org_openpsa_user_blocked_account',
345 2
            'password' => 'account_password',
346
        ];
347
348 2
        $qb = midcom_services_at_entry_dba::new_query_builder();
349 2
        $qb->add_constraint('argumentsstore', '=', serialize($args));
350 2
        $qb->add_constraint('status', '=', midcom_services_at_entry_dba::SCHEDULED);
351 2
        if ($entry = $qb->get_result(0)) {
352
            //the account is already blocked, so we just extend the block's duration
353
            $entry->start = $release_time;
354
            return $entry->update();
355
        }
356
357 2
        if (!midcom_services_at_interface::register($release_time, 'org.openpsa.user', 'reopen_account', $args)) {
358
            throw new midcom_error("Failed to register interface for re_open the user account, last Midgard error was: " . midcom_connection::get_error_string());
359
        }
360 2
        $this->person->set_parameter("org_openpsa_user_blocked_account", "account_password", $account->get_password());
361 2
        $account->set_password('', false);
362 2
        return $account->save();
363
    }
364
365
    /**
366
     * Function to delete account
367
     *
368
     * @return boolean indicates success
369
     */
370 1
    public function delete_account() : bool
371
    {
372 1
        return $this->get_account()->delete();
373
    }
374
375
    /**
376
     * Permanently disable an user account
377
     *
378
     * @return boolean - indicates success
379
     */
380 2
    public function close_account() : bool
381
    {
382 2
        $account = $this->get_account();
383
384 2
        if (!$account->get_password()) {
385
            // the account is already blocked, so skip the rest
386 1
            return true;
387
        }
388
389 2
        $this->person->set_parameter("org_openpsa_user_blocked_account", "account_password", $account->get_password());
390 2
        $account->set_password('', false);
391 2
        return $account->save();
392
    }
393
394
    /**
395
     * Reopen a blocked account.
396
     */
397 1
    public function reopen_account()
398
    {
399 1
        $account = new midcom_core_account($this->person);
400 1
        if ($account->get_password()) {
401 1
            debug_add('Account for person #' . $this->person->id . ' does have a password already');
402
        } else {
403 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

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