You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
@@ -6,29 +6,80 @@ In this chapter we will build a login page with ErlyDTL templates, add authentic
6
6
7
7
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.
8
8
9
-
### Creating a login template
9
+
### Template basics
10
10
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`:
12
30
13
31
```html
32
+
<!DOCTYPE html>
14
33
<html>
34
+
<head>
35
+
<metacharset="utf-8">
36
+
<title>{% block title %}Blog{% endblock %}</title>
37
+
</head>
15
38
<body>
16
-
<div>
17
-
{% if error %}<pstyle="color:red">{{ error }}</p>{% endif %}
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).
31
80
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
+
32
83
### Adding a controller function
33
84
34
85
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
58
109
59
110
When you specify `#{view => login}`, Nova uses `login.dtl` instead.
60
111
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 |
`{view, Variables}` and `{view, Variables, Options}` are aliases for `{ok, ...}` — they behave identically.
137
+
```
138
+
61
139
## Authentication
62
140
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.
64
142
65
143
### Security in route groups
66
144
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.
68
146
69
147
### Creating a security module
70
148
71
149
Create `src/blog_auth.erl`:
72
150
73
151
```erlang
74
152
-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
-
caseParamsof
83
-
#{<<"username">> :=Username,
84
-
<<"password">> := <<"password">>} ->
85
-
{true, #{authed=>true, username=>Username}};
86
-
_ ->
87
-
false
88
-
end.
153
+
-export([session_auth/1]).
89
154
90
-
%% Used for pages that need an active session
91
155
session_auth(Req) ->
92
156
casenova_session:get(Req, <<"username">>) of
93
157
{ok, Username} ->
94
-
{true, #{authed=>true, username=>Username}};
158
+
{true, #{username=>Username}};
95
159
{error, _} ->
96
-
false
160
+
{redirect, "/login"}
97
161
end.
98
162
```
99
163
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.
101
165
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
+
caseParamsof
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.
103
189
104
190
```admonish warning
105
191
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:
113
199
2. If `security` is `false`, skip to the controller
114
200
3. If `security` is a function, call it with the request map
115
201
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
119
206
120
207
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.
121
208
@@ -125,29 +212,45 @@ You can have different security functions for different route groups — one for
125
212
126
213
Nova has a built-in session system backed by ETS (Erlang Term Storage). Session IDs are stored in a `session_id` cookie.
127
214
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.
|`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
+
138
240
The session manager is configured in `sys.config`:
`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).
147
250
148
251
### Wiring up the login flow
149
252
150
-
Update the controller to create a session on successful login:
253
+
Update the controller to handle login, logout, and the home page:
151
254
152
255
```erlang
153
256
-module(blog_main_controller).
@@ -158,58 +261,59 @@ Update the controller to create a session on successful login:
{ok, [{error, <<"Invalid username or password">>}], #{view=>login}}.
270
+
login_post(#{params :=Params} =Req) ->
271
+
caseParamsof
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.
178
279
179
280
logout(Req) ->
180
-
{ok, _Req1} =nova_session:delete(Req),
181
-
{redirect, "/login"}.
281
+
{ok, Req1} =nova_session:delete(Req),
282
+
{redirect, "/login", Req1}.
182
283
```
183
284
184
285
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.
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
+
223
331
Now the flow is:
224
332
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
228
336
5.`/logout` deletes the session and redirects to `/login`
229
337
230
338
### Cookie options
231
339
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:
0 commit comments