Login
spider g auth
It adds a login to the project: accounts are a table of the app, a password is stored as a hash, and being signed in is a cookie the app signs. Nothing outside the app is involved.
It writes the feature src/features/auth/, a migration for the users table, tests of the whole flow, and one line in src/main.zig. Then:
/auth/registercreates an account,/auth/loginsigns in,/auth/accountshows who is signed in, with a button to sign out.
The code on this page is from Posts, the example app in examples/posts (source), where the command was run and its screens were restyled.
Who may call a route
After the command, a route that does not say .public needs someone signed in. A visitor without a session is sent to /auth/login.
Each route says who may call it, in its config:
.public = true: anyone..authenticated = true: anyone who is signed in..roles = &.{"admin"}: someone signed in whose role is one of these..policy = ...: someone signed in who passes a rule of the app, such as "wrote this post". Access policies is about those.
Posts lets everyone read and asks for a login to write:
/// Routes of the posts feature. src/main.zig mounts them with
/// `server.mountFeatures(features)`: every pub fn here with no parameters
/// that returns a spider.Group. Paths are relative to the group prefix.
///
/// Reading is for everyone: `defaults` makes the routes public. Writing
/// needs a login: those routes say `.authenticated`, which replaces the
/// default for them. Changing or deleting a post is for its author: those
/// routes name a rule of src/features/posts/policy.zig, which also needs
/// a login.
pub fn build() spider.Group {
const signed_in = .{ .authenticated = true };
var g = spider.Group.init("/posts");
_ = g
.defaults(.{ .public = true })
.get("", controller.index, .{})
.get("/export.csv", controller.exportCsv, .{})
.get("/new", controller.new, signed_in)
.post("/create", controller.create, signed_in)
.get("/:id", controller.show, .{})
.get("/:id/edit", controller.edit, .{ .policy = Posts.route(.update) })
.post("/:id/update", controller.update, .{ .policy = Posts.route(.update) })
.get("/:id/delete", controller.confirmDelete, .{ .policy = Posts.route(.delete) })
.post("/:id/delete", controller.delete, .{ .policy = Posts.route(.delete) });
return g;
}
A handler behind .authenticated can count on the visitor. Posts takes the author of a new post from there:
pub fn create(c: *spider.Ctx) !Response {
var input = (try c.parseForm(model.Input)).trimmed();
input.author = signedInName(c);
const errors = model.validate(input);
if (errors.any()) return form(c, null, input, errors);
const id = try repository.create(c.arena, input, policy.signedInId(c));
return saved(c, id);
}
/// The name of who is signed in. The routes that write are only reached
/// with a session (see routes.zig), and the session carries the name.
fn signedInName(c: *spider.Ctx) []const u8 {
return c.params.get("_auth_name") orelse "";
}
c.userId() gives the id of the account, as text, or null when nobody is signed in.
Who is signed in, on the page
Every template can read current_user: its id, email and name. Handlers do not pass it. The layout of Posts uses it for the top of every page:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{ title ?? "Posts" }</title>
<link rel="icon" type="image/png" href="/favicon.png">
<link rel="shortcut icon" href="/favicon.ico">
<link rel="stylesheet" href="/css/app.css">
</head>
<body class="ui-page">
<header class="ui-topbar">
<a class="ui-brand" href="/posts">
<svg width="14" height="32" viewBox="0 0 14 32" aria-hidden="true"><line x1="7" y1="0" x2="7" y2="22" stroke="#FF5300" stroke-width="2"/><circle cx="7" cy="25" r="6" fill="#FF5300"/></svg>
Posts
</a>
<nav class="ui-topbar-links">
if (current_user) {
<a class="ui-btn ui-btn-ghost ui-who" href="/auth/account">{ current_user.name }</a>
<form action="/auth/logout" method="post">
<button class="ui-btn ui-btn-ghost" type="submit">Sign out</button>
</form>
} else {
<a class="ui-btn ui-btn-ghost" href="/auth/login">Sign in</a>
}
if (!composing) {
<a class="ui-btn ui-btn-primary" href="/posts/new">New post</a>
}
</nav>
</header>
{ slot }
<footer class="ui-footer">
<span>Posts is an example app for Spider.</span>
<a class="ui-link" href="https://github.com/llllOllOOll/spider">Built with Spider</a>
</footer>
</body>
</html>
Features made before the login
A feature generated before the login has routes that say nothing about who may call them. Once there is a login, spider check and zig build test point at each one.
Two edits settle it. In the feature's routes.zig, the line that sets the defaults is there, commented out; put the access you want in it:
.defaults(.{ .authenticated = true })
And in its routes_test.zig, the table says who may call each route. Change the "-" of each line to match:
.{ "GET", "/posts", "authenticated" },
The middleware
The line the command adds to src/main.zig is .use(spider.session.middleware()). On every request it reads the cookie, checks that the app signed it, and tells the routes who is there. It also changes the error handler, so that a visitor who is not signed in is sent to the sign-in page:
/// Builds the server and listens. main() calls it, and so do the tests in
/// src/app_test.zig.
pub fn serve(allocator: std.mem.Allocator, io: std.Io) !void {
// What the server needs before it starts goes here. Posts needs
// nothing yet.
_ = allocator;
_ = io;
var server = spider.app(.{});
defer server.deinit();
// Routes live with their feature (src/features/<name>/routes.zig);
// mountFeatures registers every feature listed in src/features/mod.zig.
// `spider routes` prints what got registered.
try server
.use(spider.logger)
// Reads the login cookie (or a bearer token) and tells the routes
// who is there; a route that is not `.public` needs a login.
.use(spider.session.middleware())
.mountFeatures(features)
.onError(onError)
// Port and host come from spider.config.zig; the PORT variable
// (the environment or .env) replaces the port.
.listen(.{});
}
const fallback = spider.errorHandler(.{
.unauthorized_redirect = "/auth/login",
.forbidden_view = "forbidden",
});
Signing in
The generated controller checks the password and starts the session. A password is never stored or compared as it is: spider.password.hash makes what goes in the table, and spider.password.verify checks a password against it.
pub fn login(c: *spider.Ctx) !Response {
const form = try c.parseForm(model.Credentials);
const email = try model.tidyEmail(c.arena, form.email);
const user = try repository.findByEmail(c.arena, email);
// An account that does not exist is checked against a decoy hash, so
// it takes as long to refuse as a wrong password, and both get the
// same message: neither tells a stranger which emails have accounts.
const stored = if (user) |u| u.password_hash else spider.password.decoy;
const matches = spider.password.verify(c, stored, form.password);
if (user == null or !matches) return loginPage(c, email, "Wrong email or password.");
return signIn(c, user.?);
}
An email with no account gets the same answer as a wrong password, after the same work, so the form does not tell a stranger which emails are registered.
spider.session.start gives the response options that set the cookie:
/// Starts the session, remembers the email for the next sign-in on this
/// browser, and goes to the home page. Two cookies: the options of both go
/// in the same response.
fn signIn(c: *spider.Ctx, user: model.User) !Response {
const session = try spider.session.start(c, .{
.id = try std.fmt.allocPrint(c.arena, "{d}", .{user.id}),
.email = user.email,
.name = user.name,
.roles = &.{user.role},
});
// `.encode`: an email is text a person typed, not safe for a cookie as it is.
const remembered = try c.withCookie(last_email, user.email, .{ .encode = true, .max_age = one_year });
return c.redirectWith("/", .{
.headers = try std.mem.concat(c.arena, [2][]const u8, &.{ session.headers, remembered.headers }),
});
}
Signing out is spider.session.end:
pub fn logout(c: *spider.Ctx) !Response {
return c.redirectWith("/auth/login", try spider.session.end(c));
}
Creating an account
/// Anyone can create an account. To close registration, remove the two
/// /auth/register routes from routes.zig (and the link in login.html).
pub fn register(c: *spider.Ctx) !Response {
const form = try model.tidy(c.arena, try c.parseForm(model.Registration));
if (model.problem(form)) |what| return registerPage(c, form, what);
if (try repository.findByEmail(c.arena, form.email) != null) {
return registerPage(c, form, "That email already has an account. Sign in instead.");
}
const hash = try spider.password.hash(c, form.password);
const user = try repository.create(c.arena, form.name, form.email, hash);
return signIn(c, user);
}
The rules are in the model, next to the user: a name, an email that looks like one, a password of at least 8 characters. The email is stored in lower case, so Ana@Example.com and ana@example.com are one account.
Registration is open to anyone. To close it, remove the two /auth/register routes.
Roles
A new account has the role user. The role is a column of the users table, and it travels in the session. To make someone an admin, change the row:
UPDATE users SET role = 'admin' WHERE email = 'ana@example.com';
That account then passes .roles = &.{"admin"} the next time it signs in.
The secret
Sessions are signed with JWT_SECRET, in .env. spider new writes a random one. Keep the one of production out of git, and know that changing it signs everyone out.
Without one, spider dev makes one up each time the app starts, so everyone is signed out on a restart. A release build, the one you deploy, refuses to sign anyone in until JWT_SECRET is set.
A session lasts 14 days. It cannot be ended from the server before that: there is no list of sessions to remove it from.
For an API
spider g auth --api
It generates JSON routes instead of pages. POST /auth/register and POST /auth/login answer a token, and the client sends it back on every request:
Authorization: Bearer <token>
Keycloak
spider g auth --provider=keycloak generates a login through a Keycloak realm instead: the accounts and the sign-in page are Keycloak's. Login with a provider shows it, and signing in with Google.
What is not there yet
Password reset, email confirmation, a limit on sign-in attempts and two-factor are not generated.