Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
79.17% |
76 / 96 |
|
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 | namespace MediaWiki\Password; |
24 | |
25 | use DomainException; |
26 | use InvalidArgumentException; |
27 | use MediaWiki\HookContainer\HookRunner; |
28 | use MediaWiki\MediaWikiServices; |
29 | use MediaWiki\Status\Status; |
30 | use MediaWiki\User\UserIdentity; |
31 | use StatusValue; |
32 | |
33 | /** |
34 | * Check if a user's password complies with any password policies that apply to that |
35 | * user, based on the user's group membership. |
36 | * @since 1.26 |
37 | */ |
38 | class UserPasswordPolicy { |
39 | |
40 | /** |
41 | * @var array[] |
42 | */ |
43 | private $policies; |
44 | |
45 | /** |
46 | * Mapping of statements to the function that will test the password for compliance. The |
47 | * checking functions take the policy value, the user, and password, and return a Status |
48 | * object indicating compliance. |
49 | * @var callable[] |
50 | */ |
51 | private $policyCheckFunctions; |
52 | |
53 | /** |
54 | * @param array[] $policies List of policies per user group |
55 | * @param callable[] $checks mapping statement to its checking function. Checking functions are |
56 | * called with the policy value for this user, the user object, and the password to check. |
57 | */ |
58 | public function __construct( array $policies, array $checks ) { |
59 | if ( !isset( $policies['default'] ) ) { |
60 | throw new InvalidArgumentException( |
61 | 'Must include a \'default\' password policy' |
62 | ); |
63 | } |
64 | $this->policies = $policies; |
65 | |
66 | foreach ( $checks as $statement => $check ) { |
67 | if ( !is_callable( $check ) ) { |
68 | throw new InvalidArgumentException( |
69 | "Policy check functions must be callable. '$statement' isn't callable." |
70 | ); |
71 | } |
72 | $this->policyCheckFunctions[$statement] = $check; |
73 | } |
74 | } |
75 | |
76 | /** |
77 | * Check if a password meets the effective password policy for a User. |
78 | * @param UserIdentity $user whose policy we are checking |
79 | * @param string $password the password to check |
80 | * @return Status error to indicate the password didn't meet the policy, or fatal to |
81 | * indicate the user shouldn't be allowed to login. The status value will be an array, |
82 | * potentially with the following keys: |
83 | * - forceChange: do not allow the user to login without changing the password if invalid. |
84 | * - suggestChangeOnLogin: prompt for a password change on login if the password is invalid. |
85 | */ |
86 | public function checkUserPassword( UserIdentity $user, $password ) { |
87 | $effectivePolicy = $this->getPoliciesForUser( $user ); |
88 | return $this->checkPolicies( |
89 | $user, |
90 | $password, |
91 | $effectivePolicy, |
92 | $this->policyCheckFunctions |
93 | ); |
94 | } |
95 | |
96 | /** |
97 | * Check if a password meets the effective password policy for a User, using a set |
98 | * of groups they may or may not belong to. This function does not use the DB, so can |
99 | * be used in the installer. |
100 | * @param UserIdentity $user whose policy we are checking |
101 | * @param string $password the password to check |
102 | * @param string[] $groups list of groups to which we assume the user belongs |
103 | * @return Status error to indicate the password didn't meet the policy, or fatal to |
104 | * indicate the user shouldn't be allowed to login. The status value will be an array, |
105 | * potentially with the following keys: |
106 | * - forceChange: do not allow the user to login without changing the password if invalid. |
107 | * - suggestChangeOnLogin: prompt for a password change on login if the password is invalid. |
108 | */ |
109 | public function checkUserPasswordForGroups( UserIdentity $user, $password, array $groups ) { |
110 | $effectivePolicy = self::getPoliciesForGroups( |
111 | $this->policies, |
112 | $groups, |
113 | $this->policies['default'] |
114 | ); |
115 | return $this->checkPolicies( |
116 | $user, |
117 | $password, |
118 | $effectivePolicy, |
119 | $this->policyCheckFunctions |
120 | ); |
121 | } |
122 | |
123 | /** |
124 | * @param UserIdentity $user |
125 | * @param string $password |
126 | * @param array $policies List of policy statements for the group the user belongs to |
127 | * @param callable[] $policyCheckFunctions |
128 | * @return Status |
129 | */ |
130 | private function checkPolicies( UserIdentity $user, $password, $policies, $policyCheckFunctions ) { |
131 | $status = Status::newGood( [] ); |
132 | $forceChange = false; |
133 | $suggestChangeOnLogin = false; |
134 | $legacyUser = MediaWikiServices::getInstance() |
135 | ->getUserFactory() |
136 | ->newFromUserIdentity( $user ); |
137 | foreach ( $policies as $policy => $settings ) { |
138 | if ( !isset( $policyCheckFunctions[$policy] ) ) { |
139 | throw new DomainException( "Invalid password policy config. No check defined for '$policy'." ); |
140 | } |
141 | if ( !is_array( $settings ) ) { |
142 | // legacy format |
143 | $settings = [ 'value' => $settings ]; |
144 | } |
145 | if ( !array_key_exists( 'value', $settings ) ) { |
146 | throw new DomainException( "Invalid password policy config. No value defined for '$policy'." ); |
147 | } |
148 | $value = $settings['value']; |
149 | /** @var StatusValue $policyStatus */ |
150 | $policyStatus = call_user_func( |
151 | $policyCheckFunctions[$policy], |
152 | $value, |
153 | $legacyUser, |
154 | $password |
155 | ); |
156 | |
157 | if ( !$policyStatus->isGood() ) { |
158 | if ( !empty( $settings['forceChange'] ) ) { |
159 | $forceChange = true; |
160 | } |
161 | |
162 | if ( !empty( $settings['suggestChangeOnLogin'] ) ) { |
163 | $suggestChangeOnLogin = true; |
164 | } |
165 | } |
166 | $status->merge( $policyStatus ); |
167 | } |
168 | |
169 | if ( $status->isOK() ) { |
170 | if ( $forceChange ) { |
171 | $status->value['forceChange'] = true; |
172 | } elseif ( $suggestChangeOnLogin ) { |
173 | $status->value['suggestChangeOnLogin'] = true; |
174 | } |
175 | } |
176 | |
177 | return $status; |
178 | } |
179 | |
180 | /** |
181 | * Get the policy for a user, based on their group membership. Public so |
182 | * UI elements can access and inform the user. |
183 | * @param UserIdentity $user |
184 | * @return array the effective policy for $user |
185 | */ |
186 | public function getPoliciesForUser( UserIdentity $user ) { |
187 | $services = MediaWikiServices::getInstance(); |
188 | $effectivePolicy = self::getPoliciesForGroups( |
189 | $this->policies, |
190 | $services->getUserGroupManager() |
191 | ->getUserEffectiveGroups( $user ), |
192 | $this->policies['default'] |
193 | ); |
194 | |
195 | $legacyUser = $services->getUserFactory() |
196 | ->newFromUserIdentity( $user ); |
197 | ( new HookRunner( $services->getHookContainer() ) )->onPasswordPoliciesForUser( $legacyUser, $effectivePolicy ); |
198 | |
199 | return $effectivePolicy; |
200 | } |
201 | |
202 | /** |
203 | * Utility function to get the effective policy from a list of policies, based |
204 | * on a list of groups. |
205 | * @param array[] $policies List of policies per user group |
206 | * @param string[] $userGroups the groups from which we calculate the effective policy |
207 | * @param array $defaultPolicy the default policy to start from |
208 | * @return array effective policy |
209 | */ |
210 | public static function getPoliciesForGroups( array $policies, array $userGroups, |
211 | array $defaultPolicy |
212 | ) { |
213 | $effectivePolicy = $defaultPolicy; |
214 | foreach ( $policies as $group => $policy ) { |
215 | if ( in_array( $group, $userGroups ) ) { |
216 | $effectivePolicy = self::maxOfPolicies( |
217 | $effectivePolicy, |
218 | $policy |
219 | ); |
220 | } |
221 | } |
222 | |
223 | return $effectivePolicy; |
224 | } |
225 | |
226 | /** |
227 | * Utility function to get a policy that is the most restrictive of $p1 and $p2. For |
228 | * simplicity, we setup the policy values so the maximum value is always more restrictive. |
229 | * It is also used recursively to merge settings within the same policy. |
230 | * @param array $p1 |
231 | * @param array $p2 |
232 | * @return array containing the more restrictive values of $p1 and $p2 |
233 | */ |
234 | public static function maxOfPolicies( array $p1, array $p2 ) { |
235 | $ret = []; |
236 | $keys = array_merge( array_keys( $p1 ), array_keys( $p2 ) ); |
237 | foreach ( $keys as $key ) { |
238 | if ( !isset( $p1[$key] ) ) { |
239 | $ret[$key] = $p2[$key]; |
240 | } elseif ( !isset( $p2[$key] ) ) { |
241 | $ret[$key] = $p1[$key]; |
242 | } elseif ( !is_array( $p1[$key] ) && !is_array( $p2[$key] ) ) { |
243 | $ret[$key] = max( $p1[$key], $p2[$key] ); |
244 | } else { |
245 | if ( !is_array( $p1[$key] ) ) { |
246 | $p1[$key] = [ 'value' => $p1[$key] ]; |
247 | } elseif ( !is_array( $p2[$key] ) ) { |
248 | $p2[$key] = [ 'value' => $p2[$key] ]; |
249 | } |
250 | $ret[$key] = self::maxOfPolicies( $p1[$key], $p2[$key] ); |
251 | } |
252 | } |
253 | return $ret; |
254 | } |
255 | |
256 | } |
257 | |
258 | /** @deprecated since 1.43 use MediaWiki\\Password\\UserPasswordPolicy */ |
259 | class_alias( UserPasswordPolicy::class, 'UserPasswordPolicy' ); |