Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions doc/CHECK_AUTH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# Checking Credentials Without Logging In

Horde's `Horde_Core_Auth_Application` wraps the configured auth backend
(SQL, LDAP, IMAP, etc.) and manages session state on successful login.
Sometimes you need to verify that a username/password pair is valid
**without** starting a session, modifying globals or touching the
registry — for example in token-exchange API endpoints, admin tools that
test accounts or middleware that gates access to stateless resources.

## The `checkCredentials()` method

`Horde_Core_Auth_Application` exposes a dedicated method:

```php
use Horde\Core\Auth\CredentialCheckResult;

/** @var Horde_Core_Auth_Application $auth */
$result = $auth->checkCredentials($userId, ['password' => $password]);

if ($result === CredentialCheckResult::Valid) {
// credentials are good — no session was created
}
```

The method:

1. Runs the `preauthenticate` hook (same as a normal login).
2. Delegates to the underlying base driver's `authenticate()` which
performs lock checking, bad-login tracking and the actual credential
validation.
3. Does **not** call `_setAuth()` — no session is created, no registry
state is changed, no view mode is set.
4. Returns a `CredentialCheckResult` enum instead of a boolean.

### `CredentialCheckResult`

A backed enum in `Horde\Core\Auth\CredentialCheckResult` with four cases:

| Case | Meaning |
|---|---|
| `Valid` | Credentials are correct |
| `Invalid` | Wrong password or unknown user |
| `Locked` | Account locked (too many failures or admin lock) |
| `Expired` | Credentials expired (forced password change required) |

The enum also provides a factory method `fromAuthReason(int $reason)`
that maps `Horde_Auth::REASON_*` constants to the appropriate case.

## The `$login` parameter on `authenticate()`

`Horde_Auth_Base::authenticate()` has always accepted a `$login`
parameter documented as controlling whether a session is established.
Previously this parameter was accepted but ignored — authentication
always established session state.

`Horde_Core_Auth_Application::authenticate()` now honours it:

```php
// Full login (existing behaviour, unchanged)
$auth->authenticate($userId, $credentials);
$auth->authenticate($userId, $credentials, true);

// Credential check only — returns bool for backward compatibility
$auth->authenticate($userId, $credentials, false);
```

When `$login` is `false`, `authenticate()` delegates to
`checkCredentials()` internally and returns `true` if the result is
`Valid`, `false` otherwise. Use this form when you need a simple boolean
and don't need to distinguish between failure reasons.

## In-process caching

On a successful check the base driver stores the validated credentials
in its internal `$_credentials` array (an in-process, per-request
structure — not a persistent cache). If a subsequent full
`authenticate()` call happens for the same user in the same request,
backends that support it can short-circuit the validation. No passwords
are written to session files or external caches.

## `CheckCredentials` middleware

For the PSR-15 middleware stack, `Horde\Core\Middleware\CheckCredentials`
provides stateless HTTP Basic credential validation:

```
Authorization: Basic base64(user:password)
```

It parses the header, calls `checkCredentials()` on the configured auth
driver and sets two request attributes:

| Attribute | Type | When set |
|---|---|---|
| `HORDE_CREDENTIAL_CHECK` | `CredentialCheckResult` | Always (when Basic header present) |
| `HORDE_VERIFIED_USER` | `string` | Only when credentials are valid |

`HORDE_VERIFIED_USER` is intentionally distinct from
`HORDE_AUTHENTICATED_USER` (set by `AuthHordeSession` and
`AuthHttpBasic`). Downstream handlers can distinguish between a fully
logged-in user and one whose credentials have merely been verified.

### Middleware wiring

The middleware is injectable via `Horde\Injector\Attribute\Factory`:

```php
use Horde\Core\Middleware\CheckCredentials;

// The injector resolves it through CheckCredentialsFactory
$middleware = $injector->getInstance(CheckCredentials::class);
```

In a route definition that only needs credential verification (no
session):

```php
$route->middleware([
CheckCredentials::class,
// ... your handler
]);
```

### Example: token exchange endpoint

A typical use is an endpoint that accepts Basic credentials and returns
a JWT without creating a session:

