Features

Authentication

Learn how to authenticate models, implement access control, and secure your application with Tempest's flexible authentication system.

Overview

Tempest provides an authentication implementation designed to be flexible, not assuming an authenticatable model is a user. This means you can use it for API keys, service accounts, or any other system that requires authentication.

Additionally, Tempest provides a policy-based access control implementation that allows you to define fine-grained permissions for your resources.

Quick start

Tempest does not assume that all applications have users, but it is the most common case. For this reason, we provide the ability to publish a basic user model and its migration.

sh
./tempest install auth

After publishing, you may run ./tempest migrate. You now have the building blocks for your authentication.

Authentication

Tempest's authentication is flexible enough not to assume that an authenticatable model is a user. If your application uses a different system for authentication, such as an API key or a service account, you have the ability to create such a model while preserving the correct nomenclature.

To register an authenticatable model, you may create a class that implements the Authenticatable interface. This interface is automatically discovered by Tempest.

app/Authentication/User.php
use Tempest\Auth\Authentication\Authenticatable;
use Tempest\Database\PrimaryKey;
use Tempest\Database\Hashed;

final class User implements Authenticatable
{
    public PrimaryKey $id;

    public function __construct(
        public string $email,
        #[Hashed]
        public ?string $password,
    ) {}
}

Note that if you use the default database authenticatable resolver, the model must have at least a PrimaryKey property—it will be used to uniquely identify the model in the database.

Authenticating a model

Authenticating a model—in most cases, a user—is usually done in a controller. Tempest provides an Authenticator that may authenticate, deauthenticate, and access the currently authenticated model.

Because there are a lot of different ways to authenticate users or systems, Tempest doesn't provide the logic to verify authentication credentials. In the case of a user, you may use the PasswordHasher for this purpose.

app/Authentication/AuthenticationController.php
use Tempest\Auth\Authentication\Authenticator;
use Tempest\Cryptography\Password\PasswordHasher;

final readonly class AuthenticationController
{
    public function __construct(
        private Authenticator $authenticator,
        private PasswordHasher $passwordHasher,
    ) {}

    #[Post('/login')]
    public function login(LoginRequest $request): Redirect
    {
        $user = query(User::class)
            ->select()
            ->where('email', $request->email)
            ->first();

        if (! $user || ! $this->passwordHasher->verify($request->password, $user->password)) {
            return new Redirect('/login')->flash('error', 'Invalid credentials');
        }

        $this->authenticator->authenticate($user);

        return new Redirect('/');
    }
    
    #[Post('/logout')]
    public function logout(): Redirect
    {
        $this->authenticator->deauthenticate();
        
        return new Redirect('/login');
    }
}

Accessing the authenticated model

You may access the currently authenticated model by injecting the Authenticator. The authenticator provides a current() method that returns the currently authenticated model, or null if no model is authenticated.

app/ProfileController.php
use Tempest\Auth\Authentication\Authenticator;
use Tempest\Router\Get;
use Tempest\View\View;
use function Tempest\view;

final readonly class ProfileController
{
    public function __construct(
        private Authenticator $authenticator,
    ) {}

    #[Get('/profile', middleware: [MustBeAuthenticated::class])]
    public function show(): View
    {
        return view('profile.view.php', user: $this->authenticator->current());
    }
}

Alternatively, you may also inject the model directly. For instance, if you have a User model implementing Authenticatable, it can be injected as a dependency:

app/ProfileController.php
final readonly class ProfileController
{
    public function __construct(
        private User $user,
    ) {}

    #[Get('/profile', middleware: [MustBeAuthenticated::class])]
    public function show(): View
    {
        return view('profile.view.php', user: $this->user);
    }
}

In situations where the model might not be authenticated—for instance, in a route that is not protected by a middleware, you will need to make the property nullable.

Custom authenticatable resolver

The authenticatable resolver is used internally by the authenticator to resolve an unique identifier from a model and the other way around. Typically, applications use a database to store users, but you can implement custom resolvers to fetch users from other sources, such as LDAP or external APIs.

Tempest provides a DatabaseAuthenticatableResolver, which is used by default. However, you may implement your own resolver by implementing the AuthenticatableResolver interface.

app/Authentication/LdapAuthenticatableResolver.php
use Tempest\Auth\Authentication\AuthenticatableResolver;
use Tempest\Auth\Authentication\Authenticatable;
use App\Authentication\User;

