Skip to content

Latest commit

 

History

History
495 lines (378 loc) · 22.3 KB

authentication.md

File metadata and controls

495 lines (378 loc) · 22.3 KB

Authentication

Introduction

Authentication is the process of verifying an identity. Aphiria's authentication library allows for full customization to suit your application's needs. At a high level, authentication uses named schemes to authenticate principals. Each scheme gets a handler that actually performs the authentication logic, along with options for things like cookie names and lifetimes, login page paths to redirect to on authentication failure, etc.

You can enforce authentication either through attributes on a controller or a specific controller method:

use Aphiria\Api\Controllers\Controller;
use Aphiria\Authentication\Attributes\Authenticate;

#[Authenticate]
final class UserController extends Controller
{
    // ...
}

or by composing IAuthenticator in your controllers:

use Aphiria\Api\Controllers\Controller;
use Aphiria\Authentication\IAuthenticator;
use Aphiria\Routing\Attributes\Get;

final class UserController extends Controller
{
    public function __construct(private IAuthenticator $authenticator) {}
    
    #[Get('/users/:id')]
    public function getUserById(int $id): User
    {
        $authenticationResult = $this->authenticator->authenticate($this->request);
        
        if (!$authenticationResult->passed) {
            return $this->unauthorized();
        }
    
        // ...
    }
}

The #[Authenticate] attribute can also take in a scheme name or list of scheme names parameter if you wish to use a specific scheme:

#[Authenticate(schemeNames: ['cookie', 'bearer'])]
final class UserController extends Controller
{
    // ...
}

Note: Authentication will only fail if all authentication scheme names fail to authenticate. When this happens, IAuthenticator::challenge() will be called for each scheme. If authentication passes for any scheme, the principal's identities will be merged with all other successfully authenticated schemes.

Likewise, you can specify a scheme to authenticate against in IAuthenticator::authenticate():

$authenticationResult = $this->authenticator->authenticate($this->request, schemeNames: 'cookie');

Note: Not specifying a scheme name will cause authentication to use the default scheme. Also, identical to using the attribute for authentication, if you make multiple calls to IAuthenticator::authenticate() but with different scheme names, the principal returned in the result will contain all authenticated identities merged together.

We'll go into more details about how to customize responses when authentication does not pass below.

Principals

Before we go too far into authentication, let's first go over some terminology. A principal is the thing, usually a user or system process, that interacts with your application. You can authenticate a principal and authorize actions performed by it. A principal contains one or more identities, each of which may contain claims.

Example: Let's say you're going to the airport to board a flight. You, the principal, will be asked to prove you are who you claim to be, which you'll do with your passport - an identity that contains claims about your citizenship, name, and date of birth. At security, you'll be asked to re-prove your identity with your passport, and also be asked to prove that you have a ticket - another identity that contains claims about your name, your flight number, and assigned seat. Airport security will authenticate your passport and ticket, and verify that you're authorized to board the flight.

It should be noted that, in Aphiria, a principal is a generic representation of a user, and is solely meant for authentication and authorization. Your application will likely have its own abstractions for users, and those abstractions' data can be used to populate identities and claims of a principal. We'll also use the terms "user" and "principal" interchangeably from here on out.

Claims

To create a principal, you first create its claims for its identities. A claim is a statement about an identity, eg email, date of birth, roles, etc. Let's look at an example:

use Aphiria\Security\{Claim, ClaimType, Identity, User};

// Claims data is usually stored in a database
$claimsIssuer = 'example.com';
// Claim types may either be ClaimType enum values or your own custom strings
$claims = [
    // This claim stores the user's ID
    new Claim(ClaimType::NameIdentifier, 123, $claimsIssuer),
    // This claim stores the user's name
    new Claim(ClaimType::Name, 'Dave', $claimsIssuer),
    // This claim stores the user's roles
    new Claim(ClaimType::Role, 'admin', $claimsIssuer)
];
// You can also pass in an array of identities
$user = new User(new Identity($claims));

Note: You can have multiple claims of the same type in one identity. For example, you might have multiple roles for a user, each of which would have its own distinct claim.

IPrincipal contains some useful methods for aggregating claims made by all its identities:

$allClaims = $user->claims;
$allRoleClaims = $user->filterClaims(ClaimType::Role);

// Check that not only do they have a role claim type, but that its value is "admin"
if ($user->hasClaim(ClaimType::Role, 'admin')) {
    // ...
}

// Add another identity
$identity = new Identity([new Claim(ClaimType::Email, '[email protected]', 'example.com')]);
$user->addIdentity($identity);

You can also loop through all the user's identities and query them directly:

