Skip to content

Commit 4f35663

Browse files
feat: Add AuthService validateCredentials method
- test: cover canLogin()=false branch in validateCredentials() unit tests - docs: document known double-query cost in validateCredentials() - fix: use consistent error message in validateCredentials()
1 parent 45689ec commit 4f35663

4 files changed

Lines changed: 486 additions & 32 deletions

File tree

app/libs/Auth/AuthService.php

Lines changed: 85 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
<?php namespace Auth;
1+
<?php
2+
namespace Auth;
23
/**
34
* Copyright 2016 OpenStack Foundation
45
* Licensed under the Apache License, Version 2.0 (the "License");
@@ -97,21 +98,19 @@ final class AuthService extends AbstractService implements IAuthService
9798
* @param IAuthUserService $auth_user_service
9899
* @param ISecurityContextService $security_context_service
99100
* @param ITransactionService $tx_service
100-
* @params ISecurityContextService $security_context_service
101101
*/
102102
public function __construct
103103
(
104-
IUserRepository $user_repository,
105-
IOAuth2OTPRepository $otp_repository,
106-
IPrincipalService $principal_service,
107-
IUserService $user_service,
108-
IUserActionService $user_action_service,
109-
ICacheService $cache_service,
110-
IAuthUserService $auth_user_service,
104+
IUserRepository $user_repository,
105+
IOAuth2OTPRepository $otp_repository,
106+
IPrincipalService $principal_service,
107+
IUserService $user_service,
108+
IUserActionService $user_action_service,
109+
ICacheService $cache_service,
110+
IAuthUserService $auth_user_service,
111111
ISecurityContextService $security_context_service,
112-
ITransactionService $tx_service
113-
)
114-
{
112+
ITransactionService $tx_service
113+
) {
115114
parent::__construct($tx_service);
116115
$this->user_repository = $user_repository;
117116
$this->principal_service = $principal_service;
@@ -131,6 +130,17 @@ public function isUserLogged()
131130
return Auth::check();
132131
}
133132

