diff --git a/src/getting-started/views-auth-sessions.md b/src/getting-started/views-auth-sessions.md index 70a81e3..1b21f00 100644 --- a/src/getting-started/views-auth-sessions.md +++ b/src/getting-started/views-auth-sessions.md @@ -6,29 +6,80 @@ In this chapter we will build a login page with ErlyDTL templates, add authentic Nova uses [ErlyDTL](https://github.com/erlydtl/erlydtl) for HTML templating — an Erlang implementation of [Django's template language](https://django.readthedocs.io/en/1.6.x/ref/templates/builtins.html). Templates live in `src/views/` and are compiled to Erlang modules at build time. -### Creating a login template +### Template basics -Create `src/views/login.dtl`: +ErlyDTL supports the same syntax as Django templates: + +| Syntax | Purpose | Example | +|--------|---------|---------| +| `{{ var }}` | Output a variable | `{{ username }}` | +| `{% if cond %}...{% endif %}` | Conditional | `{% if error %}...{% endif %}` | +| `{% for x in list %}...{% endfor %}` | Loop | `{% for post in posts %}...{% endfor %}` | +| `{{ var\|filter }}` | Apply a filter | `{{ name\|upper }}` | +| `{{ var\|default:"n/a" }}` | Fallback value | `{{ bio\|default:"No bio" }}` | +| `{% extends "base.dtl" %}` | Inherit a layout | See below | +| `{% block name %}...{% endblock %}` | Override a block | See below | + +See the [ErlyDTL documentation](https://github.com/erlydtl/erlydtl) for the full list of tags and filters. + +### Creating a base layout + +Most pages share the same outer HTML. Template inheritance lets you define a base layout once and override specific blocks in child templates. + +Create `src/views/base.dtl`: ```html + + + + {% block title %}Blog{% endblock %} + -
- {% if error %}

{{ error }}

{% endif %} -
- -
- -
- -
-
+ +
+ {% block content %}{% endblock %} +
``` +Child templates use `{% extends "base.dtl" %}` and fill in the blocks they need. Anything outside a `{% block %}` tag in the child is ignored. + +### Creating a login template + +Create `src/views/login.dtl`: + +```html +{% extends "base.dtl" %} + +{% block title %}Login{% endblock %} + +{% block content %} +
+ {% if error %}

{{ error }}

{% endif %} +
+ + +
+ +
+ +
+
+{% endblock %} +``` + This form POSTs to `/login` with `username` and `password` fields. The URL-encoded body will be decoded by `nova_request_plugin` (which we configured in the [Plugins](plugins.md) chapter). +The hidden `_csrf_token` field is required because we enabled `nova_csrf_plugin`. Nova automatically injects the `csrf_token` variable into every template — you just need to include it in the form. Without it, the POST request would be rejected with a 403 error. + ### Adding a controller function Our generated controller is in `src/controllers/blog_main_controller.erl`: @@ -58,13 +109,40 @@ When a controller returns `{ok, Variables}` (without a `view` option), Nova look When you specify `#{view => login}`, Nova uses `login.dtl` instead. +### Template options + +The full return tuple is `{ok, Variables, Options}` where `Options` is a map that supports three keys: + +| Option | Default | Description | +|--------|---------|-------------| +| `view` | derived from module name | Which template to render | +| `headers` | `#{<<"content-type">> => <<"text/html">>}` | Response headers | +| `status_code` | `200` | HTTP status code | + +Some examples: + +```erlang +%% Render login.dtl with default 200 status +{ok, [], #{view => login}}. + +%% Render with a 422 status (useful for form validation errors) +{ok, [{error, <<"Invalid input">>}], #{view => login, status_code => 422}}. + +%% Return plain text instead of HTML +{ok, [{data, Body}], #{headers => #{<<"content-type">> => <<"text/plain">>}}}. +``` + +```admonish tip +`{view, Variables}` and `{view, Variables, Options}` are aliases for `{ok, ...}` — they behave identically. +``` + ## Authentication -Now let's handle the login form submission with a security module. +Now let's protect routes so only logged-in users can access them. ### Security in route groups -Authentication in Nova is configured per route group using the `security` key. It points to a function that receives the request and returns either `{true, AuthData}` (allow) or `false` (deny). +Authentication in Nova is configured per route group using the `security` key. It points to a function that receives the request and returns either `{true, AuthData}` (allow) or a denial value (deny). See ["How security works"](#how-security-works) below for all return values. ### Creating a security module @@ -72,34 +150,42 @@ Create `src/blog_auth.erl`: ```erlang -module(blog_auth). --export([ - username_password/1, - session_auth/1 - ]). - -%% Used for the login POST -username_password(#{params := Params}) -> - case Params of - #{<<"username">> := Username, - <<"password">> := <<"password">>} -> - {true, #{authed => true, username => Username}}; - _ -> - false - end. +-export([session_auth/1]). -%% Used for pages that need an active session session_auth(Req) -> case nova_session:get(Req, <<"username">>) of {ok, Username} -> - {true, #{authed => true, username => Username}}; + {true, #{username => Username}}; {error, _} -> - false + {redirect, "/login"} end. ``` -`username_password/1` checks the decoded form parameters. If the password matches, it returns `{true, AuthData}` — the auth data map is attached to the request and accessible in your controller as `auth_data`. +`session_auth/1` checks whether the session contains a username. If so, it returns `{true, AuthData}` — the auth data map is merged into the request and accessible in your controller as `auth_data`. If the session is empty, it redirects to the login page. -`session_auth/1` checks for an existing session (we will set this up next). +```admonish tip +Returning `{redirect, "/login"}` instead of bare `false` gives users a friendly redirect to the login page. A bare `false` would trigger the generic 401 error handler, which is more appropriate for APIs. We covered the 401 handler in the [Error Handling](../testing-errors/error-handling.md) chapter. +``` + +### Processing the login form + +Credential validation belongs in the controller, not the security function. The security function's job is to *gate access* — the login POST route is public by definition (unauthenticated users need to reach it), so it uses `security => false`. + +The controller checks the submitted credentials and either creates a session or re-renders the form with an error: + +```erlang +login_post(#{params := Params} = Req) -> + case Params of + #{<<"username">> := Username, + <<"password">> := <<"password">>} -> + nova_session:set(Req, <<"username">>, Username), + {redirect, "/"}; + _ -> + {ok, [{error, <<"Invalid username or password">>}], #{view => login}} + end. +``` + +On success, we store the username in the session and redirect to the home page. On failure, we re-render the login template with an error message — the user sees the form again instead of a raw error page. ```admonish warning This is a hardcoded password for demonstration only. In a real application you would validate credentials against a database with properly hashed passwords. @@ -113,9 +199,10 @@ The security flow for each request is: 2. If `security` is `false`, skip to the controller 3. If `security` is a function, call it with the request map 4. If it returns `{true, AuthData}`, merge `auth_data => AuthData` into the request and continue to the controller -5. If it returns `false`, trigger the 401 error handler -6. If it returns `{redirect, Path}`, redirect without calling the controller -7. If it returns `{false, StatusCode, Headers, Body}`, respond with a custom error +5. If it returns `true`, continue to the controller (no auth data attached) +6. If it returns `false`, trigger the 401 error handler +7. If it returns `{redirect, Path}`, send a 302 redirect without calling the controller +8. If it returns `{false, StatusCode, Headers, Body}`, respond with a custom error The structured `{false, StatusCode, Headers, Body}` form is useful for APIs where you want to return JSON error details instead of triggering the generic 401 handler. @@ -125,29 +212,45 @@ You can have different security functions for different route groups — one for Nova has a built-in session system backed by ETS (Erlang Term Storage). Session IDs are stored in a `session_id` cookie. +### How sessions work + +Nova automatically creates a session for every visitor. On each request, the `nova_stream_h` stream handler checks for a `session_id` cookie: + +- **Cookie exists** — the request proceeds normally. The session ID is read from the cookie when you call the session API. +- **No cookie** — Nova generates a new session ID, sets the `session_id` cookie on the response, and stores the ID in the request map. + +This means you never need to manually generate session IDs or set the session cookie. By the time your controller runs, every request already has a session — you just read from and write to it. + ### The session API ```erlang -nova_session:get(Req, <<"key">>) -> {ok, Value} | {error, not_found}. -nova_session:set(Req, <<"key">>, Value) -> ok. -nova_session:delete(Req) -> {ok, Req1}. -nova_session:delete(Req, <<"key">>) -> {ok, Req1}. -nova_session:generate_session_id() -> {ok, SessionId}. +nova_session:get(Req, Key) -> {ok, Value} | {error, not_found}. +nova_session:set(Req, Key, Value) -> ok | {error, session_id_not_set}. +nova_session:delete(Req) -> {ok, Req1}. +nova_session:delete(Req, Key) -> {ok, Req1}. ``` +| Function | Description | +|----------|-------------| +| `get/2` | Retrieve a value by key. Returns `{error, not_found}` if the key or session doesn't exist. | +| `set/3` | Store a value in the current session. | +| `delete/1` | Delete the entire session and expire the cookie (sets `max_age => 0`). Returns an updated request — use this `Req1` if you need the cookie change in the response. | +| `delete/2` | Delete a single key from the session. | + The session manager is configured in `sys.config`: ```erlang {nova, [ - {session_manager, nova_session_ets} + {use_sessions, true}, %% Enable sessions (default: true) + {session_manager, nova_session_ets} %% Backend module (default) ]} ``` -`nova_session_ets` is the default. It stores session data in an ETS table and replicates changes across clustered nodes using `nova_pubsub`. +`nova_session_ets` stores session data in an ETS table and replicates changes across clustered nodes using `nova_pubsub`. Set `use_sessions` to `false` if your application doesn't need sessions (e.g. a pure JSON API). ### Wiring up the login flow -Update the controller to create a session on successful login: +Update the controller to handle login, logout, and the home page: ```erlang -module(blog_main_controller). @@ -158,58 +261,59 @@ Update the controller to create a session on successful login: logout/1 ]). -index(#{auth_data := #{authed := true, username := Username}}) -> - {ok, [{message, <<"Hello ", Username/binary>>}]}; -index(_Req) -> - {redirect, "/login"}. +index(#{auth_data := #{username := Username}}) -> + {ok, [{message, <<"Hello ", Username/binary>>}]}. login(_Req) -> {ok, [], #{view => login}}. -login_post(#{auth_data := #{authed := true, username := Username}} = Req) -> - {ok, SessionId} = nova_session:generate_session_id(), - Req1 = cowboy_req:set_resp_cookie(<<"session_id">>, SessionId, Req, - #{path => <<"/">>, http_only => true}), - Req2 = Req1#{nova_session_id => SessionId}, - nova_session:set(Req2, <<"username">>, Username), - {redirect, "/", Req1}; -login_post(_Req) -> - {ok, [{error, <<"Invalid username or password">>}], #{view => login}}. +login_post(#{params := Params} = Req) -> + case Params of + #{<<"username">> := Username, + <<"password">> := <<"password">>} -> + nova_session:set(Req, <<"username">>, Username), + {redirect, "/"}; + _ -> + {ok, [{error, <<"Invalid username or password">>}], #{view => login}} + end. logout(Req) -> - {ok, _Req1} = nova_session:delete(Req), - {redirect, "/login"}. + {ok, Req1} = nova_session:delete(Req), + {redirect, "/login", Req1}. ``` The login flow: -1. Generate a session ID -2. Set the `session_id` cookie on the response -3. Store the username in the session -4. Redirect to the home page +1. User visits `/login` — sees the login form +2. Form POSTs to `/login` — `login_post/1` checks credentials +3. On success, store the username in the session and redirect to `/` +4. On failure, re-render the form with an error message +5. On `/`, `session_auth/1` verifies the session and populates `auth_data` +6. `/logout` deletes the session, expires the cookie, and redirects to `/login` + +Notice that `index/1` only has one clause — it pattern-matches on `auth_data` directly. Since the route group uses `session_auth/1`, unauthenticated users are redirected before the controller runs. + +The `logout/1` function passes `Req1` (from `nova_session:delete/1`) as the third element of the redirect tuple. This ensures the expired cookie is included in the response. + +```admonish tip +Nova auto-creates the session cookie, so `login_post/1` just calls `nova_session:set/3` — no manual session ID generation or cookie setting needed. +``` ### Updating the routes ```erlang routes(_Environment) -> [ - %% Public routes + %% Public routes (no auth required) #{prefix => "", security => false, routes => [ {"/login", fun blog_main_controller:login/1, #{methods => [get]}}, + {"/login", fun blog_main_controller:login_post/1, #{methods => [post]}}, {"/heartbeat", fun(_) -> {status, 200} end, #{methods => [get]}} ] }, - %% Login POST (uses username/password auth) - #{prefix => "", - security => fun blog_auth:username_password/1, - routes => [ - {"/login", fun blog_main_controller:login_post/1, #{methods => [post]}} - ] - }, - - %% Protected pages (uses session auth) + %% Protected routes (session auth required) #{prefix => "", security => fun blog_auth:session_auth/1, routes => [ @@ -220,16 +324,20 @@ routes(_Environment) -> ]. ``` +Two route groups instead of three: +1. **Public** — login (GET and POST) and heartbeat. `security => false` means no auth check. Credential validation happens inside `login_post/1`. +2. **Protected** — home page and logout. `session_auth/1` redirects unauthenticated users to `/login`. + Now the flow is: 1. User visits `/login` — sees the login form -2. Form POSTs to `/login` — `username_password/1` checks credentials -3. On success, a session is created and the user is redirected to `/` -4. On `/`, `session_auth/1` checks the session cookie +2. Form POSTs to `/login` — controller checks credentials +3. On success, a session value is set and the user is redirected to `/` +4. On `/`, `session_auth/1` checks the session 5. `/logout` deletes the session and redirects to `/login` ### Cookie options -When setting the session cookie, control its behaviour with options: +Nova sets the `session_id` cookie automatically with default options. For production, you may want to customise the cookie by setting it yourself in a [plugin](plugins.md) or by configuring Cowboy's cookie defaults: ```erlang cowboy_req:set_resp_cookie(<<"session_id">>, SessionId, Req, #{