Help language development. Donate to The Perl Foundation

cro zef:cro last updated on 2022-11-30

docs/http-auth-and-sessions.md
# HTTP Authentication, Authorization, and Sessions

This document provides an overview of how authentication, authorization, and
sessions can be handled in Cro for an HTTP application. Since applications
have a wide range of needs in this area, Cro makes it easy for applications to
plug in their own session and auth handling. At the same time, it provides a
range of components to handle the most common cases.

## The auth property of Cro::HTTP::Request

The `auth` property of a `Cro::HTTP::Request` is used to hold an object that
may represent any, or all, of:

* A current user
* A set of rights, or a means to check those rights
* Session data

For example:

* With basic authentication, there may not be any ongoing session, and the
  user is authenticated for each request. In this case, the object in `auth`
  would typically carry the username, potentially having methods on it to do
  lookups of further data about that user or fetch rights.
* With a JSON Web Token, there is no ongoing session, and the object in `auth`
  would be populated from the data in the web token, provided that the token
  can be verified.
* With a web-based login (login form on a page), typically there will be a
  session object with fields indicating if there is currently a logged in
  user. The login process would update the session object with that information
  at the time of login, and clear it at the time of logout.
* For a system without any kind of login, but that simply wishes to hold some
  information about a particular session, then the object would just represent
  that session information.

Cro does not place any constraints on the type of the object in `auth`; the
contents of this session and/or user object is left for the application to
define as it needs. However, it will be most convenient for use with the HTTP
router if the object does the `Cro::HTTP::Auth` role (which is a simple marker
role).

The `auth` property will typically be set by a piece of request middleware.
Cro provides a number of options, some built-in and others as modules that
can be installed independently.

## Router Support

The signature of a route may start with a positional parameter that:

* Is constrained by a type that does the `Cro::HTTP::Auth` role
* Is marked with the `is auth` trait (this is less convenient, but allows for
  the case where an existing object should be used, but it is not desirable to
  couple it to Cro)

Such a parameter will not be treated as the first route segment, but instead
will be populated with the contents of the `auth` property of the
`Cro::HTTP::Request` being processed. The type constraint will also be
checked; should it fail, then a HTTP 401 Unauthorized responses will
automatically be produced (which middleware may later rewrite into a redirect
to a login page, if applicable).

For systems where there are different kinds of user, it can be convenient to
create `subset` types to describe them:

```
subset Admin of My::App::Session where .is-admin;
subset LoggedIn of My::App::Session where .is-logged-in;
```

And then use them in routes like this:

```
my $app = route {
    get -> LoggedIn $user, 'my', 'profile' {
        # Use $user in some way
    }

    get -> Admin, 'system', 'log' {
        # Just use the type and don't name a variable, if the session/user
        # object is not needed
    }
}
```

Note that middleware that populates `auth` must be installed either at the
server level *or* in a `route` block with `before` (*not* `before-matched`,
which will be too late).

## In-memory session management

The `Cro::HTTP::Session::InMemory[::T]` role implements simple in-memory
session management, using a cookie to store the session ID. This is useful in
web applications. The type used to represent the session data is to be
provided by the application. For example:

```
class My::App::Session {
    has $.is-logged-in;
    has $.admin;
    has @.recently-viewed-items;
}
```

Could be used as session state by applying this middleware, either in a `route`
block:

```
my $app = route {
    before Cro::HTTP::Session::InMemory[My::App::Session].new(
        expiration => Duration.new(60 * 15),
        cookie-name => 'MY_SESSION_COOKIE_NAME'
    );

    # Protected routes here...
}
```

Or at server level (pass it to the `before` parameter of `Cro::HTTP::Server`).

The default expiration time is 30 minutes, and this impacts both the `max-age`
set on the cookie as well as the time before session state is deleted from
memory. If no cookie name is provided, a random name will be generated (to
help avoid being able to fingerprint the application platform by its session
cookie name).

Since the session state is stored in memory, it will be lost when the service
is restarted. Architecturally, it also prevents scaling out beyond a single
process. In short, this is convenient for development purposes, and may be
enough for some simple, low-traffic, applications. Consider switching to, or
even starting with, `Cro::HTTP::Session::Persistent` instead in order to
provide better scalability and user experience.

**Important:** The security of this session mechanism depends on the secrecy
of the session cookie, which will be sent with every request (this applies not
just to Cro, but to HTTP sessions in general). Therefore, HTTPS should be used
in production deployments that use this mechanism.

## Persistent session management

The `Cro::HTTP::Session::Persistent[::T]` role implements session state, with
the state being persisted (for example, in a database or key/value store). To
use it, one must implement the methods for saving a session state, loading a
session state (updating the last seen timestamp), and clearing outdated
session state.

```
class MySession does Cro::HTTP::Auth {
    has $.is-logged-in;
    has $.admin;
    has @.recently-viewed-items;
}
class MySessionStore does Cro::HTTP::Session::Persistent[MySession] {
    # This will be called whenever we need to load session state, and the
    # session ID will be passed. Return `fail` if it is not possible
    # (e.g. no such session is found).
    method load(Str $session-id --> MySession) {
        !!! 'Load session $session-id, place data into a new MySession instance'
    }

    # This method is optional, and will be called when a new session starts.
    # It by default does nothing, but may be convenient for databases with a
    # INSERT/UPDATE distinction (in which case this would be the initial
    # INSERT, and the save method would be an UPDATE). In other databases,
    # this distinction may not exist. This method may return an instance of
    # the session object; if it does not, one will be created automatically
    # (by calling `.new`).
    method create(Str $session-id) {
    }

    # This will be called whenever we need to save session state.
    method save(Str $session-id, MySession $session --> Nil) {
        !!! 'Save session $session under $session-id, probably with a timestamp'
    }

    method clear(--> Nil) {
        !!! 'Clear sessions older than the maximum age to retain them'
    }
}
```

