Skip to content

Commit d64a39d

Browse files
Taureclaude
andauthored
docs: expand views, auth & sessions chapter with template basics, CSRF, and simplified login flow (#9)
- Add ErlyDTL syntax reference table, base layout with template inheritance - Add CSRF token to login form (required by nova_csrf_plugin) - Document all template options (view, headers, status_code) - Move credential validation from security function into controller - Simplify session_auth to return {redirect, "/login"} instead of bare false - Remove manual session ID generation — use Nova's auto-created sessions - Consolidate routes from three groups to two (public + protected) - Add use_sessions config, session API return types, and delete/1 cookie behavior Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3aefe71 commit d64a39d

File tree

1 file changed

+185
-77
lines changed

1 file changed

+185
-77
lines changed

src/getting-started/views-auth-sessions.md

Lines changed: 185 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -6,29 +6,80 @@ In this chapter we will build a login page with ErlyDTL templates, add authentic
66

77
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.
88

9-
### Creating a login template
9+
### Template basics
1010

11-
Create `src/views/login.dtl`:
11+
ErlyDTL supports the same syntax as Django templates:
12+
13+
| Syntax | Purpose | Example |
14+
|--------|---------|---------|
15+
| `{{ var }}` | Output a variable | `{{ username }}` |
16+
| `{% if cond %}...{% endif %}` | Conditional | `{% if error %}...{% endif %}` |
17+
| `{% for x in list %}...{% endfor %}` | Loop | `{% for post in posts %}...{% endfor %}` |
18+
| `{{ var\|filter }}` | Apply a filter | `{{ name\|upper }}` |
19+
| `{{ var\|default:"n/a" }}` | Fallback value | `{{ bio\|default:"No bio" }}` |
20+
| `{% extends "base.dtl" %}` | Inherit a layout | See below |
21+
| `{% block name %}...{% endblock %}` | Override a block | See below |
22+
23+
See the [ErlyDTL documentation](https://github.com/erlydtl/erlydtl) for the full list of tags and filters.
24+
25+
### Creating a base layout
26+
27+
Most pages share the same outer HTML. Template inheritance lets you define a base layout once and override specific blocks in child templates.
28+
29+
Create `src/views/base.dtl`:
1230

1331
```html
32+
<!DOCTYPE html>
1433
<html>
34+
<head>
35+
<meta charset="utf-8">
36+
<title>{% block title %}Blog{% endblock %}</title>
37+
</head>
1538
<body>
16-
<div>
17-
{% if error %}<p style="color:red">{{ error }}</p>{% endif %}
18-
<form action="/login" method="post">
19-
<label for="username">Username:</label>
20-
<input type="text" id="username" name="username"><br>
21-
<label for="password">Password:</label>
22-
<input type="password" id="password" name="password"><br>
23-
<input type="submit" value="Submit">
24-
</form>
25-
</div>
39+
<nav>
40+
{% if username %}
41+
<span>{{ username }}</span> | <a href="/logout">Logout</a>
42+
{% else %}
43+
<a href="/login">Login</a>
44+
{% endif %}
45+
</nav>
46+
<main>
47+
{% block content %}{% endblock %}
48+
</main>
2649
</body>
2750
</html>
2851
```
2952

53+
Child templates use `{% extends "base.dtl" %}` and fill in the blocks they need. Anything outside a `{% block %}` tag in the child is ignored.
54+
55+
### Creating a login template
56+
57+
Create `src/views/login.dtl`:
58+
59+
```html
60+
{% extends "base.dtl" %}
61+
62+
{% block title %}Login{% endblock %}
63+
64+
{% block content %}
65+
<div>
66+
{% if error %}<p style="color:red">{{ error }}</p>{% endif %}
67+
<form action="/login" method="post">
68+
<input type="hidden" name="_csrf_token" value="{{ csrf_token }}" />
69+
<label for="username">Username:</label>
70+
<input type="text" id="username" name="username"><br>
71+
<label for="password">Password:</label>
72+
<input type="password" id="password" name="password"><br>
73+
<input type="submit" value="Submit">
74+
</form>
75+
</div>
76+
{% endblock %}
77+
```
78+
3079
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).
3180

81+
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.
82+
3283
### Adding a controller function
3384

3485
Our generated controller is in `src/controllers/blog_main_controller.erl`:
@@ -58,48 +109,83 @@ When a controller returns `{ok, Variables}` (without a `view` option), Nova look
58109

59110
When you specify `#{view => login}`, Nova uses `login.dtl` instead.
60111

112+
### Template options
113+
114+
The full return tuple is `{ok, Variables, Options}` where `Options` is a map that supports three keys:
115+
116+
| Option | Default | Description |
117+
|--------|---------|-------------|
118+
| `view` | derived from module name | Which template to render |
119+
| `headers` | `#{<<"content-type">> => <<"text/html">>}` | Response headers |
120+
| `status_code` | `200` | HTTP status code |
121+
122+
Some examples:
123+
124+
```erlang
125+
%% Render login.dtl with default 200 status
126+
{ok, [], #{view => login}}.
127+
128+
%% Render with a 422 status (useful for form validation errors)
129+
{ok, [{error, <<"Invalid input">>}], #{view => login, status_code => 422}}.
130+
131+
%% Return plain text instead of HTML
132+
{ok, [{data, Body}], #{headers => #{<<"content-type">> => <<"text/plain">>}}}.
133+
```
134+
135+
```admonish tip
136+
`{view, Variables}` and `{view, Variables, Options}` are aliases for `{ok, ...}` — they behave identically.
137+
```
138+
61139
## Authentication
62140

63-
Now let's handle the login form submission with a security module.
141+
Now let's protect routes so only logged-in users can access them.
64142

65143
### Security in route groups
66144

67-
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).
145+
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.
68146

