Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@
"lint:fix": "vendor/bin/mago lint --fix --format-after-fix",
"style": "composer fmt && composer lint:fix",
"test": "composer phpunit",
"test:stop": "composer phpunit --stop-on-error --stop-on-failure",
"test:stop": "composer phpunit -- --stop-on-error --stop-on-failure",
"lint": "vendor/bin/mago lint --potentially-unsafe --minimum-fail-level=note",
"phpstan": "vendor/bin/phpstan analyse src tests --memory-limit=1G",
"rector": "vendor/bin/rector process --no-ansi",
Expand Down
36 changes: 25 additions & 11 deletions docs/1-essentials/01-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -449,12 +449,12 @@ final readonly class AircraftController

When users submit forms—like updating profile settings, or posting comments—the data needs validation before processing. Tempest automatically validates request objects using type hints and validation attributes, then provides errors back to users when something is wrong.

On validation failure, Tempest either redirects back to the form (for web pages) or returns a 400 response (for stateless requests). Validation errors are available in two places:
On validation failure, Tempest either redirects back to the form (for web pages) or returns a 422 response (for stateless requests). Validation errors are available in two places:

- As a JSON encoded string in the `{txt}X-Validation` header
- Within the session stored in `Session::VALIDATION_ERRORS`
- Through the `b{Tempest\Http\Session\FormSession}` class

The JSON-encoded header is available for APIs built with Tempest. The session errors are available for web pages. For web pages, Tempest provides built-in view components to display errors when they occur.
For web pages, Tempest also provides built-in view components to display errors when they occur.

```html
<x-form :action="uri(StorePostController::class)">
Expand All @@ -464,7 +464,7 @@ The JSON-encoded header is available for APIs built with Tempest. The session er
</x-form>
```

`{html}<x-form>` is a view component that automatically includes the CSRF token and defaults to sending `POST` requests. `{html}<x-input>` is a view component that renders a label, input field, and validation errors all at once.
`{html}<x-form>` is a view component that defaults to sending `POST` requests. `{html}<x-input>` is a view component that renders a label, input field, and validation errors all at once.