final readonly class LdapAuthenticatableResolver implements AuthenticatableResolver
{
    public function __construct(
        private LdapClient $ldap,
    ) {}

    public function resolve(int|string $id): ?Authenticatable
    {
        $attributes = $this->ldap->findUserByIdentifier($id);

        if ($attributes === null) {
            return null;
        }

        return new User(
            username: $attributes['uid'] ?? null,
            email: $attributes['mail'] ?? null,
            displayName: $attributes['cn'] ?? null
        );
    }

    public function resolveId(Authenticatable $authenticatable): int|string
    {
        return $authenticatable->email;
    }
}

To instruct Tempest that you want to use your own resolver, you will need to create a dedicated initializer.

app/Authentication/LdapAuthenticatableResolverInitializer.php
use Tempest\Auth\Authentication\AuthenticatableResolver;

final class LdapAuthenticatableResolverInitializer implements Initializer
{
    #[Singleton]
    public function initialize(Container $container): AuthenticatableResolver
    {
        return new LdapAuthenticatableResolver(
            ldap: $container->get(LdapClient::class),
        );
    }
}

Custom authenticator

By default, Tempest uses the provided SessionAuthenticator to remember the authenticated model across requests using browser sessions.

However, you may provide your own authenticator by implementing the Authenticator interface. For instance, may want the model to be authenticated for the duration of the request only.

app/Authentication/RequestAuthenticator.php
use Tempest\Auth\Authentication\Authenticator;
use Tempest\Auth\Authentication\Authenticatable;

#[Autowire]
final class RequestAuthenticator implements Authenticator
{
    private ?Authenticatable $current = null;

    public function authenticate(Authenticatable $authenticatable): void
    {
        $this->current = $authenticatable;
    }

    public function deauthenticate(): void
    {
        $this->current = null;
    }

    public function current(): ?Authenticatable
    {
        return $this->current;
    }
}

Access control

In most applications, it is necessary to restrict access to certain resources depending on many factors. For instance, you may want to allow only the author of a post to edit it, or allow only administrators to delete other users.

To solve this problem, Tempest provides the ability to write policies. A policy defines the authorization rules for a specific resource, allowing you to implement complex business logic around who can access that resource.

This paradigm is known as policy-based access control. Policies build on the concept of actions, resources and subjects:

  • An action is a specific operation that can be performed on a resource, such as view, edit, or delete.
  • A resource may be anything represented by a class.
  • A subject is the entity that is trying to perform the action, typically the authenticated user.

Defining policies

To create a policy, you may define a method in any class and annotate it with the #[Policy] attribute. Typically, this is done in a dedicated policy class.

The attribute expects the class name of the resource as its first parameter, and the action name as the second parameter. If the resource is not specified, it will be inferred by the method's first parameter. Similarly, if the action name is not provided, the kebab-cased method name is used instead.

app/PostPolicy.php
use Tempest\Auth\AccessControl\Policy;
use Tempest\Auth\AccessControl\AccessDecision;

final class PostPolicy
{
    #[Policy(Post::class)]
    public function create(): bool
    {
        return true;
    }

    #[Policy]
    public function view(Post $post): bool
    {
        if (! $post->published) {
            return false;
        }

        return true;
    }

    #[Policy(action: ['edit', 'update'])]
    public function edit(Post $post, ?User $user): bool
    {
        if ($user === null) {
            return false;
        }

        return $post->authorId === $user->id->value;
    }
}

The policy method will be given the resource instance as the first parameter and the subject as the second one. Both of these may be null, depending on the context in which the policy is evaluated.

The policy method is expected to return a boolean value or an AccessDecision instance. The latter can be used to provide more context about the decision:

return AccessDecision::denied('You must be authenticated to perform this action.');

Checking for permissions

You may inject the AccessControl interface to check if a specific action is granted for a resource and subject. Typically, the ensureGranted() method is called in a controller.

app/Controllers/PostController.php
use Tempest\Auth\AccessControl\AccessControl;

final readonly class PostController
{
    public function __construct(
        private AccessControl $accessControl,
    ) {}

    #[Delete('/posts/{post}')]
    public function delete(Post $post): Redirect
    {
        $this->accessControl->ensureGranted('delete', $post);

        // Proceed with deletion...
        
        return new Redirect('/posts');
    }
}

Alternatively, you may use the isGranted() method. It will return a boolean indicating whether the action is granted for the resource and subject.

Note that the subject is optional in both methods—if omitted, the authenticated model is automatically provided.

Resources without instances

When evaluating the ability to perform an action on a resource without an instance, you may pass the class name of the resource as a string. Typically, this is used when checking if a subject has the permissions to create a new resource.

$accessControl->isGranted('create', resource: Post::class, subject: $user);