Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
80.00% |
76 / 95 |
|
57.14% |
4 / 7 |
CRAP | |
0.00% |
0 / 1 |
UserPasswordPolicy | |
80.00% |
76 / 95 |
|
57.14% |
4 / 7 |
35.73 | |
0.00% |
0 / 1 |
__construct | |
45.45% |
5 / 11 |
|
0.00% |
0 / 1 |
6.60 | |||
checkUserPassword | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
checkUserPasswordForGroups | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
2 | |||
checkPolicies | |
93.75% |
30 / 32 |
|
0.00% |
0 / 1 |
11.03 | |||
getPoliciesForUser | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
1 | |||
getPoliciesForGroups | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
maxOfPolicies | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
8 |
1 | <?php |
2 | /** |
3 | * Password policy checking for a user |
4 | * |
5 | * This program is free software; you can redistribute it and/or modify |
6 | * it under the terms of the GNU General Public License as published by |
7 | * the Free Software Foundation; either version 2 of the License, or |
8 | * (at your option) any later version. |
9 | * |
10 | * This program is distributed in the hope that it will be useful, |
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
13 | * GNU General Public License for more details. |
14 | * |
15 | * You should have received a copy of the GNU General Public License along |
16 | * with this program; if not, write to the Free Software Foundation, Inc., |
17 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
18 | * http://www.gnu.org/copyleft/gpl.html |
19 | * |
20 | * @file |
21 | */ |
22 | |
23 | use MediaWiki\HookContainer\HookRunner; |
24 | use MediaWiki\MediaWikiServices; |
25 | use MediaWiki\Status\Status; |
26 | use MediaWiki\User\UserIdentity; |
27 | |
28 | /** |
29 | * Check if a user's password complies with any password policies that apply to that |
30 | * user, based on the user's group membership. |
31 | * @since 1.26 |
32 | */ |
33 | class UserPasswordPolicy { |
34 | |
35 | /** |
36 | * @var array[] |
37 | */ |
38 | private $policies; |
39 | |
40 | /** |
41 | * Mapping of statements to the function that will test the password for compliance. The |
42 | * checking functions take the policy value, the user, and password, and return a Status |
43 | * object indicating compliance. |
44 | * @var callable[] |
45 | */ |
46 | private $policyCheckFunctions; |
47 | |
48 | /** |
49 | * @param array[] $policies List of lists of policies per user group |
50 | * @param callable[] $checks mapping statement to its checking function. Checking functions are |
51 | * called with the policy value for this user, the user object, and the password to check. |
52 | */ |
53 | public function __construct( array $policies, array $checks ) { |
54 | if ( !isset( $policies['default'] ) ) { |
55 | throw new InvalidArgumentException( |
56 | 'Must include a \'default\' password policy' |
57 | ); |
58 | } |
59 | $this->policies = $policies; |
60 | |
61 | foreach ( $checks as $statement => $check ) { |
62 | if ( !is_callable( $check ) ) { |
63 | throw new InvalidArgumentException( |
64 | "Policy check functions must be callable. '$statement' isn't callable." |
65 | ); |
66 | } |
67 | $this->policyCheckFunctions[$statement] = $check; |
68 | } |
69 | } |
70 | |
71 | /** |
72 | * Check if a password meets the effective password policy for a User. |
73 | * @param UserIdentity $user whose policy we are checking |
74 | * @param string $password the password to check |
75 | * @return Status error to indicate the password didn't meet the policy, or fatal to |
76 | * indicate the user shouldn't be allowed to login. The status value will be an array, |
77 | * potentially with the following keys: |
78 | * - forceChange: do not allow the user to login without changing the password if invalid. |
79 | * - suggestChangeOnLogin: prompt for a password change on login if the password is invalid. |
80 | */ |
81 | public function checkUserPassword( UserIdentity $user, $password ) { |
82 | $effectivePolicy = $this->getPoliciesForUser( $user ); |
83 | return $this->checkPolicies( |
84 | $user, |
85 | $password, |
86 | $effectivePolicy, |
87 | $this->policyCheckFunctions |
88 | ); |
89 | } |
90 | |
91 | /** |
92 | * Check if a password meets the effective password policy for a User, using a set |
93 | * of groups they may or may not belong to. This function does not use the DB, so can |
94 | * be used in the installer. |
95 | * @param UserIdentity $user whose policy we are checking |
96 | * @param string $password the password to check |
97 | * @param string[] $groups list of groups to which we assume the user belongs |
98 | * @return Status error to indicate the password didn't meet the policy, or fatal to |
99 | * indicate the user shouldn't be allowed to login. The status value will be an array, |
100 | * potentially with the following keys: |
101 | * - forceChange: do not allow the user to login without changing the password if invalid. |
102 | * - suggestChangeOnLogin: prompt for a password change on login if the password is invalid. |
103 | */ |
104 | public function checkUserPasswordForGroups( UserIdentity $user, $password, array $groups ) { |
105 | $effectivePolicy = self::getPoliciesForGroups( |
106 | $this->policies, |
107 | $groups, |
108 | $this->policies['default'] |
109 | ); |
110 | return $this->checkPolicies( |
111 | $user, |
112 | $password, |
113 | $effectivePolicy, |
114 | $this->policyCheckFunctions |
115 | ); |
116 | } |
117 | |
118 | /** |
119 | * @param UserIdentity $user |
120 | * @param string $password |
121 | * @param array $policies List of policy statements for the group the user belongs to |
122 | * @param callable[] $policyCheckFunctions |
123 | * @return Status |
124 | */ |
125 | private function checkPolicies( UserIdentity $user, $password, $policies, $policyCheckFunctions ) { |
126 | $status = Status::newGood( [] ); |
127 | $forceChange = false; |
128 | $suggestChangeOnLogin = false; |
129 | $legacyUser = MediaWikiServices::getInstance() |
130 | ->getUserFactory() |
131 | ->newFromUserIdentity( $user ); |
132 | foreach ( $policies as $policy => $settings ) { |
133 | if ( !isset( $policyCheckFunctions[$policy] ) ) { |
134 | throw new DomainException( "Invalid password policy config. No check defined for '$policy'." ); |
135 | } |
136 | if ( !is_array( $settings ) ) { |
137 | // legacy format |
138 | $settings = [ 'value' => $settings ]; |
139 | } |
140 | if ( !array_key_exists( 'value', $settings ) ) { |
141 | throw new DomainException( "Invalid password policy config. No value defined for '$policy'." ); |
142 | } |
143 | $value = $settings['value']; |
144 | /** @var StatusValue $policyStatus */ |
145 | $policyStatus = call_user_func( |
146 | $policyCheckFunctions[$policy], |
147 | $value, |
148 | $legacyUser, |
149 | $password |
150 | ); |
151 | |
152 | if ( !$policyStatus->isGood() ) { |
153 | if ( !empty( $settings['forceChange'] ) ) { |
154 | $forceChange = true; |
155 | } |
156 | |
157 | if ( !empty( $settings['suggestChangeOnLogin'] ) ) { |
158 | $suggestChangeOnLogin = true; |
159 | } |
160 | } |
161 | $status->merge( $policyStatus ); |
162 | } |
163 | |
164 | if ( $status->isOK() ) { |
165 | if ( $forceChange ) { |
166 | $status->value['forceChange'] = true; |
167 | } elseif ( $suggestChangeOnLogin ) { |
168 | $status->value['suggestChangeOnLogin'] = true; |
169 | } |
170 | } |
171 | |
172 | return $status; |
173 | } |
174 | |
175 | /** |
176 | * Get the policy for a user, based on their group membership. Public so |
177 | * UI elements can access and inform the user. |
178 | * @param UserIdentity $user |
179 | * @return array the effective policy for $user |
180 | */ |
181 | public function getPoliciesForUser( UserIdentity $user ) { |
182 | $services = MediaWikiServices::getInstance(); |
183 | $effectivePolicy = self::getPoliciesForGroups( |
184 | $this->policies, |
185 | $services->getUserGroupManager() |
186 | ->getUserEffectiveGroups( $user ), |
187 | $this->policies['default'] |
188 | ); |
189 | |
190 | $legacyUser = $services->getUserFactory() |
191 | ->newFromUserIdentity( $user ); |
192 | ( new HookRunner( $services->getHookContainer() ) )->onPasswordPoliciesForUser( $legacyUser, $effectivePolicy ); |
193 | |
194 | return $effectivePolicy; |
195 | } |
196 | |
197 | /** |
198 | * Utility function to get the effective policy from a list of policies, based |
199 | * on a list of groups. |
200 | * @param array[] $policies List of lists of policies per user group |
201 | * @param string[] $userGroups the groups from which we calculate the effective policy |
202 | * @param array $defaultPolicy the default policy to start from |
203 | * @return array effective policy |
204 | */ |
205 | public static function getPoliciesForGroups( array $policies, array $userGroups, |
206 | array $defaultPolicy |
207 | ) { |
208 | $effectivePolicy = $defaultPolicy; |
209 | foreach ( $policies as $group => $policy ) { |
210 | if ( in_array( $group, $userGroups ) ) { |
211 | $effectivePolicy = self::maxOfPolicies( |
212 | $effectivePolicy, |
213 | $policy |
214 | ); |
215 | } |
216 | } |
217 | |
218 | return $effectivePolicy; |
219 | } |
220 | |
221 | /** |
222 | * Utility function to get a policy that is the most restrictive of $p1 and $p2. For |
223 | * simplicity, we setup the policy values so the maximum value is always more restrictive. |
224 | * It is also used recursively to merge settings within the same policy. |
225 | * @param array $p1 |
226 | * @param array $p2 |
227 | * @return array containing the more restrictive values of $p1 and $p2 |
228 | */ |
229 | public static function maxOfPolicies( array $p1, array $p2 ) { |
230 | $ret = []; |
231 | $keys = array_merge( array_keys( $p1 ), array_keys( $p2 ) ); |
232 | foreach ( $keys as $key ) { |
233 | if ( !isset( $p1[$key] ) ) { |
234 | $ret[$key] = $p2[$key]; |
235 | } elseif ( !isset( $p2[$key] ) ) { |
236 | $ret[$key] = $p1[$key]; |
237 | } elseif ( !is_array( $p1[$key] ) && !is_array( $p2[$key] ) ) { |
238 | $ret[$key] = max( $p1[$key], $p2[$key] ); |
239 | } else { |
240 | if ( !is_array( $p1[$key] ) ) { |
241 | $p1[$key] = [ 'value' => $p1[$key] ]; |
242 | } elseif ( !is_array( $p2[$key] ) ) { |
243 | $p2[$key] = [ 'value' => $p2[$key] ]; |
244 | } |
245 | $ret[$key] = self::maxOfPolicies( $p1[$key], $p2[$key] ); |
246 | } |
247 | } |
248 | return $ret; |
249 | } |
250 | |
251 | } |