foreach ($user->identities as $identity) {
    $allClaims = $identity->claims;
    $allRoleClaims = $identity->filterClaims(ClaimType::Role);
    
    if ($identity->hasClaim(ClaimType::Role, 'admin')) {
        // ...
    }
    
    // A convenience method for grabbing this identity's username
    $username = $identity->name;
    // Another convenience method for grabbing this identity's ID
    $id = $identity->nameIdentifier;
}

You'll frequently find yourself dealing with a user's primary identity when checking claims. Grabbing it is easy.

$primaryIdentity = $user->primaryIdentity;

By default, this is the first identity added to the user, but it can be customized with a callback to determine the primary identity.

// This will make the last added identity the primary one
$primaryIdentitySelector = fn (array $identities): ?IIdentity => $identities[\count($identities) - 1] ?? null;
$user = new User($claims, $primaryIdentitySelector);
$userId = $user->primaryIdentity?->nameIdentifier;

Typically, the process of authentication will create the principal and store it, usually as a request property.

Builder

Aphiria provides a fluent builder syntax for principals and identities. For example, the code in the claims documentation can be simplified to:

use Aphiria\Security\PrincipalBuilder;

$user = new PrincipalBuilder('example.com')->withNameIdentifier(123)
    ->withName('Dave')
    ->withRoles('admin')
    ->build();

This will build a primary identity with the specified claims. The following fluent methods are available to build your identities in Principal:

  • withActor(string $value, ?string $issuer)
  • withAuthenticationSchemeName(string $authenticationSchemeName)
    • This must be set for the claims to be considered authenticated
  • withClaims(Claim|Claim[] $claims)
  • withCountry(string $value, ?string $issuer)
  • withDateOfBirth(DateTimeInterface $value, ?string $issuer)
  • withDns(string $value, ?string $issuer)
  • withEmail(string $value, ?string $issuer)
  • withGender(string $value, ?string $issuer)
  • withGivenName(string $value, ?string $issuer)
  • withHomePhone(string $value, ?string $issuer)
  • withLocality(string $value, ?string $issuer)
  • withMobilePhone(string $value, ?string $issuer)
  • withName(string $value, ?string $issuer)
  • withNameIdentifier(mixed $value, ?string $issuer)
  • withOtherPhone(string $value, ?string $issuer)
  • withPostalCode(string|int $value, ?string $issuer)
  • withRoles(string|string[] $value, ?string $issuer)
    • If you specify multiple roles, a unique claim for each role will be created with claim type ClaimType::Role
  • withRsa(string $value, ?string $issuer)
  • withSid(string $value, ?string $issuer)
  • withStateOrProvince(string $value, ?string $issuer)
  • withStreetAddress(string $value, ?string $issuer)
  • withSurname(string $value, ?string $issuer)
  • withThumbprint(string $value, ?string $issuer)
  • withUpn(string $value, ?string $issuer)
  • withUri(string $value, ?string $issuer)
  • withX500DistinguishedName(string $value, ?string $issuer)

Note: If you pass in an issuer into any of the above methods, it will supersede the default claims issuer set in the PrincipalBuilder constructor. An issuer must be set either in PrincipalBuilder::__construct() or in the claim builder methods above.

If you want to build multiple identities for your principal, you can.

use Aphiria\Security\IdentityBuilder;
use Aphiria\Security\PrincipalBuilder;

$user = new PrincipalBuilder('example.com')
    ->withIdentity(function (IdentityBuilder $identity) {
        $identity->withName('Dave');
    })
    ->withIdentity(function (IdentityBuilder $identity) {
        $identity->withThumbprint('abc123');
    })
    ->build();

IdentityBuilder provides the same fluent methods as PrincipalBuilder above.

You can also specify a primary identity selector via PrincipalBuilder::withPrimaryIdentitySelector() and passing in a Closure like the example above.

Authentication Schemes

Now that we've clarified some terminology, let's dive into authentication schemes. An authentication scheme defines a particular way of authenticating an identity, eg basic authentication. Each scheme has a name, the name of its handler class, and options. Handlers implement IAuthenticationSchemeHandler, which provides several methods:

  • authenticate(IRequest $request, AuthenticationScheme $scheme): AuthenticationResult
    • Attempts to authenticate credentials passed via an HTTP request and create a principal
  • challenge(IRequest $request, IResponse $response, AuthenitcationScheme $scheme): void
    • In the case that authentication fails, this is called to decorate the response to let the user know they could not successfully be authenticated, eg by redirecting to a login page or setting the status code to 401
  • forbid(IRequest $request, IResponse $response, AuthenitcationScheme $scheme): void
    • In the case that an authenticated user attempted to access a resource they do not have permission to access, this is called to decorate the response to let them know their request was forbidden, eg by redirecting to the forbidden page or setting the status code to 403