:::info
These built-in view components can be customized. Run `./tempest install view-components` and select the components to pull into the project. [Read more about installing view components here](../1-essentials/02-views.md#built-in-components).
Expand Down Expand Up @@ -542,20 +542,33 @@ final readonly class ValidateWebhook implements HttpMiddleware
{ /* … */ }
```

### Cross-site request forgery protection

Tempest provides [cross-site request forgery](https://en.wikipedia.org/wiki/Cross-site_request_forgery) protection based on the presence and values of the [`{txt}Sec-Fetch-Site`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Site) and [`{txt}Sec-Fetch-Mode`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Mode) headers through the {b`Tempest\Router\PreventCrossSiteRequestsMiddleware`} middleware, included by default in all requests.

Unlike traditional CSRF tokens, this approach uses browser-generated headers that cannot be forged by external websites:

- [`{txt}Sec-Fetch-Site`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Site) indicates whether the request came from the same domain, subdomain, a different site or if it was user-initiated, such as typing the URL directly,
- [`{txt}Sec-Fetch-Mode`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Mode) allows distinguishing between requests originating from a user navigating between HTML pages, and requests to load images and other resources.

:::info
This middleware requires browsers that support `{txt}Sec-Fetch-*` headers, which is the case for all modern browsers. You may [exclude this middleware](#excluding-route-middleware) and implement traditional CSRF protection using tokens if you need to support older browsers.
:::

### Excluding route middleware

Some routes do not require specific global middleware to be applied. For instance, API routes do not need CSRF protection. Specific middleware can be skipped by using the `without` argument of the route attribute.
Some routes do not require specific global middleware to be applied. For instance, a publicly accessible health check endpoint could bypass rate limiting that's applied to other routes. Specific middleware can be skipped by using the `without` argument of the route attribute.

```php app/Slack/ReceiveInteractionController.php
use Tempest\Router\Post;
```php app/HealthCheckController.php
use Tempest\Router\Get;
use Tempest\Http\Response;

final readonly class ReceiveInteractionController
final readonly class HealthCheckController
{
#[Post('/slack/interaction', without: [VerifyCsrfMiddleware::class, SetCookieMiddleware::class])]
#[Get('/health', without: [RateLimitMiddleware::class])]
public function __invoke(): Response
{
// …
return new Ok(['status' => 'healthy']);
}
}
```
Expand Down Expand Up @@ -639,8 +652,9 @@ Explicitly removes middleware to all associated routes.
```php
use Tempest\Router\WithoutMiddleware;
use Tempest\Router\Get;
use Tempest\Router\PreventCrossSiteRequestsMiddleware;

#[WithoutMiddleware(VerifyCsrfMiddleware::class, SetCookieMiddleware::class)]
#[WithoutMiddleware(PreventCrossSiteRequestsMiddleware::class)]
final readonly class StatelessController { /* … */ }
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,19 @@

namespace Tempest\Auth\Authentication;

use Tempest\Auth\AuthConfig;
use Tempest\Container\Container;
use Tempest\Container\Initializer;
use Tempest\Container\Singleton;
use Tempest\Http\Session\Session;
use Tempest\Http\Session\SessionManager;

final readonly class AuthenticatorInitializer implements Initializer
{
#[Singleton]
public function initialize(Container $container): Authenticator
{
return new SessionAuthenticator(
authConfig: $container->get(AuthConfig::class),
sessionManager: $container->get(SessionManager::class),
session: $container->get(Session::class),
authenticatableResolver: $container->get(AuthenticatableResolver::class),
);
Expand Down
7 changes: 4 additions & 3 deletions packages/auth/src/Authentication/SessionAuthenticator.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@

namespace Tempest\Auth\Authentication;

use Tempest\Auth\AuthConfig;
use Tempest\Http\Session\Session;
use Tempest\Http\Session\SessionManager;

final readonly class SessionAuthenticator implements Authenticator
{
public const string AUTHENTICATABLE_KEY = '#authenticatable:id';
public const string AUTHENTICATABLE_CLASS = '#authenticatable:class';

public function __construct(
private AuthConfig $authConfig,
private SessionManager $sessionManager,
private Session $session,
private AuthenticatableResolver $authenticatableResolver,
) {}
Expand All @@ -34,7 +34,8 @@ public function authenticate(Authenticatable $authenticatable): void
public function deauthenticate(): void
{
$this->session->remove(self::AUTHENTICATABLE_KEY);
$this->session->destroy();
$this->session->remove(self::AUTHENTICATABLE_CLASS);
$this->sessionManager->save($this->session);
}

public function current(): ?Authenticatable
Expand Down
11 changes: 4 additions & 7 deletions packages/core/src/AppConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,13 @@ final class AppConfig
{
public string $baseUri;

/** @var array<class-string<\Tempest\Core\InsightsProvider>> */
public array $insightsProviders = [];

public function __construct(
public ?string $name = null,

?string $baseUri = null,

/**
* @var array<class-string<\Tempest\Core\InsightsProvider>>
*/
public array $insightsProviders = [],
) {
$this->baseUri = $baseUri ?? env('BASE_URI') ?? '';
$this->baseUri = $baseUri ?: env('BASE_URI', default: '');
}
}
3 changes: 2 additions & 1 deletion packages/core/src/FrameworkKernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ public function registerExceptionHandler(): self

$handler = $this->container->get(ExceptionHandler::class);

ini_set('display_errors', 'Off'); // @mago-expect lint:no-ini-set
set_exception_handler($handler->handle(...));
set_error_handler(function (int $code, string $message, string $filename, int $line) use ($handler): bool {
$handler->handle(new ErrorException(
Expand All @@ -255,7 +256,7 @@ public function registerExceptionHandler(): self
));

return true;
}, error_levels: E_ERROR);
}, error_levels: E_ALL);

return $this;
}
Expand Down
4 changes: 2 additions & 2 deletions packages/http/src/Cookie/Cookie.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ public function __construct(
public ?int $maxAge = null,
public ?string $domain = null,
public ?string $path = '/',
public bool $secure = false,
public bool $secure = true,
public bool $httpOnly = false,
public ?SameSite $sameSite = null,
public SameSite $sameSite = SameSite::LAX,
) {}

public function withValue(string $value): self
Expand Down
6 changes: 6 additions & 0 deletions packages/http/src/Header.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace Tempest\Http;

use BackedEnum;

final class Header
{
public function __construct(
Expand All @@ -14,6 +16,10 @@ public function __construct(

public function add(mixed $value): void
{
if ($value instanceof BackedEnum) {
$value = $value->value;
}

$this->values[] = $value;
}

Expand Down
34 changes: 14 additions & 20 deletions packages/http/src/IsResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Tempest\Http\Cookie\CookieManager;
use Tempest\Http\Session\Session;
use Tempest\View\View;
use UnitEnum;

use function Tempest\get;

Expand All @@ -23,14 +24,14 @@ trait IsResponse
/** @var \Tempest\Http\Header[] */
private(set) array $headers = [];

public Session $session {
get => get(Session::class);
}

public CookieManager $cookieManager {
get => get(CookieManager::class);
}

public Session $session {
get => get(Session::class);
}

private(set) ?View $view = null;

public function getHeader(string $name): ?Header
Expand Down Expand Up @@ -72,42 +73,35 @@ public function removeHeader(string $key): self
return $this;
}

public function addSession(string $name, mixed $value): self
{
$this->session->set($name, $value);

return $this;
}

public function removeSession(string $name): self
public function addCookie(Cookie $cookie): self
{
$this->session->remove($name);
$this->cookieManager->add($cookie);

return $this;
}

public function destroySession(): self
public function removeCookie(string $key): self
{
$this->session->destroy();
$this->cookieManager->remove($key);

return $this;
}

public function addCookie(Cookie $cookie): self
public function addSession(string $name, mixed $value): self
{
$this->cookieManager->add($cookie);
$this->session->set($name, $value);

return $this;
}

public function removeCookie(string $key): self
public function removeSession(string $name): self
{
$this->cookieManager->remove($key);
$this->session->remove($name);

return $this;
}

public function flash(string $key, mixed $value): self
public function flash(string|UnitEnum $key, mixed $value): self
{
$this->session->flash($key, $value);

Expand Down
8 changes: 4 additions & 4 deletions packages/http/src/RequestHeaders.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use ArrayIterator;
use IteratorAggregate;
use LogicException;
use Tempest\Support\Str;
use Traversable;

final readonly class RequestHeaders implements ArrayAccess, IteratorAggregate
Expand All @@ -17,11 +18,10 @@
*/
public static function normalizeFromArray(array $headers): self
{
$normalized = array_combine(
return new self(array_combine(
array_map(strtolower(...), array_keys($headers)),
array_values($headers),
);
return new self($normalized);
array_values(array_map(fn (mixed $value) => Str\parse($value), $headers)),
));
}

/** @param array<string, string> $headers */
Expand Down
47 changes: 43 additions & 4 deletions packages/http/src/Response.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,41 +8,80 @@
use JsonSerializable;
use Tempest\Http\Cookie\Cookie;
use Tempest\View\View;
use UnitEnum;

interface Response
{
/**
* Gets the status code of the response.
*/
public Status $status {
get;
}

/** @var \Tempest\Http\Header[] $headers */
/**
* Gets the headers of the response.
*
* @var \Tempest\Http\Header[] $headers
*/
public array $headers {
get;
}

/**
* Gets the body of the response.
*/
public View|string|array|Generator|JsonSerializable|null $body {
get;
}

/**
* Gets a header by its name, case insensitive.
*/
public function getHeader(string $name): ?Header;

/**
* Adds a header to the response.
*/
public function addHeader(string $key, string $value): self;

/**
* Removes a header from the response.
*/
public function removeHeader(string $key): self;

/**
* Adds a value to the session.
*/
public function addSession(string $name, mixed $value): self;

public function flash(string $key, mixed $value): self;

/**
* Removes a value from the session.
*/
public function removeSession(string $name): self;

public function destroySession(): self;
/**
* Flash a value to the session for the next request.
*/
public function flash(string|UnitEnum $key, mixed $value): self;

/**
* Adds a cookie to the response.
*/
public function addCookie(Cookie $cookie): self;

/**
* Removes a cookie from the response.
*/
public function removeCookie(string $key): self;

/**
* Sets the status code of the response.
*/
public function setStatus(Status $status): self;

/**
* Sets the body of the response.
*/
public function setBody(View|string|array|Generator|null $body): self;
}
Loading