Skip to content
Merged
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
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,22 @@ All notable changes to **Tiger Core** (`webtigers/tiger-core`). Format follows

## [Unreleased]

## [1.16.1] — 2026-09-26
## [1.17.0] — 2026-09-26

### Added

- **The pluggable password factor is now read *and* write.** `Tiger_Auth_Credential_Adapter_Abstract`
gains `canSetPassword($user)` + `setPassword($user, $newPassword)`, and a new write seam
`Tiger_Service_Authentication::setPasswordFor($userId, $newPassword)` routes **every** password
write — self-service change (`Profile_Service_Security`), forgot-password
(`Authentication::resetPassword`), and admin reset (`Access_Service_User`) — through the same
provider decision `login()`/`unlock()` already use. So when a credential provider owns a user
(e.g. TigerServer's system credential), changing or resetting their password rewrites **that**
authority (the OS password — one password for the web login and SSH), not an ignored DB row.
Defaults are unchanged for the ~all installs with no provider: no provider → the DB
`user_credential` path exactly as before. A provider that owns a user but is read-only
(`canSetPassword` false — an external IdP) is left untouched and the write is refused rather than
silently landing in the superseded DB credential.

### Changed

Expand Down
6 changes: 5 additions & 1 deletion FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,11 @@ framework.
TigerServer verifies an account owner's web login against the OS/system credential so there's a single
password — as a provider *chain* (the adapter owns only the users it `appliesTo`; everyone else falls
back to the DB), and only the password factor moves: TOTP/other factors, lockout, audit and session
issuance stay in the auth service.
issuance stay in the auth service. The factor is **read *and* write**: an adapter that owns a user's
login also owns their password *writes* (`canSetPassword`/`setPassword`), so change-password,
forgot-password and admin reset all route through `Tiger_Service_Authentication::setPasswordFor` and
rewrite the same authority they authenticate against — never a bypassed DB row (a verify-only authority
like an external IdP reports "managed elsewhere" instead).
- **One-time challenges.** `auth_challenge` backs OTP / password-reset / magic-link flows —
hashed codes, single-use, TTL, attempt-limited.
- **Self-service password reset.** A themed forgot/reset flow: an emailed tokenized link
Expand Down
38 changes: 38 additions & 0 deletions library/Tiger/Auth/Credential/Adapter/Abstract.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@
* it owns (e.g. the account owner), and any identity it declines falls back to the default DB path.
* So a non-owner user the owner invited still authenticates against the DB credential unchanged.
*
* The factor is READ + WRITE, not read-only: an adapter that owns a user's login (`verify()`) also
* owns that user's password WRITES (`canSetPassword()` / `setPassword()`), so change-password and
* forgot-password rewrite the SAME authority they authenticate against (on TigerServer, the OS
* password — one password for the panel and SSH). A write-capable adapter overrides both; a
* verify-only authority (an external IdP) leaves the defaults and reports "managed elsewhere".
*
* @api
*/
abstract class Tiger_Auth_Credential_Adapter_Abstract
Expand Down Expand Up @@ -74,4 +80,36 @@ public function recordFailure($user): void
public function recordSuccess($user): void
{
}

/**
* Can this adapter WRITE a new password to its authority for a user it owns, or is that
* authority read-only (e.g. an external IdP / ActiveDirectory Tiger can't modify)? Default:
* no. An adapter that owns writes overrides this AND `setPassword()`. When false, the change-
* password and forgot-password flows leave the (ignored) DB credential untouched and report
* that the password is managed by the provider's authority — they never silently write a DB
* row the provider has superseded.
*
* @param object $user the resolved user row
* @return bool
*/
public function canSetPassword($user): bool
{
return false;
}

/**
* Write a new password to this adapter's authority for a user it owns — e.g. `chpasswd` the OS
* account on TigerServer, so a single password covers the web login AND SSH/SFTP. Called by the
* change-password / forgot-password / admin-reset flows ONLY when `canSetPassword()` is true, so
* a registered provider is never bypassed by a password write. Must fail closed (any error →
* false), never throw into the caller. Default: unsupported (false).
*
* @param object $user the resolved user row
* @param string $newPassword the new plaintext password (already policy-checked by the caller)
* @return bool true when the authority accepted the new password
*/
public function setPassword($user, string $newPassword): bool
{
return false;
}
}
52 changes: 49 additions & 3 deletions library/Tiger/Service/Authentication.php
Original file line number Diff line number Diff line change
Expand Up @@ -277,13 +277,59 @@ public function resetPassword($challengeId, $code, $newPassword, $confirm)
return ['ok' => false, 'error' => 'This reset link is invalid or has expired.'];
}

$credModel = new Tiger_Model_UserCredential();
$credId = $credModel->setPassword($userId, $newPassword);
$credModel->recordSuccess($credId); // clear any prior brute-force lockout
// Write through the SAME authority that verifies this user's login (§ setPasswordFor):
// a registered provider (e.g. TigerServer's system credential) rewrites the OS password,
// so a forgot-password reset changes the one real password; else the default DB path.
if (!$this->setPasswordFor($userId, $newPassword)) {
return ['ok' => false, 'error' => 'We could not set your password. Please try again.'];
}

