adt / doctrine-authenticator
A Doctrine authenticator for Nette framework.
Requires
- php: >=8.4
- adt/doctrine-components: ^3.3
- brick/phonenumber: ^0.7|^0.8
- doctrine/orm: ^2.9|^3.0
- nette/di: ^3.1
- nette/http: ^3.0
- nette/security: ^3.2
- symfony/console: ^6.0|^7.0|^8.0
Requires (Dev)
- nette/tester: ^2.5
Suggests
- geoip2/geoip2: Country-based fraud detection (setCountryFraudDetection)
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v2.11.1
- v2.11.0
- v2.10.5
- v2.10.4
- v2.10.3
- v2.10.2
- v2.10.1
- v2.10
- v2.9
- v2.8
- v2.7.4
- v2.7.3
- v2.7.2
- v2.7.1
- v2.7
- v2.6.2
- v2.6.1
- v2.6.0
- v2.5.4
- v2.5.3
- v2.5.2
- v2.5.1
- v2.5
- v2.4.6
- v2.4.5
- v2.4.4
- v2.4.3
- v2.4.2
- v2.4.1
- v2.4
- v2.3.3
- v2.3.2
- v2.3.1
- v2.3
- v2.2.5
- v2.2.4
- v2.2.3
- v2.2.2
- v2.2.1
- v2.2
- v2.1
- v2.0
- v1.8.0
- v1.7.2
- v1.7.1
- v1.7
- v1.6
- v1.5.2
- v1.5.1
- v1.5
- v1.4.4
- v1.4.3
- v1.4.2
- v1.4.1
- v1.4
- v1.3.1
- v1.3
- v1.2
- v1.1.2
- v1.1.1
- v1.1
- v1.0.4
- v1.0.3
- v1.0.2
- v1.0.1
- v1.0
This package is auto-updated.
Last update: 2026-09-21 15:18:14 UTC
README
- Allows you to use a Doctrine entity as a Nette identity
- Uses cookies instead of PHP sessions
- Saves IP address and User-Agent header for better abuse detection
- Detects an invalid token and call onInvalidToken callback to log and prevent possible abuse
- Invalidates token on different User-Agent header and IP address when fraudDetection is enabled and call onFraudDetection callback to log and prevent possible abuse
Install
composer require adt/doctrine-authenticator
Configuration
1) Neon configuration
services: security.user: App\Model\Security\SecurityUser security.userStorage: Nette\Bridges\SecurityHttp\CookieStorage security.authenticator: factory: App\Model\Security\Authenticator(expiration: '14 days') setup: - setFraudDetection(true) # you can disable it for automatic tests for example - setAuthLog(true) # operational auth_log table, see "Auth log" below
Add new mapping via attributes like this (if you are using nettrine):
nettrine.orm.attributes: mapping: ADT\DoctrineAuthenticator: %appDir%/../vendor/adt/doctrine-authenticator/src
or via annotations:
nettrine.orm.annotations: mapping: ADT\DoctrineAuthenticator: %appDir%/../vendor/adt/doctrine-authenticator/src
2) Create a Identity entity implementing ADT\DoctrineAuthenticator\DoctrineAuthenticatorIdentity
and adjust to your needs.
<?php namespace App\Model\Entities; use ADT\DoctrineAuthenticator\DoctrineAuthenticatorIdentity; use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\GeneratedValue; use Doctrine\ORM\Mapping\Id; /** @Entity */ #[Entity] class Identity implements DoctrineAuthenticatorIdentity { /** * @Id * @Column * @GeneratedValue */ #[Id] #[Column] #[GeneratedValue] protected ?int $id; public function getId(): int { return $this->id; } public function __clone() { $this->id = null; } /** @Column(unique=true) */ #[Column(unique: true)] protected string $email; /** @Column */ #[Column] protected string $password; public function getEmail(): string { return $this->email; } public function setEmail(string $email): self { $this->email = $email; return $this; } public function getPassword(): string { return $this->password; } public function setPassword(string $password): self { $this->password = $password; return $this; } public function getRoles(): array { return []; } public function getAuthObjectId(): string { return (string) $this->getId(); } }
3) Create a SecurityUser service extending ADT\DoctrineAuthenticator\SecurityUser
<?php namespace App\Model\Security; use App\Model\Entities\Identity; /** * @method Identity getIdentity() */ class SecurityUser extends \ADT\DoctrineAuthenticator\SecurityUser { }
4) Create Authenticator extending ADT\DoctrineAuthenticator\DoctrineAuthenticator
and adjust methods authenticate and getIdentity to your needs.
<?php namespace App\Model\Security; use ADT\DoctrineAuthenticator\DoctrineAuthenticator; use App\Model\Entities\Identity; use Doctrine\DBAL\Connection; use Doctrine\ORM\Configuration; use Doctrine\ORM\EntityManagerInterface; use Nette\Bridges\SecurityHttp\CookieStorage; use Nette\Http\Request; use Nette\Security\AuthenticationException; use Nette\Security\IIdentity; use Nette\Security\Passwords; class Authenticator extends DoctrineAuthenticator { public function __construct( string $expiration, CookieStorage $cookieStorage, Connection $connection, Configuration $configuration, Request $httpRequest, protected readonly EntityManagerInterface $em, ) { parent::__construct($expiration, $cookieStorage, $connection, $configuration, $httpRequest); $this->onInvalidToken = function(string $token) { // log probable fraud }; } public function authenticate(string $user, string $password): IIdentity { /** @var Identity $identity */ if (! $identity = $this->em->getRepository(Identity::class)->findOneBy(['email' => $user])) { throw new AuthenticationException('Identity not found!'); } if (!(new Passwords())->verify($password, $identity->getPassword())) { throw new AuthenticationException('Incorrect password!'); } return $identity; } public function getIdentity($id): IIdentity { return $this->em->getRepository(Identity::class)->find($id); } }
5) Generate migrations
for example like this:
php bin/console migrations:diff
Usage
Just call login on security user as you are used to:
$this->securityUser->login($email, $password);
Country-based fraud detection
The default fraud detection kills a session when both the IP and the User-Agent change at once. An attacker who stole the session token can trivially copy the User-Agent, so you can additionally bind the session to a country: any IP change within one country is allowed (mobile networks, CGNAT), moving to a different country kills the session even with a matching User-Agent.
setup: - setCountryFraudDetection('/geoip/GeoLite2-Country.mmdb')
Requires composer require geoip2/geoip2 and a MaxMind Country database.
The recommended way to provide and refresh the .mmdb file is the official
geoipupdate container writing into
a volume mounted read-only into the application container (MaxMind licensing
does not allow bundling the file, and it goes stale - updates are published
twice a week).
The check fails open: an unresolvable IP or a missing/unreadable database
never kills a session, it only disables the country rule (the IP+User-Agent
rule still applies). Detected frauds are recorded in the auth log with reason
country changed (CZ -> US).
Login attempt throttling
setup: - setLoginAttemptProtection(5, '-15 minutes', maxAccountAttempts: 10, maxSprayedAccounts: 20)
Upgrading an existing installation needs a migration - login_attempt gained
a successful column (default 0, every existing row is a failure) and two
indexes on username.
Failed sign-ins are counted in login_attempt over a sliding window. Three
independent counters, because no single key covers both threat models:
| counter | argument | what it stops |
|---|---|---|
| (IP, account) | $maxAttempts |
ordinary guessing from one address |
| account, all IPs | $maxAccountAttempts (default 2 * $maxAttempts) |
distributed guessing - rotating source IPs no longer buys a fresh budget against the same account (CWE-307) |
| distinct accounts per IP | $maxSprayedAccounts (default off) |
password spraying, which neither counter above sees |
The first counter is deliberately narrower than a plain per-IP counter: behind one NAT - a venue full of terminals, an office - a single mistyped password must not lock out everybody sharing that address.
Spray detection would hit those same shared addresses, so its budget is not
a flat number: it is $maxSprayedAccounts plus the number of accounts that
address has successfully signed in within $trustedIpPeriod. A venue with
a hundred terminals therefore carries a budget of a hundred-odd on its own,
while an address an attacker rented this morning gets the bare
$maxSprayedAccounts - no hand-kept IP whitelist to go stale.
Note it counts accounts failed on, so a venue signing in normally never moves the counter at all, however many terminals it has.
Known-good addresses
An account-wide counter makes a targeted lockout DoS possible - anyone who
knows an email address can burn that account's budget. It is blunted by
remembering where the account has successfully signed in from: a successful
sign-in stores a successful row for the pair (IP, account), kept for
$trustedIpPeriod (30 days). For such a pair:
- the account-wide counter does not apply at all
- the (IP, account) budget is multiplied by
$trustedIpMultiplier(4)
So a legitimate user on a machine they have used before keeps getting in while an attacker elsewhere is stopped. Nothing locks an account persistently - every counter is a sliding window, and a successful sign-in clears that account's failed attempts, so a few typos are never carried over into the next sign-in.
The marker row is refreshed in place, so a pair costs one row however often
it signs in, and markers past $trustedIpPeriod are dropped on the way.
Rejected attempts are recorded too (for the audit trail) but never counted -
otherwise each blocked request would move the window and keep the account
locked out for as long as the requests keep coming. Blocked attempts surface
in the auth log as login_blocked.
Telling the user
TooManyLoginAttemptsException extends AuthenticationException, so a form
that catches the parent shows the same "invalid credentials" it showed on the
first typo: the user cannot tell that anything changed, let alone when it ends.
getLoginThrottleStatus() exposes what the counters know, so the form can say
it:
$status = $authenticator->getLoginThrottleStatus($email); $status->remainingAttempts; // ?int - attempts left, null when throttling is off $status->blocked; // bool - is sign-in being refused right now? $status->blockedUntil; // ?DateTimeImmutable - when it works again, null if unknown $status->isBlocked(); // === $status->blocked
blockedUntil is deliberately not what isBlocked() reads. Whether to refuse
a sign-in must not depend on whether the unblock moment could also be worked
out - that is a second query, and if it came back empty the throttling would
open exactly when it is supposed to hold. A caller that shows the time has to
handle blocked without blockedUntil.
It is the same code that decides whether to refuse a sign-in, not a second copy of the counters - a caller computing "one attempt left" on its own would drift from what is enforced and promise an attempt that no longer exists.
Remaining attempts are the minimum over the counters, the unblock time their maximum: a sign-in is refused while any counter is over its limit, so the user is let back in only by the one that expires last. The spray counter joins in only once it actually blocks - it counts distinct accounts, not attempts against this one, so a partial count would be a meaningless remainder.
Call it after the failed authenticate(), so the attempt that just failed is
already counted.
Auth log (operational)
With setAuthLog(true) the authenticator records every authentication event in
the auth_log table:
| type | when |
|---|---|
login |
successful login (written in the same transaction as the session row) |
login_failed |
failed login - records the entered identity and the exception class |
login_blocked |
attempt rejected by the login-attempt protection |
logout |
session invalidated via clearIdentity() / clearSession() |
fraud_detected |
session killed because IP and User-Agent both changed |
invalid_token |
cookie token not found (metadata contains its sha256 for correlation with session.token) |
Rows are inserted through the DBAL connection (no ORM events, no unit of work) and are never updated. Passwords or other credentials are never recorded. Times are always in UTC, so the records line up with other logs.
This is an operational log, not an audit trail. It answers "who signed in,
when and from where" for support and diagnostics, and it is meant to be readable
from the project's admin. An audit trail has to live outside the application, so
that nobody who reaches the application can rewrite the record of what they did
there - build that separately from $onAuthEvent.
Having both is a deliberate choice, not an accident: two copies of the same event with different retention and a different set of readers. Just make sure the project's logging policy says so - a document claiming audit records are unreachable from the application is not true of this table.
The table grows until someone prunes it; retention is up to the project.
Clearing expired sessions
Register the extension, which registers the console command:
extensions: doctrineAuthenticator: ADT\DoctrineAuthenticator\DI\DoctrineAuthenticatorExtension
It deletes sessions whose validUntil is older than the given number of days
(defaults to 365 days, i.e. one year):
# delete sessions expired more than a year ago (default) php bin/console doctrine-authenticator:clear-expired-sessions # delete sessions expired more than 30 days ago php bin/console doctrine-authenticator:clear-expired-sessions 30
Run it periodically (e.g. via cron) to keep the session table clean.
Tests
composer install
composer test
Nette Tester over a throwaway SQLite file. A file rather than :memory: on
purpose: the authenticator opens a second connection from the same parameters
so a recorded attempt survives a rollback of the surrounding transaction, and
every in-memory connection would be its own empty database.