```php
use Horde\Core\Auth\CredentialCheckResult;

class TokenExchangeHandler implements RequestHandlerInterface
{
public function __construct(
private JwtService $jwt,
) {}

public function handle(ServerRequestInterface $request): ResponseInterface
{
$result = $request->getAttribute('HORDE_CREDENTIAL_CHECK');
$user = $request->getAttribute('HORDE_VERIFIED_USER');

if ($result !== CredentialCheckResult::Valid || $user === null) {
return $this->errorResponse($result);
}

$token = $this->jwt->generateAccessToken($user);
// return JSON response with token ...
}

private function errorResponse(?CredentialCheckResult $result): ResponseInterface
{
return match ($result) {
CredentialCheckResult::Locked => /* 423 Locked */,
CredentialCheckResult::Expired => /* 403 + password change URI */,
default => /* 401 Unauthorized */,
};
}
}
```
64 changes: 64 additions & 0 deletions lib/Horde/Core/Auth/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
* @license http://opensource.org/licenses/lgpl-2.1.php LGPL
* @package Core
*/

use Horde\Core\Auth\CredentialCheckResult;

class Horde_Core_Auth_Application extends Horde_Auth_Base
{
/**
Expand Down Expand Up @@ -125,6 +128,13 @@ public function __construct(array $params = [])
*/
public function authenticate($userId, $credentials, $login = true)
{
if (!$login) {
return $this->checkCredentials(
(string) $userId,
$credentials
) === CredentialCheckResult::Valid;
}

if (!strlen((string) ($credentials['password'] ?? ''))) {
return false;
}
Expand All @@ -151,6 +161,60 @@ public function authenticate($userId, $credentials, $login = true)
return $this->_setAuth();
}

/**
* Validate credentials without establishing a session or modifying
* any global state.
*
* Runs the preauthenticate hook and delegates to the underlying
* base driver (or the parent Horde_Auth_Base for non-horde apps).
* Lock checking and bad-login tracking in the base driver still
* apply.
*
* @param string $userId The user ID to check.
* @param array $credentials The credentials to check.
*
* @return CredentialCheckResult
*/
public function checkCredentials(
string $userId,
array $credentials
): CredentialCheckResult {
if (!strlen((string) ($credentials['password'] ?? ''))) {
return CredentialCheckResult::Invalid;
}

try {
[$userId, $credentials] = $this->runHook(
trim($userId),
$credentials,
'preauthenticate',
'authenticate'
);
} catch (Horde_Auth_Exception $e) {
return CredentialCheckResult::fromAuthReason(
$e->getCode() ?: Horde_Auth::REASON_FAILED
);
}

if ($this->_base) {
$result = $this->_base->authenticate($userId, $credentials, false);
$driver = $this->_base;
} else {
$result = parent::authenticate($userId, $credentials, false);
$driver = $this;
}

if (!$result) {
$errorCode = $driver->getError();
if ($errorCode === false) {
return CredentialCheckResult::Invalid;
}
return CredentialCheckResult::fromAuthReason((int) $errorCode);
}

return CredentialCheckResult::Valid;
}

/**
* Find out if a set of login credentials are valid.
*
Expand Down
53 changes: 53 additions & 0 deletions src/Auth/CredentialCheckResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

/**
* Copyright 2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*
* @category Horde
* @package Core
* @author Ralf Lang <ralf.lang@ralf-lang.de>
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
*/

namespace Horde\Core\Auth;

use Horde_Auth;

/**
* Result of a credential check without login.
*
* Provides a typed alternative to a bare boolean for callers that
* need to distinguish why authentication failed (locked account,
* expired password, bad credentials).
*/
enum CredentialCheckResult
{
/** Credentials are valid. */
case Valid;

/** Credentials are invalid (wrong password or unknown user). */
case Invalid;

/** Account is locked (too many failed attempts or admin lock). */
case Locked;

/** Credentials have expired (forced password change required). */
case Expired;

/**
* Build a result from a Horde_Auth reason code.
*/
public static function fromAuthReason(int $reason): self
{
return match ($reason) {
Horde_Auth::REASON_LOCKED => self::Locked,
Horde_Auth::REASON_EXPIRED => self::Expired,
default => self::Invalid,
};
}
}
31 changes: 31 additions & 0 deletions src/Factory/CheckCredentialsFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

/**
* Copyright 2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*
* @category Horde
* @package Core
* @author Ralf Lang <ralf.lang@ralf-lang.de>
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
*/

namespace Horde\Core\Factory;

use Horde\Core\Middleware\CheckCredentials;
use Horde_Core_Factory_Injector as InjectorFactory;
use Horde\Injector\Injector;

class CheckCredentialsFactory extends InjectorFactory
{
public function create(Injector $injector): CheckCredentials
{
$driver = $injector->getInstance('Horde_Core_Factory_Auth')->create();

return new CheckCredentials($driver);
}
}
Loading
Loading