133+
/**
134+
* @return User|null
135+
*/
136+
public function getCurrentUser(): ?User
137+
{
138+
$user = Auth::user();
139+
if ($user instanceof User) {
140+
return $user;
141+
}
142+
}
143+
134144
/**
135145
* Finds the OTP by value/connection/username, logs the redeem attempt (TX-A),
136146
* then validates lifecycle / value / scope / audience (TX-B).
@@ -140,13 +150,12 @@ public function isUserLogged()
140150
* @throws InvalidOTPException
141151
*/
142152
private function findAndValidateOTP(
143-
string $otp_value,
144-
string $user_name,
145-
string $otp_conn,
153+
string $otp_value,
154+
string $user_name,
155+
string $otp_conn,
146156
?string $otp_required_scopes,
147157
?Client $client
148-
): OAuth2OTP
149-
{
158+
): OAuth2OTP {
150159
// TX-A: find + log attempt (committed before any validation can throw)
151160
$otp = $this->tx_service->transaction(function () use ($otp_value, $otp_conn, $user_name, $client) {
152161

@@ -189,8 +198,10 @@ private function findAndValidateOTP(
189198
throw new InvalidOTPException("Single-use code requested scopes escalates former scopes.");
190199
}
191200

192-
if (($otp->hasClient() && is_null($client)) ||
193-
($otp->hasClient() && !is_null($client) && $client->getClientId() != $otp->getClient()->getClientId())) {
201+
if (
202+
($otp->hasClient() && is_null($client)) ||
203+
($otp->hasClient() && !is_null($client) && $client->getClientId() != $otp->getClient()->getClientId())
204+
) {
194205
throw new AuthenticationException("Single-use code audience mismatch.");
195206
}
196207

@@ -319,8 +330,7 @@ public function verifyOTPChallenge(
319330
OAuth2OTP $otpClaim,
320331
User $sessionUser,
321332
?Client $client = null
322-
): OAuth2OTP
323-
{
333+
): OAuth2OTP {
324334
Log::debug(sprintf(
325335
"AuthService::verifyOTPChallenge otp %s session user %s",
326336
$otpClaim->getValue(),
@@ -384,11 +394,10 @@ public function login(string $username, string $password, bool $remember_me): bo
384394
{
385395
Log::debug("AuthService::login");
386396

387-
$this->last_login_error = "";
388397
if (!Auth::attempt(['username' => $username, 'password' => $password], $remember_me)) {
389398
throw new AuthenticationException
390399
(
391-
"We are sorry, your username or password does not match an existing record."
400+
"username or password does not match an existing record."
392401
);
393402
}
394403
Log::debug("AuthService::login: clearing principal");
@@ -397,7 +406,7 @@ public function login(string $username, string $password, bool $remember_me): bo
397406
if (is_null($current_user) || !$current_user->canLogin())
398407
throw new AuthenticationException
399408
(
400-
"We are sorry, your username or password does not match an existing record."
409+
"username or password does not match an existing record."
401410
);
402411
$this->principal_service->register
403412
(
@@ -409,11 +418,51 @@ public function login(string $username, string $password, bool $remember_me): bo
409418
}
410419

411420
/**
412-
* @return User|null
421+
* @param string $username
422+
* @param string $password
423+
* @return User
424+
* @throws AuthenticationException
413425
*/
414-
public function getCurrentUser(): ?User
426+
public function validateCredentials(string $username, string $password): User
415427
{
416-
return Auth::user();
428+
Log::debug("AuthService::validateCredentials");
429+
430+
// retrieveByCredentials swallows AuthenticationLockedUserLoginAttempt and returns null,
431+
// so pre-check lock state here to surface a distinct message for locked accounts.
432+
$existing = $this->user_repository->getByEmailOrName($username);
433+
if (!is_null($existing) && !$existing->isActive()) {
434+
throw new AuthenticationException(
435+
sprintf("User %s is locked.", $username)
436+
);
437+
}
438+
439+
// Known cost: retrieveByCredentials() calls user_repository->getByEmailOrName() internally
440+
// (CustomAuthProvider line ~122), duplicating the query above. Eliminating it would require
441+
// either changing the provider API to accept a pre-fetched User, or moving
442+
// LockUserCounterMeasure checkpoint logic out of the provider — both out of scope here.
443+
$user = Auth::getProvider()->retrieveByCredentials([
444+
'username' => $username,
445+
'password' => $password,
446+
]);
447+
448+
if (is_null($user) || !$user instanceof User || !$user->canLogin()) {
449+
throw new AuthenticationException(
450+
"username or password does not match an existing record."
451+
);
452+
}
453+
454+
return $user;
455+
}
456+
457+
/**
458+
* @param User $user
459+
* @param bool $remember
460+
* @return void
461+
*/
462+
public function loginUser(User $user, bool $remember): void
463+
{
464+
Log::debug("AuthService::loginUser");
465+
Auth::login($user, $remember);
417466
}
418467

419468
/**
@@ -618,7 +667,8 @@ public function registerRPLogin(string $client_id): void
618667
$rps = $zlib->uncompress($rps);
619668
$rps .= '|';
620669
}
621-
if (is_null($rps)) $rps = "";
670+
if (is_null($rps))
671+
$rps = "";
622672

623673
if (!str_contains($rps, $client_id))
624674
$rps .= $client_id;
@@ -720,12 +770,15 @@ public function postLoginUserActions(int $user_id): void
720770
Log::debug(sprintf("AuthService::postLoginUserActions user %s", $user_id));
721771
$this->tx_service->transaction(function () use ($user_id) {
722772
$user = $this->user_repository->getById($user_id);
723-
if (!$user instanceof User) return;
773+
if (!$user instanceof User)
774+
return;
724775

725776
if (!$user->isActive()) {
726777
Log::warning(sprintf("AuthService::postLoginUserActions user %s is not active.", $user_id));
727-
throw new AuthenticationLockedUserLoginAttempt($user->getEmail(),
728-
sprintf("User %s is locked.", $user->getEmail()));
778+
throw new AuthenticationLockedUserLoginAttempt(
779+
$user->getEmail(),
780+
sprintf("User %s is locked.", $user->getEmail())
781+
);
729782
}
730783

731784
//update user fields
@@ -736,4 +789,4 @@ public function postLoginUserActions(int $user_id): void
736789

737790
});
738791
}
739-
}
792+
}

app/libs/Utils/Services/IAuthService.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,28 @@ public function getCurrentUser():?User;
5757
*/
5858
public function login(string $username, string $password, bool $remember_me): bool;
5959

60+
/**
61+
* Validates the supplied credentials without establishing a session.
62+
* Delegates to CustomAuthProvider::retrieveByCredentials() so security
63+
* checkpoints (LockUserCounterMeasure, etc.) still fire on failure.
64+
*
65+
* @param string $username
66+
* @param string $password
67+
* @return User
68+
* @throws AuthenticationException on invalid credentials, missing user, or locked account.
69+
*/
70+
public function validateCredentials(string $username, string $password): User;
71+
72+
/**
73+
* Establishes a Laravel session for an already-authenticated user.
74+
* Used by the 2FA flow after the second factor is verified.
75+
*
76+
* @param User $user
77+
* @param bool $remember
78+
* @return void
79+
*/
80+
public function loginUser(User $user, bool $remember): void;
81+
6082
/**
6183
* @param OAuth2OTP $otpClaim
6284
* @param Client|null $client
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<?php namespace Tests;
2+
/**
3+
* Copyright 2026 OpenStack Foundation
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
**/
14+
15+
use Auth\Exceptions\AuthenticationException;
16+
use Auth\Repositories\IUserRepository;
17+
use Auth\User;
18+
use Illuminate\Support\Facades\Auth;
19+
use LaravelDoctrine\ORM\Facades\EntityManager;
20+
use Utils\Services\IAuthService;
21+
use Utils\Services\UtilsServiceCatalog;
22+
23+
/**
24+
* Class AuthServiceValidateCredentialsIntegrationTest
25+
* Exercises AuthService::validateCredentials() against the real database and
26+
* security-checkpoint stack to verify that failed attempts increment the
27+
* user's login_failed_attempt counter (via LockUserCounterMeasure) and that
28+
* no session is established on either success or failure.
29+
*/
30+
final class AuthServiceValidateCredentialsIntegrationTest extends OpenStackIDBaseTestCase
31+
{
32+
// CustomAuthProvider looks up users via IUserRepository::getByEmailOrName(),
33+
// which currently matches only on the email column — so login uses the email
34+
// as the "username".
35+
private const SEEDED_USERNAME = 'sebastian@tipit.net';
36+
private const SEEDED_PASSWORD = '1Qaz2wsx!';
37+
38+
private IAuthService $auth_service;
39+
40+
protected function prepareForTests(): void
41+
{
42+
parent::prepareForTests();
43+
$this->auth_service = $this->app[UtilsServiceCatalog::AuthenticationService];
44+
}
45+
46+
/**
47+
* A failed validateCredentials() call must:
48+
* - throw AuthenticationException,
49+
* - NOT establish a session (Auth::check() stays false),
50+
* - trigger LockUserCounterMeasure so the user's login_failed_attempt counter increments.
51+
*/
52+
public function testFailedAttempt_incrementsLoginFailedAttemptCounter(): void
53+
{
54+
$initial_attempts = $this->getLoginFailedAttempt(self::SEEDED_USERNAME);
55+
$this->assertFalse(Auth::check(), 'precondition: no authenticated user');
56+
57+
$threw = false;
58+
try {
59+
$this->auth_service->validateCredentials(self::SEEDED_USERNAME, 'wrong-password');
60+
} catch (AuthenticationException $ex) {
61+
$threw = true;
62+
}
63+
64+
$this->assertTrue($threw, 'Expected AuthenticationException on wrong password');
65+
$this->assertFalse(Auth::check(), 'No session should be established after a failed attempt');
66+
67+
$new_attempts = $this->getLoginFailedAttempt(self::SEEDED_USERNAME);
68+
$this->assertSame(
69+
$initial_attempts + 1,
70+
$new_attempts,
71+
'login_failed_attempt counter must increment via LockUserCounterMeasure'
72+
);
73+
}
74+
75+
/**
76+
* A successful validateCredentials() call must return the user without
77+
* establishing a session — Auth::check() must remain false afterwards.
78+
*/
79+
public function testSuccessfulValidation_doesNotEstablishSession(): void
80+
{
81+
$this->assertFalse(Auth::check(), 'precondition: no authenticated user');
82+
83+
$user = $this->auth_service->validateCredentials(
84+
self::SEEDED_USERNAME,
85+
self::SEEDED_PASSWORD
86+
);
87+
88+
$this->assertInstanceOf(User::class, $user);
89+
$this->assertFalse(
90+
Auth::check(),
91+
'validateCredentials() must NOT call Auth::login() on success'
92+
);
93+
}
94+
95+
private function getLoginFailedAttempt(string $username): int
96+
{
97+
// Clear Doctrine's identity map so we read fresh state from the DB,
98+
// not a cached in-memory entity from a prior transaction.
99+
EntityManager::clear();
100+
$repo = EntityManager::getRepository(User::class);
101+
/** @var IUserRepository $repo */
102+
$user = $repo->getByEmailOrName($username);
103+
$this->assertInstanceOf(User::class, $user, "Seeded user {$username} not found");
104+
return $user->getLoginFailedAttempt();
105+
}
106+
}

0 commit comments

Comments
 (0)