Logging in involves passing data along in subsequent requests to help the application authenticate a user. If you can log in with a particular scheme, its handler should implement ILoginAuthenticationSchemeHandler, which defines two additional methods:

  • logIn(IPrincipal $user, IRequest $request, IResponse $response, AuthenticationScheme $scheme): void
    • Logs a user in and decorates the response with data, eg a cookie, to keep a user logged in for subsequent requests. authenticate() should be called prior to logIn() to make sure the credentials were valid.
  • logOut(IRequest $request, IResponse $response, AuthenticationScheme $scheme): void
    • Logs a user out and decorates the response to remove any data that was used to keep a user logged in

We'll go into more detail on how to register an authentication scheme in the example below.

Default Scheme

You can register a scheme to be your application's default. This means that any authentication that does not use a specific scheme will fall back to using the default one. Registering a default scheme is simple - just pass in true as the second parameter to AuthenticatorBuilder::withScheme():

use Aphiria\Authentication\AuthenticationScheme;
use Aphiria\Authentication\AuthenticatorBuilder;

$authenticator = new AuthenticatorBuilder()
    // ...
    ->withScheme(new AuthenticationScheme('cookie', MyCookieHandler::class), true)
    ->build();

Options

All scheme handlers accept a derived class of AuthenticationSchemeOptions to help you configure your authentication. By default, options contain a claims issuer property to help populate your claims, but you may extend AuthenticationSchemeOptions and add any additional configurable properties you may need, eg cookie names, lifetimes, paths, domains, login page paths, and forbidden page paths. We'll go into some examples below.

Basic Authentication

Basic authentication uses the Authorize request header with a base64-encoded username:password value. Aphiria provides the base class BasicAuthenticationHandler with one abstract method createAuthenticationResultFromCredentials() for you to implement. BasicAuthenticationHandler uses BasicAuthenticationOptions to configure the realm that authentication is valid in.

Let's look at an example concrete implementation of this handler:

use Aphiria\Authentication\{AuthenticationResult, AuthenticationScheme};
use Aphiria\Authentication\Schemes\BasicAuthenticationHandler;
use Aphiria\Net\Http\Request;
use Aphiria\Security\{Claim, ClaimType, Identity, User};
use PDO;

final class SqlBasicAuthenticationHandler extends BasicAuthenticationHandler
{
    public function __construct(private PDO $pdo) {}
    
    protected function createAuthenticationResultFromCredentials(
        string $username,
        string $password,
        IRequest $request,
        AuthenticationScheme $scheme
    ): AuthenticationResult {
        $sql = <<<SQL
SELECT id, email, hashed_password, array_to_json(roles) AS roles FROM users
WHERE LOWER(email) = :email
SQL;
        $statement = $this->pdo->prepare($sql);
        $statement->execute(['email' => \strtolower(\trim($username))]);
        $row = $statement->fetch(PDO::FETCH_ASSOC);
        
        if (
            $statement->rowCount() !== 1
            || !\password_verify($password, $row['hashed_password'])
        ) {
            return AuthenticationResult::fail('Invalid credentials', $scheme->name);
        }
        
        $claimsIssuer = $scheme->options->claimsIssuer ?? $scheme->name;
        $claims = [
            new Claim(ClaimType::NameIdentifier, $row['id'], $claimsIssuer),
            new Claim(ClaimType::Name, $row['email'], $claimsIssuer)
        ];
        
        foreach (\json_decode($row['roles']) as $role) {
            $claims[] = new Claim(ClaimType::Role, $role, $claimsIssuer);
        }
        
        $user = new User(new Identity($claims, $scheme->name));
        
        return AuthenticationResult::pass($user, $scheme->name);
    }
}

Let's register this scheme with the authenticator:

use Aphiria\Authentication\AuthenticationScheme;
use Aphiria\Authentication\AuthenticatorBuilder;
use Aphiria\Authentication\Schemes\BasicAuthenticationOptions;

$authenticator = new AuthenticatorBuilder()
    // ...
    ->withScheme(new AuthenticationScheme(
        'basic',
         SqlBasicAuthenticationHandler::class,
         new BasicAuthenticationOptions(realm: 'example.com', claimsIssuer: 'https://example.com')
     ))
    ->build();

In the case that you are using cookie values to authenticate, you can extend CookieAuthenticationHandler and define the methods createAuthenticationResultFromCookie() and createCookieValueForUser() to create an authentication result from a cookie value and to create the cookie value that will be used to authenticate in subsequent requests, respectively. This handler uses CookieAuthenticationOptions to give you control over your cookies.

