[TASK] Streamline PHPDoc comment matches function/method signature
[Packages/TYPO3.CMS.git] / typo3 / sysext / core / Classes / Authentication / AuthenticationService.php
1 <?php
2 namespace TYPO3\CMS\Core\Authentication;
3
4 /*
5 * This file is part of the TYPO3 CMS project.
6 *
7 * It is free software; you can redistribute it and/or modify it under
8 * the terms of the GNU General Public License, either version 2
9 * of the License, or any later version.
10 *
11 * For the full copyright and license information, please read the
12 * LICENSE.txt file that was distributed with this source code.
13 *
14 * The TYPO3 project - inspiring people to share!
15 */
16
17 use TYPO3\CMS\Core\Crypto\PasswordHashing\InvalidPasswordHashException;
18 use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory;
19 use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashInterface;
20 use TYPO3\CMS\Core\Database\Connection;
21 use TYPO3\CMS\Core\Database\ConnectionPool;
22 use TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction;
23 use TYPO3\CMS\Core\TimeTracker\TimeTracker;
24 use TYPO3\CMS\Core\Utility\GeneralUtility;
25
26 /**
27 * Authentication services class
28 */
29 class AuthenticationService extends AbstractAuthenticationService
30 {
31 /**
32 * Process the submitted credentials.
33 * In this case hash the clear text password if it has been submitted.
34 *
35 * @param array $loginData Credentials that are submitted and potentially modified by other services
36 * @param string $passwordTransmissionStrategy Keyword of how the password has been hashed or encrypted before submission
37 * @return bool
38 */
39 public function processLoginData(array &$loginData, $passwordTransmissionStrategy)
40 {
41 $isProcessed = false;
42 if ($passwordTransmissionStrategy === 'normal') {
43 $loginData['uident_text'] = $loginData['uident'];
44 $isProcessed = true;
45 }
46 return $isProcessed;
47 }
48
49 /**
50 * Find a user (eg. look up the user record in database when a login is sent)
51 *
52 * @return mixed User array or FALSE
53 */
54 public function getUser()
55 {
56 if ($this->login['status'] !== LoginType::LOGIN) {
57 return false;
58 }
59 if ((string)$this->login['uident_text'] === '') {
60 // Failed Login attempt (no password given)
61 $this->writelog(255, 3, 3, 2, 'Login-attempt from ###IP### for username \'%s\' with an empty password!', [
62 $this->login['uname']
63 ]);
64 $this->logger->warning(sprintf('Login-attempt from %s, for username \'%s\' with an empty password!', $this->authInfo['REMOTE_ADDR'], $this->login['uname']));
65 return false;
66 }
67
68 $user = $this->fetchUserRecord($this->login['uname']);
69 if (!is_array($user)) {
70 // Failed login attempt (no username found)
71 $this->writelog(255, 3, 3, 2, 'Login-attempt from ###IP###, username \'%s\' not found!!', [$this->login['uname']]);
72 $this->logger->info('Login-attempt from username \'' . $this->login['uname'] . '\' not found!', [
73 'REMOTE_ADDR' => $this->authInfo['REMOTE_ADDR']
74 ]);
75 } else {
76 $this->logger->debug('User found', [
77 $this->db_user['userid_column'] => $user[$this->db_user['userid_column']],
78 $this->db_user['username_column'] => $user[$this->db_user['username_column']]
79 ]);
80 }
81 return $user;
82 }
83
84 /**
85 * Authenticate a user: Check submitted user credentials against stored hashed password,
86 * check domain lock if configured.
87 *
88 * Returns one of the following status codes:
89 * >= 200: User authenticated successfully. No more checking is needed by other auth services.
90 * >= 100: User not authenticated; this service is not responsible. Other auth services will be asked.
91 * > 0: User authenticated successfully. Other auth services will still be asked.
92 * <= 0: Authentication failed, no more checking needed by other auth services.
93 *
94 * @param array $user User data
95 * @return int Authentication status code, one of 0, 100, 200
96 */
97 public function authUser(array $user): int
98 {
99 // Early 100 "not responsible, check other services" if username or password is empty
100 if (!isset($this->login['uident_text']) || (string)$this->login['uident_text'] === ''
101 || !isset($this->login['uname']) || (string)$this->login['uname'] === '') {
102 return 100;
103 }
104
105 if (empty($this->db_user['table'])) {
106 throw new \RuntimeException('User database table not set', 1533159150);
107 }
108
109 $submittedUsername = (string)$this->login['uname'];
110 $submittedPassword = (string)$this->login['uident_text'];
111 $passwordHashInDatabase = $user['password'];
112 $queriedDomain = $this->authInfo['HTTP_HOST'];
113 $configuredDomainLock = $user['lockToDomain'];
114 $userDatabaseTable = $this->db_user['table'];
115
116 $isSaltedPassword = false;
117 $isValidPassword = false;
118 $isReHashNeeded = false;
119 $isDomainLockMet = false;
120
121 $saltFactory = GeneralUtility::makeInstance(PasswordHashFactory::class);
122
123 // Get a hashed password instance for the hash stored in db of this user
124 try {
125 $hashInstance = $saltFactory->get($passwordHashInDatabase, TYPO3_MODE);
126 } catch (InvalidPasswordHashException $e) {
127 // This can be refactored if the 'else' part below is gone in v10: Log and return 100 here
128 $hashInstance = null;
129 }
130 // An instance of the currently configured salted password mechanism
131 // Don't catch InvalidPasswordHashException here: Only install tool should handle those configuration failures
132 $defaultHashInstance = $saltFactory->getDefaultHashInstance(TYPO3_MODE);
133
134 if ($hashInstance instanceof PasswordHashInterface) {
135 // We found a hash class that can handle this type of hash
136 $isSaltedPassword = true;
137 $isValidPassword = $hashInstance->checkPassword($submittedPassword, $passwordHashInDatabase);
138 if ($isValidPassword) {
139 if ($hashInstance->isHashUpdateNeeded($passwordHashInDatabase)
140 || $defaultHashInstance != $hashInstance
141 ) {
142 // Lax object comparison intended: Rehash if old and new salt objects are not
143 // instances of the same class.
144 $isReHashNeeded = true;
145 }
146 if (empty($configuredDomainLock)) {
147 // No domain restriction set for user in db. This is ok.
148 $isDomainLockMet = true;
149 } elseif (!strcasecmp($configuredDomainLock, $queriedDomain)) {
150 // Domain restriction set and it matches given host. Ok.
151 $isDomainLockMet = true;
152 }
153 }
154 } else {
155 // @todo @deprecated: The entire else should be removed in v10.0 as dedicated breaking patch
156 if (substr($user['password'], 0, 2) === 'M$') {
157 // If the stored db password starts with M$, it may be a md5 password that has been
158 // upgraded to a salted md5 using the old salted passwords scheduler task.
159 // See if a salt instance is returned if we cut off the M, so Md5PasswordHash kicks in
160 try {
161 $hashInstance = $saltFactory->get(substr($passwordHashInDatabase, 1), TYPO3_MODE);
162 $isSaltedPassword = true;
163 $isValidPassword = $hashInstance->checkPassword(md5($submittedPassword), substr($passwordHashInDatabase, 1));
164 if ($isValidPassword) {
165 // Upgrade this password to a sane mechanism now
166 $isReHashNeeded = true;
167 if (empty($configuredDomainLock)) {
168 // No domain restriction set for user in db. This is ok.
169 $isDomainLockMet = true;
170 } elseif (!strcasecmp($configuredDomainLock, $queriedDomain)) {
171 // Domain restriction set and it matches given host. Ok.
172 $isDomainLockMet = true;
173 }
174 }
175 } catch (InvalidPasswordHashException $e) {
176 // Still no instance found: $isSaltedPasswords is NOT set to true, logging and return done below
177 }
178 }
179 }
180
181 if (!$isSaltedPassword) {
182 // Could not find a responsible hash algorithm for given password. This is unusual since other
183 // authentication services would usually be called before this one with higher priority. We thus log
184 // the failed login but still return '100' to proceed with other services that may follow.
185 $message = 'Login-attempt from ###IP###, username \'%s\', no suitable hash method found!';
186 $this->writeLogMessage($message, $submittedUsername);
187 $this->writelog(255, 3, 3, 1, $message, [$submittedUsername]);
188 $this->logger->info(sprintf($message, $submittedUsername));
189 // Not responsible, check other services
190 return 100;
191 }
192
193 if (!$isValidPassword) {
194 // Failed login attempt - wrong password
195 $this->writeLogMessage(TYPO3_MODE . ' Authentication failed - wrong password for username \'%s\'', $submittedUsername);
196 $message = 'Login-attempt from ###IP###, username \'%s\', password not accepted!';
197 $this->writelog(255, 3, 3, 1, $message, [$submittedUsername]);
198 $this->logger->info(sprintf($message, $submittedUsername));
199 // Responsible, authentication failed, do NOT check other services
200 return 0;
201 }
202
203 if (!$isDomainLockMet) {
204 // Password ok, but configured domain lock not met
205 $errorMessage = 'Login-attempt from ###IP###, username \'%s\', locked domain \'%s\' did not match \'%s\'!';
206 $this->writeLogMessage($errorMessage, $user[$this->db_user['username_column']], $configuredDomainLock, $queriedDomain);
207 $this->writelog(255, 3, 3, 1, $errorMessage, [$user[$this->db_user['username_column']], $configuredDomainLock, $queriedDomain]);
208 $this->logger->info(sprintf($errorMessage, $user[$this->db_user['username_column']], $configuredDomainLock, $queriedDomain));
209 // Responsible, authentication ok, but domain lock not ok, do NOT check other services
210 return 0;
211 }
212
213 if ($isReHashNeeded) {
214 // Given password validated but a re-hash is needed. Do so.
215 $this->updatePasswordHashInDatabase(
216 $userDatabaseTable,
217 (int)$user['uid'],
218 $defaultHashInstance->getHashedPassword($submittedPassword)
219 );
220 }
221
222 // Responsible, authentication ok, domain lock ok. Log successful login and return 'auth ok, do NOT check other services'
223 $this->writeLogMessage(TYPO3_MODE . ' Authentication successful for username \'%s\'', $submittedUsername);
224 return 200;
225 }
226
227 /**
228 * Find usergroup records, currently only for frontend
229 *
230 * @param array $user Data of user.
231 * @param array $knownGroups Group data array of already known groups. This is handy if you want select other related groups. Keys in this array are unique IDs of those groups.
232 * @return mixed Groups array, keys = uid which must be unique
233 */
234 public function getGroups($user, $knownGroups)
235 {
236 // Attention: $knownGroups is not used within this method, but other services can use it.
237 // This parameter should not be removed!
238 // The FrontendUserAuthentication call getGroups and handover the previous detected groups.
239 $groupDataArr = [];
240 if ($this->mode === 'getGroupsFE') {
241 $groups = [];
242 if ($user[$this->db_user['usergroup_column']] ?? false) {
243 $groupList = $user[$this->db_user['usergroup_column']];
244 $groups = [];
245 $this->getSubGroups($groupList, '', $groups);
246 }
247 // ADD group-numbers if the IPmask matches.
248 foreach ($GLOBALS['TYPO3_CONF_VARS']['FE']['IPmaskMountGroups'] ?? [] as $IPel) {
249 if ($this->authInfo['REMOTE_ADDR'] && $IPel[0] && GeneralUtility::cmpIP($this->authInfo['REMOTE_ADDR'], $IPel[0])) {
250 $groups[] = (int)$IPel[1];
251 }
252 }
253 $groups = array_unique($groups);
254 if (!empty($groups)) {
255 $this->logger->debug('Get usergroups with id: ' . implode(',', $groups));
256 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
257 ->getQueryBuilderForTable($this->db_groups['table']);
258 if (!empty($this->authInfo['showHiddenRecords'])) {
259 $queryBuilder->getRestrictions()->removeByType(HiddenRestriction::class);
260 }
261
262 $res = $queryBuilder->select('*')
263 ->from($this->db_groups['table'])
264 ->where(
265 $queryBuilder->expr()->in(
266 'uid',
267 $queryBuilder->createNamedParameter($groups, Connection::PARAM_INT_ARRAY)
268 ),
269 $queryBuilder->expr()->orX(
270 $queryBuilder->expr()->eq(
271 'lockToDomain',
272 $queryBuilder->createNamedParameter('', \PDO::PARAM_STR)
273 ),
274 $queryBuilder->expr()->isNull('lockToDomain'),
275 $queryBuilder->expr()->eq(
276 'lockToDomain',
277 $queryBuilder->createNamedParameter($this->authInfo['HTTP_HOST'], \PDO::PARAM_STR)
278 )
279 )
280 )
281 ->execute();
282
283 while ($row = $res->fetch()) {
284 $groupDataArr[$row['uid']] = $row;
285 }
286 } else {
287 $this->logger->debug('No usergroups found.');
288 }
289 }
290 return $groupDataArr;
291 }
292
293 /**
294 * Fetches subgroups of groups. Function is called recursively for each subgroup.
295 * Function was previously copied from
296 * \TYPO3\CMS\Core\Authentication\BackendUserAuthentication->fetchGroups and has been slightly modified.
297 *
298 * @param string $grList Commalist of fe_groups uid numbers
299 * @param string $idList List of already processed fe_groups-uids so the function will not fall into an eternal recursion.
300 * @param array $groups
301 * @access private
302 */
303 public function getSubGroups($grList, $idList = '', &$groups)
304 {
305 // Fetching records of the groups in $grList (which are not blocked by lockedToDomain either):
306 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('fe_groups');
307 if (!empty($this->authInfo['showHiddenRecords'])) {
308 $queryBuilder->getRestrictions()->removeByType(HiddenRestriction::class);
309 }
310
311 $res = $queryBuilder
312 ->select('uid', 'subgroup')
313 ->from($this->db_groups['table'])
314 ->where(
315 $queryBuilder->expr()->in(
316 'uid',
317 $queryBuilder->createNamedParameter(
318 GeneralUtility::intExplode(',', $grList, true),
319 Connection::PARAM_INT_ARRAY
320 )
321 ),
322 $queryBuilder->expr()->orX(
323 $queryBuilder->expr()->eq(
324 'lockToDomain',
325 $queryBuilder->createNamedParameter('', \PDO::PARAM_STR)
326 ),
327 $queryBuilder->expr()->isNull('lockToDomain'),
328 $queryBuilder->expr()->eq(
329 'lockToDomain',
330 $queryBuilder->createNamedParameter($this->authInfo['HTTP_HOST'], \PDO::PARAM_STR)
331 )
332 )
333 )
334 ->execute();
335
336 // Internal group record storage
337 $groupRows = [];
338 // The groups array is filled
339 while ($row = $res->fetch()) {
340 if (!in_array($row['uid'], $groups)) {
341 $groups[] = $row['uid'];
342 }
343 $groupRows[$row['uid']] = $row;
344 }
345 // Traversing records in the correct order
346 $include_staticArr = GeneralUtility::intExplode(',', $grList);
347 // traversing list
348 foreach ($include_staticArr as $uid) {
349 // Get row:
350 $row = $groupRows[$uid];
351 // Must be an array and $uid should not be in the idList, because then it is somewhere previously in the grouplist
352 if (is_array($row) && !GeneralUtility::inList($idList, $uid) && trim($row['subgroup'])) {
353 // Make integer list
354 $theList = implode(',', GeneralUtility::intExplode(',', $row['subgroup']));
355 // Call recursively, pass along list of already processed groups so they are not processed again.
356 $this->getSubGroups($theList, $idList . ',' . $uid, $groups);
357 }
358 }
359 }
360
361 /**
362 * Method updates a FE/BE user record - in this case a new password string will be set.
363 *
364 * @param string $table Database table of this user, usually 'be_users' or 'fe_users'
365 * @param int $uid uid of user record that will be updated
366 * @param string $newPassword Field values as key=>value pairs to be updated in database
367 */
368 protected function updatePasswordHashInDatabase(string $table, int $uid, string $newPassword): void
369 {
370 $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
371 $connection->update(
372 $table,
373 ['password' => $newPassword],
374 ['uid' => $uid]
375 );
376 $this->logger->notice('Automatic password update for user record in ' . $table . ' with uid ' . $uid);
377 }
378
379 /**
380 * Writes log message. Destination log depends on the current system mode.
381 *
382 * This function accepts variable number of arguments and can format
383 * parameters. The syntax is the same as for sprintf()
384 *
385 * @param string $message Message to output
386 * @param array<int, mixed> $params
387 */
388 protected function writeLogMessage(string $message, ...$params): void
389 {
390 if (!empty($params)) {
391 $message = vsprintf($message, $params);
392 }
393 if (TYPO3_MODE === 'FE') {
394 $timeTracker = GeneralUtility::makeInstance(TimeTracker::class);
395 $timeTracker->setTSlogMessage($message);
396 }
397 $this->logger->notice($message);
398 }
399 }