return ['ok' => true, 'error' => null];
}

/**
* Set a user's password through the SAME authority that verifies their login — the configured
* `Tiger_Auth_Credential` provider when one owns this user (e.g. TigerServer's OS/system
* credential, so a change/forgot-password rewrites the real password and there stays ONE
* password for the web login AND SSH), otherwise the default DB `user_credential` path.
*
* This is the single write seam every password-write flow routes through — self-service change
* (`Profile_Service_Security`), forgot-password (`resetPassword` above), and admin reset
* (`Access_Service_User`) — so a registered provider is never bypassed by a write. A provider
* that owns the user but is read-only (`canSetPassword()` false — an external IdP) returns false
* WITHOUT touching the ignored DB credential, so the caller can report "managed elsewhere".
*
* @param string $userId the user whose password to set
* @param string $newPassword the new plaintext password (the caller has already policy-checked it)
* @return bool true on success; false if the user is unknown, the provider's
* authority is read-only, or the provider write failed
*/
public function setPasswordFor($userId, $newPassword): bool
{
$userId = (string) $userId;
$user = $userId !== '' ? (new Tiger_Model_User())->findById($userId) : null;
if (!$user) {
return false;
}

$provider = Tiger_Auth_Credential::providerFor($user);
if ($provider !== null) {
if (!$provider->canSetPassword($user)) {
return false; // the provider owns this user but its authority is read-only
}
try {
return $provider->setPassword($user, (string) $newPassword);
} catch (Throwable $e) {
return false; // a misbehaving adapter must never throw into a password write
}
}

$credModel = new Tiger_Model_UserCredential();
$credId = $credModel->setPassword($userId, (string) $newPassword);
$credModel->recordSuccess($credId); // clear any prior brute-force lockout
return true;
}

/** Map a Tiger_Policy_Password violation key to a ready-to-show message. */
protected function _policyMessage($key)
{
Expand Down
2 changes: 1 addition & 1 deletion library/Tiger/Version.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@
class Tiger_Version
{
/** Current Tiger Core version. Keep in lockstep with the git tag cut for a release. */
const VERSION = '1.16.1';
const VERSION = '1.17.0';
}
7 changes: 5 additions & 2 deletions modules/access/services/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,11 @@ public function save(array $params): void
}
// Admin authority: set/reset the password outright when provided (no current-password
// step — that's the self-service Profile_Service_Security path). Blank = unchanged.
if ($newPw !== '') {
(new Tiger_Model_UserCredential())->setPassword($newId, $newPw);
// Route through the auth write seam so a registered credential provider is honoured:
// for an ordinary DB user this is the same setPassword; for a provider-owned identity
// (e.g. the account owner on TigerServer) it rewrites the system password instead.
if ($newPw !== '' && !(new Tiger_Service_Authentication())->setPasswordFor($newId, $newPw)) {
throw new RuntimeException('Could not set the password for this user.');
}
return $newId;
});
Expand Down
1 change: 1 addition & 0 deletions modules/profile/languages/de/profile.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
'profile.security.confirm' => 'Neues Passwort bestätigen',
'profile.security.change' => 'Passwort ändern',
'profile.security.password_changed' => 'Passwort geändert.',
'profile.security.password_change_failed' => 'Passwort konnte nicht geändert werden. Bitte versuche es erneut.',
'profile.security.twofa' => 'Zwei-Faktor-Authentifizierung verwalten',

'profile.user.display_name' => 'Anzeigename',
Expand Down
1 change: 1 addition & 0 deletions modules/profile/languages/en/profile.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
'profile.security.confirm' => 'Confirm New Password',
'profile.security.change' => 'Change Password',
'profile.security.password_changed' => 'Password changed.',
'profile.security.password_change_failed' => 'Couldn’t change your password. Please try again.',
'profile.security.twofa' => 'Manage two-factor authentication',

'profile.user.display_name' => 'Display Name',
Expand Down
1 change: 1 addition & 0 deletions modules/profile/languages/es/profile.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
'profile.security.confirm' => 'Confirmar nueva contraseña',
'profile.security.change' => 'Cambiar contraseña',
'profile.security.password_changed' => 'Contraseña actualizada.',
'profile.security.password_change_failed' => 'No se pudo cambiar la contraseña. Inténtalo de nuevo.',
'profile.security.twofa' => 'Gestionar la autenticación de dos factores',

'profile.user.display_name' => 'Nombre para mostrar',
Expand Down
1 change: 1 addition & 0 deletions modules/profile/languages/fr/profile.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
'profile.security.confirm' => 'Confirmer le nouveau mot de passe',
'profile.security.change' => 'Changer le mot de passe',
'profile.security.password_changed' => 'Mot de passe modifié.',
'profile.security.password_change_failed' => 'Impossible de modifier le mot de passe. Réessayez.',
'profile.security.twofa' => 'Gérer l’authentification à deux facteurs',