We won't go over how extend CookieAuthenticationHandler because it is very similar to the example above, but here is how we would register our implementation:

use Aphiria\Authentication\AuthenticationScheme;
use Aphiria\Authentication\AuthenticatorBuilder;
use Aphiria\Authentication\Schemes\CookieAuthenticationOptions;
use Aphiria\Net\Http\Headers\SameSiteMode;

$authenticator = new AuthenticatorBuilder()
    // ...
    ->withScheme(new AuthenticationScheme(
        'cookie',
         MyCookieAuthenticationHandler::class,
         new CookieAuthenticationOptions(
            cookieName: 'authToken',
            cookieMaxAge: 360,
            cookiePath: '/',
            cookieDomain: 'example.com',
            cookieIsSecure: true,
            cookieIsHttpOnly: true,
            cookieSameSite: SameSiteMode::Strict,
            loginPagePath: '/login',
            forbiddenPagePath: '/access-denied',
            claimsIssuer: 'https://example.com'
         )
     ))
    ->build();

Now, whenever we use our cookie scheme, cookies will be set using the above options, and redirects on challenges and forbidden requests will forward to the appropriate paths.

Configuring an Authenticator

There are two recommended ways of creating your authenticator. If you're using the skeleton app, the authenticator will automatically be created for you in a binder. All you have to do is configure it in GlobalModule:

use Aphiria\Application\IApplicationBuilder;
use Aphiria\Authentication\AuthenticationScheme;
use Aphiria\Authentication\AuthenticationSchemeOptions;
use Aphiria\Framework\Application\AphiriaModule;

final class GlobalModule extends AphiriaModule
{
    public function configure(IApplicationBuilder $appBuilder): void
    {
        $this->withAuthenticationScheme($appBuilder, new AuthenticationScheme(
            'token',
            MyTokenHandler::class,
            new AuthenticationSchemeOptions(claimsIssuer: 'https://example.com')
        ));
    }
}

Then, any time you use the #[Authenticate] attribute or IAuthenticator, you'll be able to use your token scheme.

If you are not using the skeleton app, the simplest method is to use AuthenticatorBuilder to configure and build your authenticator:

use Aphiria\Authentication\AuthenticationScheme;
use Aphiria\Authentication\AuthenticationSchemeOptions;
use Aphiria\Authentication\AuthenticatorBuilder;
use Aphiria\Authentication\ContainerAuthenticationSchemeHandlerResolver;
use Aphiria\DependencyInjection\Container;

$authenticator = new AuthenticatorBuilder()
    // This will resolve our scheme handler instances
    ->withHandlerResolver(new ContainerAuthenticationSchemeHandlerResolver(new Container()))
    ->withScheme(
        'token', 
        MyTokenHandler::class,
        new AuthenticationSchemeOptions(claimsIssuer: 'https://example.com')
    )
    ->build();

Authentication Results

An AuthenticationResult is returned when authenticating. To indicate successful authentication, simply call

use Aphiria\Authentication\AuthenticationResult;

// $user is the authenticated principal
$result = AuthenticationResult::pass($user, $scheme->name);

Likewise, you can indicate a failure by calling

$result = AuthenticationResult::fail('Invalid credentials', $scheme->name);

// Or pass in an exception instead
$result = AuthenticationResult::fail(new InvalidCredentialException(), $scheme->name);

You can grab info about the result:

if (!$result->passed) {
    throw $result->failure;
}

$user = $result->user;

Customizing Authentication Failure Responses

By default, when authentication in the Authenticate middleware fails, challenge() will be called on the same scheme handler that we attempted to authenticate with. Most handlers' challenge() methods will set the status code to 401 or redirect you to the login page, depending on the implementation. If you'd like to customize this, you can extend Authenticate and override handleFailedAuthenticationResult() to return a response.

User Accessors

Once you have authenticated a principal using the Authenticate middleware, you can store and retrieve that principal for the duration of the request using IUserAccessor. By default, RequestPropertyUserAccessor will be used to store the principal as a custom property on the request. If you need to access the principal in your controller, simply call $this->user:

use Aphiria\Api\Controllers\Controller;
use Aphiria\Authentication\Attributes\Authenticate;
use Aphiria\Routing\Attributes\Delete;

#[Authenticate]
final class BookController extends Controller
{
    #[Delete('/books/:id')]
    public function deleteBook(int $id): void
    {
        $user = $this->user;
        
        // ...
    }
}

Mocking Authentication

You can learn how to mock authentication in your tests here.