69147
### Creating a security module
70148

71149
Create `src/blog_auth.erl`:
72150

73151
```erlang
74152
-module(blog_auth).
75-
-export([
76-
username_password/1,
77-
session_auth/1
78-
]).
79-
80-
%% Used for the login POST
81-
username_password(#{params := Params}) ->
82-
case Params of
83-
#{<<"username">> := Username,
84-
<<"password">> := <<"password">>} ->
85-
{true, #{authed => true, username => Username}};
86-
_ ->
87-
false
88-
end.
153+
-export([session_auth/1]).
89154

90-
%% Used for pages that need an active session
91155
session_auth(Req) ->
92156
case nova_session:get(Req, <<"username">>) of
93157
{ok, Username} ->
94-
{true, #{authed => true, username => Username}};
158+
{true, #{username => Username}};
95159
{error, _} ->
96-
false
160+
{redirect, "/login"}
97161
end.
98162
```
99163

100-
`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`.
164+
`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.
101165

102-
`session_auth/1` checks for an existing session (we will set this up next).
166+
```admonish tip
167+
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.
168+
```
169+
170+
### Processing the login form
171+
172+
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`.
173+
174+
The controller checks the submitted credentials and either creates a session or re-renders the form with an error:
175+
176+
```erlang
177+
login_post(#{params := Params} = Req) ->
178+
case Params of
179+
#{<<"username">> := Username,
180+
<<"password">> := <<"password">>} ->
181+
nova_session:set(Req, <<"username">>, Username),
182+
{redirect, "/"};
183+
_ ->
184+
{ok, [{error, <<"Invalid username or password">>}], #{view => login}}
185+
end.
186+
```
187+
188+
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.
103189

104190
```admonish warning
105191
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:
113199
2. If `security` is `false`, skip to the controller
114200
3. If `security` is a function, call it with the request map
115201
4. If it returns `{true, AuthData}`, merge `auth_data => AuthData` into the request and continue to the controller
116-
5. If it returns `false`, trigger the 401 error handler
117-
6. If it returns `{redirect, Path}`, redirect without calling the controller
118-
7. If it returns `{false, StatusCode, Headers, Body}`, respond with a custom error
202+
5. If it returns `true`, continue to the controller (no auth data attached)
203+
6. If it returns `false`, trigger the 401 error handler
204+
7. If it returns `{redirect, Path}`, send a 302 redirect without calling the controller
205+
8. If it returns `{false, StatusCode, Headers, Body}`, respond with a custom error
119206

120207
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.
121208

@@ -125,29 +212,45 @@ You can have different security functions for different route groups — one for
125212

126213
Nova has a built-in session system backed by ETS (Erlang Term Storage). Session IDs are stored in a `session_id` cookie.
127214

215+
### How sessions work
216+
217+
Nova automatically creates a session for every visitor. On each request, the `nova_stream_h` stream handler checks for a `session_id` cookie:
218+
219+
- **Cookie exists** — the request proceeds normally. The session ID is read from the cookie when you call the session API.
220+
- **No cookie** — Nova generates a new session ID, sets the `session_id` cookie on the response, and stores the ID in the request map.
221+
222+
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.
223+
128224
### The session API
129225

130226
```erlang
131-
nova_session:get(Req, <<"key">>) -> {ok, Value} | {error, not_found}.
132-
nova_session:set(Req, <<"key">>, Value) -> ok.
133-
nova_session:delete(Req) -> {ok, Req1}.
134-
nova_session:delete(Req, <<"key">>) -> {ok, Req1}.
135-
nova_session:generate_session_id() -> {ok, SessionId}.
227+
nova_session:get(Req, Key) -> {ok, Value} | {error, not_found}.
228+
nova_session:set(Req, Key, Value) -> ok | {error, session_id_not_set}.
229+
nova_session:delete(Req) -> {ok, Req1}.
230+
nova_session:delete(Req, Key) -> {ok, Req1}.
136231
```
137232

233+
| Function | Description |
234+
|----------|-------------|
235+
| `get/2` | Retrieve a value by key. Returns `{error, not_found}` if the key or session doesn't exist. |
236+
| `set/3` | Store a value in the current session. |
237+
| `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. |
238+
| `delete/2` | Delete a single key from the session. |
239+
138240
The session manager is configured in `sys.config`:
139241

140242
```erlang
141243
{nova, [
142-
{session_manager, nova_session_ets}
244+
{use_sessions, true}, %% Enable sessions (default: true)
245+
{session_manager, nova_session_ets} %% Backend module (default)
143246
]}
144247
```
145248

