Skip to content

Commit 16aacc7

Browse files
innocenzibrendt
andauthored
feat(auth)!: overhaul authentication and access control (#1516)
Co-authored-by: Brent Roose <[email protected]>
1 parent fee4da0 commit 16aacc7

File tree

76 files changed

+2636
-1160
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

76 files changed

+2636
-1160
lines changed
Lines changed: 269 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,105 +1,322 @@
11
---
2-
title: Authentication and authorization
2+
title: Authentication
3+
description: "Learn how to authenticate models, implement access control, and secure your application with Tempest's flexible authentication system."
34
keywords: "Experimental"
45
---
56

6-
:::warning
7-
The authentication and authorization implementations of Tempest are currently experimental. Although you can use them, please note that they are not covered by our backwards compatibility promise.
8-
:::
9-
107
## Overview
118

12-
Logging in (authentication) and verifying whether a user is allowed to perform a specific action (authorization) are two crucial parts of any web application. Tempest comes with a built-in authenticator and authorizer, as well as a base `User` and `Permission` model (if you want to).
9+
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.
10+
11+
Additionally, Tempest provides a [policy-based access control](#access-control) implementation that allows you to define fine-grained permissions for your resources.
12+
13+
## Quick start
14+
15+
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.
16+
17+
```sh sh
18+
./tempest install auth
19+
```
20+
21+
After publishing, you may run `./tempest migrate`. You now have the building blocks for your authentication.
1322

1423
## Authentication
1524

16-
Logging in a user can be done with the `Authenticator` class:
25+
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.
1726

18-
```php
19-
// app/AuthController.php
27+
To register an authenticatable model, you may create a class that implements the {b`Tempest\Auth\Authentication\Authenticatable`} interface. This interface is automatically discovered by Tempest.
28+
29+
```php app/Authentication/User.php
30+
use Tempest\Auth\Authentication\Authenticatable;
31+
use Tempest\Database\PrimaryKey;
32+
use Tempest\Database\Hashed;
33+
34+
final class User implements Authenticatable
35+
{
36+
public PrimaryKey $id;
37+
38+
public function __construct(
39+
public string $email,
40+
#[Hashed]
41+
public ?string $password,
42+
) {}
43+
}
44+
```
45+
46+
Note that if you use the default [database authenticatable resolver](#custom-authenticatable-resolver), the model must have at least a {b`Tempest\Database\PrimaryKey`} property—it will be used to uniquely identify the model in the database.
47+
48+
### Authenticating a model
49+
50+
Authenticating a model—in most cases, a user—is usually done in a controller. Tempest provides an {b`Tempest\Auth\Authentication\Authenticator`} that may authenticate, deauthenticate, and access the currently authenticated model.
51+
52+
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 {b`Tempest\Cryptography\Password\PasswordHasher`} for this purpose.
2053

21-
use Tempest\Auth\Authenticator;
22-
use Tempest\Http\Request;
23-
use Tempest\Http\Response;
24-
use Tempest\Http\Responses\Redirect;
54+
```php app/Authentication/AuthenticationController.php
55+
use Tempest\Auth\Authentication\Authenticator;
56+
use Tempest\Cryptography\Password\PasswordHasher;
2557

26-
final readonly class AuthController
58+
final readonly class AuthenticationController
2759
{
2860
public function __construct(
29-
private Authenticator $authenticator
61+
private Authenticator $authenticator,
62+
private PasswordHasher $passwordHasher,
3063
) {}
3164

3265
#[Post('/login')]
33-
public function login(Request $request): Response
66+
public function login(LoginRequest $request): Redirect
3467
{
35-
$user = // …
68+
$user = query(User::class)
69+
->select()
70+
->where('email', $request->email)
71+
->first();
3672

37-
$this->authenticator->login($user);
73+
if (! $user || ! $this->passwordHasher->verify($request->password, $user->password)) {
74+
return new Redirect('/login')->flash('error', 'Invalid credentials');
75+
}
76+
77+
$this->authenticator->authenticate($user);
3878

3979
return new Redirect('/');
4080
}
81+
82+
#[Post('/logout')]
83+
public function logout(): Redirect
84+
{
85+
$this->authenticator->deauthenticate();
86+
87+
return new Redirect('/login');
88+
}
4189
}
4290
```
4391

44-
Note that Tempest currently doesn't provide user management support (resolving a user from a request, user registration, password reset flow, etc.).
92+
### Accessing the authenticated model
4593

46-
## Authorization
94+
You may access the currently authenticated model by injecting the {b`Tempest\Auth\Authentication\Authenticator`}. The authenticator provides a `current()` method that returns the currently authenticated model, or `null` if no model is authenticated.
4795

48-
You can protect controller routes using the `#[Allow]` attribute:
96+
```php app/ProfileController.php
97+
use Tempest\Auth\Authentication\Authenticator;
98+
use Tempest\Router\Get;
99+
use Tempest\View\View;
100+
use function Tempest\view;
49101

50-
```php
51-
// app/AdminController.php
102+
final readonly class ProfileController
103+
{
104+
public function __construct(
105+
private Authenticator $authenticator,
106+
) {}
52107

53-
use Tempest\Auth\Allow;
54-
use Tempest\Http\Response;
108+
#[Get('/profile', middleware: [MustBeAuthenticated::class])]
109+
public function show(): View
110+
{
111+
return view('profile.view.php', user: $this->authenticator->current());
112+
}
113+
}
114+
```
55115

56-
final readonly class AdminController
116+
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:
117+
118+
```php app/ProfileController.php
119+
final readonly class ProfileController
57120
{
58-
#[Allow('permission')]
59-
public function index(): Response
121+
public function __construct(
122+
private User $user,
123+
) {}
124+
125+
#[Get('/profile', middleware: [MustBeAuthenticated::class])]
126+
public function show(): View
60127
{
61-
// …
128+
return view('profile.view.php', user: $this->user);
62129
}
63130
}
64131
```
65132

66-
Tempest uses a permission-based authorizer. That means that, in order for users to be allowed access to a route, they'll need to be granted the right permission. Permissions can be represented as strings or enums:
133+
:::warning
134+
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.
135+
:::
67136

68-
```php
69-
// app/AdminController.php
137+
### Custom authenticatable resolver
138+
139+
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.
70140

71-
use Tempest\Auth\Allow;
72-
use Tempest\Http\Response;
141+
Tempest provides a {b`Tempest\Auth\Authentication\DatabaseAuthenticatableResolver`}, which is used by default. However, you may implement your own resolver by implementing the {b`Tempest\Auth\Authentication\AuthenticatableResolver`} interface.
73142

74-
final readonly class AdminController
143+
```php app/Authentication/LdapAuthenticatableResolver.php
144+
use Tempest\Auth\Authentication\AuthenticatableResolver;
145+
use Tempest\Auth\Authentication\Authenticatable;
146+
use App\Authentication\User;
147+
148+
final readonly class LdapAuthenticatableResolver implements AuthenticatableResolver
75149
{
76-
#[Allow(UserPermission::ADMIN)]
77-
public function index(): Response
150+
public function __construct(
151+
private LdapClient $ldap,
152+
) {}
153+
154+
public function resolve(int|string $id): ?Authenticatable
78155
{
79-
// …
156+
$attributes = $this->ldap->findUserByIdentifier($id);
157+
158+
if ($attributes === null) {
159+
return null;
160+
}
161+
162+
return new User(
163+
username: $attributes['uid'] ?? null,
164+
email: $attributes['mail'] ?? null,
165+
displayName: $attributes['cn'] ?? null
166+
);
167+
}
168+
169+
public function resolveId(Authenticatable $authenticatable): int|string
170+
{
171+
return $authenticatable->email;
80172
}
81173
}
82174
```
83175

84-
## Built-in user model
176+
To instruct Tempest that you want to use your own resolver, you will need to create a dedicated [initializer](../1-essentials/05-container.md#implementing-an-initializer).
85177

86-
Tempest's authenticator and authorizer are compatible with any class implementing the {`Tempest\Auth\CanAuthenticate`} and {`Tempest\Auth\CanAuthorize`} interfaces. However, Tempest comes with a pre-built `User` model that makes it easier to get started. In order to use Tempest's `User` implementation, you must install the auth files:
178+
```php app/Authentication/LdapAuthenticatableResolverInitializer.php
179+
use Tempest\Auth\Authentication\AuthenticatableResolver;
87180

181+
final class LdapAuthenticatableResolverInitializer implements Initializer
182+
{
183+
#[Singleton]
184+
public function initialize(Container $container): AuthenticatableResolver
185+
{
186+
return new LdapAuthenticatableResolver(
187+
ldap: $container->get(LdapClient::class),
188+
);
189+
}
190+
}
88191
```
89-
./tempest install auth
90-
./tempest migrate:up
192+
193+
### Custom authenticator
194+
195+
By default, Tempest uses the provided {b`Tempest\Auth\Authentication\SessionAuthenticator`} to remember the authenticated model across requests using browser sessions.
196+
197+
However, you may provide your own authenticator by implementing the {b`Tempest\Auth\Authentication\Authenticator`} interface. For instance, may want the model to be authenticated for the duration of the request only.
198+
199+
```php app/Authentication/RequestAuthenticator.php
200+
use Tempest\Auth\Authentication\Authenticator;
201+
use Tempest\Auth\Authentication\Authenticatable;
202+
203+
#[Autowire]
204+
final class RequestAuthenticator implements Authenticator
205+
{
206+
private ?Authenticatable $current = null;
207+
208+
public function authenticate(Authenticatable $authenticatable): void
209+
{
210+
$this->current = $authenticatable;
211+
}
212+
213+
public function deauthenticate(): void
214+
{
215+
$this->current = null;
216+
}
217+
218+
public function current(): ?Authenticatable
219+
{
220+
return $this->current;
221+
}
222+
}
223+
```
224+
225+
## Access control
226+
227+
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.
228+
229+
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.
230+
231+
This paradigm is known as [policy-based access control](https://en.wikipedia.org/wiki/Attribute-based_access_control). Policies build on the concept of actions, resources and subjects:
232+
233+
- An action is a specific operation that can be performed on a resource, such as `view`, `edit`, or `delete`.
234+
- A resource may be anything represented by a class.
235+
- A subject is the entity that is trying to perform the action, typically the authenticated user.
236+
237+
### Defining policies
238+
239+
To create a policy, you may define a method in any class and annotate it with the {b`#[Tempest\Auth\AccessControl\Policy]`} attribute. Typically, this is done in a dedicated policy class.
240+
241+
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.
242+
243+
```php app/PostPolicy.php
244+
use Tempest\Auth\AccessControl\Policy;
245+
use Tempest\Auth\AccessControl\AccessDecision;
246+
247+
final class PostPolicy
248+
{
249+
#[Policy(Post::class)]
250+
public function create(): bool
251+
{
252+
return true;
253+
}
254+
255+
#[Policy]
256+
public function view(Post $post): bool
257+
{
258+
if (! $post->published) {
259+
return false;
260+
}
261+
262+
return true;
263+
}
264+
265+
#[Policy(action: ['edit', 'update'])]
266+
public function edit(Post $post, ?User $user): bool
267+
{
268+
if ($user === null) {
269+
return false;
270+
}
271+
272+
return $post->authorId === $user->id->value;
273+
}
274+
}
91275
```
92276

93-
With this `User` model, you already have a lot of helper methods in place to build your own user management flow:
277+
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.
278+
279+
The policy method is expected to return a boolean value or an {b`Tempest\Auth\AccessControl\AccessDecision`} instance. The latter can be used to provide more context about the decision:
280+
281+
```php
282+
return AccessDecision::denied('You must be authenticated to perform this action.');
283+
```
284+
285+
### Checking for permissions
286+
287+
You may inject the {b`Tempest\Auth\AccessControl\AccessControl`} interface to check if a specific action is granted for a resource and subject. Typically, the `ensureGranted()` method is called in a controller.
288+
289+
```php app/Controllers/PostController.php
290+
use Tempest\Auth\AccessControl\AccessControl;
291+
292+
final readonly class PostController
293+
{
294+
public function __construct(
295+
private AccessControl $accessControl,
296+
) {}
297+
298+
#[Delete('/posts/{post}')]
299+
public function delete(Post $post): Redirect
300+
{
301+
$this->accessControl->ensureGranted('delete', $post);
302+
303+
// Proceed with deletion...
304+
305+
return new Redirect('/posts');
306+
}
307+
}
308+
```
309+
310+
Alternatively, you may use the `isGranted()` method. It will return a boolean indicating whether the action is granted for the resource and subject.
311+
312+
:::info
313+
Note that the subject is optional in both methods—if omitted, the [authenticated model](#authentication) is automatically provided.
314+
:::
315+
316+
### Resources without instances
317+
318+
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.
94319

95320
```php
96-
use App\Auth\User;
97-
98-
$user = new User(
99-
name: 'Brent',
100-
101-
)
102-
->setPassword('password')
103-
->save()
104-
->grantPermission('admin');
321+
$accessControl->isGranted('create', resource: Post::class, subject: $user);
105322
```

packages/auth/composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "tempest/auth",
3-
"description": "A flexible authentication package for Tempest, providing user authentication and authorization.",
3+
"description": "A flexible authentication package for Tempest, providing authentication and authorization.",
44
"require": {
55
"php": "^8.4",
66
"tempest/core": "dev-main",

0 commit comments

Comments
 (0)