|
1 | 1 | --- |
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." |
3 | 4 | keywords: "Experimental" |
4 | 5 | --- |
5 | 6 |
|
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 | | - |
10 | 7 | ## Overview |
11 | 8 |
|
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. |
13 | 22 |
|
14 | 23 | ## Authentication |
15 | 24 |
|
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. |
17 | 26 |
|
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. |
20 | 53 |
|
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; |
25 | 57 |
|
26 | | -final readonly class AuthController |
| 58 | +final readonly class AuthenticationController |
27 | 59 | { |
28 | 60 | public function __construct( |
29 | | - private Authenticator $authenticator |
| 61 | + private Authenticator $authenticator, |
| 62 | + private PasswordHasher $passwordHasher, |
30 | 63 | ) {} |
31 | 64 |
|
32 | 65 | #[Post('/login')] |
33 | | - public function login(Request $request): Response |
| 66 | + public function login(LoginRequest $request): Redirect |
34 | 67 | { |
35 | | - $user = // … |
| 68 | + $user = query(User::class) |
| 69 | + ->select() |
| 70 | + ->where('email', $request->email) |
| 71 | + ->first(); |
36 | 72 |
|
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); |
38 | 78 |
|
39 | 79 | return new Redirect('/'); |
40 | 80 | } |
| 81 | + |
| 82 | + #[Post('/logout')] |
| 83 | + public function logout(): Redirect |
| 84 | + { |
| 85 | + $this->authenticator->deauthenticate(); |
| 86 | + |
| 87 | + return new Redirect('/login'); |
| 88 | + } |
41 | 89 | } |
42 | 90 | ``` |
43 | 91 |
|
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 |
45 | 93 |
|
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. |
47 | 95 |
|
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; |
49 | 101 |
|
50 | | -```php |
51 | | -// app/AdminController.php |
| 102 | +final readonly class ProfileController |
| 103 | +{ |
| 104 | + public function __construct( |
| 105 | + private Authenticator $authenticator, |
| 106 | + ) {} |
52 | 107 |
|
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 | +``` |
55 | 115 |
|
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 |
57 | 120 | { |
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 |
60 | 127 | { |
61 | | - // … |
| 128 | + return view('profile.view.php', user: $this->user); |
62 | 129 | } |
63 | 130 | } |
64 | 131 | ``` |
65 | 132 |
|
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 | +::: |
67 | 136 |
|
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. |
70 | 140 |
|
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. |
73 | 142 |
|
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 |
75 | 149 | { |
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 |
78 | 155 | { |
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; |
80 | 172 | } |
81 | 173 | } |
82 | 174 | ``` |
83 | 175 |
|
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). |
85 | 177 |
|
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; |
87 | 180 |
|
| 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 | +} |
88 | 191 | ``` |
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 | +} |
91 | 275 | ``` |
92 | 276 |
|
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. |
94 | 319 |
|
95 | 320 | ```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); |
105 | 322 | ``` |
0 commit comments