There are no concrete implementations of this role in the Cro core. However,
`Cro::HTTP::Session::Redis` exists in the module ecosystem; other options will
likely be added with time.

**Important:** The security of this session mechanism depends on the secrecy
of the session cookie, which will be sent with every request (this applies not
just to Cro, but to HTTP sessions in general). Therefore, HTTPS should be used
in production deployments that use this mechanism.

## Basic Authentication

The `Cro::HTTP::Auth::Basic[::TSession, Str $username-prop]` role is a basis
for implementing basic authentication. The `TSession` parameter is the type of
the session object to go in `auth`, and `$username-prop` is the name of the
property to set to the username if authentication succeeds (`Nil` will be
assigned otherwise). 

In the case the request's `auth` property is empty, then an instance of the
`TSession` object will be created, passing `username-prop` as a parameter
(so if `$username-prop` was set to `username`, then it would call
`MySession.new(username => $the-username)`). If `auth` already contains an
object, it assumes the property name is actually an attribute name, and
uses the metamodel to set it (thus meaning it need not be `is rw`). This
makes it possible to apply a session middleware before this middleware,
and have the current user added to the data.

The role requires the `authenticate` method to be implemented. It is passed
the username and password to authenticate, and should return `True` if it is
a valid combination and `False` otherwise.

```
class MyUser does Cro::HTTP::Auth {
    has $.username;
}
class MyBasicAuth does Cro::HTTP::Auth::Basic[MyUser, "username"] {
    method authenticate(Str $user, Str $pass --> Bool) {
        # No, don't actually do this!
        return $user eq 'c-monster' && $pass eq 'cookiecookiecookie';
    }
}
```

## JSON Web Tokens

The `Cro::HTTP::Auth::WebToken` is a base role for verifying JSON Web
Tokens.  It has `secret` and `public-key` attributes for password or
OpenSSL's public key accordingly. Either password or public key must
be set. When a request is and decode it using either password or
public key. When both `secret` and `public-key` attributes are set,
`public-key` will be used to decode token.

In case when key pair is used, `RS256` algorithm is used, otherwise
`HS256` is used.

`auth` attribute will be populated with `Nil` if method
`get-token($request)` returned `Nil` or died with an
exception. In case of success, it must return `Str`.

This role has method `set-auth($request, $result)`, that is called to
set `auth` attribute of a given request. As result of JSON decoding
may not do `Cro::HTTP::Auth` and so made request unable to be sent to
a correct route, `Cro::HTTP::Auth::WebToken::Token` wrapper class that
does `Cro::HTTP::Auth` role is used. It has a single `token` property
that is populated with the result of decoding JSON Web Token by
default on calling a `set-auth` method.

The `Cro::HTTP::Auth::WebToken::Bearer` is a role that does
`Cro::HTTP::Auth::WebToken`. Its `get-token` method is overridden to
take the token from `Auth` header of the request object. `set-auth` method
may be overridden by the user to set a custom object that does
`Cro::HTTP::Auth` role to `auth` attribute. It is installed as
middleware, either at route block or server level.

The `Cro::HTTP::Auth::WebToken::FromCookie[Str $cookie-name]` is a
role that does `Cro::HTTP::Auth::WebToken`. Its `get-token` method is
overridden to take the token from the request's cookie with given name
`$cookie-name`. `set-auth` method may be overridden by the user to set
a custom object that does `Cro::HTTP::Auth` role to `auth`
attribute. It is installed as middleware, either at route block or
server level. Additionally, this role checks `exp` claim of parsed
token. In case it is invalid, the cookie will be removed from the
request.

## Web form based login

This can be implemented by picking a session storage mechanism, and having a
field in the user/session object that indicates the user is logged in, with
some information about rights perhaps included. Since the details of login
forms and user databases vary greatly, Cro does not provide a built-in way to
achieve this. It is possible some drop-in solutions for more quickly getting
started with new applications will be published as a module in the future,
however.

The basic recipe is as follows:

```
class UserSession does Cro::HTTP::Auth {
    has $.username is rw;

    method logged-in() {
        defined $!username;
    }
}

my $app = route {
    before Cro::HTTP::Session::InMemory[UserSession].new;

    subset LoggedIn of UserSession where *.logged-in;

    get -> UserSession $s {
        content 'text/html', "Current user: {$s.logged-in ?? $s.username !! '-'}";
    }

    get -> LoggedIn $user, 'users-only' {
        content 'text/html', "Secret page just for *YOU*, $user.username()";
    }

    get -> 'login' {
        content 'text/html', q:to/HTML/;
            <form method="POST" action="/login">
              <div>
                Username: <input type="text" name="username" />
              </div>
              <div>
                Password: <input type="password" name="password" />
              </div>
              <input type="submit" value="Log In" />
            </form>
            HTML
    }

    post -> UserSession $user, 'login' {
        request-body -> (:$username, :$password, *%) {
            if valid-user-pass($username, $password) {
                $user.username = $username;
                redirect '/', :see-other;
            }
            else {
                content 'text/html', "Bad username/password";
            }
        }
    }

    sub valid-user-pass($username, $password) {
        # Call a database or similar here
        return $username eq 'c-monster' && $password eq 'cookiecookiecookie';
    }
}
```