Skip to content

Latest commit

 

History

158 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Doctrine Authenticator

  • 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) # opt-in append-only audit trail, 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).

Auth log (audit trail)

With setAuthLog(true) the authenticator writes an append-only audit record into the auth_log table for every authentication event:

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. The table is a staging buffer: the project must periodically move rows into its long-term audit store (read ORDER BY id, delete after a confirmed copy) - without that the table grows indefinitely. Passwords or other credentials are never recorded.

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.

About

Doctrine authenticator for Nette framework

Resources

Stars

1 star

Watchers

4 watching

Forks

Releases

Packages

Used by

Contributors

Languages