diff --git a/app/libs/Auth/AuthService.php b/app/libs/Auth/AuthService.php index 928c5af8..ae71663d 100644 --- a/app/libs/Auth/AuthService.php +++ b/app/libs/Auth/AuthService.php @@ -1,4 +1,5 @@ -user_repository = $user_repository; $this->principal_service = $principal_service; @@ -131,6 +131,14 @@ public function isUserLogged() return Auth::check(); } + /** + * @return User|null + */ + public function getCurrentUser(): ?User + { + return Auth::user(); + } + /** * Finds the OTP by value/connection/username, logs the redeem attempt (TX-A), * then validates lifecycle / value / scope / audience (TX-B). @@ -140,13 +148,12 @@ public function isUserLogged() * @throws InvalidOTPException */ private function findAndValidateOTP( - string $otp_value, - string $user_name, - string $otp_conn, + string $otp_value, + string $user_name, + string $otp_conn, ?string $otp_required_scopes, ?Client $client - ): OAuth2OTP - { + ): OAuth2OTP { // TX-A: find + log attempt (committed before any validation can throw) $otp = $this->tx_service->transaction(function () use ($otp_value, $otp_conn, $user_name, $client) { @@ -189,8 +196,10 @@ private function findAndValidateOTP( throw new InvalidOTPException("Single-use code requested scopes escalates former scopes."); } - if (($otp->hasClient() && is_null($client)) || - ($otp->hasClient() && !is_null($client) && $client->getClientId() != $otp->getClient()->getClientId())) { + if ( + ($otp->hasClient() && is_null($client)) || + ($otp->hasClient() && !is_null($client) && $client->getClientId() != $otp->getClient()->getClientId()) + ) { throw new AuthenticationException("Single-use code audience mismatch."); } @@ -319,8 +328,7 @@ public function verifyOTPChallenge( OAuth2OTP $otpClaim, User $sessionUser, ?Client $client = null - ): OAuth2OTP - { + ): OAuth2OTP { Log::debug(sprintf( "AuthService::verifyOTPChallenge otp %s session user %s", $otpClaim->getValue(), @@ -409,11 +417,41 @@ public function login(string $username, string $password, bool $remember_me): bo } /** + * @param string $username + * @param string $password * @return User|null + * @throws AuthenticationException */ - public function getCurrentUser(): ?User + public function validateCredentials(string $username, string $password): User { - return Auth::user(); + Log::debug("AuthService::validateCredentials"); + + try { + /** + * @var User|null $user + */ + $user = Auth::getProvider()->retrieveByCredentials(['username' => $username, 'password' => $password]); + } catch (UnverifiedEmailMemberException $ex) { + throw new AuthenticationException($ex->getMessage()); + } + + if (is_null($user) || !$user instanceof User || !$user->canLogin()) { + throw new AuthenticationException("We are sorry, your username or password does not match an existing record."); + } + return $user; + } + + /** + * @param User $user + * @param bool $remember + * @return void + */ + public function loginUser(User $user, bool $remember): void + { + Log::debug("AuthService::loginUser"); + if (!$user->canLogin()) + throw new AuthenticationException("User is not active or cannot login."); + Auth::login($user, $remember); } /** @@ -618,7 +656,8 @@ public function registerRPLogin(string $client_id): void $rps = $zlib->uncompress($rps); $rps .= '|'; } - if (is_null($rps)) $rps = ""; + if (is_null($rps)) + $rps = ""; if (!str_contains($rps, $client_id)) $rps .= $client_id; @@ -720,12 +759,15 @@ public function postLoginUserActions(int $user_id): void Log::debug(sprintf("AuthService::postLoginUserActions user %s", $user_id)); $this->tx_service->transaction(function () use ($user_id) { $user = $this->user_repository->getById($user_id); - if (!$user instanceof User) return; + if (!$user instanceof User) + return; if (!$user->isActive()) { Log::warning(sprintf("AuthService::postLoginUserActions user %s is not active.", $user_id)); - throw new AuthenticationLockedUserLoginAttempt($user->getEmail(), - sprintf("User %s is locked.", $user->getEmail())); + throw new AuthenticationLockedUserLoginAttempt( + $user->getEmail(), + sprintf("User %s is locked.", $user->getEmail()) + ); } //update user fields diff --git a/app/libs/Utils/Services/IAuthService.php b/app/libs/Utils/Services/IAuthService.php index a8fc3dda..c3d26e62 100644 --- a/app/libs/Utils/Services/IAuthService.php +++ b/app/libs/Utils/Services/IAuthService.php @@ -57,6 +57,28 @@ public function getCurrentUser():?User; */ public function login(string $username, string $password, bool $remember_me): bool; + /** + * Validates the supplied credentials without establishing a session. + * Delegates to CustomAuthProvider::retrieveByCredentials() so security + * checkpoints (LockUserCounterMeasure, etc.) still fire on failure. + * + * @param string $username + * @param string $password + * @return User + * @throws AuthenticationException on invalid credentials, missing user, or locked account. + */ + public function validateCredentials(string $username, string $password): User; + + /** + * Establishes a Laravel session for an already-authenticated user. + * Used by the 2FA flow after the second factor is verified. + * + * @param User $user + * @param bool $remember + * @return void + */ + public function loginUser(User $user, bool $remember): void; + /** * @param OAuth2OTP $otpClaim * @param Client|null $client diff --git a/tests/AuthServiceValidateCredentialsIntegrationTest.php b/tests/AuthServiceValidateCredentialsIntegrationTest.php new file mode 100644 index 00000000..ab512a6b --- /dev/null +++ b/tests/AuthServiceValidateCredentialsIntegrationTest.php @@ -0,0 +1,106 @@ +auth_service = $this->app[UtilsServiceCatalog::AuthenticationService]; + } + + /** + * A failed validateCredentials() call must: + * - throw AuthenticationException, + * - NOT establish a session (Auth::check() stays false), + * - trigger LockUserCounterMeasure so the user's login_failed_attempt counter increments. + */ + public function testFailedAttempt_incrementsLoginFailedAttemptCounter(): void + { + $initial_attempts = $this->getLoginFailedAttempt(self::SEEDED_USERNAME); + $this->assertFalse(Auth::check(), 'precondition: no authenticated user'); + + $threw = false; + try { + $this->auth_service->validateCredentials(self::SEEDED_USERNAME, 'wrong-password'); + } catch (AuthenticationException $ex) { + $threw = true; + } + + $this->assertTrue($threw, 'Expected AuthenticationException on wrong password'); + $this->assertFalse(Auth::check(), 'No session should be established after a failed attempt'); + + $new_attempts = $this->getLoginFailedAttempt(self::SEEDED_USERNAME); + $this->assertSame( + $initial_attempts + 1, + $new_attempts, + 'login_failed_attempt counter must increment via LockUserCounterMeasure' + ); + } + + /** + * A successful validateCredentials() call must return the user without + * establishing a session — Auth::check() must remain false afterwards. + */ + public function testSuccessfulValidation_doesNotEstablishSession(): void + { + $this->assertFalse(Auth::check(), 'precondition: no authenticated user'); + + $user = $this->auth_service->validateCredentials( + self::SEEDED_USERNAME, + self::SEEDED_PASSWORD + ); + + $this->assertInstanceOf(User::class, $user); + $this->assertFalse( + Auth::check(), + 'validateCredentials() must NOT call Auth::login() on success' + ); + } + + private function getLoginFailedAttempt(string $username): int + { + // Clear Doctrine's identity map so we read fresh state from the DB, + // not a cached in-memory entity from a prior transaction. + EntityManager::clear(); + $repo = EntityManager::getRepository(User::class); + /** @var IUserRepository $repo */ + $user = $repo->getByEmailOrName($username); + $this->assertInstanceOf(User::class, $user, "Seeded user {$username} not found"); + return $user->getLoginFailedAttempt(); + } +} diff --git a/tests/unit/AuthServiceValidateCredentialsTest.php b/tests/unit/AuthServiceValidateCredentialsTest.php new file mode 100644 index 00000000..a504adc4 --- /dev/null +++ b/tests/unit/AuthServiceValidateCredentialsTest.php @@ -0,0 +1,234 @@ +mock_user_repository = $this->createMock(IUserRepository::class); + $mock_otp_repository = $this->createMock(IOAuth2OTPRepository::class); + $mock_principal_service = $this->createMock(IPrincipalService::class); + $mock_user_service = $this->createMock(IUserService::class); + $mock_user_action_service = $this->createMock(IUserActionService::class); + $mock_cache_service = $this->createMock(ICacheService::class); + $mock_auth_user_service = $this->createMock(IAuthUserService::class); + $mock_security_context_service = $this->createMock(ISecurityContextService::class); + $mock_tx_service = $this->createMock(ITransactionService::class); + + $this->auth_mock = Mockery::mock('alias:Illuminate\Support\Facades\Auth'); + $this->log_mock = Mockery::mock('alias:Illuminate\Support\Facades\Log'); + + $this->log_mock->shouldReceive('debug')->zeroOrMoreTimes(); + $this->log_mock->shouldReceive('warning')->zeroOrMoreTimes(); + + $this->service = new AuthService( + $this->mock_user_repository, + $mock_otp_repository, + $mock_principal_service, + $mock_user_service, + $mock_user_action_service, + $mock_cache_service, + $mock_auth_user_service, + $mock_security_context_service, + $mock_tx_service + ); + } + + /** + * Valid credentials return the User WITHOUT establishing a session. + * Auth::login() and Auth::attempt() must NEVER be called. + */ + public function testValidCredentials_returnsUser_withoutEstablishingSession(): void + { + $username = 'jane.doe'; + $password = 'Str0ng!Pass'; + + $resolved_user = Mockery::mock('Auth\User'); + $resolved_user->shouldReceive('canLogin')->once()->andReturn(true); + + $provider_mock = Mockery::mock(CustomAuthProvider::class); + $provider_mock->shouldReceive('retrieveByCredentials') + ->once() + ->with(['username' => $username, 'password' => $password]) + ->andReturn($resolved_user); + + $this->auth_mock->shouldReceive('getProvider')->once()->andReturn($provider_mock); + $this->auth_mock->shouldNotReceive('login'); + $this->auth_mock->shouldNotReceive('attempt'); + + $returned = $this->service->validateCredentials($username, $password); + + $this->assertSame($resolved_user, $returned); + } + + /** + * Invalid credentials (provider returns null) throw AuthenticationException + * and do NOT establish a session. + */ + public function testInvalidCredentials_throwsAuthenticationException(): void + { + $username = 'jane.doe'; + $password = 'wrong'; + + $provider_mock = Mockery::mock(CustomAuthProvider::class); + $provider_mock->shouldReceive('retrieveByCredentials') + ->once() + ->with(['username' => $username, 'password' => $password]) + ->andReturn(null); + + $this->auth_mock->shouldReceive('getProvider')->once()->andReturn($provider_mock); + $this->auth_mock->shouldNotReceive('login'); + $this->auth_mock->shouldNotReceive('attempt'); + + $this->expectException(AuthenticationException::class); + + $this->service->validateCredentials($username, $password); + } + + /** + * loginUser(user, true) delegates to Auth::login with the remember flag set. + */ + public function testLoginUser_callsAuthLogin_withRememberTrue(): void + { + $user = Mockery::mock('Auth\User'); + $user->shouldReceive('canLogin')->andReturn(true); + + $this->auth_mock + ->shouldReceive('login') + ->once() + ->with($user, true); + + $this->service->loginUser($user, true); + } + + /** + * loginUser(user, false) delegates to Auth::login with remember disabled. + */ + public function testLoginUser_callsAuthLogin_withRememberFalse(): void + { + $user = Mockery::mock('Auth\User'); + $user->shouldReceive('canLogin')->andReturn(true); + + $this->auth_mock + ->shouldReceive('login') + ->once() + ->with($user, false); + + $this->service->loginUser($user, false); + } + + /** + * loginUser(user, [true|false]) and isActive or canLogin false throws an Exception. + */ + public function testLoginUser_throwsException_whenIsNotActive(): void + { + $user = Mockery::mock('Auth\User'); + $user->shouldReceive('canLogin')->andReturn(false); + + $this->auth_mock->shouldNotReceive('login'); + + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessageMatches('/User is not active or cannot login\./'); + + $this->service->loginUser($user, true); + } + + /** + * UnverifiedEmailMemberException from the provider must be caught and + * re-thrown as AuthenticationException (contract: @throws AuthenticationException only). + */ + public function testUnverifiedUser_throwsAuthenticationException(): void + { + $username = 'unverified@example.com'; + $password = 'any'; + + $provider_mock = Mockery::mock(CustomAuthProvider::class); + $provider_mock->shouldReceive('retrieveByCredentials') + ->once() + ->with(['username' => $username, 'password' => $password]) + ->andThrow(new UnverifiedEmailMemberException('Email not verified.')); + + $this->auth_mock->shouldReceive('getProvider')->once()->andReturn($provider_mock); + $this->auth_mock->shouldNotReceive('login'); + + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('Email not verified.'); + + $this->service->validateCredentials($username, $password); + } + + /** + * Provider returns a valid User but canLogin() is false (locked/inactive): + * must throw AuthenticationException — not silently return the user. + */ + public function testUserCannotLogin_throwsAuthenticationException(): void + { + $username = 'locked@example.com'; + $password = 'any'; + + $locked_user = Mockery::mock('Auth\User'); + $locked_user->shouldReceive('canLogin')->once()->andReturn(false); + + $provider_mock = Mockery::mock(CustomAuthProvider::class); + $provider_mock->shouldReceive('retrieveByCredentials') + ->once() + ->with(['username' => $username, 'password' => $password]) + ->andReturn($locked_user); + + $this->auth_mock->shouldReceive('getProvider')->once()->andReturn($provider_mock); + $this->auth_mock->shouldNotReceive('login'); + + $this->expectException(AuthenticationException::class); + + $this->service->validateCredentials($username, $password); + } + +}