146-
`nova_session_ets` is the default. It stores session data in an ETS table and replicates changes across clustered nodes using `nova_pubsub`.
249+
`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).
147250

148251
### Wiring up the login flow
149252

150-
Update the controller to create a session on successful login:
253+
Update the controller to handle login, logout, and the home page:
151254

152255
```erlang
153256
-module(blog_main_controller).
@@ -158,58 +261,59 @@ Update the controller to create a session on successful login:
158261
logout/1
159262
]).
160263

161-
index(#{auth_data := #{authed := true, username := Username}}) ->
162-
{ok, [{message, <<"Hello ", Username/binary>>}]};
163-
index(_Req) ->
164-
{redirect, "/login"}.
264+
index(#{auth_data := #{username := Username}}) ->
265+
{ok, [{message, <<"Hello ", Username/binary>>}]}.
165266

166267
login(_Req) ->
167268
{ok, [], #{view => login}}.
168269

169-
login_post(#{auth_data := #{authed := true, username := Username}} = Req) ->
170-
{ok, SessionId} = nova_session:generate_session_id(),
171-
Req1 = cowboy_req:set_resp_cookie(<<"session_id">>, SessionId, Req,
172-
#{path => <<"/">>, http_only => true}),
173-
Req2 = Req1#{nova_session_id => SessionId},
174-
nova_session:set(Req2, <<"username">>, Username),
175-
{redirect, "/", Req1};
176-
login_post(_Req) ->
177-
{ok, [{error, <<"Invalid username or password">>}], #{view => login}}.
270+
login_post(#{params := Params} = Req) ->
271+
case Params of
272+
#{<<"username">> := Username,
273+
<<"password">> := <<"password">>} ->
274+
nova_session:set(Req, <<"username">>, Username),
275+
{redirect, "/"};
276+
_ ->
277+
{ok, [{error, <<"Invalid username or password">>}], #{view => login}}
278+
end.
178279

179280
logout(Req) ->
180-
{ok, _Req1} = nova_session:delete(Req),
181-
{redirect, "/login"}.
281+
{ok, Req1} = nova_session:delete(Req),
282+
{redirect, "/login", Req1}.
182283
```
183284

184285
The login flow:
185-
1. Generate a session ID
186-
2. Set the `session_id` cookie on the response
187-
3. Store the username in the session
188-
4. Redirect to the home page
286+
1. User visits `/login` — sees the login form
287+
2. Form POSTs to `/login``login_post/1` checks credentials
288+
3. On success, store the username in the session and redirect to `/`
289+
4. On failure, re-render the form with an error message
290+
5. On `/`, `session_auth/1` verifies the session and populates `auth_data`
291+
6. `/logout` deletes the session, expires the cookie, and redirects to `/login`
292+
293+
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.
294+
295+
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.
296+
297+
```admonish tip
298+
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.
299+
```
189300

190301
### Updating the routes
191302

192303
```erlang
193304
routes(_Environment) ->
194305
[
195-
%% Public routes
306+
%% Public routes (no auth required)
196307
#{prefix => "",
197308
security => false,
198309
routes => [
199310
{"/login", fun blog_main_controller:login/1, #{methods => [get]}},
311+
{"/login", fun blog_main_controller:login_post/1, #{methods => [post]}},
200312
{"/heartbeat", fun(_) -> {status, 200} end, #{methods => [get]}}
201313
]
202314
},
203315

204-
%% Login POST (uses username/password auth)
205-
#{prefix => "",
206-
security => fun blog_auth:username_password/1,
207-
routes => [
208-
{"/login", fun blog_main_controller:login_post/1, #{methods => [post]}}
209-
]
210-
},
211-
212-
%% Protected pages (uses session auth)
316+
%% Protected routes (session auth required)
213317
#{prefix => "",
214318
security => fun blog_auth:session_auth/1,
215319
routes => [
@@ -220,16 +324,20 @@ routes(_Environment) ->
220324
].
221325
```
222326

327+
Two route groups instead of three:
328+
1. **Public** — login (GET and POST) and heartbeat. `security => false` means no auth check. Credential validation happens inside `login_post/1`.
329+
2. **Protected** — home page and logout. `session_auth/1` redirects unauthenticated users to `/login`.
330+
223331
Now the flow is:
224332
1. User visits `/login` — sees the login form
225-
2. Form POSTs to `/login``username_password/1` checks credentials
226-
3. On success, a session is created and the user is redirected to `/`
227-
4. On `/`, `session_auth/1` checks the session cookie
333+
2. Form POSTs to `/login`controller checks credentials
334+
3. On success, a session value is set and the user is redirected to `/`
335+
4. On `/`, `session_auth/1` checks the session
228336
5. `/logout` deletes the session and redirects to `/login`
229337

230338
### Cookie options
231339

232-
When setting the session cookie, control its behaviour with options:
340+
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:
233341

234342
```erlang
235343
cowboy_req:set_resp_cookie(<<"session_id">>, SessionId, Req, #{

0 commit comments

Comments
 (0)