'profile.user.display_name' => 'Nom affiché',
Expand Down
1 change: 1 addition & 0 deletions modules/profile/languages/hi/profile.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
'profile.security.confirm' => 'नए पासवर्ड की पुष्टि करें',
'profile.security.change' => 'पासवर्ड बदलें',
'profile.security.password_changed' => 'पासवर्ड बदल दिया गया।',
'profile.security.password_change_failed' => 'पासवर्ड बदला नहीं जा सका। कृपया पुनः प्रयास करें।',
'profile.security.twofa' => 'टू-फैक्टर प्रमाणीकरण प्रबंधित करें',

'profile.user.display_name' => 'प्रदर्शित नाम',
Expand Down
1 change: 1 addition & 0 deletions modules/profile/languages/pt/profile.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
'profile.security.confirm' => 'Confirmar nova senha',
'profile.security.change' => 'Alterar senha',
'profile.security.password_changed' => 'Senha atualizada.',
'profile.security.password_change_failed' => 'Não foi possível alterar a senha. Tente novamente.',
'profile.security.twofa' => 'Gerenciar a autenticação de dois fatores',

'profile.user.display_name' => 'Nome de exibição',
Expand Down
1 change: 1 addition & 0 deletions modules/profile/languages/tlh/profile.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
'profile.security.confirm' => 'Confirm New Password',
'profile.security.change' => 'Change Password',
'profile.security.password_changed' => 'Password changed.',
'profile.security.password_change_failed' => 'Couldn’t change your password. Please try again.',
'profile.security.twofa' => 'Manage two-factor authentication',

'profile.user.display_name' => 'Display Name',
Expand Down
12 changes: 10 additions & 2 deletions modules/profile/services/Security.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,17 @@ public function changePassword(array $params): void

$new = (string) $form->getValue('new_password');
try {
$this->_transaction(function () use ($userId, $new) {
(new Tiger_Model_UserCredential())->setPassword($userId, $new);
// Route through the auth service's write seam so a registered credential provider (e.g.
// TigerServer's system credential) is honoured — changing the password here rewrites the
// OS password, not an ignored DB row. Falls back to the DB credential when no provider
// owns this user. False = provider write failed / a read-only (externally managed) authority.
$ok = $this->_transaction(function () use ($userId, $new) {
return (new Tiger_Service_Authentication())->setPasswordFor($userId, $new);
});
if (!$ok) {
$this->_error('profile.security.password_change_failed');
return;
}
$this->_success([], 'profile.security.password_changed');
} catch (Throwable $e) {
$this->_error(APPLICATION_ENV !== 'production' ? $e->getMessage() : 'core.api.error.general');
Expand Down
29 changes: 29 additions & 0 deletions tests/Unit/Auth/CredentialTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,26 @@ public function a_throwing_adapter_fails_safe_to_db(): void
$this->configureProvider('server');
$this->assertNull(Tiger_Auth_Credential::providerFor($this->user()), 'appliesTo throwing → null, never break login');
}

#[Test]
public function the_factor_is_read_only_by_default_so_writes_never_leak_to_an_ignored_db_row(): void
{
// A verify-only adapter (the AD/external-IdP shape) must NOT claim it can write, and its
// default setPassword is a no-op false — so setPasswordFor reports "managed elsewhere"
// instead of silently writing a DB credential the provider has superseded.
$verifyOnly = new FakeOwnerAdapter();
$this->assertFalse($verifyOnly->canSetPassword($this->user()), 'read-only authority by default');
$this->assertFalse($verifyOnly->setPassword($this->user(), 'whatever'), 'default setPassword writes nothing');
}

#[Test]
public function a_write_capable_adapter_owns_the_password_write(): void
{
$writable = new FakeWritableAdapter();
$this->assertTrue($writable->canSetPassword($this->user()), 'declares it can write');
$this->assertTrue($writable->setPassword($this->user(), 'good'), 'accepts a write it can perform');
$this->assertFalse($writable->setPassword($this->user(), ''), 'fails closed when it cannot');
}
}

/** Applies to everyone. */
Expand All @@ -112,3 +132,12 @@ class FakeThrowingAdapter extends Tiger_Auth_Credential_Adapter_Abstract
public function appliesTo($user): bool { throw new \RuntimeException('boom'); }
public function verify($user, string $password): bool { return false; }
}

/** Owns the user AND its password writes (the TigerServer `server` adapter shape). */
class FakeWritableAdapter extends Tiger_Auth_Credential_Adapter_Abstract
{
public function appliesTo($user): bool { return true; }
public function verify($user, string $password): bool { return $password === 'good'; }
public function canSetPassword($user): bool { return true; }
public function setPassword($user, string $newPassword): bool { return $newPassword !== ''; }
}
Loading