Skip to content

Commit f4ee340

Browse files
feat: Add AuthService validateCredentials method
1 parent 823bfd0 commit f4ee340

5 files changed

Lines changed: 452 additions & 6 deletions

File tree

app/libs/Auth/AuthService.php

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,6 @@ final class AuthService extends AbstractService implements IAuthService
9696
* @param IAuthUserService $auth_user_service
9797
* @param ISecurityContextService $security_context_service
9898
* @param ITransactionService $tx_service
99-
* @params ISecurityContextService $security_context_service
10099
*/
101100
public function __construct
102101
(
@@ -135,7 +134,11 @@ public function isUserLogged()
135134
*/
136135
public function getCurrentUser(): ?User
137136
{
138-
return Auth::user();
137+
$user = Auth::user();
138+
if ($user instanceof User) {
139+
return $user;
140+
}
141+
return null;
139142
}
140143

141144
/**
@@ -149,11 +152,10 @@ public function login(string $username, string $password, bool $remember_me): bo
149152
{
150153
Log::debug("AuthService::login");
151154

152-
$this->last_login_error = "";
153155
if (!Auth::attempt(['username' => $username, 'password' => $password], $remember_me)) {
154156
throw new AuthenticationException
155157
(
156-
"We are sorry, your username or password does not match an existing record."
158+
"username or password does not match an existing record."
157159
);
158160
}
159161
Log::debug("AuthService::login: clearing principal");
@@ -162,7 +164,7 @@ public function login(string $username, string $password, bool $remember_me): bo
162164
if(is_null($current_user) || !$current_user->canLogin())
163165
throw new AuthenticationException
164166
(
165-
"We are sorry, your username or password does not match an existing record."
167+
"username or password does not match an existing record."
166168
);
167169
$this->principal_service->register
168170
(
@@ -173,6 +175,50 @@ public function login(string $username, string $password, bool $remember_me): bo
173175
return true;
174176
}
175177

178+
/**
179+
* @param string $username
180+
* @param string $password
181+
* @return User
182+
* @throws AuthenticationException
183+
*/
184+
public function validateCredentials(string $username, string $password): User
185+
{
186+
Log::debug("AuthService::validateCredentials");
187+
188+
// retrieveByCredentials swallows AuthenticationLockedUserLoginAttempt and returns null,
189+
// so pre-check lock state here to surface a distinct message for locked accounts.
190+
$existing = $this->user_repository->getByEmailOrName($username);
191+
if (!is_null($existing) && !$existing->isActive()) {
192+
throw new AuthenticationException(
193+
sprintf("User %s is locked.", $username)
194+
);
195+
}
196+
197+
$user = Auth::getProvider()->retrieveByCredentials([
198+
'username' => $username,
199+
'password' => $password,
200+
]);
201+
202+
if (is_null($user) || !$user instanceof User || !$user->canLogin()) {
203+
throw new AuthenticationException(
204+
"We are sorry, your username or password does not match an existing record."
205+
);
206+
}
207+
208+
return $user;
209+
}
210+
211+
/**
212+
* @param User $user
213+
* @param bool $remember
214+
* @return void
215+
*/
216+
public function loginUser(User $user, bool $remember): void
217+
{
218+
Log::debug("AuthService::loginUser");
219+
Auth::login($user, $remember);
220+
}
221+
176222
/**
177223
* @param OAuth2OTP $otpClaim
178224
* @param Client|null $client
@@ -264,7 +310,7 @@ public function loginWithOTP(OAuth2OTP $otpClaim, ?Client $client = null, bool $
264310

265311
if(!$user->canLogin()){
266312
Log::warning(sprintf("AuthService::loginWithOTP user %s cannot login ( is not active ).", $user->getId()));
267-
throw new AuthenticationException("We are sorry, your username or password does not match an existing record.");
313+
throw new AuthenticationException("username or password does not match an existing record.");
268314
}
269315

270316
$otp->setAuthTime(time());

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+
}

tests/CustomAuthProviderTest.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,24 @@
1212
* limitations under the License.
1313
**/
1414
use Auth\CustomAuthProvider;
15+
use Auth\User;
1516
use Utils\Services\UtilsServiceCatalog;
1617
use OpenId\Services\OpenIdServiceCatalog;
1718
use Auth\Repositories\IUserRepository;
1819
use Auth\IAuthenticationExtensionService;
1920
use Illuminate\Support\Facades\App;
21+
use Mockery;
2022
/**
2123
* Class CustomAuthProviderTest
2224
*/
2325
final class CustomAuthProviderTest extends TestCase {
2426

27+
public function tearDown(): void
28+
{
29+
parent::tearDown();
30+
Mockery::close();
31+
}
32+
2533
/**
2634
* @return CustomAuthProvider
2735
*/
@@ -39,4 +47,31 @@ public function testCreateProvider(){
3947

4048
return $provider;
4149
}
50+
51+
public function testValidateCredentialsReturnsFalseWhenUserCannotLoginDueToUnverifiedEmail(): void
52+
{
53+
// A freshly constructed User has active=true, email_verified=false → canLogin()=false.
54+
// This covers the canLogin()===false branch for a reason other than isActive().
55+
$user = new User();
56+
57+
$repo = Mockery::mock(IUserRepository::class);
58+
$repo->shouldReceive('getByEmailOrName')
59+
->with('unverified@example.com')
60+
->andReturn($user);
61+
62+
$provider = new CustomAuthProvider(
63+
$repo,
64+
App::make(IAuthenticationExtensionService::class),
65+
App::make(OpenIdServiceCatalog::UserService),
66+
App::make(UtilsServiceCatalog::CheckPointService),
67+
App::make(UtilsServiceCatalog::TransactionService)
68+
);
69+
70+
$result = $provider->validateCredentials(
71+
$user,
72+
['username' => 'unverified@example.com', 'password' => 'any-password']
73+
);
74+
75+
$this->assertFalse($result);
76+
}
4277
}

0 commit comments

Comments
 (0)