Login Security Solution - Version 0.0.4

Version Description

  • Initial import to plugins.svn.wordpress.org.
Download this release

Release Info

Developer convissor
Plugin Icon wp plugin Login Security Solution
Version 0.0.4
Comparing to
See all releases

Version 0.0.4

admin.inc ADDED
@@ -0,0 +1,830 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+
3
+ /**
4
+ * The user interface and activation/deactivation methods for administering
5
+ * the Login Security Solution WordPress plugin
6
+ *
7
+ * @package login-security-solution
8
+ * @link http://wordpress.org/extend/plugins/login-security-solution/
9
+ * @version 0.0.1
10
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPLv2
11
+ * @author Daniel Convissor <danielc@analysisandsolutions.com>
12
+ * @copyright The Analysis and Solutions Company, 2012
13
+ */
14
+
15
+ /**
16
+ * The user interface and activation/deactivation methods for administering
17
+ * the Login Security Solution WordPress plugin
18
+ *
19
+ * @package login-security-solution
20
+ * @link http://wordpress.org/extend/plugins/login-security-solution/
21
+ * @version 0.0.1
22
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPLv2
23
+ * @author Daniel Convissor <danielc@analysisandsolutions.com>
24
+ * @copyright The Analysis and Solutions Company, 2012
25
+ */
26
+ class login_security_solution_admin extends login_security_solution {
27
+ /**
28
+ * Metadata and labels for each element of the plugin's options
29
+ * @var array
30
+ */
31
+ protected $fields;
32
+
33
+ /**
34
+ * Key for the change password "don't remind me" checkbox
35
+ * @var string
36
+ */
37
+ protected $key_checkbox_remind = 'check_remind';
38
+
39
+ /**
40
+ * Key for the change password "confirmation" checkbox
41
+ * @var string
42
+ */
43
+ protected $key_checkbox_require = 'check_reqire';
44
+
45
+ /**
46
+ * ID slug for the plugin's password change page
47
+ * @var string
48
+ */
49
+ protected $option_pw_force_change_name;
50
+
51
+ /**
52
+ * Text for the plugin's password change page "don't remind me" button
53
+ * @var string
54
+ */
55
+ protected $text_button_remind;
56
+
57
+ /**
58
+ * Text for the plugin's password change page require button
59
+ * @var string
60
+ */
61
+ protected $text_button_require;
62
+
63
+ /**
64
+ * Title for the plugin's settings page
65
+ * @var string
66
+ */
67
+ protected $text_settings;
68
+
69
+ /**
70
+ * Title for the plugin's password change page
71
+ * @var string
72
+ */
73
+ protected $text_pw_force_change;
74
+
75
+
76
+ /**
77
+ * Sets the object's properties and options
78
+ *
79
+ * @return void
80
+ *
81
+ * @uses login_security_solution::initialize() to set the object's
82
+ * properties
83
+ * @uses login_security_solution_admin::set_fields() to populate the
84
+ * $fields property
85
+ */
86
+ public function __construct() {
87
+ $this->initialize();
88
+ $this->set_fields();
89
+
90
+ // Combine plugin's name with translation already in WP.
91
+ $this->text_settings = self::NAME . ' ' . __('Settings');
92
+
93
+ // NON-STANDARD: This is for the password change page.
94
+ $this->option_pw_force_change_name = self::ID . '-pw-force-change-done';
95
+ $this->text_pw_force_change = __('Change All Passwords', self::ID);
96
+ $this->text_button_remind = __("Do not remind me about this", self::ID);
97
+ $this->text_button_require = __("Require All Passwords Be Changed", self::ID);
98
+ }
99
+
100
+ /*
101
+ * ===== ACTIVATION & DEACTIVATION CALLBACK METHODS =====
102
+ */
103
+
104
+ /**
105
+ * Establishes the tables and settings when the plugin is activated
106
+ * @return void
107
+ */
108
+ public function activate() {
109
+ global $wpdb;
110
+
111
+ /*
112
+ * Create or alter the plugin's tables as needed.
113
+ */
114
+
115
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
116
+
117
+ // Note: dbDelta() requires two spaces after "PRIMARY KEY". Werid.
118
+ // WP's insert/prepare/etc don't handle NULL's (at least in 3.3).
119
+ $sql = "CREATE TABLE `$this->table_fail` (
120
+ fail_id BIGINT(20) NOT NULL AUTO_INCREMENT,
121
+ ip VARCHAR(39) NOT NULL DEFAULT '',
122
+ user_login VARCHAR(60) NOT NULL DEFAULT '',
123
+ pass_md5 varchar(64) NOT NULL DEFAULT '',
124
+ date_failed TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
125
+ PRIMARY KEY (fail_id),
126
+ KEY (ip (9)),
127
+ KEY (user_login (5)),
128
+ KEY (pass_md5 (10))
129
+ )";
130
+
131
+ dbDelta($sql);
132
+ if ($wpdb->last_error) {
133
+ die($wpdb->last_error);
134
+ }
135
+
136
+ /*
137
+ * Save this plugin's options to the database.
138
+ */
139
+
140
+ update_option($this->option_name, $this->options);
141
+ add_option($this->option_pw_force_change_name, 0, '', 'no');
142
+
143
+ /*
144
+ * Store password hashes.
145
+ */
146
+
147
+ if ($this->options['pw_reuse_count']) {
148
+ $sql = "SELECT ID, user_pass FROM `$wpdb->users`";
149
+ $result = $wpdb->get_results($sql, ARRAY_A);
150
+ if (!$result) {
151
+ die(self::ID . ' could not find users.');
152
+ }
153
+
154
+ foreach ($result as $user) {
155
+ if (!$this->save_pw_hash($user['ID'], $user['user_pass'])) {
156
+ die(self::ID . ' could not save password hash.');
157
+ }
158
+ }
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Removes the tables and settings when the plugin is deactivated
164
+ * if the deactivate_deletes_data option is turned on
165
+ * @return void
166
+ */
167
+ public function deactivate() {
168
+ global $wpdb;
169
+
170
+ $prior_error_setting = $wpdb->show_errors;
171
+ $wpdb->show_errors = false;
172
+ $denied = 'command denied to user';
173
+
174
+ $wpdb->query("DROP TABLE `$this->table_fail`");
175
+ if ($wpdb->last_error) {
176
+ if (strpos($wpdb->last_error, $denied) === false) {
177
+ die($wpdb->last_error);
178
+ }
179
+ }
180
+
181
+ $wpdb->show_errors = $prior_error_setting;
182
+
183
+ $package_id = self::ID;
184
+ $wpdb->escape_by_ref($package_id);
185
+
186
+ $wpdb->query("DELETE FROM `$wpdb->options`
187
+ WHERE option_name LIKE '$package_id%'");
188
+
189
+ $wpdb->query("DELETE FROM `$wpdb->usermeta`
190
+ WHERE meta_key LIKE '$package_id%'");
191
+ }
192
+
193
+ /*
194
+ * ===== ADMIN USER INTERFACE =====
195
+ */
196
+
197
+ /**
198
+ * Sets the metadata and labels for each element of the plugin's
199
+ * options
200
+ *
201
+ * @return void
202
+ * @uses login_security_solution_admin::$fields to hold the data
203
+ */
204
+ protected function set_fields() {
205
+ $this->fields = array(
206
+ 'idle_timeout' => array(
207
+ 'group' => 'misc',
208
+ 'label' => __("Idle Timeout", self::ID),
209
+ 'text' => __("Close inactive sessions after this many minutes. 0 disables this feature.", self::ID),
210
+ 'type' => 'int',
211
+ ),
212
+ 'disable_logins' => array(
213
+ 'group' => 'misc',
214
+ 'label' => __("Maintenance Mode", self::ID),
215
+ 'text' => __("Disable logins from users who are not administrators and disable posting of comments?", self::ID),
216
+ 'type' => 'bool',
217
+ 'bool0' => __("Off, let all users log in.", self::ID),
218
+ 'bool1' => __("On, disable comments and only let administrators log in.", self::ID),
219
+ ),
220
+ 'deactivate_deletes_data' => array(
221
+ 'group' => 'misc',
222
+ 'label' => __("Deactivation", self::ID),
223
+ 'text' => __("Should deactivating the plugin remove all of the plugin's data and settings?", self::ID),
224
+ 'type' => 'bool',
225
+ 'bool0' => __("No, preserve the data for future use.", self::ID),
226
+ 'bool1' => __("Yes, delete the damn data.", self::ID),
227
+ ),
228
+
229
+ 'login_fail_minutes' => array(
230
+ 'group' => 'login',
231
+ 'label' => __("Match Time", self::ID),
232
+ 'text' => __("How far back, in minutes, should login failures look for matching data?", self::ID),
233
+ 'type' => 'int',
234
+ ),
235
+ 'login_fail_tier_2' => array(
236
+ 'group' => 'login',
237
+ 'label' => __("Delay Tier 2", self::ID),
238
+ 'text' => sprintf(__("How many matching login failures should it take to get into this (%d - %d second) Delay Tier? Must be >= %d.", self::ID), 4, 30, 2),
239
+ 'type' => 'int',
240
+ 'greater_than' => 2,
241
+ ),
242
+ 'login_fail_tier_3' => array(
243
+ 'group' => 'login',
244
+ 'label' => __("Delay Tier 3", self::ID),
245
+ 'text' => sprintf(__("How many matching login failures should it take to get into this (%d - %d second) Delay Tier? Must be > Delay Tier 2.", self::ID), 25, 60),
246
+ 'type' => 'int',
247
+ ),
248
+ 'login_fail_notify' => array(
249
+ 'group' => 'login',
250
+ 'label' => __("Failure Notification", self::ID),
251
+ 'text' => __("Notify the administrator upon every x matching login failures. 0 disables this feature.", self::ID),
252
+ 'type' => 'int',
253
+ ),
254
+ 'login_fail_breach_notify' => array(
255
+ 'group' => 'login',
256
+ 'label' => __("Breach Notification", self::ID),
257
+ 'text' => __("Notify the administrator if a successful login uses data matching x login failures. 0 disables this feature.", self::ID),
258
+ 'type' => 'int',
259
+ ),
260
+ 'login_fail_breach_pw_force_change' => array(
261
+ 'group' => 'login',
262
+ 'label' => __("Breach Email Confirm", self::ID),
263
+ 'text' => __("If a successful login uses data matching x login failures, immediately log the user out and require them to use WordPress' lost password process. 0 disables this feature.", self::ID),
264
+ 'type' => 'int',
265
+ ),
266
+
267
+ 'pw_length' => array(
268
+ 'group' => 'pw',
269
+ 'label' => __("Length", self::ID),
270
+ 'text' => sprintf(__("How long must passwords be? Must be >= %d.", self::ID), 8),
271
+ 'type' => 'int',
272
+ 'greater_than' => 8,
273
+ ),
274
+ 'pw_complexity_exemption_length' => array(
275
+ 'group' => 'pw',
276
+ 'label' => __("Complexity Exemption", self::ID),
277
+ 'text' => sprintf(__("How long must passwords be to be exempt from the complexity requirements? Must be >= %d.", self::ID), 20),
278
+ 'type' => 'int',
279
+ 'greater_than' => 20,
280
+ ),
281
+ 'pw_change_days' => array(
282
+ 'group' => 'pw',
283
+ 'label' => __("Aging", self::ID),
284
+ 'text' => __("How many days old can a password be before requiring it be changed? Not recommended. 0 disables this feature.", self::ID),
285
+ 'type' => 'int',
286
+ ),
287
+ 'pw_change_grace_period_minutes' => array(
288
+ 'group' => 'pw',
289
+ 'label' => __("Grace Period", self::ID),
290
+ 'text' => sprintf(__("How many minutes should a user have to change their password once they know it has expired? Must be >= %d.", self::ID), 5),
291
+ 'type' => 'int',
292
+ 'greater_than' => 5,
293
+ ),
294
+ 'pw_reuse_count' => array(
295
+ 'group' => 'pw',
296
+ 'label' => __("History", self::ID),
297
+ 'text' => __("How many passwords should be remembered? Prevents reuse of old passwords. 0 disables this feature.", self::ID),
298
+ 'type' => 'int',
299
+ ),
300
+ );
301
+ }
302
+
303
+ /**
304
+ * A filter to add a "Settings" link in this plugin's description
305
+ *
306
+ * NOTE: This method is automatically called by WordPress for each
307
+ * plugin being displayed on WordPress' Plugins admin page.
308
+ *
309
+ * @param array $links the links generated thus far
310
+ * @param string $file the name of the plugin currently being rendered
311
+ * @return array
312
+ */
313
+ public function plugin_action_links($links, $file) {
314
+ if ($file == self::ID . '/' . self::ID . '.php') {
315
+ $links[] = '<a href="options-general.php?page='
316
+ . self::ID . '">' . __('Settings') . '</a>';
317
+
318
+ // NON-STANDARD: This is for the password change page.
319
+ $links[] = '<a href="options-general.php?page='
320
+ . $this->option_pw_force_change_name . '">'
321
+ . $this->text_pw_force_change . '</a>' ;
322
+ }
323
+ return $links;
324
+ }
325
+
326
+ /**
327
+ * Declares a menu item and callback for this plugin's settings page
328
+ *
329
+ * NOTE: This method is automatically called by WordPress when the
330
+ * any admin page is rendered
331
+ */
332
+ public function admin_menu() {
333
+ add_options_page(
334
+ $this->text_settings,
335
+ self::NAME,
336
+ 'activate_plugins',
337
+ self::ID,
338
+ array(&$this, 'page_settings')
339
+ );
340
+ }
341
+
342
+ /**
343
+ * Declares the callbacks for rendering and validating this plugin's
344
+ * settings sections and fields
345
+ *
346
+ * NOTE: This method is automatically called by WordPress when the
347
+ * any admin page is rendered
348
+ */
349
+ public function admin_init() {
350
+ register_setting(
351
+ $this->option_name,
352
+ $this->option_name,
353
+ array(&$this, 'validate')
354
+ );
355
+
356
+ add_settings_section(
357
+ self::ID . '-login',
358
+ __('Login Failure Policies', self::ID),
359
+ array(&$this, 'section_login'),
360
+ self::ID
361
+ );
362
+ add_settings_section(
363
+ self::ID . '-pw',
364
+ __('Password Policies', self::ID),
365
+ array(&$this, 'section_blank'),
366
+ self::ID
367
+ );
368
+ add_settings_section(
369
+ self::ID . '-misc',
370
+ __('Miscellaneous Policies', self::ID),
371
+ array(&$this, 'section_blank'),
372
+ self::ID
373
+ );
374
+
375
+ // Dynamically declare each field using the info in $fields.
376
+ foreach ($this->fields as $id => $field) {
377
+ add_settings_field(
378
+ $id,
379
+ $field['label'],
380
+ array(&$this, $id),
381
+ self::ID,
382
+ self::ID . '-' . $field['group']
383
+ );
384
+ }
385
+ }
386
+
387
+ /**
388
+ * The callback for rendering the settings page
389
+ * @return void
390
+ */
391
+ public function page_settings() {
392
+ echo '<h2>' . htmlspecialchars($this->text_settings) . '</h2>';
393
+ echo '<form action="options.php" method="post">' . "\n";
394
+ settings_fields($this->option_name);
395
+ do_settings_sections(self::ID);
396
+ submit_button();
397
+ echo '</form>';
398
+ }
399
+
400
+ /**
401
+ * The callback for "rendering" the sections that don't have text
402
+ * @return void
403
+ */
404
+ public function section_blank() {
405
+ }
406
+
407
+ /**
408
+ * The callback for rendering the "Login Failures Policy" section
409
+ * @return void
410
+ */
411
+ public function section_login() {
412
+ echo '<p>';
413
+ _e("This plugin stores the IP address, username and password for each failed log in attempt.", self::ID);
414
+ echo ' ';
415
+ _e("The data from future login failures are compared against the historical data.", self::ID);
416
+ echo ' ';
417
+ _e("If any of the data points match, the plugin delays printing out the failure message.", self::ID);
418
+ echo ' ';
419
+ _e("The goal is for the responses to take so long that the attackers give up and go find an easier target.", self::ID);
420
+ echo ' ';
421
+ _e("The length of the delay is broken up into three tiers.", self::ID);
422
+ echo ' ';
423
+ _e("The amount of the delay increases in higher tiers.", self::ID);
424
+ echo ' ';
425
+ _e("The delay time within each tier is randomized to complicate profiling by attackers.", self::ID);
426
+ echo '</p>';
427
+ }
428
+
429
+ /**
430
+ * The callback for rendering the fields
431
+ * @return void
432
+ *
433
+ * @uses login_security_solution_admin::input_radio() for rendering
434
+ * radio buttons
435
+ * @uses login_security_solution_admin::input_text() for rendering
436
+ * text input boxes
437
+ */
438
+ public function __call($name, $params) {
439
+ if (empty($this->fields[$name]['type'])) {
440
+ return;
441
+ }
442
+ switch ($this->fields[$name]['type']) {
443
+ case 'bool':
444
+ $this->input_radio($name);
445
+ break;
446
+ case 'int':
447
+ $this->input_text($name);
448
+ break;
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Renders the radio button inputs
454
+ * @return void
455
+ */
456
+ protected function input_radio($name) {
457
+ echo htmlspecialchars($this->fields[$name]['text']) . '<br/>';
458
+ echo '<input type="radio" value="0" name="'
459
+ . htmlspecialchars($this->option_name)
460
+ . '[' . htmlspecialchars($name) . ']"'
461
+ . ($this->options[$name] ? '' : ' checked="checked"') . ' /> ';
462
+ echo htmlspecialchars($this->fields[$name]['bool0']);
463
+ echo '<br/>';
464
+ echo '<input type="radio" value="1" name="'
465
+ . htmlspecialchars($this->option_name)
466
+ . '[' . htmlspecialchars($name) . ']"'
467
+ . ($this->options[$name] ? ' checked="checked"' : '') . ' /> ';
468
+ echo htmlspecialchars($this->fields[$name]['bool1']);
469
+ }
470
+
471
+ /**
472
+ * Renders the text input boxes
473
+ * @return void
474
+ */
475
+ protected function input_text($name) {
476
+ echo '<input type="text" size="3" name="'
477
+ . htmlspecialchars($this->option_name)
478
+ . '[' . htmlspecialchars($name) . ']"'
479
+ . ' value="' . htmlspecialchars($this->options[$name]) . '" /> ';
480
+ echo htmlspecialchars($this->fields[$name]['text']);
481
+ echo ' ' . __('Default:', self::ID) . ' '
482
+ . htmlspecialchars($this->options_default[$name]) . '.';
483
+ }
484
+
485
+ /**
486
+ * Validates the user input
487
+ *
488
+ * NOTE: WordPress saves the data even if this method says there are
489
+ * errors. So this method sets any inappropriate data to the default
490
+ * values.
491
+ *
492
+ * @param array $in the input submitted by the form
493
+ * @return array the sanitized data to be saved
494
+ */
495
+ public function validate($in) {
496
+ $out = $this->options_default;
497
+ if (!is_array($in)) {
498
+ // Not translating this since only hackers will see it.
499
+ add_settings_error($this->option_name, $this->option_name,
500
+ 'Input must be an array.');
501
+ return $out;
502
+ }
503
+
504
+ $gt_format = __("must be >= '%s',", self::ID);
505
+ $default = __("so we used the default value instead.", self::ID);
506
+
507
+ // Dynamically validate each field using the info in $fields.
508
+ foreach ($this->fields as $name => $field) {
509
+ if (!array_key_exists($name, $in)) {
510
+ continue;
511
+ }
512
+
513
+ if (!is_scalar($in[$name])) {
514
+ // Not translating this since only hackers will see it.
515
+ add_settings_error($this->option_name, $name, "'"
516
+ . htmlspecialchars($field['label'])
517
+ . "' was not a scalar, $default");
518
+ continue;
519
+ }
520
+
521
+ switch ($field['type']) {
522
+ case 'bool':
523
+ if ($in[$name] != 0 && $in[$name] != 1) {
524
+ // Not translating this since only hackers will see it.
525
+ add_settings_error($this->option_name, $name, "'"
526
+ . htmlspecialchars($field['label'])
527
+ . "' must be '0' or '1', $default");
528
+ continue 2;
529
+ }
530
+ break;
531
+ case 'int':
532
+ if (!ctype_digit($in[$name])) {
533
+ add_settings_error($this->option_name, $name, "'"
534
+ . htmlspecialchars($field['label'])
535
+ . "' " . __("must be an integer,", self::ID)
536
+ . ' ' . $default);
537
+ continue 2;
538
+ }
539
+ if (array_key_exists('greater_than', $field)
540
+ && $in[$name] < $field['greater_than'])
541
+ {
542
+ add_settings_error($this->option_name, $name, "'"
543
+ . htmlspecialchars($field['label'])
544
+ . "' " . sprintf($gt_format,
545
+ $field['greater_than'])
546
+ . ' ' . $default);
547
+ continue 2;
548
+ }
549
+ break;
550
+ }
551
+ $out[$name] = $in[$name];
552
+ }
553
+
554
+ // Special check to make sure Delay Tier 3 > Delay Tier 2.
555
+ $name = 'login_fail_tier_3';
556
+ if ($out[$name] <= $out['login_fail_tier_2']) {
557
+ add_settings_error($this->option_name, $name, "'"
558
+ . htmlspecialchars($this->fields[$name]['label'])
559
+ . "' " . sprintf($gt_format,
560
+ htmlspecialchars($this->fields['login_fail_tier_2']['label']))
561
+ . ' ' . $default);
562
+
563
+ $out[$name] = $out['login_fail_tier_2'] + 5;
564
+ }
565
+
566
+ // Speical check to ensure reuse count is set if aging is enabled.
567
+ $name = 'pw_reuse_count';
568
+ if ($out['pw_change_days'] && !$out[$name]) {
569
+ add_settings_error($this->option_name, $name, "'"
570
+ . htmlspecialchars($this->fields[$name]['label'])
571
+ . "' " . sprintf($gt_format, 1)
572
+ . ' ' . $default);
573
+
574
+ $out[$name] = 5;
575
+ }
576
+
577
+ return $out;
578
+ }
579
+
580
+ /*
581
+ * ===== NON-STANDARD: ADMIN UI FOR FORCING PASSWORD CHANGES =====
582
+ */
583
+
584
+ /**
585
+ * Declares a menu item and callback for the force password change page
586
+ *
587
+ * NOTE: This method is automatically called by WordPress when the
588
+ * any admin page is rendered
589
+ */
590
+ public function admin_menu_pw_force_change() {
591
+ add_options_page(
592
+ $this->text_pw_force_change,
593
+ '',
594
+ 'activate_plugins',
595
+ $this->option_pw_force_change_name,
596
+ array(&$this, 'page_pw_force_change')
597
+ );
598
+ }
599
+
600
+ /**
601
+ * Declares the callbacks for rendering and validating the
602
+ * force password change page
603
+ *
604
+ * NOTE: This method is automatically called by WordPress when the
605
+ * any admin page is rendered
606
+ */
607
+ public function admin_init_pw_force_change() {
608
+ register_setting(
609
+ $this->option_pw_force_change_name,
610
+ $this->option_pw_force_change_name,
611
+ array(&$this, 'validate_pw_force_change')
612
+ );
613
+
614
+ add_settings_field(
615
+ 'checkbox',
616
+ '',
617
+ array(&$this, 'field_blank'),
618
+ $this->option_pw_force_change_name
619
+ );
620
+ add_settings_field(
621
+ 'submit',
622
+ '',
623
+ array(&$this, 'field_blank'),
624
+ $this->option_pw_force_change_name
625
+ );
626
+ }
627
+
628
+ /**
629
+ * The callback for rendering the force password change page
630
+ * @return void
631
+ */
632
+ public function page_pw_force_change() {
633
+ echo '<h2>' . htmlspecialchars($this->text_pw_force_change) . '</h2>';
634
+
635
+ echo '<p>';
636
+ _e("There may be cases where everyone's password should be reset.", self::ID);
637
+ echo ' ';
638
+ printf(__("This page, provided by the %s plugin, offers that functionality.", self::ID), htmlspecialchars(self::NAME));
639
+ echo '</p>';
640
+
641
+ echo '<p>';
642
+ _e("Submitting this form sets a flag that forces all users to utilize WordPress' built in password reset functionality.", self::ID);
643
+ echo ' ';
644
+ _e("Users who are presently logged in will be logged out the next time they view a page that requires authentication.", self::ID);
645
+ echo '</p>';
646
+
647
+ echo '<form action="options.php" method="post">' . "\n";
648
+ settings_fields($this->option_pw_force_change_name);
649
+
650
+ $this->echo_div();
651
+
652
+ echo '<p><strong><input type="checkbox" value="1" name="'
653
+ . htmlspecialchars($this->option_pw_force_change_name)
654
+ . '[' . htmlspecialchars($this->key_checkbox_require)
655
+ . ']" /> ';
656
+ _e("Confirm that you want to force all users to change their passwords by checking this box, then click the button, below.", self::ID);
657
+ echo '</strong></p>';
658
+
659
+ submit_button(
660
+ $this->text_button_require,
661
+ 'primary',
662
+ htmlspecialchars($this->option_pw_force_change_name) . '[submit]'
663
+ );
664
+
665
+ echo "</div>\n";
666
+
667
+ if (!$this->was_pw_force_change_done()) {
668
+ $this->echo_div();
669
+
670
+ echo '<p><input type="checkbox" value="1" name="'
671
+ . htmlspecialchars($this->option_pw_force_change_name)
672
+ . '[' . htmlspecialchars($this->key_checkbox_remind)
673
+ . ']" /> ';
674
+ _e("No thanks. I know what I'm doing. Please don't remind me about this.", self::ID);
675
+ echo '</p>';
676
+
677
+ submit_button(
678
+ $this->text_button_remind,
679
+ 'secondary',
680
+ htmlspecialchars($this->option_pw_force_change_name) . '[submit]'
681
+ );
682
+
683
+ echo "</div>\n";
684
+ }
685
+
686
+ echo '</form>';
687
+ }
688
+
689
+ /**
690
+ * Receives the user input and calls the force_change() method
691
+ *
692
+ * @param array $in the input submitted by the form
693
+ * @return array an empty array
694
+ *
695
+ * @uses login_security_solution_admin::force_change_for_all() to flag
696
+ * everyone's account with the password change requirement
697
+ */
698
+ public function validate_pw_force_change($in) {
699
+ $out = $this->was_pw_force_change_done();
700
+
701
+ if (is_array($in)
702
+ && !empty($in['submit'])
703
+ && is_scalar($in['submit']))
704
+ {
705
+ $crossed = __("You have checked a box that does not correspond with the button you pressed. Please check and press buttons inside the same section.", self::ID);
706
+
707
+ switch ($in['submit']) {
708
+ case $this->text_button_remind:
709
+ if (!empty($in[$this->key_checkbox_require])) {
710
+ add_settings_error($this->option_pw_force_change_name,
711
+ $this->option_pw_force_change_name, $crossed);
712
+ } elseif (empty($in[$this->key_checkbox_remind])) {
713
+ add_settings_error($this->option_pw_force_change_name, $this->option_pw_force_change_name, __("Please confirm that you really want to do this. Put a check in the 'No thanks' box before hitting the submit button.", self::ID));
714
+ } else {
715
+ // Translaton already in WP.
716
+ add_settings_error($this->option_pw_force_change_name,
717
+ $this->option_pw_force_change_name,
718
+ __('Success!'), 'updated');
719
+ $out = 1;
720
+ }
721
+ break;
722
+ case $this->text_button_require:
723
+ if (!empty($in[$this->key_checkbox_remind])) {
724
+ add_settings_error($this->option_pw_force_change_name,
725
+ $this->option_pw_force_change_name, $crossed);
726
+ } elseif (empty($in[$this->key_checkbox_require])) {
727
+ add_settings_error($this->option_pw_force_change_name, $this->option_pw_force_change_name, __("Please confirm that you really want to do this. Put a check in the 'Confirm' box before hitting the submit button.", self::ID));
728
+ } else {
729
+ $result = $this->force_change_for_all();
730
+ if ($result === true) {
731
+ // Translaton already in WP.
732
+ add_settings_error($this->option_pw_force_change_name,
733
+ $this->option_pw_force_change_name,
734
+ __('Success!'), 'updated');
735
+ $out = 1;
736
+ } else {
737
+ add_settings_error($this->option_pw_force_change_name,
738
+ $this->option_pw_force_change_name, $result);
739
+ }
740
+ }
741
+ break;
742
+ }
743
+ }
744
+
745
+ return $out;
746
+ }
747
+
748
+ /**
749
+ * Produces a notice at the top of each admin page, telling admins to
750
+ * run the Change All Passwords process
751
+ *
752
+ * NOTE: This method is automatically called by WordPress when the
753
+ * any admin page is rendered AND our Change All Passwords function
754
+ * has not been called.
755
+ *
756
+ * @return void
757
+ */
758
+ public function admin_notices() {
759
+ if (!current_user_can('activate_plugins')) {
760
+ return;
761
+ }
762
+
763
+ echo '<div class="error">';
764
+
765
+ echo '<p><strong>';
766
+ _e("You have not asked your users to change their passwords since the plugin was activated. Most users have weak passwords. This plugin's password policies protect your site from brute force attacks. Please improve <em>everyone's</em> security by making all users pick new, strong, passwords.", self::ID);
767
+ echo '</strong></p>';
768
+
769
+ echo '<p><strong>';
770
+ _e("Speaking of which, do YOU have a strong password? Make sure by changing yours once you've submitted the Change All Passwords form.", self::ID);
771
+ echo '</strong></p>';
772
+
773
+ echo '<p><strong><a href="options-general.php?page=' . htmlspecialchars($this->option_pw_force_change_name) . '">' . $this->text_pw_force_change . "</a></strong></p>\n";
774
+
775
+ echo "</div>\n";
776
+ }
777
+
778
+ /**
779
+ * Produces a div tag for making borders
780
+ * @return void
781
+ */
782
+ protected function echo_div() {
783
+ echo '<div style="margin: 0 1em 1em 0; border: thin solid black; padding: 0 1em;">';
784
+ }
785
+
786
+ /**
787
+ * Sets a user metadata key for each user in the database, requiring
788
+ * them to reset their passwords
789
+ * @return mixed true on success, string with error message on problem
790
+ */
791
+ protected function force_change_for_all() {
792
+ global $user_ID, $wpdb;
793
+
794
+ if (!current_user_can('activate_plugins')) {
795
+ // Translaton already in WP.
796
+ return __('You do not have sufficient permissions to access this page.');
797
+ }
798
+
799
+ $sql = "INSERT INTO `$wpdb->usermeta`
800
+ (user_id, meta_key, meta_value)
801
+ SELECT ID, %s, 1
802
+ FROM `$wpdb->users`
803
+ LEFT JOIN `$wpdb->usermeta`
804
+ ON (`$wpdb->usermeta`.user_id
805
+ = `$wpdb->users`.ID
806
+ AND meta_key = %s)
807
+ WHERE meta_value IS NULL
808
+ AND `$wpdb->users`.ID <> %d";
809
+
810
+ $sql = $wpdb->prepare($sql, $this->umk_pw_force_change,
811
+ $this->umk_pw_force_change, $user_ID);
812
+
813
+ $wpdb->query($sql);
814
+ if ($wpdb->last_error) {
815
+ return $wpdb->last_error;
816
+ }
817
+
818
+ return true;
819
+ }
820
+
821
+ /**
822
+ * Gets the indicator of the status of whether the password
823
+ * change feature has been used after activation
824
+ *
825
+ * @return bool has the password change feature been used?
826
+ */
827
+ protected function was_pw_force_change_done() {
828
+ return (bool) get_option($this->option_pw_force_change_name, false);
829
+ }
830
+ }
languages/login-security-solution.pot ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (C) 2012 Login Security Solution
2
+ # This file is distributed under the same license as the Login Security Solution package.
3
+ msgid ""
4
+ msgstr ""
5
+ "Project-Id-Version: Login Security Solution 0.0.1\n"
6
+ "Report-Msgid-Bugs-To: http://wordpress.org/tag/login-security-solution\n"
7
+ "POT-Creation-Date: 2012-03-19 19:35:46+00:00\n"
8
+ "MIME-Version: 1.0\n"
9
+ "Content-Type: text/plain; charset=UTF-8\n"
10
+ "Content-Transfer-Encoding: 8bit\n"
11
+ "PO-Revision-Date: 2012-MO-DA HO:MI+ZONE\n"
12
+ "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
13
+ "Language-Team: LANGUAGE <LL@li.org>\n"
14
+
15
+ #: login-security-solution.php:422
16
+ msgid "Invalid username or password."
17
+ msgstr ""
18
+
19
+ #: login-security-solution.php:428 tests/LoginErrorsTest.php:110
20
+ #: tests/LoginErrorsTest.php:122
21
+ msgid "Password reset is not allowed for this user"
22
+ msgstr ""
23
+
24
+ #: login-security-solution.php:453 tests/LoginMessageTest.php:55
25
+ msgid "It has been over %d minutes since your last action."
26
+ msgstr ""
27
+
28
+ #: login-security-solution.php:454 tests/LoginMessageTest.php:56
29
+ msgid "Please log back in."
30
+ msgstr ""
31
+
32
+ #: login-security-solution.php:457 tests/LoginMessageTest.php:66
33
+ msgid "The grace period for changing your password has expired."
34
+ msgstr ""
35
+
36
+ #: login-security-solution.php:458 tests/LoginMessageTest.php:67
37
+ msgid "Please submit this form to reset your password."
38
+ msgstr ""
39
+
40
+ #: login-security-solution.php:461 tests/LoginMessageTest.php:77
41
+ msgid "Your password must be reset."
42
+ msgstr ""
43
+
44
+ #: login-security-solution.php:462 tests/LoginMessageTest.php:78
45
+ msgid "Please submit this form to reset it."
46
+ msgstr ""
47
+
48
+ #: login-security-solution.php:465 tests/LoginMessageTest.php:88
49
+ msgid "Your password has expired. Please log and change it."
50
+ msgstr ""
51
+
52
+ #: login-security-solution.php:466 tests/LoginMessageTest.php:89
53
+ msgid "We provide a %d minute grace period to do so."
54
+ msgstr ""
55
+
56
+ #: login-security-solution.php:472 tests/LoginMessageTest.php:103
57
+ #: tests/LoginMessageTest.php:118
58
+ msgid "The site is undergoing maintenance."
59
+ msgstr ""
60
+
61
+ #: login-security-solution.php:473 tests/LoginMessageTest.php:104
62
+ #: tests/LoginMessageTest.php:119
63
+ msgid "Please try again later."
64
+ msgstr ""
65
+
66
+ #: login-security-solution.php:532
67
+ msgid "<strong>ERROR</strong>: Passwords can not be reused."
68
+ msgstr ""
69
+
70
+ #: login-security-solution.php:785
71
+ msgid "Component Count Value from Current Attempt"
72
+ msgstr ""
73
+
74
+ #: login-security-solution.php:787
75
+ msgid "Network IP %5d %s"
76
+ msgstr ""
77
+
78
+ #: login-security-solution.php:789
79
+ msgid "Username %5d %s"
80
+ msgstr ""
81
+
82
+ #: login-security-solution.php:791
83
+ msgid "Password MD5 %5d %s"
84
+ msgstr ""
85
+
86
+ #: login-security-solution.php:1498
87
+ msgid "Your website, %s, may have been broken in to."
88
+ msgstr ""
89
+
90
+ #: login-security-solution.php:1501
91
+ msgid ""
92
+ "Someone just logged in using the following components. Prior to that, some "
93
+ "combination of those components were a part of %d failed attempts to log in "
94
+ "during the past %d minutes:"
95
+ msgstr ""
96
+
97
+ #: login-security-solution.php:1506
98
+ msgid ""
99
+ "The user has been logged out and will be required to confirm their identity "
100
+ "via the password reset functionality."
101
+ msgstr ""
102
+
103
+ #: login-security-solution.php:1535
104
+ msgid "Your website, %s, is undergoing a brute force attack."
105
+ msgstr ""
106
+
107
+ #: login-security-solution.php:1538
108
+ msgid ""
109
+ "There have been at least %d failed attempts to log in during the past %d "
110
+ "minutes that used one or more of the following components:"
111
+ msgstr ""
112
+
113
+ #: login-security-solution.php:1543
114
+ msgid ""
115
+ "The %s plugin for WordPress is repelling the attack by making their login "
116
+ "failures take a very long time."
117
+ msgstr ""
118
+
119
+ #: login-security-solution.php:1849 tests/PasswordValidationTest.php:436
120
+ msgid "<strong>ERROR</strong>: Password not set."
121
+ msgstr ""
122
+
123
+ #: login-security-solution.php:1864 tests/PasswordValidationTest.php:447
124
+ msgid "<strong>ERROR</strong>: Passwords must be strings."
125
+ msgstr ""
126
+
127
+ #: login-security-solution.php:1882 tests/PasswordValidationTest.php:460
128
+ msgid "<strong>ERROR</strong>: Passwords must use ASCII characters."
129
+ msgstr ""
130
+
131
+ #: login-security-solution.php:1901 tests/PasswordValidationTest.php:477
132
+ #: tests/PasswordValidationTest.php:491
133
+ msgid "<strong>ERROR</strong>: Password is too short."
134
+ msgstr ""
135
+
136
+ #: login-security-solution.php:1910 tests/PasswordValidationTest.php:517
137
+ msgid ""
138
+ "<strong>ERROR</strong>: Passwords must either contain numbers or be %d "
139
+ "characters long."
140
+ msgstr ""
141
+
142
+ #: login-security-solution.php:1919 tests/PasswordValidationTest.php:504
143
+ msgid ""
144
+ "<strong>ERROR</strong>: Passwords must either contain punctuation marks / "
145
+ "symbols or be %d characters long."
146
+ msgstr ""
147
+
148
+ #: login-security-solution.php:1928 tests/PasswordValidationTest.php:530
149
+ msgid ""
150
+ "<strong>ERROR</strong>: Passwords must either contain upper-case and lower-"
151
+ "case letters or be %d characters long."
152
+ msgstr ""
153
+
154
+ #: login-security-solution.php:1938 tests/PasswordValidationTest.php:543
155
+ msgid "<strong>ERROR</strong>: Passwords can't be sequential keys."
156
+ msgstr ""
157
+
158
+ #: login-security-solution.php:1947 tests/PasswordValidationTest.php:556
159
+ msgid ""
160
+ "<strong>ERROR</strong>: Passwords can't have that many sequential characters."
161
+ msgstr ""
162
+
163
+ #: login-security-solution.php:1963 tests/PasswordValidationTest.php:569
164
+ #: tests/PasswordValidationTest.php:582
165
+ msgid "<strong>ERROR</strong>: Passwords can't contain user data."
166
+ msgstr ""
167
+
168
+ #: login-security-solution.php:1974 tests/PasswordValidationTest.php:595
169
+ msgid "<strong>ERROR</strong>: Passwords can't contain site info."
170
+ msgstr ""
171
+
172
+ #: login-security-solution.php:1983 tests/PasswordValidationTest.php:608
173
+ msgid "<strong>ERROR</strong>: Password is too common."
174
+ msgstr ""
175
+
176
+ #: login-security-solution.php:1992 tests/PasswordValidationTest.php:621
177
+ msgid ""
178
+ "<strong>ERROR</strong>: Passwords can't be variations of dictionary words."
179
+ msgstr ""
180
+
181
+ #. Plugin Name of the plugin/theme
182
+ msgid "Login Security Solution"
183
+ msgstr ""
184
+
185
+ #. Plugin URI of the plugin/theme
186
+ msgid "http://wordpress.org/extend/plugins/login-security-solution/"
187
+ msgstr ""
188
+
189
+ #. Description of the plugin/theme
190
+ msgid ""
191
+ "Requires very strong passwords, repels brute force login attacks, prevents "
192
+ "login information disclosures, expires idle sessions, notifies admins of "
193
+ "attacks and breaches, permits administrators to disable logins for "
194
+ "maintenance or emergency reasons and reset all passwords."
195
+ msgstr ""
196
+
197
+ #. Author of the plugin/theme
198
+ msgid "Daniel Convissor"
199
+ msgstr ""
200
+
201
+ #. Author URI of the plugin/theme
202
+ msgid "http://www.analysisandsolutions.com/"
203
+ msgstr ""
login-security-solution.php ADDED
@@ -0,0 +1,2001 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+
3
+ /**
4
+ * Plugin Name: Login Security Solution
5
+ *
6
+ * Description: Requires very strong passwords, repels brute force login attacks, prevents login information disclosures, expires idle sessions, notifies admins of attacks and breaches, permits administrators to disable logins for maintenance or emergency reasons and reset all passwords.
7
+ *
8
+ * Plugin URI: http://wordpress.org/extend/plugins/login-security-solution/
9
+ * Version: 0.0.4
10
+ * Author: Daniel Convissor
11
+ * Author URI: http://www.analysisandsolutions.com/
12
+ * License: GPLv2
13
+ * @package login-security-solution
14
+ */
15
+
16
+ /**
17
+ * The instantiated version of this plugin's class
18
+ */
19
+ $GLOBALS['login_security_solution'] = new login_security_solution;
20
+
21
+ /**
22
+ * The Login Security Solution plugin enhances WordPress' security
23
+ *
24
+ * @package login-security-solution
25
+ * @link http://wordpress.org/extend/plugins/login-security-solution/
26
+ * @version 0.0.1
27
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPLv2
28
+ * @author Daniel Convissor <danielc@analysisandsolutions.com>
29
+ * @copyright The Analysis and Solutions Company, 2012
30
+ */
31
+ class login_security_solution {
32
+ /**
33
+ * This plugin's identifier
34
+ */
35
+ const ID = 'login-security-solution';
36
+
37
+ /**
38
+ * This plugin's name
39
+ */
40
+ const NAME = 'Login Security Solution';
41
+
42
+ /**
43
+ * This plugin's table name prefix
44
+ * @var string
45
+ */
46
+ protected $prefix = 'login_security_solution_';
47
+
48
+
49
+ /**
50
+ * Is the dict command available?
51
+ * @var bool true/false if known, null if unknown
52
+ */
53
+ protected $available_dict;
54
+
55
+ /**
56
+ * Is the grep command available?
57
+ * @var bool true/false if known, null if unknown
58
+ */
59
+ protected $available_grep;
60
+
61
+ /**
62
+ * Is PHP's mbstring extension enabled?
63
+ * @var bool true/false if known, null if unknown
64
+ */
65
+ protected $available_mbstring;
66
+
67
+ /**
68
+ * Location of our dictionary files
69
+ *
70
+ * Public for use by utilities.
71
+ *
72
+ * @var string
73
+ */
74
+ public $dir_dictionaries;
75
+
76
+ /**
77
+ * Location of our sequence files
78
+ * @var string
79
+ */
80
+ protected $dir_sequences;
81
+
82
+ /**
83
+ * Our URI query string key for passing messages to the login form
84
+ * @var string
85
+ */
86
+ protected $key_login_msg;
87
+
88
+ /**
89
+ * Has the internationalization text domain been loaded?
90
+ * @var bool
91
+ */
92
+ protected $loaded_textdomain = false;
93
+
94
+ /**
95
+ * This plugin's options
96
+ *
97
+ * Options from the database are merged on top of the default options.
98
+ *
99
+ * @see login_security_solution::set_options() to obtain the saved
100
+ * settings
101
+ * @var array
102
+ */
103
+ protected $options = array();
104
+
105
+ /**
106
+ * This plugin's default options
107
+ * @var array
108
+ */
109
+ protected $options_default = array(
110
+ 'deactivate_deletes_data' => 0,
111
+ 'disable_logins' => 0,
112
+ 'idle_timeout' => 15,
113
+ 'login_fail_minutes' => 120,
114
+ 'login_fail_tier_2' => 5,
115
+ 'login_fail_tier_3' => 10,
116
+ 'login_fail_notify' => 20,
117
+ 'login_fail_breach_notify' => 6,
118
+ 'login_fail_breach_pw_force_change' => 6,
119
+ 'pw_change_days' => 0,
120
+ 'pw_change_grace_period_minutes' => 15,
121
+ 'pw_complexity_exemption_length' => 20,
122
+ 'pw_length' => 8,
123
+ 'pw_reuse_count' => 0,
124
+ );
125
+
126
+ /**
127
+ * Our option name for storing the plugin's settings
128
+ * @var string
129
+ */
130
+ protected $option_name;
131
+
132
+ /**
133
+ * Name, with $table_prefix, of the table tracking login failures
134
+ * @var string
135
+ */
136
+ protected $table_fail;
137
+
138
+ /**
139
+ * Is this class being used by our unit tests?
140
+ * @var bool
141
+ */
142
+ protected $testing = false;
143
+
144
+ /**
145
+ * Our usermeta key for tracking when passwords were changed
146
+ * @var string
147
+ */
148
+ protected $umk_changed;
149
+
150
+ /**
151
+ * Our usermeta key for tracking when a password grace period started
152
+ * @var string
153
+ */
154
+ protected $umk_grace_period;
155
+
156
+ /**
157
+ * Our usermeta key for tracking old passwords
158
+ * @var string
159
+ */
160
+ protected $umk_hashes;
161
+
162
+ /**
163
+ * Our usermeta key for tracking when the user last hit the site
164
+ * @var string
165
+ */
166
+ protected $umk_last_active;
167
+
168
+
169
+ /**
170
+ * Declares the WordPress action and filter callbacks
171
+ *
172
+ * @return void
173
+ * @uses login_security_solution::initialize() to set the object's
174
+ * properties
175
+ */
176
+ public function __construct() {
177
+ $this->initialize();
178
+
179
+ add_action('auth_cookie_valid', array(&$this, 'check'), 1, 2);
180
+ add_action('password_reset', array(&$this, 'password_reset'), 10, 2);
181
+ add_action('user_profile_update_errors',
182
+ array(&$this, 'user_profile_update_errors'), 999, 3);
183
+
184
+ add_filter('login_errors', array(&$this, 'login_errors'));
185
+ add_filter('login_message', array(&$this, 'login_message'));
186
+
187
+ if ($this->options['disable_logins']) {
188
+ add_filter('comments_open', array(&$this, 'comments_open'));
189
+ }
190
+
191
+ if ($this->options['idle_timeout']) {
192
+ add_action('wp_login', array(&$this, 'delete_last_active'));
193
+ add_action('wp_logout', array(&$this, 'delete_last_active'));
194
+ }
195
+
196
+ if ($this->options['login_fail_breach_notify']
197
+ || $this->options['login_fail_breach_pw_force_change'])
198
+ {
199
+ add_action('wp_login', array(&$this, 'wp_login'), 10, 2);
200
+ }
201
+
202
+ if (is_admin()) {
203
+ $this->load_plugin_textdomain();
204
+
205
+ require_once dirname(__FILE__) . '/admin.inc';
206
+ $admin = new login_security_solution_admin;
207
+
208
+ add_action('admin_menu', array(&$admin, 'admin_menu'));
209
+ add_action('admin_init', array(&$admin, 'admin_init'));
210
+ add_filter('plugin_action_links', array(&$admin, 'plugin_action_links'), 10, 2);
211
+
212
+ register_activation_hook(__FILE__, array(&$admin, 'activate'));
213
+ if ($this->options['deactivate_deletes_data']) {
214
+ register_deactivation_hook(__FILE__, array(&$admin, 'deactivate'));
215
+ }
216
+
217
+ // NON-STANDARD: This is for the password change page.
218
+ add_action('admin_menu', array(&$admin, 'admin_menu_pw_force_change'));
219
+ add_action('admin_init', array(&$admin, 'admin_init_pw_force_change'));
220
+ if (!$admin->was_pw_force_change_done()) {
221
+ add_action('admin_notices', array(&$admin, 'admin_notices'));
222
+ }
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Sets the object's properties and options
228
+ *
229
+ * This is separated out from the constructor to avoid undesirable
230
+ * recursion. The constructor sometimes instantiates the admin class,
231
+ * which is a child of this class. So this method permits both the
232
+ * parent and child classes access to the settings and properties.
233
+ *
234
+ * @return void
235
+ *
236
+ * @uses login_security_solution::set_options() to replace the default
237
+ * options with those stored in the database
238
+ */
239
+ protected function initialize() {
240
+ global $wpdb;
241
+
242
+ $this->table_fail = $wpdb->prefix . $this->prefix . 'fail';
243
+
244
+ $this->key_login_msg = self::ID . '-login-msg-id';
245
+ $this->option_name = self::ID . '-options';
246
+ $this->umk_changed = self::ID . '-pw-changed-time';
247
+ $this->umk_pw_force_change = self::ID . '-pw-force-change';
248
+ $this->umk_grace_period = self::ID . '-pw-grace-period-start-time';
249
+ $this->umk_hashes = self::ID . '-pw-hashes';
250
+ $this->umk_last_active = self::ID . '-last-active';
251
+
252
+ $this->dir_dictionaries = dirname(__FILE__) . '/pw_dictionaries/';
253
+ $this->dir_sequences = dirname(__FILE__) . '/pw_sequences/';
254
+
255
+ $this->set_options();
256
+
257
+ if ($this->options['login_fail_tier_2'] < 2) {
258
+ $this->options['login_fail_tier_2'] = 2;
259
+ }
260
+ if ($this->options['pw_change_days']
261
+ && !$this->options['pw_reuse_count'])
262
+ {
263
+ $this->options['pw_reuse_count'] = 5;
264
+ }
265
+ if ($this->options['pw_change_grace_period_minutes'] < 5) {
266
+ $this->options['pw_change_grace_period_minutes'] = 5;
267
+ }
268
+ if ($this->options['pw_complexity_exemption_length'] < 20) {
269
+ $this->options['pw_complexity_exemption_length'] = 20;
270
+ }
271
+ if ($this->options['pw_length'] < 8) {
272
+ $this->options['pw_length'] = 8;
273
+ }
274
+ }
275
+
276
+ /*
277
+ * ===== ACTION & FILTER CALLBACK METHODS =====
278
+ */
279
+
280
+ /**
281
+ * Redirects the current user to the login screen if their password
282
+ * is expired or needs to be reset
283
+ *
284
+ * NOTE: This method is automatically called by WordPress after
285
+ * successful validation of authentication cookies.
286
+ *
287
+ * @param array $cookie_elements values from the user's cookies
288
+ * @param WP_User $user the current user
289
+ * @return mixed return values provided for unit testing
290
+ *
291
+ * @uses login_security_solution::is_idle() to know if it has been too
292
+ * long since the user's last action
293
+ * @uses login_security_solution::is_pw_expired() to know if it has been
294
+ * too long since the password was last changed
295
+ * @uses login_security_solution::get_pw_force_change() to know if the
296
+ * user has to change their password for other reasons
297
+ * @uses login_security_solution::$options for the disable_logins setting
298
+ * @uses login_security_solution::redirect_to_login() to send the user to
299
+ * the login form and tell them what the problem is
300
+ */
301
+ public function check($cookie_elements, $user) {
302
+ global $current_user;
303
+
304
+ // The auth_cookie_valid action may be executed multiple times.
305
+ // Bail if the current_user has not been determined yet.
306
+ if (!($current_user instanceof WP_User) || empty($user->ID)) {
307
+ return false;
308
+ }
309
+
310
+ /*
311
+ * NOTE: redirect_to_login() calls exit(), except when unit testing.
312
+ */
313
+
314
+ if ($this->is_idle($user->ID)) {
315
+ $this->redirect_to_login('idle', true);
316
+ return -5;
317
+ }
318
+
319
+ if ($this->is_pw_expired($user->ID)) {
320
+ $grace = $this->check_pw_grace_period($user->ID);
321
+ if ($grace === true) {
322
+ // First time they've been here since password expired.
323
+ $this->redirect_to_login('pw_grace', true);
324
+ return -1;
325
+ } elseif ($grace === false) {
326
+ // Grace period has expired.
327
+ $this->redirect_to_login('pw_expired', false, 'retrievepassword');
328
+ return -2;
329
+ }
330
+ // Grace period is in effect, let them slide for now.
331
+ }
332
+
333
+ if ($this->get_pw_force_change($user->ID)) {
334
+ $this->redirect_to_login('pw_force', false, 'retrievepassword');
335
+ return -3;
336
+ }
337
+
338
+ if ($this->options['disable_logins']
339
+ && !current_user_can('administrator'))
340
+ {
341
+ $this->redirect_to_login();
342
+ return -4;
343
+ }
344
+
345
+ return true;
346
+ }
347
+
348
+ /**
349
+ * Tells WordPress to disallow commenting on posts
350
+ *
351
+ * NOTE: This method is automatically called by WordPress when checking
352
+ * to see if comments are allowed on a post AND our "disable_logins"
353
+ * option is enabled
354
+ *
355
+ * @return bool always returns false
356
+ */
357
+ public function comments_open() {
358
+ return false;
359
+ }
360
+
361
+ /**
362
+ * Removes the current user's last active time metadata
363
+ *
364
+ * NOTE: This method is automatically called by WordPress when users
365
+ * log in or out.
366
+ *
367
+ * @return bool|null null if $user_ID and $user_name are unknown
368
+ */
369
+ public function delete_last_active() {
370
+ global $user_ID, $user_name;
371
+
372
+ if (empty($user_ID)) {
373
+ if (empty($user_name)) {
374
+ return;
375
+ }
376
+ $user = get_user_by('login', $user_name);
377
+ $user_ID = $user->ID;
378
+ }
379
+
380
+ return delete_user_meta($user_ID, $this->umk_last_active);
381
+ }
382
+
383
+ /**
384
+ * Alters the failure messages from logins and password resets that
385
+ * contain information disclosures
386
+ *
387
+ * The following measures are necessary, at least in WordPress 3.3:
388
+ * + Changes invalid user name message from log in process.
389
+ * + Changes invalid password message from log in process.
390
+ * + Unsets the user name when the password is wrong.
391
+ * + Changes invalid user name message from lost password process.
392
+ *
393
+ * These cloaking measures complicate cracking attempts by keeping
394
+ * attackers from knowing that half of the puzzle has been solved.
395
+ *
396
+ * NOTE: This method is automatically called by WordPress when attempted
397
+ * logins are unssucessful.
398
+ *
399
+ * @param string $out the output from earlier login_errors filters
400
+ * @return string
401
+ *
402
+ * @uses login_security_solution::process_login_fail() to log the failure
403
+ * and lock the user out if necessary
404
+ */
405
+ public function login_errors($out = '') {
406
+ global $errors, $user_name;
407
+
408
+ if (isset($_REQUEST['action']) && $_REQUEST['action'] == 'register') {
409
+ // Do not alter "invalid_username" or "invalid_email" messages
410
+ // from registration process. (WP 3.3 reuses error codes.)
411
+ return $out;
412
+ }
413
+
414
+ $error_codes = $errors->get_error_codes();
415
+
416
+ $codes_to_cloak = array('incorrect_password', 'invalid_username');
417
+ if (array_intersect($error_codes, $codes_to_cloak)) {
418
+ unset($_POST['log']);
419
+ $user_pass = empty($_POST['pwd']) ? '' : $_POST['pwd'];
420
+ $this->process_login_fail($user_name, $user_pass);
421
+ $this->load_plugin_textdomain();
422
+ return __('Invalid username or password.', self::ID);
423
+ }
424
+
425
+ $codes_to_cloak = array('invalid_email', 'invalidcombo');
426
+ if (array_intersect($error_codes, $codes_to_cloak)) {
427
+ // This text is lifted directly from WordPress.
428
+ return __('Password reset is not allowed for this user');
429
+ }
430
+
431
+ return $out;
432
+ }
433
+
434
+ /**
435
+ * Adds our message to the other messages that appear above the login form
436
+ *
437
+ * NOTE: This method is automatically called by WordPress for displaying
438
+ * text above the login form.
439
+ *
440
+ * @param string $out the output from earlier login_message filters
441
+ * @return string
442
+ *
443
+ * @uses login_security_solution::$key_login_msg to know which $_GET
444
+ * parameter to watch for our message ID's
445
+ */
446
+ public function login_message($out = '') {
447
+ $this->load_plugin_textdomain();
448
+ $ours = '';
449
+
450
+ if (!empty($_GET[$this->key_login_msg])) {
451
+ switch ($_GET[$this->key_login_msg]) {
452
+ case 'idle':
453
+ $ours = sprintf(__('It has been over %d minutes since your last action.', self::ID), $this->options['idle_timeout']);
454
+ $ours .= ' ' . __('Please log back in.', self::ID);
455
+ break;
456
+ case 'pw_expired':
457
+ $ours = __('The grace period for changing your password has expired.', self::ID);
458
+ $ours .= ' ' . __('Please submit this form to reset your password.', self::ID);
459
+ break;
460
+ case 'pw_force':
461
+ $ours = __('Your password must be reset.', self::ID);
462
+ $ours .= ' ' . __('Please submit this form to reset it.', self::ID);
463
+ break;
464
+ case 'pw_grace':
465
+ $ours = __('Your password has expired. Please log and change it.', self::ID);
466
+ $ours .= ' ' . sprintf(__('We provide a %d minute grace period to do so.', self::ID), $this->options['pw_change_grace_period_minutes']);
467
+ break;
468
+ }
469
+ }
470
+
471
+ if ($this->options['disable_logins']) {
472
+ $ours = __('The site is undergoing maintenance.', self::ID);
473
+ $ours .= ' ' . __('Please try again later.', self::ID);
474
+ }
475
+
476
+ if ($ours) {
477
+ $out .= '<p class="login message">'
478
+ . htmlspecialchars($ours) . '</p>';
479
+ }
480
+
481
+ return $out;
482
+ }
483
+
484
+ /**
485
+ * Conveys the password change information to the user's metadata
486
+ *
487
+ * NOTE: This method is automatically called by WordPress when users
488
+ * provide their new password via the password reset functionality.
489
+ *
490
+ * @param WP_User the user object being edited
491
+ * @param string $user_pass the unhashed new password
492
+ * @return void
493
+ *
494
+ * @uses login_security_solution::process_pw_metadata() to update user
495
+ * metadata
496
+ */
497
+ public function password_reset($user, $user_pass) {
498
+ if (empty($user->ID)) {
499
+ return false;
500
+ }
501
+ $this->process_pw_metadata($user->ID, $user_pass);
502
+ }
503
+
504
+ /**
505
+ * Ensures passwords meet policy requirements
506
+ *
507
+ * NOTE: This method is automatically called by WordPress when users save
508
+ * their profile information or when admins add a user. The callback
509
+ * is activated in the edit_user() function in wp-admin/includes/user.php.
510
+ *
511
+ * @param WP_User the user object being edited
512
+ * @param bool $update is this an existing user?
513
+ * @param WP_Error the means to provide specific error messages
514
+ * @return bool|null return values provided for unit testing
515
+ *
516
+ * @uses login_security_solution::is_pw_reused() to know if it's an old
517
+ * pw
518
+ * @uses login_security_solution::validate_pw() to know if the pw is
519
+ * kosher
520
+ * @uses login_security_solution::process_pw_metadata() to update user
521
+ * metadata
522
+ */
523
+ public function user_profile_update_errors(&$errors, $update, $user) {
524
+ if ($update) {
525
+ if (empty($user->user_pass) || empty($user->ID)) {
526
+ // Password is not being changed.
527
+ return null;
528
+ }
529
+ if ($this->is_pw_reused($user->user_pass, $user->ID)) {
530
+ $this->load_plugin_textdomain();
531
+ $errors->add(self::ID,
532
+ __("<strong>ERROR</strong>: Passwords can not be reused.", self::ID),
533
+ array('form-field' => 'pass1')
534
+ );
535
+ return false;
536
+ }
537
+ }
538
+ $answer = $this->validate_pw($user, $errors);
539
+
540
+ // Empty ID means an admin is adding a new user.
541
+ if (!empty($user->ID) && !$errors->get_error_codes()) {
542
+ $this->process_pw_metadata($user->ID, $user->user_pass);
543
+ }
544
+
545
+ return $answer;
546
+ }
547
+
548
+ /**
549
+ * Removes the current user's last active time metadata
550
+ *
551
+ * NOTE: This method is automatically called by WordPress when users
552
+ * successfully log in.
553
+ *
554
+ * @param string $user_name the user name from the current login form
555
+ * @param WP_User $user the current user
556
+ * @return mixed return values provided for unit testing
557
+ *
558
+ * @uses login_security_solution::get_network_ip() gets the IP's
559
+ * "network" part
560
+ * @uses login_security_solution::md5() to hash the password
561
+ * @uses login_security_solution::get_login_fail() to see if
562
+ * they're over the limit
563
+ * @uses login_security_solution::$options for the
564
+ * login_fail_breach_notify value
565
+ * @uses login_security_solution::$options for the
566
+ * login_fail_breach_pw_force_change value
567
+ * @uses login_security_solution::set_pw_force_change() to keep atackers
568
+ * from doing damage or changing the account's email address
569
+ * @uses login_security_solution::notify_breach() to warn of the breach
570
+ */
571
+ public function wp_login($user_name, $user) {
572
+ if (!$user_name) {
573
+ return;
574
+ }
575
+ if (!$this->options['login_fail_breach_notify']
576
+ && !$this->options['login_fail_breach_pw_force_change'])
577
+ {
578
+ return -1;
579
+ }
580
+
581
+ $network_ip = $this->get_network_ip();
582
+ $pass_md5 = $this->md5(empty($_POST['pwd']) ? '' : $_POST['pwd']);
583
+
584
+ $return = 1;
585
+ $fails = $this->get_login_fail($network_ip, $user_name, $pass_md5);
586
+
587
+ if ($this->options['login_fail_breach_pw_force_change']
588
+ && $fails['total'] >= $this->options['login_fail_breach_pw_force_change'])
589
+ {
590
+ $this->set_pw_force_change($user->ID);
591
+ $return += 2;
592
+ }
593
+
594
+ if ($this->options['login_fail_breach_notify']
595
+ && $fails['total'] >= $this->options['login_fail_breach_notify'])
596
+ {
597
+ $this->notify_breach($network_ip, $user_name, $pass_md5, $fails);
598
+ $return += 4;
599
+ }
600
+
601
+ return $return;
602
+ }
603
+
604
+ /*
605
+ * ===== INTERNAL METHODS ====
606
+ */
607
+
608
+ /**
609
+ * Examines and manipulates password grace periods as needed
610
+ *
611
+ * @param int $user_ID the current user's ID number
612
+ * @return mixed true if the grace period just started, integer of
613
+ * minutes remaining if in effect, false if exceeded
614
+ *
615
+ * @uses login_security_solution::get_pw_grace_period() to know the grace
616
+ * period starting time
617
+ * @uses login_security_solution::set_pw_grace_period() to set the grace
618
+ * period starting time if it does not exist
619
+ * @uses login_security_solution::$options for the
620
+ * pw_change_grace_period_minutes setting
621
+ */
622
+ protected function check_pw_grace_period($user_ID) {
623
+ $start = $this->get_pw_grace_period($user_ID);
624
+ if (!$start) {
625
+ $this->set_pw_grace_period($user_ID);
626
+ return true;
627
+ }
628
+
629
+ $remaining = $start - time()
630
+ + ($this->options['pw_change_grace_period_minutes'] * 60);
631
+
632
+ if ($remaining < 0) {
633
+ return false;
634
+ }
635
+ return $remaining;
636
+ }
637
+
638
+ /**
639
+ * Changes commonly used transpositions into their actual equivalents
640
+ *
641
+ * @param string $pw the string to clean up
642
+ * @return string the human readable string
643
+ */
644
+ protected function convert_leet_speak($pw) {
645
+ $leet = array('!', '@', '$', '+', '1', '3', '4', '5', '6', '9', '0');
646
+ $normal = array('i', 'a', 's', 't', 'l', 'e', 'a', 's', 'b', 'g', 'o');
647
+ return str_replace($leet, $normal, $pw);
648
+ }
649
+
650
+ /**
651
+ * Remove's the "force password change" flag from the user's metadata
652
+ *
653
+ * @param int $user_ID the current user's ID number
654
+ * @return bool
655
+ */
656
+ protected function delete_pw_force_change($user_ID) {
657
+ return delete_user_meta($user_ID, $this->umk_pw_force_change);
658
+ }
659
+
660
+ /**
661
+ * Remove's the "password grace period" from the user's metadata
662
+ *
663
+ * @param int $user_ID the current user's ID number
664
+ * @return bool
665
+ */
666
+ protected function delete_pw_grace_period($user_ID) {
667
+ return delete_user_meta($user_ID, $this->umk_grace_period);
668
+ }
669
+
670
+ /**
671
+ * Obtains the IP address from $_SERVER['REMOTE_ADDR']
672
+ *
673
+ * Also performs basic sanity checks on the addresses.
674
+ *
675
+ * @return string the IP address. Empty string if input is bad.
676
+ *
677
+ * @uses login_security_solution::normalize_ip() to clean up addresses
678
+ */
679
+ protected function get_ip() {
680
+ if (empty($_SERVER['REMOTE_ADDR'])) {
681
+ return '';
682
+ }
683
+
684
+ return $this->normalize_ip($_SERVER['REMOTE_ADDR']);
685
+ }
686
+
687
+ /**
688
+ * Obtains the timestamp of the given user's last hit on the site
689
+ *
690
+ * @param int $user_ID the current user's ID number
691
+ * @return int the Unix timestamp of the user's last hit
692
+ */
693
+ protected function get_last_active($user_ID) {
694
+ return (int) get_user_meta($user_ID, $this->umk_last_active, true);
695
+ }
696
+
697
+ /**
698
+ * Obtains the number of login failures for the current IP, user name
699
+ * and password in the period specified by login_fail_minutes
700
+ *
701
+ * @param string $network_ip a prior result from get_network_ip()
702
+ * @param string $user_name the user name from the current login form
703
+ * @param string $pass_md5 the md5 hashed new password
704
+ * @return array an associative array with the details
705
+ *
706
+ * @uses login_security_solution::$options for the login_fail_minutes
707
+ * setting
708
+ */
709
+ protected function get_login_fail($network_ip, $user_name, $pass_md5) {
710
+ global $wpdb;
711
+
712
+ $wpdb->escape_by_ref($user_name);
713
+ $wpdb->escape_by_ref($pass_md5);
714
+
715
+ if ($network_ip) {
716
+ // Can't use wpdb::prepare() because it adds quote marks.
717
+ $wpdb->escape_by_ref($network_ip);
718
+ $ip_search = "ip LIKE '$network_ip%'";
719
+ } else {
720
+ $ip_search = "ip = ''";
721
+ }
722
+
723
+ $sql = "SELECT COUNT(*) AS total,
724
+ SUM(IF($ip_search, 1, 0)) AS network_ip,
725
+ SUM(IF(user_login = '$user_name', 1, 0)) AS user_name,
726
+ SUM(IF(pass_md5 = '$pass_md5', 1, 0)) AS pass_md5
727
+ FROM `$this->table_fail`
728
+ WHERE (ip LIKE '$network_ip%'
729
+ OR user_login = '$user_name'
730
+ OR pass_md5 = '$pass_md5')
731
+ AND date_failed > DATE_SUB(NOW(), INTERVAL "
732
+ . (int) $this->options['login_fail_minutes'] . " MINUTE)";
733
+
734
+ return $wpdb->get_row($sql, ARRAY_A);
735
+ }
736
+
737
+ /**
738
+ * Gets the "network" component of an IP address
739
+ *
740
+ * The "network" component for IPv4 is the first three groups ("Class C")
741
+ * while for IPv6 it is the first four groups.
742
+ *
743
+ * WARNING: This method performs no validation because the data comes
744
+ * from get_ip() which has already performed sanity checks.
745
+ *
746
+ * @param string $ip a prior result from get_ip(). Defaults to
747
+ * $_SERVER['REMOTE_ADDR'].
748
+ *
749
+ * @return string the IP address. Empty string if input is bad.
750
+ *
751
+ * @uses login_security_solution::get_ip() to get the
752
+ * $_SERVER['REMOTE_ADDR']
753
+ */
754
+ protected function get_network_ip($ip = '') {
755
+ if (!$ip) {
756
+ $ip = $this->get_ip();
757
+ if (!$ip) {
758
+ return $ip;
759
+ }
760
+ }
761
+
762
+ if (!is_string($ip)) {
763
+ return '';
764
+ }
765
+
766
+ if (strpos($ip, ':') === false) {
767
+ return substr($ip, 0, strrpos($ip, '.'));
768
+ } else {
769
+ $groups = explode(':', $ip);
770
+ return implode(':', array_intersect_key($groups, array(0, 1, 2, 3)));
771
+ }
772
+ }
773
+
774
+ /**
775
+ * Produces text for use in the notify messages
776
+ *
777
+ * @param string $network_ip a prior result from get_network_ip()
778
+ * @param string $user_name the user name from the current login form
779
+ * @param string $pass_md5 the md5 hashed new password
780
+ * @return string
781
+ */
782
+ protected function get_notify_counts($network_ip, $user_name, $pass_md5,
783
+ $fails)
784
+ {
785
+ return __("Component Count Value from Current Attempt", self::ID)
786
+ . "\n------------ ----- --------------------------------\n"
787
+ . sprintf(__("Network IP %5d %s", self::ID),
788
+ $fails['network_ip'], $network_ip) . "\n"
789
+ . sprintf(__("Username %5d %s", self::ID),
790
+ $fails['user_name'], $user_name) . "\n"
791
+ . sprintf(__("Password MD5 %5d %s", self::ID),
792
+ $fails['pass_md5'], $pass_md5) . "\n\n";
793
+ }
794
+
795
+ /**
796
+ * Obtains the timestamp of when the user last changed their password
797
+ *
798
+ * @param int $user_ID the current user's ID number
799
+ * @return int the Unix timestamp of the user's last password change
800
+ */
801
+ protected function get_pw_changed_time($user_ID) {
802
+ return (int) get_user_meta($user_ID, $this->umk_changed, true);
803
+ }
804
+
805
+ /**
806
+ * Reads the "force password change" flag from the user's metadata
807
+ *
808
+ * @param int $user_ID the current user's ID number
809
+ * @return bool does the user need to change their password?
810
+ */
811
+ protected function get_pw_force_change($user_ID) {
812
+ return (bool) get_user_meta($user_ID, $this->umk_pw_force_change, true);
813
+ }
814
+
815
+ /**
816
+ * Obtains the timestamp of when the user's "password grace period"
817
+ * started
818
+ *
819
+ * @param int $user_ID the current user's ID number
820
+ * @return int the Unix timestamp of the user's grace period beginning
821
+ */
822
+ protected function get_pw_grace_period($user_ID) {
823
+ return (int) get_user_meta($user_ID, $this->umk_grace_period, true);
824
+ }
825
+
826
+ /**
827
+ * Obtains the password hashes from the user's metadata
828
+ *
829
+ * @param int $user_ID the current user's ID number
830
+ * @return array the user's existing pasword hashes
831
+ */
832
+ protected function get_pw_hashes($user_ID) {
833
+ $hashes = get_user_meta($user_ID, $this->umk_hashes, true);
834
+ if (empty($hashes)) {
835
+ $hashes = array();
836
+ } elseif (!is_array($hashes)) {
837
+ $hashes = (array) $hashes;
838
+ }
839
+ return $hashes;
840
+ }
841
+
842
+ /**
843
+ * Does the password or given string use the same text?
844
+ *
845
+ * @param string $pw the password to examine
846
+ * @param string $string the string to compare the password against
847
+ * @return bool
848
+ */
849
+ protected function has_match($pw, $string) {
850
+ if (!is_string($string)) {
851
+ return false;
852
+ }
853
+ $string = trim($string);
854
+ if (!$string) {
855
+ return false;
856
+ }
857
+ if (stripos($pw, $string) !== false) {
858
+ return true;
859
+ }
860
+ if (stripos($string, $pw) !== false) {
861
+ return true;
862
+ }
863
+ return false;
864
+ }
865
+
866
+ /**
867
+ * Saves the failed login's info in the database
868
+ *
869
+ * @param string $ip a prior result from get_ip()
870
+ * @param string $user_name the user name from the current login form
871
+ * @param string $pass_md5 the md5 hashed new password
872
+ * @return void
873
+ */
874
+ protected function insert_fail($ip, $user_login, $pass_md5) {
875
+ global $wpdb;
876
+
877
+ $wpdb->insert(
878
+ $this->table_fail,
879
+ array(
880
+ 'ip' => $ip,
881
+ 'user_login' => $user_login,
882
+ 'pass_md5' => $pass_md5,
883
+ ),
884
+ array('%s', '%s', '%s')
885
+ );
886
+ }
887
+
888
+ /**
889
+ * Examines how long ago the current user last interacted with the
890
+ * site and takes appropriate action
891
+ *
892
+ * @param int $user_ID the user's id number
893
+ * @return mixed true if idle. Other replies all evaluate to empty
894
+ * but use different types to aid unit testing.
895
+ *
896
+ * @uses login_security_solution::$options for the idle_timeout value
897
+ * @uses login_security_solution::get_last_active() for the user's last
898
+ * hit time
899
+ * @uses login_security_solution::set_last_active() to update the user's
900
+ * time
901
+ */
902
+ public function is_idle($user_ID) {
903
+ if (!$this->options['idle_timeout']) {
904
+ return null;
905
+ }
906
+
907
+ $last_active = $this->get_last_active($user_ID);
908
+ if (!$last_active) {
909
+ $this->set_last_active($user_ID);
910
+ return 0;
911
+ }
912
+
913
+ if (($this->options['idle_timeout'] * 60) + $last_active < time()) {
914
+ return true;
915
+ }
916
+
917
+ $this->set_last_active($user_ID);
918
+
919
+ return false;
920
+ }
921
+
922
+ /**
923
+ * Does this password show up in the "dict" program?
924
+ *
925
+ * @param string $pw the password to examine
926
+ * @return bool|null true or false if known, null if dict isn't available
927
+ */
928
+ protected function is_pw_dict_program($pw) {
929
+ if ($this->available_dict === false) {
930
+ return null;
931
+ }
932
+
933
+ $term = escapeshellarg($pw);
934
+ exec("dict -m -s exact $term 2>&1", $output, $result);
935
+ if (!$result) {
936
+ return true;
937
+ } elseif ($result == 127) {
938
+ $this->available_dict = false;
939
+ return null;
940
+ }
941
+ return false;
942
+ }
943
+
944
+ /**
945
+ * Is this password in our dictionary files?
946
+ *
947
+ * The checks are done using "grep." If grep is not available, each file
948
+ * is examined using file() and in_array().
949
+ *
950
+ * The dictionary files are in the "pw_dictionaries" directory. Feel free
951
+ * to add your own dictionary files. Please be aware that checking the
952
+ * files is computationally "expensive" and the larger the files become,
953
+ * the more time and memory is needed. Thus it is wise to only put
954
+ * passwords your files that would not be caught by our other tests.
955
+ * The "utilties/reduce-dictionary-files.php" script can be used to
956
+ * weed out unnecessary entries.
957
+ *
958
+ * @param string $pw the password to examine
959
+ * @return bool
960
+ */
961
+ protected function is_pw_dictionary($pw) {
962
+ if ($this->available_grep === true) {
963
+ return $this->is_pw_dictionary__grep($pw);
964
+ } elseif ($this->available_grep === false) {
965
+ return $this->is_pw_dictionary__file($pw);
966
+ }
967
+ $result = $this->is_pw_dictionary__grep($pw);
968
+ if ($result !== null) {
969
+ return $result;
970
+ }
971
+ return $this->is_pw_dictionary__file($pw);
972
+ }
973
+
974
+ /**
975
+ * Examines the password files via file() and in_array()
976
+ *
977
+ * @param string $pw the password to examine
978
+ * @return bool
979
+ */
980
+ protected function is_pw_dictionary__file($pw) {
981
+ $dir = new DirectoryIterator($this->dir_dictionaries);
982
+ foreach ($dir as $file) {
983
+ if ($file->isDir()) {
984
+ continue;
985
+ }
986
+ $words = file($this->dir_dictionaries . $file->getFilename(),
987
+ FILE_IGNORE_NEW_LINES|FILE_SKIP_EMPTY_LINES);
988
+ if (in_array($pw, $words)) {
989
+ return true;
990
+ }
991
+ }
992
+ return false;
993
+ }
994
+
995
+ /**
996
+ * Examines the password files via grep, if it is available
997
+ *
998
+ * @param string $pw the password to examine
999
+ * @return bool|null true or false if known, null if grep isn't available
1000
+ */
1001
+ protected function is_pw_dictionary__grep($pw) {
1002
+ if ($this->available_grep === false) {
1003
+ return null;
1004
+ }
1005
+
1006
+ $term = escapeshellarg($pw);
1007
+ $dir = escapeshellarg($this->dir_dictionaries);
1008
+ exec("grep -iqrx $term $dir", $output, $result);
1009
+ if (!$result) {
1010
+ return true;
1011
+ } elseif ($result == 127) {
1012
+ $this->available_grep = false;
1013
+ return null;
1014
+ }
1015
+ return false;
1016
+ }
1017
+
1018
+ /**
1019
+ * Is the user's password expired?
1020
+ *
1021
+ * @param int $user_ID the user's id number
1022
+ * @return mixed true if expired. Other replies all evaluate to empty
1023
+ * but use different types to aid unit testing.
1024
+ *
1025
+ * @uses login_security_solution::$options for the pw_change_days value
1026
+ * @uses login_security_solution::get_last_changed_time() to get the last
1027
+ * time the user changed their password
1028
+ * @uses login_security_solution::set_last_changed_time() to update the
1029
+ * user's password changed time if it's not available
1030
+ */
1031
+ protected function is_pw_expired($user_ID) {
1032
+ if (!$this->options['pw_change_days']) {
1033
+ return null;
1034
+ }
1035
+ $time = $this->get_pw_changed_time($user_ID);
1036
+ if (!$time) {
1037
+ $this->set_pw_changed_time($user_ID);
1038
+ return 0;
1039
+ }
1040
+ if (((time() - $time) / 86400) > $this->options['pw_change_days']) {
1041
+ return true;
1042
+ }
1043
+ return false;
1044
+ }
1045
+
1046
+ /**
1047
+ * Does the password use the site's name, url or description?
1048
+ *
1049
+ * @param string $pw the password to examine
1050
+ * @return bool
1051
+ */
1052
+ protected function is_pw_like_bloginfo($pw) {
1053
+ // Note: avoiding get_bloginfo() because it's very expensive.
1054
+ if ($this->has_match($pw, get_option('blogname'))) {
1055
+ return true;
1056
+ }
1057
+ if ($this->has_match($pw, get_option('siteurl'))) {
1058
+ return true;
1059
+ }
1060
+ if ($this->has_match($pw, get_option('blogdescription'))) {
1061
+ return true;
1062
+ }
1063
+ return false;
1064
+ }
1065
+
1066
+ /**
1067
+ * Does the password contain data from the user's profile?
1068
+ *
1069
+ * @param string $pw the password to examine
1070
+ * @return bool
1071
+ */
1072
+ protected function is_pw_like_user_data($pw, $user) {
1073
+ if (!empty($user->user_login)) {
1074
+ if ($this->has_match($pw, $user->user_login)) {
1075
+ return true;
1076
+ }
1077
+ }
1078
+ if (!empty($user->user_email)) {
1079
+ if ($this->has_match($pw, $user->user_email)) {
1080
+ return true;
1081
+ }
1082
+ }
1083
+ if (!empty($user->user_url)) {
1084
+ if ($this->has_match($pw, $user->user_url)) {
1085
+ return true;
1086
+ }
1087
+ }
1088
+ if (!empty($user->first_name)) {
1089
+ if ($this->has_match($pw, $user->first_name)) {
1090
+ return true;
1091
+ }
1092
+ }
1093
+ if (!empty($user->last_name)) {
1094
+ if ($this->has_match($pw, $user->last_name)) {
1095
+ return true;
1096
+ }
1097
+ }
1098
+ if (!empty($user->nickname)) {
1099
+ if ($this->has_match($pw, $user->nickname)) {
1100
+ return true;
1101
+ }
1102
+ }
1103
+ if (!empty($user->display_name)) {
1104
+ if ($this->has_match($pw, $user->display_name)) {
1105
+ return true;
1106
+ }
1107
+ }
1108
+ if (!empty($user->aim)) {
1109
+ if ($this->has_match($pw, $user->aim)) {
1110
+ return true;
1111
+ }
1112
+ }
1113
+ if (!empty($user->yim)) {
1114
+ if ($this->has_match($pw, $user->yim)) {
1115
+ return true;
1116
+ }
1117
+ }
1118
+ if (!empty($user->jabber)) {
1119
+ if ($this->has_match($pw, $user->jabber)) {
1120
+ return true;
1121
+ }
1122
+ }
1123
+ return false;
1124
+ }
1125
+
1126
+ /**
1127
+ * Does the password lack numbers?
1128
+ *
1129
+ * @param string $pw the password to examine
1130
+ * @return bool
1131
+ */
1132
+ protected function is_pw_missing_numeric($pw) {
1133
+ return !preg_match('/\d/u', $pw);
1134
+ }
1135
+
1136
+ /**
1137
+ * Does the password lack punctuation characters?
1138
+ *
1139
+ * @param string $pw the password to examine
1140
+ * @return bool
1141
+ */
1142
+ protected function is_pw_missing_punct_chars($pw) {
1143
+ return !preg_match('/[^\p{L}\p{Nd}]/u', $pw);
1144
+ }
1145
+
1146
+ /**
1147
+ * Does the password lack upper-case letters and lower-case letters?
1148
+ *
1149
+ * @param string $pw the password to examine
1150
+ * @return bool
1151
+ */
1152
+ protected function is_pw_missing_upper_lower_chars($pw) {
1153
+ if ($this->available_mbstring) {
1154
+ $upper = mb_strtoupper($pw);
1155
+ $lower = mb_strtolower($pw);
1156
+ if ($upper == $lower) {
1157
+ if (preg_match('/^[\P{L}\p{Nd}]+$/u', $pw)) {
1158
+ // Contains only numbers or punctuation. Sorry, Charlie.
1159
+ return true;
1160
+ }
1161
+ // Unicameral alphabet. That's cool.
1162
+ return false;
1163
+ }
1164
+ if ($pw != $lower && $pw != $upper) {
1165
+ return false;
1166
+ }
1167
+ return true;
1168
+ } else {
1169
+ if (!preg_match('/[[:upper:]]/u', $pw)) {
1170
+ return true;
1171
+ }
1172
+ if (!preg_match('/[[:lower:]]/u', $pw)) {
1173
+ return true;
1174
+ }
1175
+ return false;
1176
+ }
1177
+ }
1178
+
1179
+ /**
1180
+ * Does the password contain things other than ASCII characters?
1181
+ *
1182
+ * @param string $pw the password to examine
1183
+ * @return bool
1184
+ */
1185
+ protected function is_pw_outside_ascii($pw) {
1186
+ return !preg_match('/^[!-~]+$/u', $pw);
1187
+ }
1188
+
1189
+ /**
1190
+ * Is the user's password the same as one they've used earlier?
1191
+ *
1192
+ * @param string $pw the password to examine
1193
+ * @return mixed true if reused. Other replies all evaluate to empty
1194
+ * but use different types to aid unit testing.
1195
+ */
1196
+ protected function is_pw_reused($pw, $user_ID) {
1197
+ if (!$this->options['pw_reuse_count']) {
1198
+ return null;
1199
+ }
1200
+ $hashes = $this->get_pw_hashes($user_ID);
1201
+ if (empty($hashes)) {
1202
+ return 0;
1203
+ }
1204
+ foreach ($hashes as $hash) {
1205
+ if (wp_check_password($pw, $hash)) {
1206
+ return true;
1207
+ }
1208
+ }
1209
+ return false;
1210
+ }
1211
+
1212
+ /**
1213
+ * Does the password contain characters in alphabetic or numeric order?
1214
+ *
1215
+ * @param string $pw the password to examine
1216
+ * @return bool
1217
+ */
1218
+ protected function is_pw_sequential_codepoints($pw) {
1219
+ $chars = $this->split($pw);
1220
+ $prior = array_shift($chars);
1221
+ $transitions = 0;
1222
+ foreach ($chars as $char) {
1223
+ if (abs( hexdec(bin2hex($char)) - hexdec(bin2hex($prior)) ) > 2) {
1224
+ $transitions++;
1225
+ }
1226
+ $prior = $char;
1227
+ }
1228
+ return ($transitions < 5);
1229
+ }
1230
+
1231
+ /**
1232
+ * Does the password contain groups of characters next to each other
1233
+ * on the keyboard?
1234
+ *
1235
+ * This method uses files stored in the "pw_sequences" directory. Each
1236
+ * file represents a different keyboard/language. The files are created
1237
+ * (for left-to-right languages) by typing each character on the keyboard
1238
+ * starting with the top left key, working across the top row, then
1239
+ * starting again on the left side of the next row down. Do the full
1240
+ * keyboard in upper-case mode first. Then continue by doing the board
1241
+ * in lower-case mode. Feel free to add your own files.
1242
+ *
1243
+ * @param string $pw the password to examine
1244
+ * @return bool
1245
+ */
1246
+ protected function is_pw_sequential_file($pw) {
1247
+ // First, determine offsets where character type changes occur.
1248
+ $split = preg_split('/(?<=[^[:punct:]])([[:punct:]])|(?<=[^[:alpha:]])([[:alpha:]])|(?<=\D)(\d)/', $pw, -1, PREG_SPLIT_OFFSET_CAPTURE|PREG_SPLIT_DELIM_CAPTURE|PREG_SPLIT_NO_EMPTY);
1249
+
1250
+ if (count($split) == 1) {
1251
+ // All one character type.
1252
+ $parts_fwd = array($pw);
1253
+ $parts_rev = array($this->strrev($pw));
1254
+ } else {
1255
+ // Multiple character types.
1256
+
1257
+ // Don't want info from first element.
1258
+ array_shift($split);
1259
+
1260
+ $parts_fwd = array();
1261
+ $parts_rev = array();
1262
+ $start = 0;
1263
+
1264
+ // Now use those offsets to extract the character type blocks.
1265
+ foreach ($split as $part) {
1266
+ if ($this->strlen($part[0]) == 1) {
1267
+ $length = $part[1] - $start;
1268
+ if ($length > 2) {
1269
+ // Only examine blocks with 3 or more characters.
1270
+ $fwd = $this->substr($pw, $start, $length);
1271
+ $parts_fwd[] = $fwd;
1272
+ $parts_rev[] = $this->strrev($fwd);
1273
+ }
1274
+ $start = $part[1];
1275
+ }
1276
+ }
1277
+ $length = $this->strlen($pw) - $start;
1278
+ if ($length > 2) {
1279
+ // Only add the last block if it's 3 or more characters.
1280
+ $fwd = $this->substr($pw, $start, $length);
1281
+ $parts_fwd[] = $fwd;
1282
+ $parts_rev[] = $this->strrev($fwd);
1283
+ }
1284
+ }
1285
+
1286
+ if (!$parts_fwd) {
1287
+ return false;
1288
+ }
1289
+
1290
+ $dir = new DirectoryIterator($this->dir_sequences);
1291
+ foreach ($dir as $file) {
1292
+ if ($file->isDir()) {
1293
+ continue;
1294
+ }
1295
+ $kbd = file_get_contents($this->dir_sequences . $file->getFileName());
1296
+
1297
+ foreach ($parts_fwd as $key => $part) {
1298
+ if ($this->strlen($part) < 3) {
1299
+ continue;
1300
+ }
1301
+ if (strpos($kbd, $part) !== false) {
1302
+ return true;
1303
+ }
1304
+ if (strpos($kbd, $parts_rev[$key]) !== false) {
1305
+ return true;
1306
+ }
1307
+ }
1308
+ }
1309
+ return false;
1310
+ }
1311
+
1312
+ /**
1313
+ * A centralized way to load the plugin's textdomain for
1314
+ * internationalization
1315
+ * @return void
1316
+ */
1317
+ protected function load_plugin_textdomain() {
1318
+ if (!$this->loaded_textdomain) {
1319
+ load_plugin_textdomain(self::ID, false, self::ID . '/languages');
1320
+ $this->loaded_textdomain = true;
1321
+ }
1322
+ }
1323
+
1324
+ /**
1325
+ * Sends a message to my debug log
1326
+ */
1327
+ public function log($msg) {
1328
+ if (!is_scalar($msg)) {
1329
+ $msg = var_export($msg, true);
1330
+ }
1331
+ file_put_contents('/var/tmp/' . self::ID . '.log', "$msg\n", FILE_APPEND);
1332
+ }
1333
+
1334
+ /**
1335
+ * Generates a reproducible hash of the password
1336
+ *
1337
+ * Needed because WP's hash function creates different output each time,
1338
+ * making it impossible to search against.
1339
+ *
1340
+ * @param string $pw the password to process
1341
+ * @return string the hashed password
1342
+ *
1343
+ * @uses AUTH_SALT to prevent rainbow table lookups
1344
+ */
1345
+ protected function md5($pw) {
1346
+ return md5(AUTH_SALT . $pw);
1347
+ }
1348
+
1349
+ /**
1350
+ * Formats and sanity checks IP addresses
1351
+ *
1352
+ * @param string $ip the IP address to check
1353
+ * @return string the formatted address. Empty string if input is bad.
1354
+ */
1355
+ protected function normalize_ip($ip) {
1356
+ if (!is_string($ip)) {
1357
+ return '';
1358
+ }
1359
+ $ip = trim($ip);
1360
+ if ($ip == '') {
1361
+ return $ip;
1362
+ }
1363
+ if (strpos($ip, ':') === false) {
1364
+ return $this->normalize_ipv4($ip);
1365
+ } else {
1366
+ return $this->normalize_ipv6($ip);
1367
+ }
1368
+ }
1369
+
1370
+ /**
1371
+ * Userland means for sanity checking IPv4 addresses
1372
+ *
1373
+ * @param string $ip the IPv4 address, in "." separated format
1374
+ * @return string the IP address. Empty string if input is bad.
1375
+ */
1376
+ protected function normalize_ipv4($ip) {
1377
+ $groups = explode('.', $ip);
1378
+ if (count($groups) != 4) {
1379
+ return '';
1380
+ }
1381
+ $out = array();
1382
+ foreach ($groups as $group) {
1383
+ $group = (int) $group;
1384
+ if ($group > 255) {
1385
+ return '';
1386
+ }
1387
+ $out[] = $group;
1388
+ }
1389
+ return implode('.', $out);
1390
+ }
1391
+
1392
+ /**
1393
+ * Fills in compressed groups, providing a consistent format usable for
1394
+ * wildcard searching
1395
+ *
1396
+ * Also performs sanity checks.
1397
+ *
1398
+ * The output does not comply with RFC 5952 because compressed addresses
1399
+ * can cause mistakes in our "LIKE '$network_ip%'" queries.
1400
+ *
1401
+ * @link http://tools.ietf.org/html/rfc5952 A Recommendation for IPv6
1402
+ * Address Text Representation
1403
+ *
1404
+ * @param string $ip the IPv6 address, in ":" separated format
1405
+ * @return string the formatted address. Empty string if input is bad.
1406
+ */
1407
+ protected function normalize_ipv6($ip) {
1408
+ if (strpos($ip, ':::') !== false || $ip == '::') {
1409
+ return '';
1410
+ }
1411
+
1412
+ $groups = explode(':', $ip);
1413
+
1414
+ $compression_location = strpos($ip, '::');
1415
+ if ($compression_location === 0) {
1416
+ array_shift($groups);
1417
+ } elseif ($compression_location == strlen($ip) - 2) {
1418
+ array_pop($groups);
1419
+ }
1420
+
1421
+ $count = count($groups);
1422
+
1423
+ if ($count > 8) {
1424
+ return '';
1425
+ }
1426
+ if ($count < 8) {
1427
+ if (strpos($groups[$count -1], '.') !== false) {
1428
+ // Embedded IPv4.
1429
+ $prior = hexdec($groups[$count - 2]);
1430
+ if ($prior == 0 || $prior == 65535) {
1431
+ $ipv4 = $this->normalize_ipv4($groups[$count - 1]);
1432
+ if ($ipv4) {
1433
+ if ($prior) {
1434
+ return '0:0:0:0:0:ffff:' . $ipv4;
1435
+ } else {
1436
+ return '0:0:0:0:0:0:' . $ipv4;
1437
+ }
1438
+ }
1439
+ }
1440
+ return '';
1441
+ }
1442
+ if ($compression_location === false) {
1443
+ return '';
1444
+ }
1445
+ }
1446
+
1447
+ $out = array();
1448
+ $missing = 9 - $count;
1449
+ foreach ($groups as $key => $value) {
1450
+ if ($value === '') {
1451
+ $out = array_merge($out, array_fill(0, $missing, '0'));
1452
+ } else {
1453
+ // Ensure no leading 0's and that values are legit.
1454
+ if (ctype_digit($value)) {
1455
+ $value = (int) $value;
1456
+ if ($value > 9999) {
1457
+ return '';
1458
+ }
1459
+ } else {
1460
+ $tmp = hexdec($value);
1461
+ if ($tmp > 65535) {
1462
+ return '';
1463
+ }
1464
+ $value = dechex($tmp);
1465
+ }
1466
+ $out[] = $value;
1467
+ }
1468
+ }
1469
+
1470
+ $ip = implode(':', $out);
1471
+ return $ip;
1472
+ }
1473
+
1474
+ /**
1475
+ * Sends an email to the blog's administrator telling them a breakin
1476
+ * may have occurred
1477
+ *
1478
+ * @param string $network_ip a prior result from get_network_ip()
1479
+ * @param string $user_name the user name from the current login form
1480
+ * @param string $pass_md5 the md5 hashed new password
1481
+ * @return bool
1482
+ *
1483
+ * @uses login_security_solution::get_notify_counts() for some shared text
1484
+ * @uses wp_mail() to send the messages
1485
+ */
1486
+ protected function notify_breach($network_ip, $user_name, $pass_md5,
1487
+ $fails)
1488
+ {
1489
+ $this->load_plugin_textdomain();
1490
+
1491
+ $to = $this->sanitize_whitespace(get_option('admin_email'));
1492
+
1493
+ $blog = get_option('blogname');
1494
+ $subject = sprintf('POTENTIAL INTRUSION AT %s', $blog);
1495
+ $subject = $this->sanitize_whitespace($subject);
1496
+
1497
+ $message =
1498
+ sprintf(__("Your website, %s, may have been broken in to.", self::ID),
1499
+ $blog) . "\n\n"
1500
+
1501
+ . sprintf(__("Someone just logged in using the following components. Prior to that, some combination of those components were a part of %d failed attempts to log in during the past %d minutes:", self::ID),
1502
+ $fails['total'], $this->options['login_fail_minutes']) . "\n\n"
1503
+
1504
+ . $this->get_notify_counts($network_ip, $user_name, $pass_md5, $fails)
1505
+
1506
+ . __("The user has been logged out and will be required to confirm their identity via the password reset functionality.", self::ID) . "\n";
1507
+
1508
+ return wp_mail($to, $subject, $message);
1509
+ }
1510
+
1511
+ /**
1512
+ * Sends an email to the blog's administrator telling them that the site
1513
+ * is being attacked
1514
+ *
1515
+ * @param string $network_ip a prior result from get_network_ip()
1516
+ * @param string $user_name the user name from the current login form
1517
+ * @param string $pass_md5 the md5 hashed new password
1518
+ * @return bool
1519
+ *
1520
+ * @uses login_security_solution::get_notify_counts() for some shared text
1521
+ * @uses wp_mail() to send the messages
1522
+ */
1523
+ protected function notify_fail($network_ip, $user_name, $pass_md5,
1524
+ $fails)
1525
+ {
1526
+ $this->load_plugin_textdomain();
1527
+
1528
+ $to = $this->sanitize_whitespace(get_option('admin_email'));
1529
+
1530
+ $blog = get_option('blogname');
1531
+ $subject = sprintf('ATTACK HAPPENING TO %s', $blog);
1532
+ $subject = $this->sanitize_whitespace($subject);
1533
+
1534
+ $message =
1535
+ sprintf(__("Your website, %s, is undergoing a brute force attack.", self::ID),
1536
+ $blog) . "\n\n"
1537
+
1538
+ . sprintf(__("There have been at least %d failed attempts to log in during the past %d minutes that used one or more of the following components:", self::ID),
1539
+ $fails['total'], $this->options['login_fail_minutes']) . "\n\n"
1540
+
1541
+ . $this->get_notify_counts($network_ip, $user_name, $pass_md5, $fails)
1542
+
1543
+ . sprintf(__("The %s plugin for WordPress is repelling the attack by making their login failures take a very long time.", self::ID),
1544
+ self::NAME) . "\n";
1545
+
1546
+ return wp_mail($to, $subject, $message);
1547
+ }
1548
+
1549
+ /**
1550
+ * Records the failed login, disconnects the database, then calls sleep()
1551
+ * for increasing amounts of time as more failures come in
1552
+ *
1553
+ * @param string $user_name the user name from the current login form
1554
+ * @param string $user_pass the unhashed new password
1555
+ * @return int the number of seconds sleep()'ed (for use by unit tests)
1556
+ *
1557
+ * @uses login_security_solution::get_ip() to get the IP address
1558
+ * @uses login_security_solution::get_network_ip() gets the IP's
1559
+ * "network" part
1560
+ * @uses login_security_solution::md5() to hash the password
1561
+ * @uses login_security_solution::get_login_fail() to see if
1562
+ * they're over the limit
1563
+ * @uses login_security_solution::notify_fail() to warn of an attack
1564
+ */
1565
+ protected function process_login_fail($user_name, $user_pass) {
1566
+ global $wpdb;
1567
+
1568
+ $ip = $this->get_ip();
1569
+ $network_ip = $this->get_network_ip($ip);
1570
+ $pass_md5 = $this->md5($user_pass);
1571
+
1572
+ $this->insert_fail($ip, $user_name, $pass_md5);
1573
+
1574
+ $fails = $this->get_login_fail($network_ip, $user_name, $pass_md5);
1575
+
1576
+ if ($this->options['login_fail_notify']
1577
+ && ! ($fails['total'] % $this->options['login_fail_notify']))
1578
+ {
1579
+ $this->notify_fail($network_ip, $user_name, $pass_md5, $fails);
1580
+ }
1581
+
1582
+ $sleep = 0;
1583
+ if ($fails['total'] < $this->options['login_fail_tier_2']) {
1584
+ // Use random, overlapping sleep times to complicate profiling.
1585
+ $sleep = rand(1, 7);
1586
+ } elseif ($fails['total'] < $this->options['login_fail_tier_3']) {
1587
+ $sleep = rand(4, 30);
1588
+ } else {
1589
+ $sleep = rand(25, 60);
1590
+ }
1591
+
1592
+ if ($sleep) {
1593
+ if (!$this->testing) {
1594
+ // Keep login failures from becoming denial of service attacks.
1595
+ mysql_close($wpdb->dbh);
1596
+
1597
+ // Increasingly slow down attackers to the point they'll give up.
1598
+ sleep($sleep);
1599
+ }
1600
+ }
1601
+
1602
+ return $sleep;
1603
+ }
1604
+
1605
+ /**
1606
+ * Updates and removes the password related user metadata as needed
1607
+ *
1608
+ * For use when a password is changed.
1609
+ *
1610
+ * @param int $user_ID the user's id number
1611
+ * @param string $user_pass the unhashed new password
1612
+ * @return void
1613
+ */
1614
+ protected function process_pw_metadata($user_ID, $user_pass) {
1615
+ if ($this->options['pw_change_days']) {
1616
+ $this->set_pw_changed_time($user_ID);
1617
+ }
1618
+ if ($this->options['pw_reuse_count']) {
1619
+ $this->save_pw_hash($user_ID, wp_hash_password($user_pass));
1620
+ }
1621
+ $this->delete_pw_force_change($user_ID);
1622
+ $this->delete_pw_grace_period($user_ID);
1623
+ }
1624
+
1625
+ /**
1626
+ * Sends HTTP Location headers that direct users to the login page
1627
+ *
1628
+ * Also permits adding message ID's to the URI query string that get
1629
+ * interpreted by our login_message() method, which displays them above
1630
+ * the login form.
1631
+ *
1632
+ * Utilizes WordPress' "redirect_to" functionality to bring users back to
1633
+ * where they came from once they have logged in.
1634
+ *
1635
+ * @param string $login_msg_id the ID representing the message to
1636
+ * display above the login form
1637
+ * @param bool $use_rt use WP's "redirect_to" on successful login?
1638
+ * @param bool $action "login" (default) or "retrievepassword"
1639
+ * @return void
1640
+ *
1641
+ * @uses login_security_solution::$key_login_msg to know which $_GET
1642
+ * parameter to put the message id into
1643
+ * @see login_security_solution::login_message() for rendering the
1644
+ * messages
1645
+ * @uses wp_login_url() to know where the login form is
1646
+ * @uses wp_logout() to deactivate the current session
1647
+ * @uses wp_redirect() to perform the actual redirect
1648
+ */
1649
+ protected function redirect_to_login($login_msg_id = '', $use_rt = false,
1650
+ $action = 'login')
1651
+ {
1652
+ if ($use_rt && !empty($_SERVER['REQUEST_URI'])) {
1653
+ $uri = wp_login_url($_SERVER['REQUEST_URI']);
1654
+ } else {
1655
+ $uri = wp_login_url();
1656
+ }
1657
+ $uri = $this->sanitize_whitespace($uri);
1658
+
1659
+ if (strpos($uri, '?') === false) {
1660
+ $uri .= '?';
1661
+ } else {
1662
+ $uri .= '&';
1663
+ }
1664
+ $uri .= 'action=' . urlencode($action);
1665
+
1666
+ if ($login_msg_id) {
1667
+ $uri .= '&' . urlencode($this->key_login_msg) . '='
1668
+ . urlencode($login_msg_id);
1669
+ }
1670
+
1671
+ wp_logout();
1672
+ wp_redirect($uri);
1673
+
1674
+ if (!$this->testing) {
1675
+ exit;
1676
+ }
1677
+ }
1678
+
1679
+ /**
1680
+ * Replaces all whitespace characters with one space
1681
+ * @param string $in the string to clean
1682
+ * @return string the cleaned string
1683
+ */
1684
+ protected function sanitize_whitespace($in) {
1685
+ return preg_replace('/\s+/', ' ', $in);
1686
+ }
1687
+
1688
+ /**
1689
+ * Logs password hashes to prevent passwords from being reused frequently
1690
+ *
1691
+ * Note: duplicate hashes are not stored.
1692
+ *
1693
+ * @param int $user_ID the user's id number
1694
+ * @param string $new_hash the wp hashed password to save
1695
+ * @return mixed true on success, 1 if hash is already stored
1696
+ */
1697
+ protected function save_pw_hash($user_ID, $new_hash) {
1698
+ $hashes = $this->get_pw_hashes($user_ID);
1699
+
1700
+ if (in_array($new_hash, $hashes)) {
1701
+ return 1;
1702
+ }
1703
+
1704
+ $hashes[] = $new_hash;
1705
+
1706
+ $cut = count($hashes) - $this->options['pw_reuse_count'];
1707
+ if ($cut > 0) {
1708
+ array_splice($hashes, 0, $cut);
1709
+ }
1710
+
1711
+ update_user_meta($user_ID, $this->umk_hashes, $hashes);
1712
+
1713
+ return true;
1714
+ }
1715
+
1716
+ /**
1717
+ * Stores the present time in the given user's "last active" metadata
1718
+ *
1719
+ * @param int $user_ID the current user's ID number
1720
+ * @return int|bool the record number if added, TRUE if updated, FALSE
1721
+ * if error
1722
+ */
1723
+ protected function set_last_active($user_ID) {
1724
+ return update_user_meta($user_ID, $this->umk_last_active, time());
1725
+ }
1726
+
1727
+ /**
1728
+ * Replaces the default option values with those stored in the database
1729
+ * @uses login_security_solution::$options to hold the data
1730
+ */
1731
+ protected function set_options() {
1732
+ $options = get_option($this->option_name);
1733
+ if (!is_array($options)) {
1734
+ $options = array();
1735
+ }
1736
+ $this->options = array_merge($this->options_default, $options);
1737
+ }
1738
+
1739
+ /**
1740
+ * Stores the present time in the given user's "password changed" metadata
1741
+ *
1742
+ * @param int $user_ID the current user's ID number
1743
+ * @return int|bool the record number if added, TRUE if updated, FALSE
1744
+ * if error
1745
+ */
1746
+ protected function set_pw_changed_time($user_ID) {
1747
+ return update_user_meta($user_ID, $this->umk_changed, time());
1748
+ }
1749
+
1750
+ /**
1751
+ * Puts the "force password change" flag into the user's metadata
1752
+ *
1753
+ * @param int $user_ID the current user's ID number
1754
+ * @return int|bool the record number if added, TRUE if updated, FALSE
1755
+ * if error
1756
+ */
1757
+ protected function set_pw_force_change($user_ID) {
1758
+ return update_user_meta($user_ID, $this->umk_pw_force_change, 1);
1759
+ }
1760
+
1761
+ /**
1762
+ * Stores the present time in the given user's "password grace period"
1763
+ * metadata
1764
+ *
1765
+ * @param int $user_ID the current user's ID number
1766
+ * @return int|bool the record number if added, TRUE if updated, FALSE
1767
+ * if error
1768
+ */
1769
+ protected function set_pw_grace_period($user_ID) {
1770
+ return update_user_meta($user_ID, $this->umk_grace_period, time());
1771
+ }
1772
+
1773
+ /**
1774
+ * Breaks a password up into an array of individual characters
1775
+ *
1776
+ * @param string $pw the password to examine
1777
+ * @return array
1778
+ */
1779
+ protected function split($pw) {
1780
+ return preg_split('/(?<!^)(?!$)/u', $pw);
1781
+ }
1782
+
1783
+ /**
1784
+ * Determines how long a string is using mb_strlen() if available
1785
+ *
1786
+ * @param string $pw the string to evaluate
1787
+ * @return int the length of the string
1788
+ */
1789
+ protected function strlen($pw) {
1790
+ if ($this->available_mbstring) {
1791
+ return mb_strlen($pw);
1792
+ } else {
1793
+ return strlen($pw);
1794
+ }
1795
+ }
1796
+
1797
+ /**
1798
+ * Removes non-letter and non-numeric characters from the password
1799
+ *
1800
+ * @param string $pw the password to examine
1801
+ * @return string
1802
+ */
1803
+ protected function strip_nonword_chars($pw) {
1804
+ return preg_replace('/[^\p{L}\p{Nd}]/u', '', $pw);
1805
+ }
1806
+
1807
+ /**
1808
+ * Reverses a string in a multibyte safe way
1809
+ *
1810
+ * @param sring $pw the string to examine
1811
+ * @return sring the reversed string
1812
+ */
1813
+ protected function strrev($pw) {
1814
+ return implode('', array_reverse($this->split($pw)));
1815
+ }
1816
+
1817
+ /**
1818
+ * Extracts parts of strings, using mb_substr() if available
1819
+ *
1820
+ * @param string $pw the string to evaluate
1821
+ * @param int $start the starting index (0 based)
1822
+ * @param int $length the number of characters to get
1823
+ * @return string the desired part of the password
1824
+ */
1825
+ protected function substr($pw, $start, $length) {
1826
+ if ($this->available_mbstring) {
1827
+ return mb_substr($pw, $start, $length);
1828
+ } else {
1829
+ return substr($pw, $start, $length);
1830
+ }
1831
+ }
1832
+
1833
+ /**
1834
+ * Is the password valid?
1835
+ *
1836
+ * @param WP_User|string the user object or password to be examined
1837
+ * @param WP_Error the means to provide specific error messages
1838
+ * @return bool
1839
+ */
1840
+ public function validate_pw($user, &$errors = null) {
1841
+ $this->load_plugin_textdomain();
1842
+
1843
+ if (is_object($user)) {
1844
+ $all_tests = true;
1845
+
1846
+ if (empty($user->user_pass)) {
1847
+ if ($errors !== null) {
1848
+ $errors->add(self::ID,
1849
+ __("<strong>ERROR</strong>: Password not set.", self::ID),
1850
+ array('form-field' => 'pass1')
1851
+ );
1852
+ }
1853
+ return false;
1854
+ }
1855
+ $pw = $user->user_pass;
1856
+ } else {
1857
+ $all_tests = false;
1858
+ $pw = $user;
1859
+ }
1860
+
1861
+ if (!is_string($pw)) {
1862
+ if ($errors !== null) {
1863
+ $errors->add(self::ID,
1864
+ __("<strong>ERROR</strong>: Passwords must be strings.", self::ID),
1865
+ array('form-field' => 'pass1')
1866
+ );
1867
+ }
1868
+ return false;
1869
+ }
1870
+
1871
+ $pw = trim($pw);
1872
+
1873
+ if ($this->available_mbstring === null) {
1874
+ $this->available_mbstring = extension_loaded('mbstring');
1875
+ }
1876
+
1877
+ if (!$this->available_mbstring
1878
+ && $this->is_pw_outside_ascii($pw))
1879
+ {
1880
+ if ($errors !== null) {
1881
+ $errors->add(self::ID,
1882
+ __("<strong>ERROR</strong>: Passwords must use ASCII characters.", self::ID),
1883
+ array('form-field' => 'pass1')
1884
+ );
1885
+ }
1886
+ return false;
1887
+ }
1888
+
1889
+ $length = $this->strlen($pw);
1890
+ if ($length < $this->options['pw_complexity_exemption_length']) {
1891
+ $enforce_complexity = true;
1892
+ } else {
1893
+ $enforce_complexity = false;
1894
+ }
1895
+
1896
+ // NOTE: tests ordered from fastest to slowest.
1897
+
1898
+ if ($length < $this->options['pw_length']) {
1899
+ if ($errors !== null) {
1900
+ $errors->add(self::ID,
1901
+ __("<strong>ERROR</strong>: Password is too short.", self::ID),
1902
+ array('form-field' => 'pass1')
1903
+ );
1904
+ }
1905
+ return false;
1906
+ }
1907
+ if ($enforce_complexity && $this->is_pw_missing_numeric($pw)) {
1908
+ if ($errors !== null) {
1909
+ $errors->add(self::ID,
1910
+ sprintf(__("<strong>ERROR</strong>: Passwords must either contain numbers or be %d characters long.", self::ID), $this->options['pw_complexity_exemption_length']),
1911
+ array('form-field' => 'pass1')
1912
+ );
1913
+ }
1914
+ return false;
1915
+ }
1916
+ if ($enforce_complexity && $this->is_pw_missing_punct_chars($pw)) {
1917
+ if ($errors !== null) {
1918
+ $errors->add(self::ID,
1919
+ sprintf(__("<strong>ERROR</strong>: Passwords must either contain punctuation marks / symbols or be %d characters long.", self::ID), $this->options['pw_complexity_exemption_length']),
1920
+ array('form-field' => 'pass1')
1921
+ );
1922
+ }
1923
+ return false;
1924
+ }
1925
+ if ($enforce_complexity && $this->is_pw_missing_upper_lower_chars($pw)) {
1926
+ if ($errors !== null) {
1927
+ $errors->add(self::ID,
1928
+ sprintf(__("<strong>ERROR</strong>: Passwords must either contain upper-case and lower-case letters or be %d characters long.", self::ID), $this->options['pw_complexity_exemption_length']),
1929
+ array('form-field' => 'pass1')
1930
+ );
1931
+ }
1932
+ return false;
1933
+ }
1934
+
1935
+ if ($this->is_pw_sequential_file($pw)) {
1936
+ if ($errors !== null) {
1937
+ $errors->add(self::ID,
1938
+ __("<strong>ERROR</strong>: Passwords can't be sequential keys.", self::ID),
1939
+ array('form-field' => 'pass1')
1940
+ );
1941
+ }
1942
+ return false;
1943
+ }
1944
+ if ($this->is_pw_sequential_codepoints($pw)) {
1945
+ if ($errors !== null) {
1946
+ $errors->add(self::ID,
1947
+ __("<strong>ERROR</strong>: Passwords can't have that many sequential characters.", self::ID),
1948
+ array('form-field' => 'pass1')
1949
+ );
1950
+ }
1951
+ return false;
1952
+ }
1953
+
1954
+ $non_leet = $this->convert_leet_speak($pw);
1955
+ $stripped = $this->strip_nonword_chars($non_leet);
1956
+
1957
+ if ($all_tests
1958
+ && ($this->is_pw_like_user_data($pw, $user)
1959
+ || $this->is_pw_like_user_data($stripped, $user)))
1960
+ {
1961
+ if ($errors !== null) {
1962
+ $errors->add(self::ID,
1963
+ __("<strong>ERROR</strong>: Passwords can't contain user data.", self::ID),
1964
+ array('form-field' => 'pass1')
1965
+ );
1966
+ }
1967
+ return false;
1968
+ }
1969
+ if ($this->is_pw_like_bloginfo($pw)
1970
+ || $this->is_pw_like_bloginfo($stripped))
1971
+ {
1972
+ if ($errors !== null) {
1973
+ $errors->add(self::ID,
1974
+ __("<strong>ERROR</strong>: Passwords can't contain site info.", self::ID),
1975
+ array('form-field' => 'pass1')
1976
+ );
1977
+ }
1978
+ return false;
1979
+ }
1980
+ if ($all_tests && $this->is_pw_dictionary($pw)) {
1981
+ if ($errors !== null) {
1982
+ $errors->add(self::ID,
1983
+ __("<strong>ERROR</strong>: Password is too common.", self::ID),
1984
+ array('form-field' => 'pass1')
1985
+ );
1986
+ }
1987
+ return false;
1988
+ }
1989
+ if ($this->is_pw_dict_program($stripped)) {
1990
+ if ($errors !== null) {
1991
+ $errors->add(self::ID,
1992
+ __("<strong>ERROR</strong>: Passwords can't be variations of dictionary words.", self::ID),
1993
+ array('form-field' => 'pass1')
1994
+ );
1995
+ }
1996
+ return false;
1997
+ }
1998
+
1999
+ return true;
2000
+ }
2001
+ }
pw_dictionaries/test.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
1
+ This1sAnObscure)asswordForTesting
2
+ Pa$$w0rd
3
+ Pa$$w0rd1
4
+ 简化字的昨天今天和明天
pw_sequences/us-101-keyboard.txt ADDED
@@ -0,0 +1 @@
 
1
+ ~!@#$%^&*()_+QWERTYUIOP{}|ASDFGHJKL:"ZXCVBNM<>?`1234567890-=qwertyuiop[]\asdfghjkl;'zxcvbnm,./
readme.txt ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ === Login Security Solution ===
2
+ Contributors: convissor
3
+ Tags: login, password, idle, timeout, maintenance, security, attack, hack, lock, ban
4
+ Requires at least: 3.0
5
+ Tested up to: 3.3.1
6
+ Stable tag: trunk
7
+
8
+ Repels brute force attacks (by IP, name, password). Requires very strong passwords (complex, don't match site/user info, etc). Plus much more!
9
+
10
+ == Description ==
11
+
12
+ * Blocks brute force and dictionary attacks without inconveniencing
13
+ legitimate users
14
+ + Tracks IP addresses, usernames, and passwords
15
+ + If a login failure uses data matching a past failure, the plugin
16
+ slows down response times. The more failures, the longer the delay.
17
+ This encourages attackers to give up and go find an easier target.
18
+ + If an account seems breached, the "user" is immediately logged out
19
+ and forced to use WordPress' password reset utility. This prevents
20
+ any damage from being done and verifies the user's identity. All
21
+ without intervention by an administrator.
22
+ + Can notify the administrator of attacks and breaches
23
+ + Supports IPv6
24
+
25
+ * Thoroughly examines the strength of new passwords. Includes full
26
+ UTF-8 character set support if PHP's `mbstring` extension is enabled.
27
+ The tests have caught every password dictionary entry I've tried.
28
+ + Minimum length (customizable)
29
+ + Doesn't match blog info
30
+ + Doesn't match user data
31
+ + Must either have numbers in it or be very long
32
+ + Must either have punctuation, upper and lower case characters or be
33
+ very long
34
+ + Non-sequential codepoints
35
+ + Non-sequential keystrokes (custom sequence files can be added)
36
+ + Not in the password dictionary files you've provided (if any)
37
+ + Decodes "leet" speak
38
+ + Not found by the `dict` dictionary program (if available)
39
+
40
+ * Password aging (optional)
41
+ + Users need to change password every x days (customizable)
42
+ + Grace period for picking a new password (customizable)
43
+ + Remembers old passwords (quantity is customizable)
44
+
45
+ * Administrators can require all users to change their passwords
46
+ + Done via a flag in each user's database entry
47
+ + No mail is sent, keeping your server off of spam lists
48
+
49
+ * Logs out idle sessions (optional) (idle time is customizable)
50
+
51
+ * Maintenance mode (optional)
52
+ + Publicly viewable content remains visible
53
+ + Disables logins by all users, except administrators
54
+ + Logs out existing sessions, except administrators
55
+ + Disables posting of comments
56
+ + Useful for maintenance or emergency reasons
57
+ + This is separate from WordPress' maintenance mode
58
+
59
+ * Prevents information disclosures from failed logins
60
+
61
+
62
+ = Improvements Over Similar WordPress Plugins =
63
+
64
+ * The plugin itself is secure against SQL, HTML, and header injections
65
+ * Notice-free code means no information disclosures if error_reporting = E_ALL
66
+ * Only loads files, actions, and filters needed for enabled options
67
+ and the page's context
68
+ * Provides an option to have deactivation remove all of this plugin's
69
+ data from the database
70
+ * Uses WordPress' features rather than fighting or overriding them
71
+ * No advertising, promotions, or beacons
72
+ * Proper internationalization support
73
+ * Clean, documented code
74
+ * Unit tests covering 100% of the main class
75
+
76
+
77
+ = Securing Your WordPress Site is Important =
78
+
79
+ You're probably thinking "There's nothing valuable on my website. No one
80
+ will bother breaking into it." What you need to realize is that attackers
81
+ are going after your visitors. They put stealth code on your website
82
+ that pushes malware into the browsers of the people looking at your site.
83
+
84
+ > According to SophosLabs more than 30,000 websites are infected
85
+ > every day and 80% of those infected sites are legitimate.
86
+ > Eighty-five percent of all malware, including viruses, worms,
87
+ > spyware, adware and Trojans, comes from the web. Today,
88
+ > drive-by downloads have become the top web threat.
89
+ >
90
+ > -- [*Security Threat Report 2012*](http://www.sophos.com/en-us/security-news-trends/reports/security-threat-report/html-08.aspx)
91
+
92
+ So if your site does get cracked, not only do you waste hours cleaning up,
93
+ your reputation is sullied, security software flags your site as dangerous,
94
+ and worst of all, you've inadvertently helped infect the computers of your
95
+ clients and friends.
96
+
97
+
98
+ == Installation ==
99
+
100
+ 1. Download the package
101
+ from `http://wordpress.org/extend/plugins/login-security-solution/`
102
+
103
+ 1. Unzip the file.
104
+
105
+ 1. Our existing tests are very effective, catching all of the 2 million
106
+ entries in the Dazzlepod password list. But if you need to block
107
+ specific passwords that my tests miss, this plugin offers the ability
108
+ to provide your own dictionary files.
109
+
110
+ Add a file to the `pw_dictionaries` directory and place those passwords
111
+ in it. One password per line.
112
+
113
+ Please be aware that checking the password files is computationally
114
+ expensive. The following script runs through each of the password
115
+ files and weeds out passwords caught by the other
116
+ tests:
117
+
118
+ php utilities/reduce-dictionary-files.php
119
+
120
+ 1. If your website has a large number of non-English-speaking users:
121
+
122
+ * See if a keyboard sequence file exists in this plugin's
123
+ `pw_sequences` directory for your target languages. The following steps
124
+ are for left-to-right languages. (For right-to-left languages, flip the
125
+ direction of the motions indicated.)
126
+ + Open a text editor and create a file in the `pw_sequences`
127
+ directory
128
+ + Hold down the shift key
129
+ + Press the top left **character** key of the keyboard.
130
+ NOTE: during this entire process, do not press function, control
131
+ or whitespace keys (like tab, enter, delete, arrows, space, etc).
132
+ + Work your way across the top row, pressing each key across the
133
+ row, one by one
134
+ + Press the left-most character key in the second row
135
+ + Go across the second row pressing each key
136
+ + Continue through the entire keyboard in the same manner
137
+ + Let go of the shift key
138
+ + Re-start the process at the top left key of the keyboard and
139
+ work your way through the keyboard, now in lower-case mode
140
+ + Save the file and close the editor
141
+ + Feel free to submit the files to me so others can use it. See
142
+ the features request section, below.
143
+
144
+ * If a translation file for your language does not exist in this
145
+ plugin's `languages` directory, add one. Read
146
+ http://codex.wordpress.org/I18n_for_WordPress_Developers
147
+ for details. Send me the file and I'll include it in future
148
+ releases. See the features request section, below.
149
+
150
+ 1. The last step of the new password validation process is checking if
151
+ the password matches an entry in the `dict` program. See if `dict`
152
+ is installed on your server and consider installing it if not.
153
+ http://en.wikipedia.org/wiki/Dict
154
+
155
+ 1. Upload the `login-security-solution` directory to your
156
+ server's `/wp-content/plugins/` directory
157
+
158
+ 1. Activate the plugin through the "Plugins" menu in WordPress.
159
+
160
+ 1. Adjust the settings as desired. This plugin's settings page can be
161
+ reached via a sub-menu entry under WordPress' "Settings" menu or this
162
+ plugin's entry on WordPress' "Plugins" page.
163
+
164
+ 1. Run the "Change All Passwords" process. This is necessary to ensure
165
+ all of your users have strong passwords. The user interface for
166
+ doing so is accessible via a link in this plugin's entry on
167
+ WordPress' "Plugins" page.
168
+
169
+ 1. Ensure your password is strong by changing it.
170
+
171
+
172
+ = Unit Tests =
173
+
174
+ A thorough set of unit tests are found in the `tests` directory.
175
+
176
+ The plugin needs to be placed in the `wp-contents/plugins` directory of
177
+ a working WordPress installation. The plugin does not need to be
178
+ activated for the tests to run.
179
+
180
+ To execute the tests, `cd` into the `tests` directory and call `phpunit .`.
181
+
182
+ Please note that the tests make extensive use of database transactions.
183
+ Many tests will be skipped if your `wp_options` and `wp_usermeta` tables
184
+ are not using the `InnoDB` storage engine.
185
+
186
+
187
+ = Removal =
188
+
189
+ 1. This plugin offers the ability to remove all of this plugin's settings
190
+ from your database. Go to WordPress' "Plugins" admin interface and
191
+ click the "Settings" link for this plugin. In the "Deactivate" entry,
192
+ click the "Yes, delete the damn data" button and save the form.
193
+
194
+ 1. Use WordPress' "Plugins" admin interface to and click the "Deactivate"
195
+ link.
196
+
197
+ 1. Remove the `login-security-solution` directory.
198
+
199
+
200
+ == Frequently Asked Questions ==
201
+
202
+ Ask and ye shall receive.
203
+
204
+
205
+ == Changelog ==
206
+
207
+ = 0.0.4 =
208
+ * Initial import to `plugins.svn.wordpress.org`.
209
+
210
+ = 0.0.3 =
211
+ * Fix mixups in the code saving the "Change All Passwords" admin UI.
212
+ * Adjust IdleTest so it doesn't radically change `wp_users` auto increment.
213
+ * Tested under WordPress 3.3.1.
214
+ * Unit tests pass using PHP 5.4.0RC8-dev, 5.3.11-dev, and 5.2.18-dev.
215
+
216
+ = 0.0.2 =
217
+ * Use Unicode character properties to improve portability.
218
+ * Stop tests short if not in a wp install.
219
+ * Skip dict test if dict not available.
220
+ * Skip database tests if transactions are not available.
221
+ * Tested under WordPress 3.3.1.
222
+ * Unit tests pass using PHP 5.4.0RC8-dev, 5.3.11-dev, and 5.2.18-dev.
223
+
224
+ = 0.0.1 =
225
+ * Post the code for public review.
226
+ * Tested under WordPress 3.3.1.
227
+
228
+
229
+ == To Do ==
230
+
231
+ * Delete old data in the `fail` table.
232
+ * Add some JS/AJAX magic to make users' lives easier by also validating
233
+ passwords on the front end prior to submission. Patches welcome!
234
+
235
+
236
+ == Bugs and Feature Requests ==
237
+
238
+ Report bugs and submit feature requests by opening a ticket in WordPress'
239
+ plugins Trac website: http://plugins.trac.wordpress.org/report.
240
+ Select `login-security-solution` in the "Component" list.
241
+
242
+
243
+ == Inspiration and References ==
244
+
245
+ * Password Research
246
+ + [You can never have too many passwords: techniques for evaluating a huge corpus](http://www.cl.cam.ac.uk/~jcb82/doc/B12-IEEESP-evaluating_a_huge_password_corpus.pdf)
247
+ + [Analyzing Password Strength](http://www.cs.ru.nl/bachelorscripties/2010/Martin_Devillers___0437999___Analyzing_password_strength.pdf)
248
+ + [Consumer Password Worst Practices](http://www.imperva.com/docs/WP_Consumer_Password_Worst_Practices.pdf)
249
+ + [Preventing Brute Force Attacks on your Web Login](http://www.bryanrite.com/preventing-brute-force-attacks-on-your-web-login/)
250
+ + [Password Strength](http://xkcd.com/936/)
251
+
252
+ * Technical Info
253
+ + [The Extreme UTF-8 Table](http://doc.infosnel.nl/extreme_utf-8.html)
254
+ + [A Recommendation for IPv6 Address Text Representation](http://tools.ietf.org/html/rfc5952)
255
+
256
+ * Password Lists
257
+ + [Dazzlepod Password List](http://dazzlepod.com/site_media/txt/passwords.txt)
258
+ + [Common Passwords](http://www.searchlores.org/commonpass1.htm)
259
+ + [The Top 500 Worst Passwords of All Time](http://www.whatsmypass.com/the-top-500-worst-passwords-of-all-time)
tests/Accessor.php ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+
3
+ /**
4
+ * Extend the class to be tested, providing access to protected elements
5
+ *
6
+ * @package login-security-solution
7
+ * @author Daniel Convissor <danielc@analysisandsolutions.com>
8
+ * @copyright The Analysis and Solutions Company, 2012
9
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPLv2
10
+ */
11
+
12
+ /**
13
+ * Obtain the parent class.
14
+ * Use dirname(dirname()) because safe mode can disable "../".
15
+ */
16
+ require_once dirname(dirname(__FILE__)) . '/login-security-solution.php';
17
+
18
+ /**
19
+ * Get the admin class
20
+ */
21
+ require_once dirname(dirname(__FILE__)) . '/admin.inc';
22
+
23
+ // Remove automatically created object.
24
+ unset($GLOBALS['login_security_solution']);
25
+
26
+ /**
27
+ * Extend the class to be tested, providing access to protected elements
28
+ *
29
+ * @package login-security-solution
30
+ * @author Daniel Convissor <danielc@analysisandsolutions.com>
31
+ * @copyright The Analysis and Solutions Company, 2012
32
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPLv2
33
+ */
34
+ class Accessor extends login_security_solution_admin {
35
+ /**
36
+ * This plugin's table name prefix FOR TESTING
37
+ * @var string
38
+ */
39
+ protected $prefix = 'login_security_solution__tests__';
40
+
41
+ /**
42
+ * Is this class being used by our unit tests?
43
+ * @var bool
44
+ */
45
+ protected $testing = true;
46
+
47
+
48
+ public function __call($method, $args) {
49
+ return call_user_func_array(array($this, $method), $args);
50
+ }
51
+ public function __get($property) {
52
+ return $this->$property;
53
+ }
54
+ public function __set($property, $value) {
55
+ $this->$property = $value;
56
+ }
57
+ public function get_data_element($key) {
58
+ return $this->data[$key];
59
+ }
60
+ }
tests/DisableLoginTest.php ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+
3
+ /**
4
+ * Test the disable login functionality
5
+ *
6
+ * @package login-security-solution
7
+ * @author Daniel Convissor <danielc@analysisandsolutions.com>
8
+ * @copyright The Analysis and Solutions Company, 2012
9
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPLv2
10
+ */
11
+
12
+ /**
13
+ * Get the class we will use for testing
14
+ */
15
+ require_once dirname(__FILE__) . '/TestCase.php';
16
+
17
+ /**
18
+ * Test the disable login functionality
19
+ *
20
+ * @package login-security-solution
21
+ * @author Daniel Convissor <danielc@analysisandsolutions.com>
22
+ * @copyright The Analysis and Solutions Company, 2012
23
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPLv2
24
+ */
25
+ class DisableLoginTest extends TestCase {
26
+ public static function setUpBeforeClass() {
27
+ parent::$db_needed = true;
28
+ parent::set_up_before_class();
29
+ }
30
+
31
+
32
+ public function test_disable_login__false() {
33
+ $options = self::$lss->options;
34
+ $options['disable_logins'] = 0;
35
+ self::$lss->options = $options;
36
+
37
+ $actual = self::$lss->check(array(), $this->user);
38
+ $this->assertTrue($actual, 'Bad return value.');
39
+ }
40
+
41
+ public function test_disable_login__true() {
42
+ $options = self::$lss->options;
43
+ $options['disable_logins'] = 1;
44
+ self::$lss->options = $options;
45
+
46
+ $expected_error = 'Cannot modify header information';
47
+ $this->expected_errors($expected_error);
48
+ self::$location_expected = get_option('siteurl')
49
+ . '/wp-login.php?action=login';
50
+
51
+ $actual = self::$lss->check(array(), $this->user);
52
+
53
+ $this->assertTrue($this->were_expected_errors_found(),
54
+ "Expected error not found: '$expected_error'");
55
+ $this->assertEquals(self::$location_expected, self::$location_actual,
56
+ 'wp_redirect() produced unexpected location header.');
57
+
58
+ $this->assertSame(-4, $actual, 'Bad return value.');
59
+ }
60
+
61
+ public function test_disable_login__true_but_admin() {
62
+ global $current_user;
63
+
64
+ $options = self::$lss->options;
65
+ $options['disable_logins'] = 1;
66
+ self::$lss->options = $options;
67
+
68
+ wp_set_current_user(1);
69
+
70
+ $actual = self::$lss->check(array(), $this->user);
71
+ $this->assertTrue($actual, 'Bad return value.');
72
+ }
73
+ }
tests/IdleTest.php ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+
3
+ /**
4
+ * Test the idle timeout functionality
5
+ *
6
+ * @package login-security-solution
7
+ * @author Daniel Convissor <danielc@analysisandsolutions.com>
8
+ * @copyright The Analysis and Solutions Company, 2012
9
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPLv2
10
+ */
11
+
12
+ /**
13
+ * Get the class we will use for testing
14
+ */
15
+ require_once dirname(__FILE__) . '/TestCase.php';
16
+
17
+ /**
18
+ * Test the idle timeout functionality
19
+ *
20
+ * @package login-security-solution
21
+ * @author Daniel Convissor <danielc@analysisandsolutions.com>
22
+ * @copyright The Analysis and Solutions Company, 2012
23
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPLv2
24
+ */
25
+ class IdleTest extends TestCase {
26
+ public static function setUpBeforeClass() {
27
+ parent::$db_needed = true;
28
+ parent::set_up_before_class();
29
+ }
30
+
31
+
32
+ public function test_get_last_active__empty_1() {
33
+ $actual = self::$lss->get_last_active($this->user->ID);
34
+ $this->assertSame(0, $actual);
35
+ }
36
+
37
+ public function test_set_last_active__add() {
38
+ $actual = self::$lss->set_last_active($this->user->ID);
39
+ $this->assertInternalType('integer', $actual, 'Bad return value.');
40
+ }
41
+
42
+ /**
43
+ * @depends test_set_last_active__add
44
+ */
45
+ public function test_set_last_active__update() {
46
+ sleep(1);
47
+ $actual = self::$lss->set_last_active($this->user->ID);
48
+ $this->assertTrue($actual, 'Bad return value.');
49
+ }
50
+
51
+ /**
52
+ * @depends test_set_last_active__update
53
+ */
54
+ public function test_get_last_active__something() {
55
+ $actual = self::$lss->get_last_active($this->user->ID);
56
+ $diff = (time() - $actual) < 1;
57
+ $this->assertGreaterThanOrEqual(0, $diff, 'Time was too long ago.');
58
+ $this->assertLessThanOrEqual(1, $diff, 'Time was in the future.');
59
+ }
60
+
61
+ /**
62
+ * @depends test_get_last_active__something
63
+ */
64
+ public function test_delete_last_active__1() {
65
+ global $user_ID;
66
+ $user_ID = $this->user->ID;
67
+ $actual = self::$lss->delete_last_active();
68
+ $this->assertTrue($actual);
69
+ }
70
+
71
+ /**
72
+ * @depends test_delete_last_active__1
73
+ */
74
+ public function test_get_last_active__empty_2() {
75
+ $actual = self::$lss->get_last_active($this->user->ID);
76
+ $this->assertSame(0, $actual);
77
+ }
78
+
79
+ public function test_delete_last_active__null_both() {
80
+ global $user_ID, $user_name;
81
+ $user_ID = null;
82
+ $user_name = null;
83
+ $actual = self::$lss->delete_last_active();
84
+ $this->assertNull($actual, 'Bad return value.');
85
+ }
86
+
87
+ public function test_delete_last_active__user_name() {
88
+ global $user_ID, $user_name, $wpdb;
89
+
90
+ $actual = $wpdb->insert(
91
+ $wpdb->users,
92
+ array(
93
+ 'user_login' => $this->user->user_login,
94
+ )
95
+ );
96
+ $this->assertSame(1, $actual, 'Could not insert sample record.');
97
+
98
+ $actual = self::$lss->set_last_active($wpdb->insert_id);
99
+ $this->assertInternalType('integer', $actual, 'Set last active...');
100
+
101
+ $user_ID = null;
102
+ $user_name = $this->user->user_login;
103
+ $actual = self::$lss->delete_last_active();
104
+ $this->assertTrue($actual, 'Delete last active...');
105
+ }
106
+
107
+ /*
108
+ * IS IDLE
109
+ */
110
+
111
+ public function test_is_idle__off() {
112
+ $options = self::$lss->options;
113
+ $options['idle_timeout'] = 0;
114
+ self::$lss->options = $options;
115
+
116
+ $actual = self::$lss->is_idle($this->user->ID);
117
+ $this->assertNull($actual);
118
+ }
119
+
120
+ /**
121
+ * @depends test_delete_last_active__user_name
122
+ */
123
+ public function test_is_idle__add() {
124
+ $options = self::$lss->options;
125
+ $options['idle_timeout'] = 15;
126
+ self::$lss->options = $options;
127
+
128
+ $actual = self::$lss->is_idle($this->user->ID);
129
+ $this->assertSame(0, $actual, 'Bad return value.');
130
+
131
+ $actual = self::$lss->get_last_active($this->user->ID);
132
+ $diff = (time() - $actual) < 1;
133
+ $this->assertGreaterThanOrEqual(0, $diff, 'Time was too long ago.');
134
+ $this->assertLessThanOrEqual(1, $diff, 'Time was in the future.');
135
+ }
136
+
137
+ /**
138
+ * @depends test_is_idle__add
139
+ */
140
+ public function test_is_idle__update() {
141
+ $options = self::$lss->options;
142
+ $options['idle_timeout'] = 1;
143
+ self::$lss->options = $options;
144
+
145
+ $actual = self::$lss->is_idle($this->user->ID);
146
+ $this->assertFalse($actual);
147
+ }
148
+
149
+ /**
150
+ * @depends test_is_idle__update
151
+ */
152
+ public function test_is_idle__true() {
153
+ $options = self::$lss->options;
154
+ $options['idle_timeout'] = -1;
155
+ self::$lss->options = $options;
156
+
157
+ $actual = self::$lss->is_idle($this->user->ID);
158
+ $this->assertTrue($actual);
159
+ }
160
+
161
+ /*
162
+ * CHECK
163
+ */
164
+
165
+ /**
166
+ * @depends test_delete_last_active__user_name
167
+ */
168
+ public function test_check__empty_user_id() {
169
+ $this->user->ID = null;
170
+ $actual = self::$lss->check(array(), $this->user);
171
+ $this->assertFalse($actual, 'Bad return value.');
172
+ }
173
+
174
+ /**
175
+ * @depends test_delete_last_active__user_name
176
+ */
177
+ public function test_check__empty() {
178
+ $actual = self::$lss->check(array(), null);
179
+ $this->assertFalse($actual, 'Bad return value.');
180
+ }
181
+
182
+ /**
183
+ * @depends test_delete_last_active__user_name
184
+ */
185
+ public function test_check__non_user() {
186
+ $other = new stdClass;
187
+ $actual = self::$lss->check(array(), $other);
188
+ $this->assertFalse($actual, 'Bad return value.');
189
+ }
190
+
191
+ /**
192
+ * @depends test_delete_last_active__user_name
193
+ */
194
+ public function test_check__okay() {
195
+ $options = self::$lss->options;
196
+ $options['idle_timeout'] = 1;
197
+ self::$lss->options = $options;
198
+
199
+ $actual = self::$lss->check(array(), $this->user);
200
+ $this->assertTrue($actual);
201
+ }
202
+
203
+ /**
204
+ * @depends test_check__okay
205
+ */
206
+ public function test_check__fail() {
207
+ $options = self::$lss->options;
208
+ $options['idle_timeout'] = -1;
209
+ self::$lss->options = $options;
210
+
211
+ $expected_error = 'Cannot modify header information';
212
+ $this->expected_errors($expected_error);
213
+ self::$location_expected = get_option('siteurl')
214
+ . '/wp-login.php?action=login&'
215
+ . self::$lss->key_login_msg . '=idle';
216
+
217
+ $actual = self::$lss->check(array(), $this->user);
218
+
219
+ $this->assertTrue($this->were_expected_errors_found(),
220
+ "Expected error not found: '$expected_error'");
221
+ $this->assertEquals(self::$location_expected, self::$location_actual,
222
+ 'wp_redirect() produced unexpected location header.');
223
+
224
+ $this->assertSame(-5, $actual, 'Bad return value.');
225
+ }
226
+ }
tests/IpTest.php ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+
3
+ /**
4
+ * Test IP related methods
5
+ *
6
+ * @package login-security-solution
7
+ * @author Daniel Convissor <danielc@analysisandsolutions.com>
8
+ * @copyright The Analysis and Solutions Company, 2012
9
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPLv2
10
+ */
11
+
12
+ /**
13
+ * Get the class we will use for testing
14
+ */
15
+ require_once dirname(__FILE__) . '/TestCase.php';
16
+
17
+ /**
18
+ * Test IP related methods
19
+ *
20
+ * @package login-security-solution
21
+ * @author Daniel Convissor <danielc@analysisandsolutions.com>
22
+ * @copyright The Analysis and Solutions Company, 2012
23
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPLv2
24
+ */
25
+ class IpTest extends TestCase {
26
+ public static function setUpBeforeClass() {
27
+ parent::$db_needed = false;
28
+ parent::set_up_before_class();
29
+ }
30
+
31
+
32
+ public function data_ipv4() {
33
+ return array(
34
+ array('good' => array('1.2.3.4' => '1.2.3.4')),
35
+ array('zerofill' => array('005.006.007.008' => '5.6.7.8')),
36
+ array('big' => array('1.256.3.4' => '')),
37
+ array('short' => array('1.2.3' => '')),
38
+ array('long' => array('1.2.3.4.5' => '')),
39
+ array('text' => array('abcd' => '')),
40
+ array('empty' => array('' => '')),
41
+ array('trim empty' => array(' ' => '')),
42
+ );
43
+ }
44
+
45
+ public function data_ipv6() {
46
+ return array(
47
+ array('good' => array('1:2:3:4:5:6:7:8' => '1:2:3:4:5:6:7:8')),
48
+ array('zerofill' => array('01:002:0003:4:5:6:7:8' => '1:2:3:4:5:6:7:8')),
49
+ array('hex' => array('FFFF:D1D1:3:4:5:6:7:8' => 'ffff:d1d1:3:4:5:6:7:8')),
50
+ // Not a kosher input format, but make it work anyway.
51
+ array('compress mid 1' => array('1:2:3:4::6:7:8' => '1:2:3:4:0:6:7:8')),
52
+ array('compress mid 2' => array('1:2:3::6:7:8' => '1:2:3:0:0:6:7:8')),
53
+ array('compress mid 3' => array('1:2::6:7:8' => '1:2:0:0:0:6:7:8')),
54
+ // Not a kosher input format, but make it work anyway.
55
+ array('compress left 1' => array('::2:3:4:5:6:7:8' => '0:2:3:4:5:6:7:8')),
56
+ array('compress left 2' => array('::3:4:5:6:7:8' => '0:0:3:4:5:6:7:8')),
57
+ array('compress left 3' => array('::4:5:6:7:8' => '0:0:0:4:5:6:7:8')),
58
+ // Not a kosher input format, but make it work anyway.
59
+ array('compress mid 1' => array('1:2:3:4::6:7:8' => '1:2:3:4:0:6:7:8')),
60
+ array('compress right 1' => array('1:2:3:4:5:6:7::' => '1:2:3:4:5:6:7:0')),
61
+ array('compress right 2' => array('1:2:3:4:5:6::' => '1:2:3:4:5:6:0:0')),
62
+ array('compress right 3' => array('1:2:3:4:5::' => '1:2:3:4:5:0:0:0')),
63
+ array('too many 1' => array('1:2:3:4:5:6:7:8:9' => '')),
64
+ array('too few 1' => array('1:2:3:4:5:6:7' => '')),
65
+ array('too few 2' => array('1:2:3:4:5:6' => '')),
66
+ array('too many compressions' => array('1:2:::8' => '')),
67
+ array('empty' => array('' => '')),
68
+ array('unspecified' => array('::' => '')),
69
+ array('text' => array('abcd' => '')),
70
+ array('hextobig' => array('FFFF:D1D1D1:3:4:5:6:7:8' => '')),
71
+ array('inttobig' => array('FFFF:D1D1:3:40000:5:6:7:8' => '')),
72
+ array('embed compat' => array('::1.2.3.4' => '0:0:0:0:0:0:1.2.3.4')),
73
+ array('embed compat filled' => array('0:0:0:0:0:0:1.2.3.4' => '0:0:0:0:0:0:1.2.3.4')),
74
+ array('embed map' => array('::ffff:1.2.3.4' => '0:0:0:0:0:ffff:1.2.3.4')),
75
+ array('embed bad prefix' => array('::acac:1.2.3.4' => '')),
76
+ array('embed map bad 4' => array('::ffff:1.2.666.4' => '')),
77
+ );
78
+ }
79
+
80
+ /**
81
+ * @dataProvider data_ipv4
82
+ */
83
+ public function test_normalize_ipv4($data) {
84
+ list($input, $expect) = each($data);
85
+ $actual = self::$lss->normalize_ipv4($input);
86
+ $this->assertEquals($expect, $actual);
87
+ }
88
+ /**
89
+ * @dataProvider data_ipv6
90
+ */
91
+ public function test_normalize_ipv6($data) {
92
+ list($input, $expect) = each($data);
93
+ $actual = self::$lss->normalize_ipv6($input);
94
+ $this->assertEquals($expect, $actual);
95
+ }
96
+
97
+ /**#@+
98
+ * get_ip()
99
+ */
100
+ /**
101
+ * @dataProvider data_ipv4
102
+ */
103
+ public function test_get_ip__ipv4($data) {
104
+ list($input, $expect) = each($data);
105
+ $_SERVER['REMOTE_ADDR'] = $input;
106
+ $actual = self::$lss->get_ip();
107
+ $this->assertEquals($expect, $actual);
108
+ }
109
+ public function test_get_ip__ipv4_array() {
110
+ $_SERVER['REMOTE_ADDR'] = array('foo');
111
+ $actual = self::$lss->get_ip();
112
+ $this->assertEquals('', $actual);
113
+ }
114
+ /**
115
+ * @dataProvider data_ipv6
116
+ */
117
+ public function test_get_ip__ipv6($data) {
118
+ list($input, $expect) = each($data);
119
+ $_SERVER['REMOTE_ADDR'] = $input;
120
+ $actual = self::$lss->get_ip();
121
+ $this->assertEquals($expect, $actual);
122
+ }
123
+ /**#@- **/
124
+
125
+ /**#@+
126
+ * get_network_ip()
127
+ */
128
+ public function test_get_network_ip__empty() {
129
+ $_SERVER['REMOTE_ADDR'] = '';
130
+ $actual = self::$lss->get_network_ip();
131
+ $this->assertEquals('', $actual);
132
+ }
133
+ public function test_get_network_ip__array_remote() {
134
+ $_SERVER['REMOTE_ADDR'] = array('1.2.3.4');
135
+ $actual = self::$lss->get_network_ip();
136
+ $this->assertEquals('', $actual);
137
+ }
138
+ public function test_get_network_ip__array_param() {
139
+ $actual = self::$lss->get_network_ip(array('1.2.3.4'));
140
+ $this->assertEquals('', $actual);
141
+ }
142
+ public function test_get_network_ip__ipv4() {
143
+ $actual = self::$lss->get_network_ip('1.2.3.4');
144
+ $this->assertEquals('1.2.3', $actual);
145
+ }
146
+ public function test_get_network_ip__ipv4_remote_addr() {
147
+ $_SERVER['REMOTE_ADDR'] = '1.2.3.4';
148
+ $actual = self::$lss->get_network_ip();
149
+ $this->assertEquals('1.2.3', $actual);
150
+ }
151
+ public function test_get_network_ip__ipv6() {
152
+ $actual = self::$lss->get_network_ip('1:2:3:4:5:6:7:8');
153
+ $this->assertEquals('1:2:3:4', $actual);
154
+ }
155
+ public function test_get_network_ip__ipv6_remote_addr() {
156
+ $_SERVER['REMOTE_ADDR'] = '1:2:3:4:5:6:7:8';
157
+ $actual = self::$lss->get_network_ip();
158
+ $this->assertEquals('1:2:3:4', $actual);
159
+ }
160
+ /**#@- **/
161
+ }
tests/LoginErrorsTest.php ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+
3
+ /**
4
+ * Test the login errors filter
5
+ *
6
+ * @package login-security-solution
7
+ * @author Daniel Convissor <danielc@analysisandsolutions.com>
8
+ * @copyright The Analysis and Solutions Company, 2012
9
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPLv2
10
+ */
11
+
12
+ /**
13
+ * Get the class we will use for testing
14
+ */
15
+ require_once dirname(__FILE__) . '/TestCase.php';
16
+
17
+ /**
18
+ * Test the login errors filter
19
+ *
20
+ * @package login-security-solution
21
+ * @author Daniel Convissor <danielc@analysisandsolutions.com>
22
+ * @copyright The Analysis and Solutions Company, 2012
23
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPLv2
24
+ */
25
+ class LoginErrorsTest extends TestCase {
26
+ protected $ip;
27
+ protected $network_ip;
28
+ protected $user_name;
29
+ protected $user_pass;
30
+ protected $pass_md5;
31
+
32
+
33
+ public static function setUpBeforeClass() {
34
+ parent::$db_needed = true;
35
+ parent::set_up_before_class();
36
+ }
37
+
38
+ public function setUp() {
39
+ global $errors;
40
+
41
+ parent::setUp();
42
+
43
+ $this->ip = '1.2.3.4';
44
+ $_SERVER['REMOTE_ADDR'] = $this->ip;
45
+ $this->network_ip = '1.2.3';
46
+
47
+ $this->user_name = 'test';
48
+ $this->user_pass = 'ababab';
49
+ $this->pass_md5 = self::$lss->md5($this->user_pass);
50
+
51
+ $options = self::$lss->options;
52
+ $options['login_fail_minutes'] = 60;
53
+ $options['login_fail_notify'] = 0;
54
+ $options['login_fail_breach_notify'] = 0;
55
+ self::$lss->options = $options;
56
+
57
+ $errors = new WP_Error;
58
+ $_POST['log'] = 'username';
59
+ $_POST['pwd'] = $this->user_pass;
60
+ unset($_REQUEST['action']);
61
+ }
62
+
63
+
64
+ public function test_login_errors__nothing() {
65
+ global $errors, $user_name;
66
+
67
+ $actual = self::$lss->login_errors('input');
68
+ $this->assertEquals('input', $actual, 'Output should not be modified.');
69
+ $this->assertArrayHasKey('log', $_POST, "POST log shouldn't be touched.");
70
+ }
71
+
72
+ public function test_login_errors__register() {
73
+ global $errors, $user_name;
74
+
75
+ $errors->add('invalid_username', 'blargh');
76
+ $_REQUEST['action'] = 'register';
77
+
78
+ $actual = self::$lss->login_errors('input');
79
+ $this->assertEquals('input',
80
+ $actual, 'Output should not be modified.');
81
+ $this->assertArrayHasKey('log', $_POST, "POST log shouldn't be touched.");
82
+ }
83
+
84
+ public function test_login_errors__bad_name() {
85
+ global $errors, $user_name;
86
+
87
+ $errors->add('invalid_username', 'blargh');
88
+ $user_name = $this->user_name;
89
+
90
+ $actual = self::$lss->login_errors('input');
91
+
92
+ $this->assertEquals('Invalid username or password.',
93
+ $actual, 'Output should have been modified.');
94
+ $this->assertArrayNotHasKey('log', $_POST, "POST log should be unset.");
95
+ }
96
+
97
+ public function test_login_errors__bad_pw() {
98
+ global $errors, $user_name;
99
+
100
+ $errors->add('incorrect_password', 'blargh');
101
+ $user_name = $this->user_name;
102
+
103
+ $actual = self::$lss->login_errors('input');
104
+
105
+ $this->assertEquals('Invalid username or password.',
106
+ $actual, 'Output should have been modified.');
107
+ $this->assertArrayNotHasKey('log', $_POST, "POST log should be unset.");
108
+ }
109
+
110
+ public function test_login_errors__reset_bad_email() {
111
+ global $errors, $user_name;
112
+
113
+ $errors->add('invalid_email', 'blargh');
114
+
115
+ $actual = self::$lss->login_errors('input');
116
+
117
+ $this->assertEquals(__('Password reset is not allowed for this user'),
118
+ $actual, 'Output should have been modified.');
119
+ $this->assertArrayHasKey('log', $_POST, "POST log shouldn't be touched.");
120
+ }
121
+
122
+ public function test_login_errors__reset_bad_combo() {
123
+ global $errors, $user_name;
124
+
125
+ $errors->add('invalidcombo', 'blargh');
126
+
127
+ $actual = self::$lss->login_errors('input');
128
+
129
+ $this->assertEquals(__('Password reset is not allowed for this user'),
130
+ $actual, 'Output should have been modified.');
131
+ $this->assertArrayHasKey('log', $_POST, "POST log shouldn't be touched.");
132
+ }
133
+ }
tests/LoginFailTest.php ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+
3
+ /**
4
+ * Test login failure and lockout functionality
5
+ *
6
+ * @package login-security-solution
7
+ * @author Daniel Convissor <danielc@analysisandsolutions.com>
8
+ * @copyright The Analysis and Solutions Company, 2012
9
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPLv2
10
+ */
11
+
12
+ /**
13
+ * Get the class we will use for testing
14
+ */
15
+ require_once dirname(__FILE__) . '/TestCase.php';
16
+
17
+ /**
18
+ * Test login failure and lockout functionality
19
+ *
20
+ * @package login-security-solution
21
+ * @author Daniel Convissor <danielc@analysisandsolutions.com>
22
+ * @copyright The Analysis and Solutions Company, 2012
23
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPLv2
24
+ */
25
+ class LoginFailTest extends TestCase {
26
+ protected $ip;
27
+ protected $network_ip;
28
+ protected $user_name;
29
+ protected $pass_md5;
30
+
31
+
32
+ public static function setUpBeforeClass() {
33
+ parent::$db_needed = true;
34
+ parent::set_up_before_class();
35
+ }
36
+
37
+ public function setUp() {
38
+ parent::setUp();
39
+
40
+ $this->ip = '1.2.3.4';
41
+ $_SERVER['REMOTE_ADDR'] = $this->ip;
42
+ $this->network_ip = '1.2.3';
43
+
44
+ $this->user_name = 'test';
45
+ $this->pass_md5 = 'ababab';
46
+
47
+ $options = self::$lss->options;
48
+ $options['login_fail_minutes'] = 60;
49
+ $options['login_fail_notify'] = 4;
50
+ $options['login_fail_tier_2'] = 3;
51
+ $options['login_fail_tier_3'] = 4;
52
+ $options['login_fail_breach_notify'] = 4;
53
+ $options['login_fail_breach_pw_force_change'] = 4;
54
+ self::$lss->options = $options;
55
+ }
56
+
57
+
58
+ /*
59
+ * LOGIN FAIL
60
+ */
61
+
62
+ public function test_insert_fail() {
63
+ self::$lss->insert_fail($this->ip, $this->user_name, $this->pass_md5);
64
+ $this->check_fail_record($this->ip, $this->user_name, $this->pass_md5);
65
+
66
+ self::$lss->insert_fail($this->ip, $this->user_name, 'other md5');
67
+ $this->check_fail_record($this->ip, $this->user_name, 'other md5');
68
+ }
69
+
70
+ /**
71
+ * @depends test_insert_fail
72
+ */
73
+ public function test_get_login_fail() {
74
+ $expected = array(
75
+ 'total' => '2',
76
+ 'network_ip' => '2',
77
+ 'user_name' => '2',
78
+ 'pass_md5' => '1',
79
+ );
80
+
81
+ $actual = self::$lss->get_login_fail($this->network_ip,
82
+ $this->user_name, $this->pass_md5);
83
+
84
+ $this->assertEquals($expected, $actual);
85
+ }
86
+
87
+ /**
88
+ * @depends test_insert_fail
89
+ */
90
+ public function test_get_login_fail__empty_ip() {
91
+ global $wpdb;
92
+
93
+ $expected = array(
94
+ 'total' => '3',
95
+ 'network_ip' => '1',
96
+ 'user_name' => '3',
97
+ 'pass_md5' => '2',
98
+ );
99
+
100
+ $wpdb->query('SAVEPOINT pre_threshold');
101
+
102
+ self::$lss->insert_fail('', $this->user_name, $this->pass_md5);
103
+ $this->check_fail_record('', $this->user_name, $this->pass_md5);
104
+
105
+ $actual = self::$lss->get_login_fail('',
106
+ $this->user_name, $this->pass_md5);
107
+
108
+ $this->assertEquals($expected, $actual);
109
+
110
+ $wpdb->query('ROLLBACK TO pre_threshold');
111
+ }
112
+
113
+ /*
114
+ * PROCESS LOGIN FAIL
115
+ */
116
+
117
+ /**
118
+ * @depends test_get_login_fail
119
+ */
120
+ public function test_process_login_fail__pre_threshold() {
121
+ global $wpdb;
122
+
123
+ self::$lss->process_login_fail($this->user_name, $this->pass_md5);
124
+
125
+ $this->assertInternalType('integer', $wpdb->insert_id,
126
+ 'This should be an insert id.');
127
+ }
128
+
129
+ public function test_wp_login__null() {
130
+ $actual = self::$lss->wp_login(null, null);
131
+ $this->assertNull($actual, 'Bad return value.');
132
+ }
133
+
134
+ /**
135
+ * @depends test_process_login_fail__pre_threshold
136
+ */
137
+ public function test_wp_login__pre_breach_threshold() {
138
+ $actual = self::$lss->wp_login($this->user_name, $this->user);
139
+ $this->assertSame(1, $actual, 'wp_login() return value...');
140
+
141
+ $actual = self::$lss->get_pw_force_change($this->user->ID);
142
+ $this->assertFalse($actual, 'get_pw_force_change() return value...');
143
+ }
144
+
145
+ /**
146
+ * @depends test_process_login_fail__pre_threshold
147
+ */
148
+ public function test_process_login_fail__post_threshold() {
149
+ self::$mail_file_basename = __METHOD__;
150
+
151
+ try {
152
+ // Do THE deed.
153
+ self::$lss->process_login_fail($this->user_name, $this->pass_md5);
154
+ } catch (Exception $e) {
155
+ $this->fail($e->getMessage());
156
+ }
157
+
158
+ $this->check_mail_file();
159
+ }
160
+
161
+ /**
162
+ * @depends test_process_login_fail__post_threshold
163
+ */
164
+ public function test_wp_login__post_breach_threshold() {
165
+ self::$mail_file_basename = __METHOD__;
166
+
167
+ try {
168
+ // Do THE deed.
169
+ $actual = self::$lss->wp_login($this->user_name, $this->user);
170
+ } catch (Exception $e) {
171
+ $this->fail($e->getMessage());
172
+ }
173
+ $this->assertSame(7, $actual, 'Bad return value.');
174
+
175
+ $actual = self::$lss->get_pw_force_change($this->user->ID);
176
+ $this->assertTrue($actual, 'get_pw_force_change() return value...');
177
+
178
+ $this->check_mail_file();
179
+ }
180
+
181
+ /**
182
+ * @depends test_process_login_fail__post_threshold
183
+ */
184
+ public function test_wp_login__post_breach_threshold_only_notify() {
185
+ $options = self::$lss->options;
186
+ $options['login_fail_breach_pw_force_change'] = 0;
187
+ self::$lss->options = $options;
188
+
189
+ self::$lss->delete_pw_force_change($this->user->ID);
190
+ self::$mail_file_basename = 'LoginFailTest::test_wp_login__post_breach_threshold';
191
+
192
+ try {
193
+ // Do THE deed.
194
+ $actual = self::$lss->wp_login($this->user_name, $this->user);
195
+ } catch (Exception $e) {
196
+ $this->fail($e->getMessage());
197
+ }
198
+ $this->assertSame(5, $actual, 'Bad return value.');
199
+
200
+ $actual = self::$lss->get_pw_force_change($this->user->ID);
201
+ $this->assertFalse($actual, 'get_pw_force_change() return value...');
202
+
203
+ $this->check_mail_file();
204
+ }
205
+
206
+ /**
207
+ * @depends test_process_login_fail__post_threshold
208
+ */
209
+ public function test_wp_login__post_breach_threshold_only_force() {
210
+ $options = self::$lss->options;
211
+ $options['login_fail_breach_notify'] = 0;
212
+ self::$lss->options = $options;
213
+
214
+ self::$lss->delete_pw_force_change($this->user->ID);
215
+
216
+ try {
217
+ // Do THE deed.
218
+ $actual = self::$lss->wp_login($this->user_name, $this->user);
219
+ } catch (Exception $e) {
220
+ $this->fail($e->getMessage());
221
+ }
222
+ $this->assertSame(3, $actual, 'Bad return value.');
223
+
224
+ $actual = self::$lss->get_pw_force_change($this->user->ID);
225
+ $this->assertTrue($actual, 'get_pw_force_change() return value...');
226
+ }
227
+
228
+ /**
229
+ * @depends test_process_login_fail__post_threshold
230
+ */
231
+ public function test_wp_login__post_breach_threshold_no_action() {
232
+ $options = self::$lss->options;
233
+ $options['login_fail_breach_notify'] = 0;
234
+ $options['login_fail_breach_pw_force_change'] = 0;
235
+ self::$lss->options = $options;
236
+
237
+ self::$lss->delete_pw_force_change($this->user->ID);
238
+
239
+ try {
240
+ // Do THE deed.
241
+ $actual = self::$lss->wp_login($this->user_name, $this->user);
242
+ } catch (Exception $e) {
243
+ $this->fail($e->getMessage());
244
+ }
245
+ $this->assertSame(-1, $actual, 'Bad return value.');
246
+
247
+ $actual = self::$lss->get_pw_force_change($this->user->ID);
248
+ $this->assertFalse($actual, 'get_pw_force_change() return value...');
249
+ }
250
+ }
tests/LoginMessageTest.php ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+
3
+ /**
4
+ * Test the login message filter
5
+ *
6
+ * @package login-security-solution
7
+ * @author Daniel Convissor <danielc@analysisandsolutions.com>
8
+ * @copyright The Analysis and Solutions Company, 2012
9
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPLv2
10
+ */
11
+
12
+ /**
13
+ * Get the class we will use for testing
14
+ */
15
+ require_once dirname(__FILE__) . '/TestCase.php';
16
+
17
+ /**
18
+ * Test the login message filter
19
+ *
20
+ * @package login-security-solution
21
+ * @author Daniel Convissor <danielc@analysisandsolutions.com>
22
+ * @copyright The Analysis and Solutions Company, 2012
23
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPLv2
24
+ */
25
+ class LoginMessageTest extends TestCase {
26
+ public static function setUpBeforeClass() {
27
+ parent::$db_needed = false;
28
+ parent::set_up_before_class();
29
+ }
30
+
31
+
32
+ public function ours($ours) {
33
+ return '<p class="login message">'
34
+ . htmlspecialchars($ours) . '</p>';
35
+ }
36
+
37
+ public function test_login_message__unset() {
38
+ unset($_GET[self::$lss->key_login_msg]);
39
+
40
+ $actual = self::$lss->login_message('input');
41
+ $this->assertEquals('input', $actual, 'Output should not be modified.');
42
+ }
43
+
44
+ public function test_login_message__empty() {
45
+ $_GET[self::$lss->key_login_msg] = '';
46
+
47
+ $actual = self::$lss->login_message('input');
48
+ $this->assertEquals('input', $actual, 'Output should not be modified.');
49
+ }
50
+
51
+ public function test_login_message__bogus() {
52
+ $_GET[self::$lss->key_login_msg] = 'ajkdslfasjdlfaskjdl';
53
+
54
+ $actual = self::$lss->login_message('input');
55
+ $this->assertEquals('input', $actual, 'Output should not be modified.');
56
+ }
57
+
58
+ public function test_login_message__idle() {
59
+ $_GET[self::$lss->key_login_msg] = 'idle';
60
+
61
+ $ours = sprintf(__('It has been over %d minutes since your last action.', self::ID), self::$lss->options['idle_timeout']);
62
+ $ours .= ' ' . __('Please log back in.', self::ID);
63
+
64
+ $actual = self::$lss->login_message('input');
65
+ $this->assertEquals('input' . $this->ours($ours), $actual,
66
+ 'Output should have been modified.');
67
+ }
68
+
69
+ public function test_login_message__pw_expired() {
70
+ $_GET[self::$lss->key_login_msg] = 'pw_expired';
71
+
72
+ $ours = __('The grace period for changing your password has expired.', self::ID);
73
+ $ours .= ' ' . __('Please submit this form to reset your password.', self::ID);
74
+
75
+ $actual = self::$lss->login_message('input');
76
+ $this->assertEquals('input' . $this->ours($ours), $actual,
77
+ 'Output should have been modified.');
78
+ }
79
+
80
+ public function test_login_message__pw_force() {
81
+ $_GET[self::$lss->key_login_msg] = 'pw_force';
82
+
83
+ $ours = __('Your password must be reset.', self::ID);
84
+ $ours .= ' ' . __('Please submit this form to reset it.', self::ID);
85
+
86
+ $actual = self::$lss->login_message('input');
87
+ $this->assertEquals('input' . $this->ours($ours), $actual,
88
+ 'Output should have been modified.');
89
+ }
90
+
91
+ public function test_login_message__pw_grace() {
92
+ $_GET[self::$lss->key_login_msg] = 'pw_grace';
93
+
94
+ $ours = __('Your password has expired. Please log and change it.', self::ID);
95
+ $ours .= ' ' . sprintf(__('We provide a %d minute grace period to do so.', self::ID), self::$lss->options['pw_change_grace_period_minutes']);
96
+
97
+ $actual = self::$lss->login_message('input');
98
+ $this->assertEquals('input' . $this->ours($ours), $actual,
99
+ 'Output should have been modified.');
100
+ }
101
+
102
+ public function test_login_message__disable_logins__no_key() {
103
+ $_GET[self::$lss->key_login_msg] = '';
104
+
105
+ $options = self::$lss->options;
106
+ $options['disable_logins'] = 1;
107
+ self::$lss->options = $options;
108
+
109
+ $ours = __('The site is undergoing maintenance.', self::ID);
110
+ $ours .= ' ' . __('Please try again later.', self::ID);
111
+
112
+ $actual = self::$lss->login_message('input');
113
+ $this->assertEquals('input' . $this->ours($ours), $actual,
114
+ 'Output should have been modified.');
115
+ }
116
+
117
+ public function test_login_message__disable_logins__key() {
118
+ $_GET[self::$lss->key_login_msg] = 'pw_grace';
119
+
120
+ $options = self::$lss->options;
121
+ $options['disable_logins'] = 1;
122
+ self::$lss->options = $options;
123
+
124
+ $ours = __('The site is undergoing maintenance.', self::ID);
125
+ $ours .= ' ' . __('Please try again later.', self::ID);
126
+
127
+ $actual = self::$lss->login_message('input');
128
+ $this->assertEquals('input' . $this->ours($ours), $actual,
129
+ 'Output should have been modified.');
130
+ }
131
+ }
tests/PasswordChangeTest.php ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+
3
+ /**
4
+ * Test the behaviors when passwords are changed
5
+ *
6
+ * @package login-security-solution
7
+ * @author Daniel Convissor <danielc@analysisandsolutions.com>
8
+ * @copyright The Analysis and Solutions Company, 2012
9
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPLv2
10
+ */
11
+
12
+ /**
13
+ * Get the class we will use for testing
14
+ */
15
+ require_once dirname(__FILE__) . '/TestCase.php';
16
+
17
+ /**
18
+ * Test the behaviors when passwords are changed
19
+ *
20
+ * @package login-security-solution
21
+ * @author Daniel Convissor <danielc@analysisandsolutions.com>
22
+ * @copyright The Analysis and Solutions Company, 2012
23
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPLv2
24
+ */
25
+ class PasswordChangeTest extends TestCase {
26
+ protected static $pass_1;
27
+ protected static $pass_2;
28
+ protected static $hash_1;
29
+ protected static $hash_2;
30
+
31
+
32
+ public static function setUpBeforeClass() {
33
+ parent::$db_needed = true;
34
+ parent::set_up_before_class();
35
+
36
+ self::$pass_1 = self::USER_PASS;
37
+ self::$pass_2 = '!AJd81aasjk2@';
38
+ self::$hash_1 = wp_hash_password(self::$pass_1);
39
+ self::$hash_2 = wp_hash_password(self::$pass_2);
40
+ }
41
+
42
+ public function setUp() {
43
+ parent::setUp();
44
+
45
+ $options = self::$lss->options;
46
+ $options['pw_change_days'] = 10;
47
+ $options['pw_length'] = 8;
48
+ $options['pw_reuse_count'] = 3;
49
+ self::$lss->options = $options;
50
+
51
+ self::$lss->set_pw_force_change($this->user->ID);
52
+ $actual = self::$lss->get_pw_force_change($this->user->ID);
53
+ $this->assertTrue($actual, 'Problem setting up force change.');
54
+
55
+ self::$lss->set_pw_grace_period($this->user->ID);
56
+ $actual = self::$lss->get_pw_grace_period($this->user->ID);
57
+ $this->assertGreaterThan(0, $actual, 'Problem setting up grace period.');
58
+ }
59
+
60
+
61
+ protected function ensure_grace_and_force_are_empty() {
62
+ $actual = self::$lss->get_pw_force_change($this->user->ID);
63
+ $this->assertFalse($actual, 'Force change should be false.');
64
+
65
+ $actual = self::$lss->get_pw_grace_period($this->user->ID);
66
+ $this->assertSame(0, $actual, 'Grace period should be 0.');
67
+ }
68
+
69
+ protected function ensure_grace_and_force_are_populated() {
70
+ $actual = self::$lss->get_pw_force_change($this->user->ID);
71
+ $this->assertTrue($actual, 'Force change should not be cleared.');
72
+
73
+ $actual = self::$lss->get_pw_grace_period($this->user->ID);
74
+ $this->assertGreaterThan(0, $actual, 'Grace period should not be cleared.');
75
+ }
76
+
77
+
78
+ /*
79
+ * HASHES / REUSED
80
+ */
81
+
82
+ public function test_get_pw_hashes__empty() {
83
+ global $wpdb;
84
+
85
+ $wpdb->query('SAVEPOINT empty');
86
+
87
+ $actual = self::$lss->get_pw_hashes($this->user->ID);
88
+ $this->assertSame(array(), $actual);
89
+ }
90
+
91
+ public function test_save_pw_hash__non_array_edge_case() {
92
+ update_user_meta($this->user->ID, self::$lss->umk_hashes, 'foo');
93
+
94
+ $actual = self::$lss->get_pw_hashes($this->user->ID);
95
+ $this->assertEquals(array('foo'), $actual);
96
+
97
+ delete_user_meta($this->user->ID, self::$lss->umk_hashes);
98
+ }
99
+
100
+ public function test_is_pw_reused__no_reuse_count() {
101
+ $options = self::$lss->options;
102
+ $options['pw_reuse_count'] = 0;
103
+ self::$lss->options = $options;
104
+
105
+ $actual = self::$lss->is_pw_reused('abc', $this->user->ID);
106
+ $this->assertNull($actual);
107
+ }
108
+
109
+ public function test_is_pw_reused__empty() {
110
+ $actual = self::$lss->is_pw_reused('abc', $this->user->ID);
111
+ $this->assertSame(0, $actual);
112
+ }
113
+
114
+ public function test_save_pw_hash__new() {
115
+ $actual = self::$lss->save_pw_hash($this->user->ID, self::$hash_1);
116
+ $this->assertTrue($actual);
117
+ }
118
+
119
+ public function test_save_pw_hash__exists() {
120
+ $actual = self::$lss->save_pw_hash($this->user->ID, self::$hash_1);
121
+ $this->assertSame(1, $actual);
122
+ }
123
+
124
+ public function test_get_pw_hashes__onehash() {
125
+ $actual = self::$lss->get_pw_hashes($this->user->ID);
126
+ $this->assertEquals(array(self::$hash_1), $actual);
127
+ }
128
+
129
+ public function test_is_pw_reused__yes() {
130
+ $actual = self::$lss->is_pw_reused(self::$pass_1, $this->user->ID);
131
+ $this->assertTrue($actual);
132
+ }
133
+
134
+ public function test_is_pw_reused__no() {
135
+ $actual = self::$lss->is_pw_reused(self::$pass_2, $this->user->ID);
136
+ $this->assertFalse($actual);
137
+ }
138
+
139
+ public function test_save_pw_hash__overflow() {
140
+ global $wpdb;
141
+
142
+ self::$lss->save_pw_hash($this->user->ID, 'new1');
143
+ self::$lss->save_pw_hash($this->user->ID, 'new2');
144
+ self::$lss->save_pw_hash($this->user->ID, 'new3');
145
+
146
+ $expected = array('new1', 'new2', 'new3');
147
+ $actual = self::$lss->get_pw_hashes($this->user->ID);
148
+ $this->assertEquals($expected, $actual);
149
+
150
+ $wpdb->query('ROLLBACK TO empty');
151
+ }
152
+
153
+ /*
154
+ * RESET
155
+ */
156
+
157
+ public function test_password_reset__nullid() {
158
+ $this->user->ID = null;
159
+ $actual = self::$lss->password_reset($this->user, self::$pass_2);
160
+ $this->assertFalse($actual);
161
+ }
162
+
163
+ public function test_password_reset__options_0() {
164
+ $options = self::$lss->options;
165
+ $options['pw_change_days'] = 0; // Don't set change time.
166
+ $options['pw_reuse_count'] = 0; // Don't save hashes.
167
+ self::$lss->options = $options;
168
+
169
+ // Do the deed.
170
+ $actual = self::$lss->password_reset($this->user, self::$pass_1);
171
+ $this->assertNull($actual, 'password_reset() should return null.');
172
+
173
+ // Check the outcome...
174
+ $actual = self::$lss->get_pw_changed_time($this->user->ID);
175
+ $this->assertSame(0, $actual, 'Changed time should be 0.');
176
+
177
+ $actual = self::$lss->get_pw_hashes($this->user->ID);
178
+ $this->assertSame(array(), $actual, 'Hashes should be empty.');
179
+
180
+ $this->ensure_grace_and_force_are_empty();
181
+ }
182
+
183
+ public function test_password_reset__normal() {
184
+ global $wpdb;
185
+
186
+ $actual = self::$lss->password_reset($this->user, self::$pass_1);
187
+ $this->assertNull($actual, 'password_reset() should return null.');
188
+
189
+ // Check the outcome.
190
+ $actual = self::$lss->get_pw_changed_time($this->user->ID);
191
+ $this->assertGreaterThan(0, $actual, 'Changed time should be 0.');
192
+
193
+ $actual = self::$lss->is_pw_reused(self::$pass_1, $this->user->ID);
194
+ $this->assertTrue($actual, 'Password should show up as reused');
195
+
196
+ $this->ensure_grace_and_force_are_empty();
197
+
198
+ $wpdb->query('ROLLBACK TO empty');
199
+ }
200
+
201
+ /*
202
+ * PROFILE UPDATE
203
+ */
204
+
205
+ /**
206
+ * @depends test_password_reset__normal
207
+ */
208
+ public function test_profile_update__no_pass() {
209
+ $errors = new WP_Error;
210
+ $this->user->user_pass = null;
211
+ $actual = self::$lss->user_profile_update_errors($errors, 1, $this->user);
212
+ $this->assertNull($actual);
213
+ }
214
+
215
+ /**
216
+ * @depends test_password_reset__normal
217
+ */
218
+ public function test_profile_update__update_no_id() {
219
+ $this->user->ID = null;
220
+
221
+ $errors = new WP_Error;
222
+ $actual = self::$lss->user_profile_update_errors($errors, 1, $this->user);
223
+ $this->assertNull($actual);
224
+ }
225
+
226
+ /**
227
+ * @depends test_password_reset__normal
228
+ */
229
+ public function test_profile_update__reused() {
230
+ global $wpdb;
231
+ self::$lss->save_pw_hash($this->user->ID, self::$hash_1);
232
+
233
+ $errors = new WP_Error;
234
+ $actual = self::$lss->user_profile_update_errors($errors, 1, $this->user);
235
+ $this->assertFalse($actual, 'Bad return value.');
236
+ $this->assertEquals(
237
+ "<strong>ERROR</strong>: Passwords can not be reused.",
238
+ $errors->get_error_message()
239
+ );
240
+
241
+ $wpdb->query('ROLLBACK TO empty');
242
+ }
243
+
244
+ /**
245
+ * @depends test_password_reset__normal
246
+ */
247
+ public function test_profile_update__add_reused_okay() {
248
+ global $wpdb;
249
+ self::$lss->save_pw_hash($this->user->ID, self::$hash_1);
250
+
251
+ $errors = new WP_Error;
252
+ $actual = self::$lss->user_profile_update_errors($errors, 0, $this->user);
253
+ $this->assertTrue($actual, 'Bad return value.');
254
+
255
+ $this->ensure_grace_and_force_are_empty();
256
+
257
+ $wpdb->query('ROLLBACK TO empty');
258
+ }
259
+
260
+ /**
261
+ * @depends test_password_reset__normal
262
+ */
263
+ public function test_profile_update__short() {
264
+ $this->user->user_pass = 'aA1!';
265
+
266
+ $errors = new WP_Error;
267
+ $actual = self::$lss->user_profile_update_errors($errors, 0, $this->user);
268
+ $this->assertFalse($actual, 'Bad return value.');
269
+ $this->assertEquals(
270
+ "<strong>ERROR</strong>: Password is too short.",
271
+ $errors->get_error_message()
272
+ );
273
+
274
+ $this->ensure_grace_and_force_are_populated();
275
+ }
276
+
277
+ /**
278
+ * @depends test_password_reset__normal
279
+ */
280
+ public function test_profile_update__add() {
281
+ $tmp_id = $this->user->ID;
282
+ $this->user->ID = null;
283
+
284
+ $errors = new WP_Error;
285
+ $actual = self::$lss->user_profile_update_errors($errors, 0, $this->user);
286
+ $this->assertTrue($actual, 'Bad return value.');
287
+
288
+ $this->user->ID = $tmp_id;
289
+ $this->ensure_grace_and_force_are_populated();
290
+ }
291
+ }
tests/PasswordExpirationTest.php ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+
3
+ /**
4
+ * Test the password expiration functionality
5
+ *
6
+ * @package login-security-solution
7
+ * @author Daniel Convissor <danielc@analysisandsolutions.com>
8
+ * @copyright The Analysis and Solutions Company, 2012
9
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPLv2
10
+ */
11
+
12
+ /**
13
+ * Get the class we will use for testing
14
+ */
15
+ require_once dirname(__FILE__) . '/TestCase.php';
16
+
17
+ /**
18
+ * Test the password expiration functionality
19
+ *
20
+ * @package login-security-solution
21
+ * @author Daniel Convissor <danielc@analysisandsolutions.com>
22
+ * @copyright The Analysis and Solutions Company, 2012
23
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPLv2
24
+ */
25
+ class PasswordExpirationTest extends TestCase {
26
+ public static function setUpBeforeClass() {
27
+ parent::$db_needed = true;
28
+ parent::set_up_before_class();
29
+ }
30
+
31
+ public function setUp() {
32
+ parent::setUp();
33
+
34
+ $options = self::$lss->options;
35
+ $options['pw_change_days'] = 10;
36
+ $options['pw_change_grace_period_minutes'] = 10;
37
+ self::$lss->options = $options;
38
+ }
39
+
40
+
41
+ /*
42
+ * CHANGED TIME CRUD
43
+ */
44
+
45
+ public function test_get_pw_changed_time__0() {
46
+ $actual = self::$lss->get_pw_changed_time($this->user->ID);
47
+ $this->assertSame(0, $actual);
48
+ }
49
+
50
+ public function test_set_pw_changed_time__add() {
51
+ global $wpdb;
52
+ $wpdb->query('SAVEPOINT no_pw_change_time');
53
+
54
+ $actual = self::$lss->set_pw_changed_time($this->user->ID);
55
+ $this->assertInternalType('integer', $actual, 'Bad return value.');
56
+ }
57
+
58
+ /**
59
+ * @depends test_set_pw_changed_time__add
60
+ */
61
+ public function test_set_pw_changed_time__update() {
62
+ sleep(1);
63
+ $actual = self::$lss->set_pw_changed_time($this->user->ID);
64
+ $this->assertTrue($actual, 'Bad return value.');
65
+ }
66
+
67
+ /**
68
+ * @depends test_set_pw_changed_time__update
69
+ */
70
+ public function test_get_pw_changed_time__something() {
71
+ global $wpdb;
72
+
73
+ $actual = self::$lss->get_pw_changed_time($this->user->ID);
74
+ $diff = (time() - $actual) < 1;
75
+ $this->assertGreaterThanOrEqual(0, $diff, 'Time was too long ago.');
76
+ $this->assertLessThanOrEqual(1, $diff, 'Time was in the future.');
77
+
78
+ $wpdb->query('ROLLBACK TO no_pw_change_time');
79
+ }
80
+
81
+ /*
82
+ * GRACE PERIOD CRUD
83
+ */
84
+
85
+ public function test_get_pw_grace_period__0() {
86
+ $actual = self::$lss->get_pw_grace_period($this->user->ID);
87
+ $this->assertSame(0, $actual);
88
+ }
89
+
90
+ public function test_set_pw_grace_period__add() {
91
+ $actual = self::$lss->set_pw_grace_period($this->user->ID);
92
+ $this->assertInternalType('integer', $actual, 'Bad return value.');
93
+ }
94
+
95
+ /**
96
+ * @depends test_set_pw_grace_period__add
97
+ */
98
+ public function test_set_pw_grace_period__update() {
99
+ sleep(1);
100
+ $actual = self::$lss->set_pw_grace_period($this->user->ID);
101
+ $this->assertTrue($actual, 'Bad return value.');
102
+ }
103
+
104
+ /**
105
+ * @depends test_set_pw_grace_period__update
106
+ */
107
+ public function test_get_pw_grace_period__something() {
108
+ $actual = self::$lss->get_pw_grace_period($this->user->ID);
109
+ $diff = (time() - $actual) < 1;
110
+ $this->assertGreaterThanOrEqual(0, $diff, 'Time was too long ago.');
111
+ $this->assertLessThanOrEqual(1, $diff, 'Time was in the future.');
112
+ }
113
+
114
+ /**
115
+ * @depends test_set_pw_grace_period__add
116
+ */
117
+ public function test_delete_pw_grace_period() {
118
+ $actual = self::$lss->delete_pw_grace_period($this->user->ID);
119
+ $this->assertTrue($actual, 'Bad return value.');
120
+ }
121
+
122
+ /*
123
+ * IS EXPIRED
124
+ */
125
+
126
+ public function test_is_pw_expired__disabled() {
127
+ $options = self::$lss->options;
128
+ $options['pw_change_days'] = 0;
129
+ self::$lss->options = $options;
130
+
131
+ $actual = self::$lss->is_pw_expired($this->user->ID);
132
+ $this->assertNull($actual, 'Bad return value.');
133
+ }
134
+
135
+ /**
136
+ * @depends test_set_pw_changed_time__add
137
+ * @depends test_get_pw_changed_time__something
138
+ */
139
+ public function test_is_pw_expired__new() {
140
+ $actual = self::$lss->is_pw_expired($this->user->ID);
141
+ $this->assertSame(0, $actual, 'Bad return value.');
142
+ }
143
+
144
+ /**
145
+ * @depends test_is_pw_expired__new
146
+ */
147
+ public function test_is_pw_expired__not_expired() {
148
+ $actual = self::$lss->is_pw_expired($this->user->ID);
149
+ $this->assertFalse($actual, 'Bad return value.');
150
+ }
151
+
152
+ /**
153
+ * @depends test_is_pw_expired__new
154
+ */
155
+ public function test_is_pw_expired__expired() {
156
+ $options = self::$lss->options;
157
+ $options['pw_change_days'] = -1;
158
+ self::$lss->options = $options;
159
+
160
+ $actual = self::$lss->is_pw_expired($this->user->ID);
161
+ $this->assertTrue($actual, 'Bad return value.');
162
+ }
163
+
164
+ /*
165
+ * CHECK GRACE PERIOD
166
+ */
167
+
168
+ /**
169
+ * @depends test_delete_pw_grace_period
170
+ */
171
+ public function test_check_pw_grace_period__unset() {
172
+ $actual = self::$lss->check_pw_grace_period($this->user->ID);
173
+ $this->assertTrue($actual, 'Bad return value.');
174
+ }
175
+
176
+ /**
177
+ * @depends test_check_pw_grace_period__unset
178
+ */
179
+ public function test_check_pw_grace_period__in_effect() {
180
+ $actual = self::$lss->check_pw_grace_period($this->user->ID);
181
+ $expect = self::$lss->options['pw_change_grace_period_minutes'] * 60;
182
+ $this->assertSame($expect, $actual, 'Bad return value.');
183
+ }
184
+
185
+ /**
186
+ * @depends test_check_pw_grace_period__in_effect
187
+ */
188
+ public function test_check_pw_grace_period__expired() {
189
+ $options = self::$lss->options;
190
+ $options['pw_change_grace_period_minutes'] = -1;
191
+ self::$lss->options = $options;
192
+
193
+ $actual = self::$lss->check_pw_grace_period($this->user->ID);
194
+ $this->assertFalse($actual, 'Bad return value.');
195
+ }
196
+
197
+ public function test_delete_pw_grace_period__cleanup() {
198
+ $actual = self::$lss->delete_pw_grace_period($this->user->ID);
199
+ $this->assertTrue($actual, 'Bad return value.');
200
+ }
201
+
202
+ /*
203
+ * CHECK STATUS
204
+ */
205
+
206
+ public function test_check__empty_user() {
207
+ $actual = self::$lss->check(array(), null);
208
+ $this->assertFalse($actual, 'Bad return value.');
209
+ }
210
+
211
+ public function test_check__pre_expiration() {
212
+ $actual = self::$lss->check(array(), $this->user);
213
+ $this->assertTrue($actual);
214
+ }
215
+
216
+ /**
217
+ * @depends test_get_pw_changed_time__something
218
+ */
219
+ public function test_check__post_expiration_first() {
220
+ $options = self::$lss->options;
221
+ $options['pw_change_days'] = -1;
222
+ self::$lss->options = $options;
223
+
224
+ $expected_error = 'Cannot modify header information';
225
+ $this->expected_errors($expected_error);
226
+ self::$location_expected = get_option('siteurl')
227
+ . '/wp-login.php?action=login&'
228
+ . self::$lss->key_login_msg . '=pw_grace';
229
+
230
+ $actual = self::$lss->check(array(), $this->user);
231
+
232
+ $this->assertTrue($this->were_expected_errors_found(),
233
+ "Expected error not found: '$expected_error'");
234
+ $this->assertEquals(self::$location_expected, self::$location_actual,
235
+ 'wp_redirect() produced unexpected location header.');
236
+
237
+ $this->assertSame(-1, $actual, 'Bad return value.');
238
+ }
239
+
240
+ /**
241
+ * @depends test_get_pw_changed_time__something
242
+ */
243
+ public function test_check__post_expiration_grace_expired() {
244
+ $options = self::$lss->options;
245
+ $options['pw_change_days'] = -1;
246
+ $options['pw_change_grace_period_minutes'] = -1;
247
+ self::$lss->options = $options;
248
+
249
+ $expected_error = 'Cannot modify header information';
250
+ $this->expected_errors($expected_error);
251
+ self::$location_expected = get_option('siteurl')
252
+ . '/wp-login.php?action=retrievepassword&'
253
+ . self::$lss->key_login_msg . '=pw_expired';
254
+
255
+ $actual = self::$lss->check(array(), $this->user);
256
+
257
+ $this->assertTrue($this->were_expected_errors_found(),
258
+ "Expected error not found: '$expected_error'");
259
+ $this->assertEquals(self::$location_expected, self::$location_actual,
260
+ 'wp_redirect() produced unexpected location header.');
261
+
262
+ $this->assertSame(-2, $actual, 'Bad return value.');
263
+ }
264
+
265
+ public function test_redirect_to_login__other() {
266
+ $_SERVER['REQUEST_URI'] = '/some/page';
267
+
268
+ $expected_error = 'Cannot modify header information';
269
+ $this->expected_errors($expected_error);
270
+ self::$location_expected = get_option('siteurl')
271
+ . '/wp-login.php?redirect_to=%2Fsome%2Fpage&action=acti%26n&'
272
+ . self::$lss->key_login_msg . '=me%26g';
273
+
274
+ self::$lss->redirect_to_login('me&g', true, 'acti&n');
275
+
276
+ $this->assertTrue($this->were_expected_errors_found(),
277
+ "Expected error not found: '$expected_error'");
278
+ $this->assertEquals(self::$location_expected, self::$location_actual,
279
+ 'wp_redirect() produced unexpected location header.');
280
+ }
281
+ }
tests/PasswordForceChangeTest.php ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+
3
+ /**
4
+ * Test the force password change functionality
5
+ *
6
+ * @package login-security-solution
7
+ * @author Daniel Convissor <danielc@analysisandsolutions.com>
8
+ * @copyright The Analysis and Solutions Company, 2012
9
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPLv2
10
+ */
11
+
12
+ /**
13
+ * Get the class we will use for testing
14
+ */
15
+ require_once dirname(__FILE__) . '/TestCase.php';
16
+
17
+ /**
18
+ * Test the force password change functionality
19
+ *
20
+ * @package login-security-solution
21
+ * @author Daniel Convissor <danielc@analysisandsolutions.com>
22
+ * @copyright The Analysis and Solutions Company, 2012
23
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPLv2
24
+ */
25
+ class PasswordForceChangeTest extends TestCase {
26
+ public static function setUpBeforeClass() {
27
+ parent::$db_needed = true;
28
+ parent::set_up_before_class();
29
+ }
30
+
31
+
32
+ public function test_get_pw_force_change__false_1() {
33
+ $actual = self::$lss->get_pw_force_change($this->user->ID);
34
+ $this->assertFalse($actual);
35
+ }
36
+
37
+ public function test_check__pre_threshold() {
38
+ $actual = self::$lss->check(array(), $this->user);
39
+ $this->assertTrue($actual);
40
+ }
41
+
42
+ public function test_set_pw_force_change__add() {
43
+ $actual = self::$lss->set_pw_force_change($this->user->ID);
44
+ $this->assertInternalType('integer', $actual, 'Bad return value.');
45
+ }
46
+
47
+ /**
48
+ * @depends test_set_pw_force_change__add
49
+ */
50
+ public function test_get_pw_force_change__true() {
51
+ $actual = self::$lss->get_pw_force_change($this->user->ID);
52
+ $this->assertTrue($actual);
53
+ }
54
+
55
+ /**
56
+ * @depends test_get_pw_force_change__true
57
+ */
58
+ public function test_check__post_threshold() {
59
+ $expected_error = 'Cannot modify header information';
60
+ $this->expected_errors($expected_error);
61
+ self::$location_expected = get_option('siteurl')
62
+ . '/wp-login.php?action=retrievepassword&'
63
+ . self::$lss->key_login_msg . '=pw_force';
64
+
65
+ $actual = self::$lss->check(array(), $this->user);
66
+
67
+ $this->assertTrue($this->were_expected_errors_found(),
68
+ "Expected error not found: '$expected_error'");
69
+ $this->assertEquals(self::$location_expected, self::$location_actual,
70
+ 'wp_redirect() produced unexpected location header.');
71
+
72
+ $this->assertSame(-3, $actual, 'Bad return value.');
73
+ }
74
+
75
+ /**
76
+ * @depends test_get_pw_force_change__true
77
+ */
78
+ public function test_delete_pw_force_change() {
79
+ $actual = self::$lss->delete_pw_force_change($this->user->ID);
80
+ $this->assertTrue($actual);
81
+ }
82
+
83
+ /**
84
+ * @depends test_delete_pw_force_change
85
+ */
86
+ public function test_get_pw_force_change__false_2() {
87
+ $actual = self::$lss->get_pw_force_change($this->user->ID);
88
+ $this->assertFalse($actual);
89
+ }
90
+ }
tests/PasswordValidationTest.php ADDED
@@ -0,0 +1,660 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+
3
+ /**
4
+ * Test the password validation functionality
5
+ *
6
+ * @package login-security-solution
7
+ * @author Daniel Convissor <danielc@analysisandsolutions.com>
8
+ * @copyright The Analysis and Solutions Company, 2012
9
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPLv2
10
+ */
11
+
12
+ /**
13
+ * Get the class we will use for testing
14
+ */
15
+ require_once dirname(__FILE__) . '/TestCase.php';
16
+
17
+ /**
18
+ * Test the password validation functionality
19
+ *
20
+ * @package login-security-solution
21
+ * @author Daniel Convissor <danielc@analysisandsolutions.com>
22
+ * @copyright The Analysis and Solutions Company, 2012
23
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPLv2
24
+ */
25
+ class PasswordValidationTest extends TestCase {
26
+ protected static $dict_available;
27
+ protected static $grep_available;
28
+ protected static $mbstring_available;
29
+
30
+
31
+ public static function setUpBeforeClass() {
32
+ parent::$db_needed = false;
33
+ parent::set_up_before_class();
34
+
35
+ if (self::$lss->is_pw_dict_program('zygote')) {
36
+ self::$dict_available = true;
37
+ }
38
+ if (self::$lss->is_pw_dictionary__grep('Pa$$w0rd1')) {
39
+ self::$grep_available = true;
40
+ }
41
+ self::$mbstring_available = extension_loaded('mbstring');
42
+ }
43
+
44
+ public function setUp() {
45
+ parent::setUp();
46
+ self::$lss->available_mbstring = extension_loaded('mbstring');
47
+
48
+ $options = self::$lss->options;
49
+ $options['pw_complexity_exemption_length'] = 20;
50
+ $options['pw_length'] = 8;
51
+ self::$lss->options = $options;
52
+
53
+ self::$lss->available_dict = self::$dict_available;
54
+ self::$lss->available_grep = self::$grep_available;
55
+ self::$lss->available_mbstring = self::$mbstring_available;
56
+ }
57
+
58
+
59
+ public function test_is_pw_dictionary__grepavail() {
60
+ if (!self::$dict_available) {
61
+ $this->markTestSkipped('grep not available');
62
+ }
63
+ self::$lss->available_grep = true;
64
+ $actual = self::$lss->is_pw_dictionary('Pa$$w0rd1');
65
+ $this->assertTrue($actual);
66
+ }
67
+
68
+ public function test_is_pw_dictionary__grepunavail() {
69
+ self::$lss->available_grep = false;
70
+ $actual = self::$lss->is_pw_dictionary('Pa$$w0rd1');
71
+ $this->assertTrue($actual);
72
+ }
73
+
74
+
75
+ public function test_dict_program__unavailable() {
76
+ self::$lss->available_dict = false;
77
+ $actual = self::$lss->is_pw_dict_program('foo');
78
+ $this->assertNull($actual);
79
+ }
80
+
81
+ public function test_dict_program_false() {
82
+ if (!self::$dict_available) {
83
+ $this->markTestSkipped('dict is not available');
84
+ }
85
+ $tests = array(
86
+ "thiscannotbeaword",
87
+ "简化字的昨天今天和明天",
88
+ );
89
+ foreach ($tests as $pw) {
90
+ $actual = self::$lss->is_pw_dict_program($pw);
91
+ $this->assertFalse($actual, "Should have passed: '$pw'");
92
+ }
93
+ }
94
+ public function test_dict_program_true() {
95
+ if (!self::$dict_available) {
96
+ $this->markTestSkipped('dict is not available');
97
+ }
98
+ $tests = array(
99
+ "password",
100
+ );
101
+ foreach ($tests as $pw) {
102
+ $actual = self::$lss->is_pw_dict_program($pw);
103
+ $this->assertTrue($actual, "Should have failed: '$pw'");
104
+ }
105
+ }
106
+
107
+ public function test_dictionary__grep_unavailable() {
108
+ self::$lss->available_grep = false;
109
+ $actual = self::$lss->is_pw_dictionary__grep('foo');
110
+ $this->assertNull($actual);
111
+ }
112
+
113
+ public function test_dictionary__file_false() {
114
+ if (!self::$grep_available) {
115
+ $this->markTestSkipped('grep is not available');
116
+ }
117
+ $tests = array(
118
+ "thiscannotbeaword",
119
+ "化字的昨天今天和明",
120
+ );
121
+ foreach ($tests as $pw) {
122
+ $actual = self::$lss->is_pw_dictionary__file($pw);
123
+ $this->assertFalse($actual, "Should have passed: '$pw'");
124
+ }
125
+ }
126
+ public function test_dictionary__file_true() {
127
+ if (!self::$grep_available) {
128
+ $this->markTestSkipped('grep is not available');
129
+ }
130
+ $tests = array(
131
+ 'Pa$$w0rd1',
132
+ "简化字的昨天今天和明天",
133
+ );
134
+ foreach ($tests as $pw) {
135
+ $actual = self::$lss->is_pw_dictionary__file($pw);
136
+ $this->assertTrue($actual, "Should have failed: '$pw'");
137
+ }
138
+ }
139
+
140
+ public function test_dictionary__grep_false() {
141
+ if (!self::$grep_available) {
142
+ $this->markTestSkipped('grep is not available');
143
+ }
144
+ $tests = array(
145
+ "thiscannotbeaword",
146
+ "化字的昨天今天和明",
147
+ );
148
+ foreach ($tests as $pw) {
149
+ $actual = self::$lss->is_pw_dictionary__grep($pw);
150
+ $this->assertFalse($actual, "Should have passed: '$pw'");
151
+ }
152
+ }
153
+ public function test_dictionary__grep_true() {
154
+ if (!self::$grep_available) {
155
+ $this->markTestSkipped('grep is not available');
156
+ }
157
+ $tests = array(
158
+ 'Pa$$w0rd1',
159
+ "简化字的昨天今天和明天",
160
+ );
161
+ foreach ($tests as $pw) {
162
+ $actual = self::$lss->is_pw_dictionary__grep($pw);
163
+ $this->assertTrue($actual, "Should have failed: '$pw'");
164
+ }
165
+ }
166
+
167
+ public function test_like_bloginfo_false() {
168
+ $tests = array(
169
+ "zzzzzzzzz",
170
+ "简化字的昨天今天和明天",
171
+ );
172
+ foreach ($tests as $pw) {
173
+ $actual = self::$lss->is_pw_like_bloginfo($pw);
174
+ $this->assertFalse($actual, "Should have passed: '$pw'");
175
+ }
176
+ }
177
+ public function test_like_bloginfo_true() {
178
+ $tests = array(
179
+ get_bloginfo('name'),
180
+ 'othertextwith' . get_bloginfo('name'),
181
+ get_bloginfo('url'),
182
+ 'othertextwith' . get_bloginfo('url'),
183
+ get_bloginfo('description'),
184
+ 'othertextwith' . get_bloginfo('description'),
185
+ );
186
+ foreach ($tests as $pw) {
187
+ $actual = self::$lss->is_pw_like_bloginfo($pw);
188
+ $this->assertTrue($actual, "Should have failed: '$pw'");
189
+ }
190
+ }
191
+
192
+ public function test_like_user_data_false() {
193
+ $tests = array(
194
+ "yyyy",
195
+ "简字昨今和天",
196
+ );
197
+ foreach ($tests as $pw) {
198
+ $actual = self::$lss->is_pw_like_user_data($pw, $this->user);
199
+ $this->assertFalse($actual, "Should have passed: '$pw'");
200
+ }
201
+ }
202
+ public function test_like_user_data_true() {
203
+ $tests = array(
204
+ "aaaa",
205
+ "bbbbbbbb",
206
+ "cccccccc",
207
+ "dddddddd",
208
+ "eeeeeeee",
209
+ "ffff",
210
+ "简化字的昨天今天和明天",
211
+ "hhhh",
212
+ "iiii",
213
+ "jjjj",
214
+ );
215
+ foreach ($tests as $pw) {
216
+ $actual = self::$lss->is_pw_like_user_data($pw, $this->user);
217
+ $this->assertTrue($actual, "Should have failed: '$pw'");
218
+ }
219
+ }
220
+
221
+ public function test_outside_ascii_false() {
222
+ $tests = array(
223
+ "aba!123",
224
+ "aba~123",
225
+ );
226
+ foreach ($tests as $pw) {
227
+ $actual = self::$lss->is_pw_outside_ascii($pw);
228
+ $this->assertFalse($actual, "Should have passed: '$pw'");
229
+ }
230
+ }
231
+ public function test_outside_ascii_true() {
232
+ $tests = array(
233
+ "aba\n123",
234
+ "简化字的昨天今天和明天",
235
+ );
236
+ foreach ($tests as $pw) {
237
+ $actual = self::$lss->is_pw_outside_ascii($pw);
238
+ $this->assertTrue($actual, "Should have failed: '$pw'");
239
+ }
240
+ }
241
+
242
+ public function test_missing_numeric_false() {
243
+ $tests = array(
244
+ "aA1!",
245
+ "123",
246
+ "ةيب8رعلا",
247
+ );
248
+ foreach ($tests as $pw) {
249
+ $actual = self::$lss->is_pw_missing_numeric($pw);
250
+ $this->assertFalse($actual, "Should have passed: '$pw'");
251
+ }
252
+ }
253
+ public function test_missing_numeric_true() {
254
+ $tests = array(
255
+ "abc",
256
+ "ABC",
257
+ "!@#",
258
+ "aAb",
259
+ "a!a!",
260
+ "A!A!",
261
+ "aA!",
262
+ "ةيبرعلا",
263
+ );
264
+ foreach ($tests as $pw) {
265
+ $actual = self::$lss->is_pw_missing_numeric($pw);
266
+ $this->assertTrue($actual, "Should have failed: '$pw'");
267
+ }
268
+ }
269
+
270
+ public function test_missing_punct_chars_false() {
271
+ $tests = array(
272
+ "aA1!",
273
+ "#",
274
+ ".",
275
+ "Россия.",
276
+ "「中國哲學史大綱」、胡適",
277
+ );
278
+ foreach ($tests as $pw) {
279
+ $actual = self::$lss->is_pw_missing_punct_chars($pw);
280
+ $this->assertFalse($actual, "Should have passed: '$pw'");
281
+ }
282
+ }
283
+ public function test_missing_punct_chars_true() {
284
+ $tests = array(
285
+ "123",
286
+ "abc",
287
+ "ABC",
288
+ "aAb",
289
+ "a1a",
290
+ "aA1",
291
+ "a",
292
+ "A",
293
+ "1",
294
+ "Россия",
295
+ "中國哲學史大綱胡適",
296
+ );
297
+ foreach ($tests as $pw) {
298
+ $actual = self::$lss->is_pw_missing_punct_chars($pw);
299
+ $this->assertTrue($actual, "Should have failed: '$pw'");
300
+ }
301
+ }
302
+
303
+ public function test_missing_upper_lower_chars_false() {
304
+ $tests = array(
305
+ "aA1!",
306
+ "aAb",
307
+ "aA!",
308
+ "БбƤƥ", // Bicameral UTF-8.
309
+ "חح", // Unicameral UTF-8.
310
+ );
311
+ foreach ($tests as $pw) {
312
+ $actual = self::$lss->is_pw_missing_upper_lower_chars($pw);
313
+ $this->assertFalse($actual, "Should have passed: '$pw'");
314
+ }
315
+ }
316
+ public function test_missing_upper_lower_chars_true() {
317
+ $tests = array(
318
+ "123",
319
+ "abc",
320
+ "ABC",
321
+ "!@#",
322
+ "a1a",
323
+ "a!a!",
324
+ "A!A!",
325
+ "a1!",
326
+ "A1!",
327
+ "бƥ",
328
+ "БƤ",
329
+ );
330
+ foreach ($tests as $pw) {
331
+ $actual = self::$lss->is_pw_missing_upper_lower_chars($pw);
332
+ $this->assertTrue($actual, "Should have failed: '$pw'");
333
+ }
334
+ }
335
+
336
+ public function test_missing_upper_lower_chars_false__nomb() {
337
+ self::$lss->available_mbstring = false;
338
+ $tests = array(
339
+ "aA1!",
340
+ "aAb",
341
+ "aA!",
342
+ );
343
+ foreach ($tests as $pw) {
344
+ $actual = self::$lss->is_pw_missing_upper_lower_chars($pw);
345
+ $this->assertFalse($actual, "Should have passed: '$pw'");
346
+ }
347
+ }
348
+
349
+ public function test_missing_upper_lower_chars_true__nomb() {
350
+ self::$lss->available_mbstring = false;
351
+ $tests = array(
352
+ "123",
353
+ "abc",
354
+ "ABC",
355
+ "!@#",
356
+ "a1a",
357
+ "a!a!",
358
+ "A!A!",
359
+ "a1!",
360
+ "A1!",
361
+ );
362
+ foreach ($tests as $pw) {
363
+ $actual = self::$lss->is_pw_missing_upper_lower_chars($pw);
364
+ $this->assertTrue($actual, "Should have failed: '$pw'");
365
+ }
366
+ }
367
+
368
+ public function test_sequential_codepoints_false() {
369
+ $tests = array(
370
+ "agke58#",
371
+ "תירִבְעִ",
372
+ "ⲘⲉⲧⲢⲉⲙ̀ⲛⲭⲏⲙⲓ",
373
+ );
374
+ foreach ($tests as $pw) {
375
+ $actual = self::$lss->is_pw_sequential_codepoints($pw);
376
+ $this->assertFalse($actual, "Should have passed: '$pw'");
377
+ }
378
+ }
379
+ public function test_sequential_codepoints_true() {
380
+ $tests = array(
381
+ "1234",
382
+ "abcd",
383
+ "ABCD",
384
+ "%&'(",
385
+ "דגבא",
386
+ "ϣϥϧ",
387
+ );
388
+ foreach ($tests as $pw) {
389
+ $actual = self::$lss->is_pw_sequential_codepoints($pw);
390
+ $this->assertTrue($actual, "Should have failed: '$pw'");
391
+ }
392
+ }
393
+
394
+ public function test_sequential_file_false() {
395
+ $tests = array(
396
+ "1357",
397
+ "adg!#%135yip579", // not sequential
398
+ "adg!#%135yu579", // "yu" is sequential, but too short
399
+ );
400
+ foreach ($tests as $pw) {
401
+ $actual = self::$lss->is_pw_sequential_file($pw);
402
+ $this->assertFalse($actual, "Should have passed: '$pw'");
403
+ }
404
+ }
405
+ public function test_sequential_file_true() {
406
+ $tests = array(
407
+ "^&*()",
408
+ "asdf",
409
+ "QWERT",
410
+ "fdsa",
411
+ "adg!#%135yui579", // "yui" is sequential
412
+ "adg!#%135iuy579", // "iuy" is "yui" sequence reversed
413
+ );
414
+ foreach ($tests as $pw) {
415
+ $actual = self::$lss->is_pw_sequential_file($pw);
416
+ $this->assertTrue($actual, "Should have failed: '$pw'");
417
+ }
418
+ }
419
+
420
+ public function test_strip_nonword_chars() {
421
+ $tests = array(
422
+ "^a&*b()c2" => "abc2",
423
+ "「中國哲學史大綱」、胡適" => "中國哲學史大綱胡適",
424
+ "2חַ״וּדּ" => "2חוד",
425
+ );
426
+ foreach ($tests as $in => $expect) {
427
+ $actual = self::$lss->strip_nonword_chars($in);
428
+ $this->assertEquals($expect, $actual);
429
+ }
430
+ }
431
+
432
+
433
+ public function test_validate_pw__notset() {
434
+ $errors = new WP_Error;
435
+ $user = new stdClass;
436
+ $actual = self::$lss->validate_pw($user, $errors);
437
+ $this->assertFalse($actual,
438
+ "password not being set should have failed.");
439
+ $this->assertEquals(
440
+ __("<strong>ERROR</strong>: Password not set.", self::ID),
441
+ $errors->get_error_message()
442
+ );
443
+ }
444
+
445
+ public function test_validate_pw__array() {
446
+ $errors = new WP_Error;
447
+ $actual = self::$lss->validate_pw(array('abc'), $errors);
448
+ $this->assertFalse($actual,
449
+ "'array('abc')' should have failed.");
450
+ $this->assertEquals(
451
+ __("<strong>ERROR</strong>: Passwords must be strings.", self::ID),
452
+ $errors->get_error_message()
453
+ );
454
+ }
455
+
456
+ public function test_validate_pw__ascii() {
457
+ self::$lss->available_mbstring = false;
458
+
459
+ $errors = new WP_Error;
460
+ $actual = self::$lss->validate_pw($this->user, $errors);
461
+ $this->assertFalse($actual,
462
+ "'" . $this->user->user_pass . "' should have failed.");
463
+ $this->assertEquals(
464
+ __("<strong>ERROR</strong>: Passwords must use ASCII characters.", self::ID),
465
+ $errors->get_error_message()
466
+ );
467
+ }
468
+
469
+ public function test_validate_pw__short_mb() {
470
+ if (!self::$mbstring_available) {
471
+ $this->markTestSkipped('mbstring not available');
472
+ }
473
+
474
+ $this->user->user_pass = '简化字的昨天今';
475
+
476
+ $errors = new WP_Error;
477
+ $actual = self::$lss->validate_pw($this->user, $errors);
478
+ $this->assertFalse($actual,
479
+ "'" . $this->user->user_pass . "' should have failed.");
480
+ $this->assertEquals(
481
+ __("<strong>ERROR</strong>: Password is too short.", self::ID),
482
+ $errors->get_error_message()
483
+ );
484
+ }
485
+
486
+ public function test_validate_pw__short_nomb() {
487
+ self::$lss->available_mbstring = false;
488
+ $this->user->user_pass = 'aA1!';
489
+
490
+ $errors = new WP_Error;
491
+ $actual = self::$lss->validate_pw($this->user, $errors);
492
+ $this->assertFalse($actual,
493
+ "'" . $this->user->user_pass . "' should have failed.");
494
+ $this->assertEquals(
495
+ __("<strong>ERROR</strong>: Password is too short.", self::ID),
496
+ $errors->get_error_message()
497
+ );
498
+ }
499
+
500
+ public function test_validate_pw__nopunct() {
501
+ $this->user->user_pass = '123456789012';
502
+
503
+ $errors = new WP_Error;
504
+ $actual = self::$lss->validate_pw($this->user, $errors);
505
+ $this->assertFalse($actual,
506
+ "'" . $this->user->user_pass . "' should have failed.");
507
+ $this->assertEquals(
508
+ sprintf(__("<strong>ERROR</strong>: Passwords must either contain punctuation marks / symbols or be %d characters long.", self::ID), self::$lss->options['pw_complexity_exemption_length']),
509
+ $errors->get_error_message()
510
+ );
511
+ }
512
+
513
+ public function test_validate_pw__nonumbers() {
514
+ $this->user->user_pass = 'axj*@UXyqy';
515
+
516
+ $errors = new WP_Error;
517
+ $actual = self::$lss->validate_pw($this->user, $errors);
518
+ $this->assertFalse($actual,
519
+ "'" . $this->user->user_pass . "' should have failed.");
520
+ $this->assertEquals(
521
+ sprintf(__("<strong>ERROR</strong>: Passwords must either contain numbers or be %d characters long.", self::ID), self::$lss->options['pw_complexity_exemption_length']),
522
+ $errors->get_error_message()
523
+ );
524
+ }
525
+
526
+ public function test_validate_pw__noupperlower() {
527
+ $this->user->user_pass = 'axj*1@yqy';
528
+
529
+ $errors = new WP_Error;
530
+ $actual = self::$lss->validate_pw($this->user, $errors);
531
+ $this->assertFalse($actual,
532
+ "'" . $this->user->user_pass . "' should have failed.");
533
+ $this->assertEquals(
534
+ sprintf(__("<strong>ERROR</strong>: Passwords must either contain upper-case and lower-case letters or be %d characters long.", self::ID), self::$lss->options['pw_complexity_exemption_length']),
535
+ $errors->get_error_message()
536
+ );
537
+ }
538
+
539
+ public function test_validate_pw__sequentialfile() {
540
+ $this->user->user_pass = 'alGb02i&*()';
541
+
542
+ $errors = new WP_Error;
543
+ $actual = self::$lss->validate_pw($this->user, $errors);
544
+ $this->assertFalse($actual,
545
+ "'" . $this->user->user_pass . "' should have failed.");
546
+ $this->assertEquals(
547
+ __("<strong>ERROR</strong>: Passwords can't be sequential keys.", self::ID),
548
+ $errors->get_error_message()
549
+ );
550
+ }
551
+
552
+ public function test_validate_pw__sequentialcodepoints() {
553
+ $this->user->user_pass = 'abAB12!@';
554
+
555
+ $errors = new WP_Error;
556
+ $actual = self::$lss->validate_pw($this->user, $errors);
557
+ $this->assertFalse($actual,
558
+ "'" . $this->user->user_pass . "' should have failed.");
559
+ $this->assertEquals(
560
+ __("<strong>ERROR</strong>: Passwords can't have that many sequential characters.", self::ID),
561
+ $errors->get_error_message()
562
+ );
563
+ }
564
+
565
+ public function test_validate_pw__userdata() {
566
+ $this->user->user_pass = $this->user->nickname;
567
+
568
+ $errors = new WP_Error;
569
+ $actual = self::$lss->validate_pw($this->user, $errors);
570
+ $this->assertFalse($actual,
571
+ "'" . $this->user->user_pass . "' should have failed.");
572
+ $this->assertEquals(
573
+ __("<strong>ERROR</strong>: Passwords can't contain user data.", self::ID),
574
+ $errors->get_error_message()
575
+ );
576
+ }
577
+
578
+ public function test_validate_pw__userdata_leet() {
579
+ $this->user->user_pass = 'this@@@@ShouldGetNa1led';
580
+
581
+ $errors = new WP_Error;
582
+ $actual = self::$lss->validate_pw($this->user, $errors);
583
+ $this->assertFalse($actual,
584
+ "'" . $this->user->user_pass . "' should have failed.");
585
+ $this->assertEquals(
586
+ __("<strong>ERROR</strong>: Passwords can't contain user data.", self::ID),
587
+ $errors->get_error_message()
588
+ );
589
+ }
590
+
591
+ public function test_validate_pw__bloginfo() {
592
+ $this->user->user_pass = 'Ja!k2' . get_bloginfo('description');
593
+
594
+ $errors = new WP_Error;
595
+ $actual = self::$lss->validate_pw($this->user, $errors);
596
+ $this->assertFalse($actual,
597
+ "'" . $this->user->user_pass . "' should have failed.");
598
+ $this->assertEquals(
599
+ __("<strong>ERROR</strong>: Passwords can't contain site info.", self::ID),
600
+ $errors->get_error_message()
601
+ );
602
+ }
603
+
604
+ public function test_validate_pw__dictionary() {
605
+ $this->user->user_pass = 'Pa$$w0rd1';
606
+
607
+ $errors = new WP_Error;
608
+ $actual = self::$lss->validate_pw($this->user, $errors);
609
+ $this->assertFalse($actual,
610
+ "'" . $this->user->user_pass . "' should have failed.");
611
+ $this->assertEquals(
612
+ __("<strong>ERROR</strong>: Password is too common.", self::ID),
613
+ $errors->get_error_message()
614
+ );
615
+ }
616
+
617
+ public function test_validate_pw__dict() {
618
+ if (!self::$dict_available) {
619
+ $this->markTestSkipped('grep not available');
620
+ }
621
+ $this->user->user_pass = 'R3n0vat!on';
622
+
623
+ $errors = new WP_Error;
624
+ $actual = self::$lss->validate_pw($this->user, $errors);
625
+ $this->assertFalse($actual,
626
+ "'" . $this->user->user_pass . "' should have failed.");
627
+ $this->assertEquals(
628
+ __("<strong>ERROR</strong>: Passwords can't be variations of dictionary words.", self::ID),
629
+ $errors->get_error_message()
630
+ );
631
+ }
632
+
633
+ public function test_validate_pw__good() {
634
+ $errors = new WP_Error;
635
+ $actual = self::$lss->validate_pw($this->user, $errors);
636
+ $this->assertTrue($actual,
637
+ "'" . $this->user->user_pass . "' should have passed.");
638
+ $this->assertEmpty($errors->get_error_message());
639
+ }
640
+
641
+ public function test_validate_pw__good_complex_exempt() {
642
+ $this->user->user_pass = 'this is a very long password not complex';
643
+
644
+ $errors = new WP_Error;
645
+ $actual = self::$lss->validate_pw($this->user, $errors);
646
+ $this->assertTrue($actual,
647
+ "'" . $this->user->user_pass . "' should have passed.");
648
+ $this->assertEmpty($errors->get_error_message());
649
+ }
650
+
651
+ public function test_has_match_array() {
652
+ $actual = self::$lss->has_match('foo', array());
653
+ $this->assertFalse($actual);
654
+ }
655
+
656
+ public function test_has_match_empty() {
657
+ $actual = self::$lss->has_match('foo', '');
658
+ $this->assertFalse($actual);
659
+ }
660
+ }
tests/TestCase.php ADDED
@@ -0,0 +1,345 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+
3
+ /**
4
+ * Parent TestCase class containing common methods and properties, plus an
5
+ * override for the wp_mail() function
6
+ *
7
+ * @package login-security-solution
8
+ * @author Daniel Convissor <danielc@analysisandsolutions.com>
9
+ * @copyright The Analysis and Solutions Company, 2012
10
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPLv2
11
+ */
12
+
13
+ /*
14
+ * Keep PHPUnit from messing up WordPress' crazy use of globals.
15
+ *
16
+ * This prevents the following errors:
17
+ * Call to a member function add_rule() on a non-object in wp-includes/rewrite.php
18
+ * Call to a member function add_rewrite_tag() on a non-object in wp-includes/taxonomy.php
19
+ */
20
+ global $wp_rewrite;
21
+
22
+ /**
23
+ * Overrides the wp_mail() function so we can ensure the messages are
24
+ * composed when and how they should be
25
+ *
26
+ * @uses TestCase::mail_to_file() to store the data for later comparison
27
+ */
28
+ function wp_mail($to, $subject, $message) {
29
+ TestCase::mail_to_file($to, $subject, $message);
30
+ }
31
+
32
+ /**
33
+ * Overrides the wp_redirect() function so we can ensure headers are
34
+ * composed when and how they should be
35
+ *
36
+ * @uses TestCase::wp_redirect() to store the data for later comparison
37
+ */
38
+ function wp_redirect($location, $status = 302) {
39
+ TestCase::wp_redirect($location, $status);
40
+ }
41
+
42
+ /**
43
+ * Gather the WordPress infrastructure.
44
+ * Use dirname(dirname()) because safe mode can disable "../".
45
+ */
46
+ $wp_load = dirname(dirname(dirname(dirname(dirname(__FILE__))))) . '/wp-load.php';
47
+ if (!is_readable($wp_load)) {
48
+ die("The plugin must be in the 'wp-content/plugins' directory of a working WordPress installation.\n");
49
+ }
50
+ require_once $wp_load;
51
+
52
+ /**
53
+ * Get the class we will use for testing
54
+ */
55
+ require_once dirname(__FILE__) . '/Accessor.php';
56
+
57
+ /**
58
+ * Obtain the PHPUnit infrastructure
59
+ */
60
+ require_once 'PHPUnit/Autoload.php';
61
+
62
+ /**
63
+ * Parent TestCase class containing common methods and properties
64
+ *
65
+ * @package login-security-solution
66
+ * @author Daniel Convissor <danielc@analysisandsolutions.com>
67
+ * @copyright The Analysis and Solutions Company, 2012
68
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPLv2
69
+ */
70
+ abstract class TestCase extends PHPUnit_Framework_TestCase {
71
+ const USER_PASS = 'aA1!gt%v简E8#';
72
+ const ID = 'login-security-solution';
73
+
74
+
75
+ /**
76
+ * Keep PHPUnit from messing up WordPress' crazy use of globals.
77
+ *
78
+ * This prevents the following errors:
79
+ * + Call to a member function add_rule() on a non-object in
80
+ * wp-includes/rewrite.php
81
+ * + Call to a member function add_rewrite_tag() on a non-object in
82
+ * wp-includes/taxonomy.php
83
+ */
84
+ protected $backupGlobals = false;
85
+
86
+ /**
87
+ * Does the current test class touch the database?
88
+ * @var bool
89
+ */
90
+ protected static $db_needed = true;
91
+
92
+ /**
93
+ * Does the database support transactions?
94
+ * @var bool
95
+ */
96
+ protected static $db_has_transactions = false;
97
+
98
+ /**
99
+ * Error messges our error handler should expect
100
+ * @var array
101
+ * @see TestCase::expected_errors()
102
+ */
103
+ protected $expected_error_list = array();
104
+
105
+ /**
106
+ * Did the expected errors happen?
107
+ * @var bool
108
+ * @see TestCase::were_expected_errors_found()
109
+ */
110
+ protected $expected_errors_found = false;
111
+
112
+ /**
113
+ * The actual "Location" header sent by wp_redirect()
114
+ * @var string
115
+ */
116
+ protected static $location_actual;
117
+
118
+ /**
119
+ * The "Location" header we expect wp_redirect() to send
120
+ * @var string
121
+ */
122
+ protected static $location_expected;
123
+
124
+ /**
125
+ * @var Accessor
126
+ */
127
+ protected static $lss;
128
+
129
+ /**
130
+ * Name of the mail file
131
+ * @var string
132
+ */
133
+ protected static $mail_file_basename;
134
+
135
+ /**
136
+ * Path and name of the mail file
137
+ * @var string
138
+ */
139
+ protected static $mail_file;
140
+
141
+ /**
142
+ * Path to the temporary directory
143
+ * @var string
144
+ */
145
+ protected static $temp_dir;
146
+
147
+ /**
148
+ * A mockup of the WP_User object
149
+ * @var WP_User
150
+ */
151
+ protected $user;
152
+
153
+
154
+ /**
155
+ * Prepares the environment before the first test is run
156
+ *
157
+ * NOTE: Not using standard setUpBeforeClass() because we need to have
158
+ * the child class setting one of this class' static properties
159
+ *
160
+ * @return void
161
+ */
162
+ public static function set_up_before_class() {
163
+ global $wpdb, $wp_object_cache;
164
+
165
+ $wp_object_cache = new WP_Object_Cache;
166
+
167
+ self::$lss = new Accessor;
168
+
169
+ if (self::$db_needed && self::are_transactions_available()) {
170
+ self::$db_has_transactions = true;
171
+ self::$lss->activate();
172
+ $wpdb->query('START TRANSACTION');
173
+ } else {
174
+ self::$db_has_transactions = false;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Destroys the environment once the final test is done
180
+ */
181
+ public static function tearDownAfterClass() {
182
+ global $wpdb;
183
+
184
+ if (self::$db_has_transactions) {
185
+ $wpdb->query('ROLLBACK');
186
+ self::$lss->deactivate();
187
+ }
188
+
189
+ self::$lss = null;
190
+ }
191
+
192
+ public function setUp() {
193
+ if (self::$db_needed && !self::$db_has_transactions) {
194
+ $this->markTestSkipped('Database transactions are needed to test these features, but your "options" and "usermeta" tables are not using the InnoDB engine.');
195
+ }
196
+
197
+ self::$location_actual = null;
198
+ self::$location_expected = null;
199
+ self::$mail_file = null;
200
+ self::$mail_file_basename = null;
201
+
202
+ $_SERVER['SERVER_PROTOCOL'] = 'http';
203
+
204
+ $this->user = new WP_User;
205
+ $this->user->ID = 9999999999;
206
+ $this->user->user_login = 'aaaa';
207
+ $this->user->user_email = 'bbbb';
208
+ $this->user->user_url = 'cccc';
209
+ $this->user->first_name = 'dddd';
210
+ $this->user->last_name = 'eeee';
211
+ $this->user->nickname = 'fff@1F*8ffff';
212
+ $this->user->display_name = '简化字';
213
+ $this->user->aim = 'hhhhhhhh';
214
+ $this->user->yim = 'iiiiiiii';
215
+ $this->user->jabber = 'jjjjjjjj';
216
+ $this->user->user_pass = self::USER_PASS;
217
+ }
218
+
219
+ public function tearDown() {
220
+ if (self::$mail_file) {
221
+ @unlink(self::$mail_file);
222
+ }
223
+ $this->expected_error_list = array();
224
+ restore_error_handler();
225
+ }
226
+
227
+
228
+ /**
229
+ * Determines if both the options and usermeta tables use InnoDB
230
+ * @return bool
231
+ */
232
+ protected static function are_transactions_available() {
233
+ global $wpdb;
234
+
235
+ $opt = $wpdb->get_row("SHOW CREATE TABLE `$wpdb->options`", ARRAY_N);
236
+ $usr = $wpdb->get_row("SHOW CREATE TABLE `$wpdb->usermeta`", ARRAY_N);
237
+
238
+ return (strpos($opt[1], 'ENGINE=InnoDB')
239
+ && strpos($usr[1], 'ENGINE=InnoDB'));
240
+ }
241
+
242
+ /**
243
+ * Examines the last record inserted into the fail table
244
+ */
245
+ protected function check_fail_record($ip, $user_name, $pass_md5) {
246
+ global $wpdb;
247
+
248
+ $this->assertInternalType('integer', $wpdb->insert_id,
249
+ 'This should be an insert id.');
250
+
251
+ $sql = 'SELECT *, SYSDATE() AS sysdate
252
+ FROM `' . self::$lss->table_fail . '`
253
+ WHERE fail_id = %d';
254
+ $actual = $wpdb->get_row($wpdb->prepare($sql, $wpdb->insert_id));
255
+ if (!$actual) {
256
+ $this->fail('Could not find the record in the "fail" table.');
257
+ }
258
+
259
+ $this->assertEquals($ip, $actual->ip, "'ip' field mismatch.");
260
+ $this->assertEquals($user_name, $actual->user_login,
261
+ "'user_name' field mismatch.");
262
+ $this->assertEquals($pass_md5, $actual->pass_md5,
263
+ "'pass_md5' field mismatch.");
264
+
265
+ $date_failed = new DateTime($actual->date_failed);
266
+ $sysdate = new DateTime($actual->sysdate);
267
+ $interval = $date_failed->diff($sysdate);
268
+ $this->assertLessThanOrEqual('00000000000001',
269
+ $interval->format('%Y%M%D%H%I%S'),
270
+ "'date_failed' field off by over 1 second: $actual->date_failed.");
271
+ }
272
+
273
+ /**
274
+ * @see TestCase::were_expected_errors_found()
275
+ */
276
+ protected function expected_errors($error_messages) {
277
+ $this->expected_error_list = (array) $error_messages;
278
+ set_error_handler(array(&$this, 'expected_errors_handler'));
279
+ }
280
+
281
+ /**
282
+ * @see TestCase::expected_errors()
283
+ */
284
+ protected function were_expected_errors_found() {
285
+ restore_error_handler();
286
+ return $this->expected_errors_found;
287
+ }
288
+
289
+ /**
290
+ * Checks if expected errors were found
291
+ */
292
+ public function expected_errors_handler($errno, $errstr) {
293
+ foreach ($this->expected_error_list as $expect) {
294
+ if (strpos($errstr, $expect) !== false) {
295
+ $this->expected_errors_found = true;
296
+ return true;
297
+ }
298
+ }
299
+ return false;
300
+ }
301
+
302
+ /**
303
+ * Writes the "mail" contents to a file for later comparison
304
+ */
305
+ public static function mail_to_file($to, $subject, $message) {
306
+ if (!self::$temp_dir) {
307
+ self::$temp_dir = sys_get_temp_dir();
308
+ }
309
+ if (!self::$mail_file_basename) {
310
+ throw new Exception('wp_mail() called at unexpected time'
311
+ . ' (mail_file_basename was not set).');
312
+ }
313
+ self::$mail_file = self::$temp_dir . '/' . self::$mail_file_basename;
314
+
315
+ $contents = 'To: ' . implode(', ', (array) $to) . "\n"
316
+ . "Subject: $subject\n\n$message";
317
+
318
+ return file_put_contents(self::$mail_file, $contents);
319
+ }
320
+
321
+ /**
322
+ * Examines the actual mail file against the expected mail file
323
+ */
324
+ protected function check_mail_file() {
325
+ if (!self::$mail_file) {
326
+ $this->fail('wp_mail() has not been called.');
327
+ }
328
+
329
+ $this->assertStringMatchesFormatFile(
330
+ dirname(__FILE__) . '/expected/' . self::$mail_file_basename,
331
+ file_get_contents(self::$mail_file)
332
+ );
333
+ }
334
+
335
+ /**
336
+ * Writes the location header to a variable for later comparison
337
+ */
338
+ public static function wp_redirect($location, $status) {
339
+ if (!self::$location_expected) {
340
+ throw new Exception('wp_redirect() called at unexpected time'
341
+ . ' ($location_expected was not set).');
342
+ }
343
+ self::$location_actual = $location;
344
+ }
345
+ }
tests/expected/LoginFailTest::test_process_login_fail__post_threshold ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ To: %a
2
+ Subject: ATTACK HAPPENING TO %a
3
+
4
+ Your website, %s, is undergoing a brute force attack.
5
+
6
+ There have been at least 4 failed attempts to log in during the past 60 minutes that used one or more of the following components:
7
+
8
+ Component Count Value from Current Attempt
9
+ ------------ ----- --------------------------------
10
+ Network IP 4 1.2.3
11
+ Username 4 test
12
+ Password MD5 2 %s
13
+
14
+ The Login Security Solution plugin for WordPress is repelling the attack by making their login failures take a very long time.
tests/expected/LoginFailTest::test_wp_login__post_breach_threshold ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ To: %a
2
+ Subject: POTENTIAL INTRUSION AT %a
3
+
4
+ Your website, %s, may have been broken in to.
5
+
6
+ Someone just logged in using the following components. Prior to that, some combination of those components were a part of 4 failed attempts to log in during the past 60 minutes:
7
+
8
+ Component Count Value from Current Attempt
9
+ ------------ ----- --------------------------------
10
+ Network IP 4 1.2.3
11
+ Username 4 test
12
+ Password MD5 %d %s
13
+
14
+ The user has been logged out and will be required to confirm their identity via the password reset functionality.
tests/password-validation-profiler.php ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+
3
+ /**
4
+ * Test speeds of various password tests
5
+ *
6
+ * @package login-security-solution
7
+ * @author Daniel Convissor <danielc@analysisandsolutions.com>
8
+ * @copyright The Analysis and Solutions Company, 2012
9
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPLv2
10
+ */
11
+
12
+ /**
13
+ * Gather the WordPress infrastructure.
14
+ * Use dirname(dirname()) because safe mode can disable "../".
15
+ */
16
+ require_once dirname(dirname(dirname(dirname(dirname(__FILE__))))) . '/wp-load.php';
17
+
18
+ /**
19
+ * Obtain the plugin class
20
+ */
21
+ require_once dirname(dirname(__FILE__)) . '/login-security-solution.php';
22
+ $lss = $GLOBALS['login_security_solution'];
23
+
24
+ $user = new stdClass;
25
+ $user->user_login = 'aaaa';
26
+ $user->user_email = 'bbbb';
27
+ $user->user_url = 'cccc';
28
+ $user->first_name = 'dddd';
29
+ $user->last_name = 'eeee';
30
+ $user->nickname = 'ffffffff';
31
+ $user->display_name = 'gggggggg';
32
+ $user->aim = 'hhhhhhhh';
33
+ $user->yim = 'iiiiiiii';
34
+ $user->jabber = 'jjjjjjjj';
35
+ $user->user_pass = 'aA1!gt%vE8#';
36
+
37
+ for ($i = 0; $i < 100; $i++) {
38
+ $lss->validate_pw($user);
39
+ }
utilities/reduce-dictionary-files.php ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+
3
+ /**
4
+ * Shrinks the size of password dictionary files by removing entries
5
+ * that fail our other tests
6
+ *
7
+ * @package login-security-solution
8
+ * @author Daniel Convissor <danielc@analysisandsolutions.com>
9
+ * @copyright The Analysis and Solutions Company, 2012
10
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPLv2
11
+ */
12
+
13
+ /**
14
+ * Gather the WordPress infrastructure.
15
+ * Use dirname(dirname()) because safe mode can disable "../".
16
+ */
17
+ require_once dirname(dirname(dirname(dirname(dirname(__FILE__))))) . '/wp-load.php';
18
+
19
+ /**
20
+ * Obtain the plugin class
21
+ */
22
+ require_once dirname(dirname(__FILE__)) . '/login-security-solution.php';
23
+ $lss = $GLOBALS['login_security_solution'];
24
+
25
+ $files = array();
26
+ $dir = new DirectoryIterator($lss->dir_dictionaries);
27
+ foreach ($dir as $file) {
28
+ if ($file->isDir() || $file->getFilename() == 'test.txt') {
29
+ continue;
30
+ }
31
+ $files[$lss->dir_dictionaries . $file->getFilename()] = $file->getSize();
32
+ }
33
+
34
+ // Sort by size.
35
+ asort($files);
36
+
37
+ // Simplify array. For future needs.
38
+ $file_names = array_keys($files);
39
+
40
+ foreach ($file_names as $file) {
41
+ $fh_old = fopen($file, 'r');
42
+ $fh_new = fopen("$file.new", 'w');
43
+ while ($line = fgets($fh_old)) {
44
+ if ($lss->validate_pw($line)) {
45
+ $fh_new->fwrite("$line\n");
46
+ }
47
+ }
48
+ fclose($fh_new);
49
+ fclose($fh_old);
50
+ rename("$file.new", $file);
51
+ }
52
+
53
+ // The existing tests knock out all of the dictionary passwords.
54
+ // So don't bother completing the code to reduce things further.
55
+ exit;
56
+
57
+ foreach ($file_names as $file) {
58
+ $smaller_file = array_shift($file_names);
59
+
60
+ if ($file != $smaller_file) {
61
+ foreach ($file_names as $bigger_file) {
62
+ // grep
63
+ }
64
+ }
65
+ }