Protect pages with built-in auth or an external proxy.
lazysite ships with built-in cookie-based authentication as the default
path. The same mechanism supports drop-in replacement by any external
auth proxy that sets X-Remote-* headers (Authentik, Authelia, etc.).
The processor reads the same auth headers regardless of which model is in use. Protected pages, group checks, and TT variables behave identically.
lazysite-auth.pl authenticates users against a flat-file user
database, sets a signed HMAC cookie on success, and translates that
cookie into X-Remote-User/X-Remote-Groups headers for the
processor on subsequent requests.
On localhost, a user entry with no password hash allows password-less sign-in. This is a development convenience; in production, every account must have a password.
Configure Apache to route requests through the auth wrapper before the processor:
FallbackResource /cgi-bin/lazysite-auth.pl
The auth wrapper reads the cookie, populates auth headers, and hands
off to lazysite-processor.pl if the request is authenticated (or
public).
Use the manager Users page, or the lazysite-users.pl CLI:
perl tools/lazysite-users.pl --docroot /path/to/public_html \
add alice secretpassword
perl tools/lazysite-users.pl --docroot /path/to/public_html \
group-add alice admins
add USERNAME PASSWORD Add a new user
passwd USERNAME NEWPASSWORD Change password
remove USERNAME Remove user and group memberships; names the
access rules the account owned, which stay
list List all users
group-add USERNAME GROUP Add user to group
group-remove USERNAME GROUP Remove user from group
groups List all groups and members
Users (lazysite/auth/users):
alice:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
bob:5994471abb01112afcc18159f6cc74b4f511b99806da59b3caf5a9c173cacfc5
Each line is username:sha256hex. Lines starting with # are comments.
A user line with no hash (just username:) allows passwordless sign-in
on localhost only.
Groups (lazysite/auth/groups):
admins: alice
lazysite-admins: alice
editors: alice, bob
members: alice, bob, carol
Each line is groupname: user1, user2, ....
The users file is plain text with SHA256 hex hashes. Generate a password hash:
echo -n 'mypassword' | sha256sum | cut -d' ' -f1
Add a user by appending to the file:
echo "alice:$(echo -n 'mypassword' | sha256sum | cut -d' ' -f1)" \
>> lazysite/auth/users
Or with Perl (if sha256sum is not available):
perl -MDigest::SHA=sha256_hex -e 'print sha256_hex("mypassword")'
Groups are plain text too - edit lazysite/auth/groups in any text
editor. Set permissions after editing:
chmod 640 lazysite/auth/users
chmod 644 lazysite/auth/groups
The starter includes login.md and logout.md. The login form POSTs
to /login and logout is at /logout. On successful login a signed
cookie is set and the user is redirected to the original page (via the
next parameter).
The HMAC secret lives at lazysite/auth/.secret (chmod 0660 -
owner + group, never world, so the site user's CLI tools and the
web-server CGI can both use it whichever minted it first).
A session is the signed cookie itself - there is no server-side session
store on the request path. Two small side files make sessions visible and
revocable: at login the cookie payload carries a random session id and one
line (who / when / IP / device) is appended to
lazysite/auth/sessions.jsonl (a line is pruned once it is older than the
session lifetime, session_lifetime, 24 hours by default), and cookie
verification consults lazysite/auth/revoked.json when it exists. The
manager Sessions page lists the live sessions and can sign out a single
session, all of one user's sessions ("Sign out everywhere"), or - by
rotating the signing secret - everyone at once. Require sign-in again, for
the sessions you select, makes each sign in again at its next request: a
session made through an outside provider goes back through that provider (the
same session continues, its groups read afresh), and a local session is signed
out, so the person signs in again with their password. Losing the registry only
degrades the listing; cookies minted before this feature cannot be listed
but are still killable per-user or by rotation.
Changing a credential signs the account's other sessions out. A password
change - the account's own, an operator's passwd, or a redeemed setup or
reset link - and turning two-factor off each end every session of that
account except the one that made the change, which stays signed in. A setup
or reset link is not a session, so redeeming one ends them all. A later "Sign
out everywhere" ends the kept session too.
Removing an account signs out every one of its sessions, and so does renaming
one, for the old name. A session also stops counting the moment its account
is no longer in lazysite/auth/users, or is disabled; if the account file
cannot be read, every session is refused until it is repaired. An account
created later under a removed name starts with no sessions.
The dev server auto-detects built-in auth when lazysite/auth/users
exists and uses the auth wrapper automatically.
The operator creates an account and sets its parameters; the user provisions their own secret. The operator never sets or handles a password. One primitive underlies every flow: a single-use, short-lived, hashed claim token.
On the Users page, an account card has Generate setup link next to Generate
credential. It mints a claim and shows a one-time URL (…/claim?u=<user>&c=<code>)
to hand over by any channel. The user opens it and the /claim page presents a
set-a-password form (interactive account) or a mint-and-reveal-token action
(machine account). The claim is consumed on success and expires after 24 h.
ui off) accounts cannot redeem a
set-password claim./account)Every signed-in account has a page of its own at /account, in the site's own
layout and theme, outside the manager. It needs no capability and grants none:
it acts only on the account that is signed in. On it a member can see who they
are signed in as, change their password (with their current one; the new one
meets the password policy below, and every other session of the account is
signed out), see where they are signed in, sign out one of those sessions, or
sign out everywhere.
/account is a system page: the engine's copy is used unless a site
writes its own account.md, and it comes back if a site's copy is deleted.
The login page's "already signed in" panel links to it. The panel shows only
while the site still honours the session: the page asks the server
(/cgi-bin/lazysite-auth.pl?action=session), and a session that was revoked,
has ended or has gone stale shows the sign-in form instead. The panel also
offers "Sign in as someone else", for an account the page asked for does not
admit: it signs out and comes back to the form, keeping the page to return to.::: account-summary, ::: account-password, ::: account-sessions. A
layout's components/<name>.tt of the same name wins; keep the data-ls-*
attributes, which /assets/lazysite-account.js binds.me-* actions (me, me-password,
me-sessions, me-session-revoke, me-sign-out-everywhere). Their subject is
always the signed-in account - none of them takes an account name - they are
cookie-only (an access key is refused), every change needs the session's
CSRF token and is audited, and a password check counts against the sign-in
rate limiter for both the address and the account.::: account-bell, actions me-notices and
me-notices-seen): the notices a notification rule addressed to this
account, newest first, with read, dismiss, and dismiss all (every notice read
and out of the list; a notice that arrives later is unread). Never the site's
broadcast notices - those are the operators' bell, behind notifications -
and never another account's. The account keeps its own last 100, however
busy the site's store is.::: account-2fa, actions me-2fa-enrol, me-2fa-confirm,
me-2fa-disable, me-recovery-codes): set up with the current password and
confirm with a code from the app; turn off or make new recovery codes with the
password and a current code. See "Two-factor (TOTP)" below.::: account-passkeys, actions me-passkeys, me-passkey-begin,
me-passkey-add, me-passkey-remove): list, add (with the current password when
the account has one) and remove your own. See "Passkeys" below.::: account-push, actions me-push, me-push-subscribe,
me-push-unsubscribe): turn the installed app's notices on or off for the phone the
page is open on. A notice pushed this way says only that something is waiting. See
forms: notices to the app.::: account-phone, action me-phone): the number the
site's text messages go to, in international form; empty removes it. Only the
webhook route carries it, and an operator decides who gets texts.account_page: disabled in lazysite.conf turns it off: /account answers
404 (unless the site wrote its own page) and the me-* actions refuse, naming
the setting.A password must be at least password_min_length characters long - 12 unless
lazysite.conf says otherwise (a value from 8 to 128; anything else is ignored
and 12 stands). The rule applies to every way a password is set: the users
tool's add, passwd and account-create, setup-sysop, a setup or reset
link, and a member changing their own on /account. Characters are counted,
not bytes. A password set before the rule keeps signing in until it is
changed. A setup link given a password that is too short is not used up: the
page says how long a password must be and offers the form again.
Registration is by invitation: nothing public creates an account. A site that wants visitors to ask puts a native form on a public page - the submission is stored, quarantined and rate-limited like any other, and raises the operator's usual notification.
The operator then approves it. account-approve does in one step what would
otherwise be three: it creates the account with no password, places it in
whichever groups are flagged to take registrations, and mints the claim link to
send back. The person sets their own credential at /claim, so the operator
never sees, chooses or transmits a password.
lazysite users --docroot /path/to/public_html \
account-approve jbloggs --email jbloggs@example.org
Give it the address the registration came from. --email records it on the
account, and that one field is what turns the rest of the loop self-service: the
person can go to the sign-in page, ask for a link, and receive a fresh one at
their own address without the operator doing anything. Without it the claim link
you were handed is the only way in, for ever - so if you lose it, you are
minting another by hand.
The link goes to the address on the account, never to whoever asked, which is what makes asking safe: the request proves nothing, the mailbox does. The answer is the same whether or not an account exists, so the form cannot be used to find out who has one.
A malformed address creates nothing at all - the approval is refused before the
account exists, rather than leaving a credential-less account behind for a typo.
So does a username holding anything but letters, digits, ., _ and -: it
is refused naming the character, never shortened to a different name.
A flagged group the account cannot be placed in - a backend group, or one you may not confer - does not stop the approval: the account and its link are made, and the approval names each group that did not take and why (on the command line, in the result, and in the audit trail), so you can place it yourself.
Or approve from the submission itself. When the registration form's conf
names its address field - account_email: email - the manager's Submissions
page shows Approve on each of its rows. That runs this same approval with
the address read from the stored row and the username taken from it (the local
part, lowercased), so nobody retypes either. A name that cannot be used, is
already taken, or an address already on another account is refused with the
reason and creates nothing. See Approving an account from a
submission.
Which group they land in is a group's own flag. On the Groups page, a group can be marked "add anonymous user registrations to this group". More than one group may carry it, and an approved account joins all of them.
Setting that flag is the strongest conferral this system has - it decides what a
person nobody has met holds on the day they are approved - so it passes the same
authority check as granting that group's capabilities one at a time. An operator
who could not confer manage_content cannot confer it to every future
registrant by ticking a box on a group that has it.
With no group flagged, an approved account joins nothing: it can sign in and
sees exactly what an anonymous visitor sees until an operator places it. That is
the safe default, because no shipped group grants only a login. A site that
wants approved registrations to be able to DO something makes a group for it -
write_data plus membership of the relevant tables' writable_by is the shape
for an app whose users write their own rows.
Approving requires full user management. It creates a top-level account, so a
delegated sub-manager (create_sub_users without manage_users) cannot call
it.
Where the SMTP extension is configured and the account has an email, /login shows
a Forgot password? link → /forgot takes a username or email and mails a
set-password claim. The response is identical whether or not an account matched -
it never reveals whether an account or email exists. The reset email is recorded
in the audit trail (action forgot) against the matched account.
An interactive account can enrol TOTP two-factor (RFC 6238). Enrolment shows a
shared secret + an otpauth:// URI (QR) and issues one-time recovery codes;
after enrolment, login requires a valid 6-digit code (or a recovery code) before
the cookie is issued. Two-factor applies to interactive (password → cookie) login
only - token / WebDAV / connector auth is unchanged, since the token is already
the strong factor there.
# the account enrols its own on /account; the sysadmin at the shell can still
perl tools/lazysite-users.pl --docroot /path/to/public_html mfa-enroll alice
Each person sets up their own (SM1042, ruled "Self-enrol only"): on their
account page, /account, with their current password; they confirm it with a
code from the app, and only they see the secret and the recovery codes. Turning
it off, or making new recovery codes, needs the password and a current code (or
one recovery code), and turning it off signs the account's other sessions out.
On the Users page a manager can only reset someone's two-factor (turn it off,
which signs that account out everywhere) - never set it up or see a code.
The shared secret lives in user-settings.json under the same 0640/2770
protection as other credentials (the auth dir is off the web and group-restricted;
no at-rest encryption - an accepted tradeoff for self-hosting).
A passkey signs a person in with their phone's or computer's fingerprint, face or screen lock, with nothing to remember. The site keeps only the passkey's public key, with the account; no secret is stored. Passwords and emailed links keep working beside it.
/claim
page offers "Use a passkey instead" next to the password form. The link is used
up as it would be for a password, and every other session of the account is
signed out. An account set up this way has no password at all (its stored
credential is a locked marker no password matches); it can be given one later
with a setup link./account (::: account-passkeys, actions
me-passkeys, me-passkey-begin, me-passkey-add, me-passkey-remove): name
it, confirm it is you, and let the phone make it. An account with a password
confirms with the current password; an account that signs in by passkey only (it
has no password, or is in a passkey_only group) confirms with one of the
passkeys it already holds - never with its session alone. Each is listed with when it was added and last used, and removed with one
button. An account holds up to 20.passkey as its
detail. Two-factor is not asked for after a passkey: the authenticator must verify
the person (fingerprint, face, PIN or screen lock), so a passkey counts as two
factors. An authenticator that cannot is refused when the passkey is added, with
a message saying so, and an answer without that verification never signs in. The site's installed app (app_name) signs in through the same page.users action passkey-wipe, which needs manage_users; the shell:
lazysite users --docroot /path/to/public_html passkey-wipe alice). It also signs the account out everywhere,
and is audited. A manager can never add or remove a single passkey for someone.passkey_only on the group (lazysite users --docroot /path/to/public_html group-set phones passkey_only on,
or group-settings-set through the users action); the sign-in page then
refuses its members' passwords, after the password matched, and says to use a
passkey. It is checked in the password sign-in and nowhere else. If the group
settings cannot be read, every password sign-in is refused, the operator's
included (the site cannot tell; SM1024). It is a fault to repair, not to work
around: the site log and lazysite check name the file, and the sysadmin repairs
or restores lazysite/auth/groups-settings.json from the command line.none).libcryptx-perl, which the package
recommends). Without it the sign-in and setup pages do not offer a passkey, the
account page says what is missing, and lazysite check warns when an account
already holds one.An account may carry expires_at (an epoch); after it, all authentication for
that account fails - time-boxed access for a contractor or a temporary partner.
Distinct from token expiry.
/forgot, /claim, and the partner exchange
never reveal whether an account, email, or claim is valid beyond success/failure./dav).claim-redeem, forgot, token-exchange,
token-rotate, user-claim-create, user-mfa-enroll, and the OAuth events
(oauth-register, oauth-authorize, oauth-refresh, connect).Set auth: in front matter:
---
title: Members Area
auth: required
---
Values:
required - user must be authenticated. Unauthenticated requests
redirect to the login page.optional - auth headers are read if present but access is not
restricted. Use for pages that show different content to logged-in
users.none - no auth check. This is the default.auth: manager belongs to the manager's own pages, whose gate enforces it.
On any other page it would enforce nothing, so the page is refused to everyone
(403, logged) and lazysite validate reports it: write auth: required, with
auth_groups to say who.
---
title: Admin Dashboard
auth: required
auth_groups:
- admins
- editors
---
The user must be authenticated AND in at least one listed group.
Users in the wrong group see the 403 page. A page that names auth_groups
is a signed-in page whatever its auth: says - with auth: none, optional
or no auth: at all, an anonymous visitor is sent to sign in. The one
exception is a paid page (payment: required), where auth_groups is the
list of members who skip payment and everyone else is charged - see
Payment.
The list may also be written inline, auth_groups: [admins, editors];
the two forms mean the same. A value the engine cannot read as a list of
group names - a single bare name (auth_groups: admins), an empty list,
an unclosed [, a name with a space in it - is refused rather than
ignored: the page answers 403 to everyone, signed in or not, and the log
names the key. The same holds for an auth: value other than
required, optional or none, and a payment: value other than
required or none (one level of quotes and any case are fine).
lazysite validate reports each of these.
Set auth_default: in lazysite/lazysite.conf:
auth_default: required
Pages without auth: in front matter inherit this value. Default
is none when not set. The login page is always accessible
regardless of the site-wide default.
It applies to pages, not to files. A .html with no Markdown source, a PDF,
an image or a downloadable archive is not a page - it has no front matter, so
there is nothing for this setting to inherit into. auth_default: required will
bounce every page to the login form and still serve those files to anyone who
knows the path. Protecting them is the next section, and it is a separate,
explicit act.
A file with no page source is protected by giving it an entry in
lazysite/auth/acls.json - the same per-file access list the manager, WebDAV and
the MCP connector already use. A read list is what protects it:
{
"private/brief.pdf": { "read": ["alice", "@staff"] },
"private": { "read": ["@staff"] }
}
@name is a group.Three behaviours worth knowing before you rely on it:
owner does not protect anything. Ownership is not a
read restriction here, and it is not one in the manager either. You need a
read list. The same holds for a write list: it says who may change a page,
so a page under an owner or write rule alone is cached and listed (sitemap,
feeds, search) like any public page. Only a read list or draft changes
that.403. Protected files are never stored by a
shared cache.To see what is already restricted, and what has actually been refused, see Auditing access below.
The same folder entry gates the section's pages, not only its files. To hold back an unfinished area, write one entry:
{ "upcoming": { "read": ["@editors"] } }
Every page under /upcoming/ now requires an editor, and so does every image and
PDF in it - one rule, one place. No page in the section needs auth: front
matter, and a page that carries auth: none does not escape the section
gate: a section you can hold back only if every page inside it agrees is not a
section gate at all.
Publishing is deleting the entry. Remove it and the whole subtree goes public in one act - no per-page edits, no partially-released section.
A page under a gated prefix is never written to the shared HTML cache, so a render for a permitted user cannot leak to the next anonymous visitor.
A protected section answers with a login redirect, which tells anyone who tries
the URL that it exists. For a section that is not ready to be known about -
an unlaunched product, a client area before announcement - add draft:
{ "upcoming": { "read": ["@editors"], "draft": true } }
That changes two things:
sitemap.xml, llms.txt, the
feeds, and any scan: page list. That is unconditional: a registry file is
generated once and then served to everyone from disk, so a draft page listed in
it is published no matter what the page itself answers.Editors on the read list preview it normally by signing in. With no read
list, any signed-in user may preview it and the public still cannot - draft
deliberately breaks the usual "no read list means anyone" rule, because a draft
that was public would not be a draft.
Publishing is removing draft - or removing the entry entirely, which also
drops the access gate. The section goes live and enters the sitemap on the next
render.
This is the part that catches people. A web server answers a request for an existing file from disk without consulting anything - that is what web servers are for. When it does, lazysite never sees the request and no ACL can apply.
So the front end has to be told: when this site has an ACL store, hand existing files to lazysite instead of serving them. The shipped Apache and nginx templates and the built-in dev server already do this, and there is nothing to configure - install or regenerate the vhost and it is in place.
If you run any other web server - Caddy, lighttpd, a CDN or a reverse proxy in front - you must add the equivalent rule yourself, or ACLs on static files will silently do nothing. The rule is:
If
<docroot>/lazysite/auth/acls.jsonor<docroot>-lazysite/auth/acls.jsonexists, route a request for an existing file to/cgi-bin/lazysite-auth.plinstead of serving it from disk.
Three details that are easy to get wrong:
Test both places. The second is where the store lives once a site's engine
tree has been moved out of the document root (lazysite migrate-engine-tree).
A rule that tests only the first stops firing the moment a site is migrated,
and nothing about the site looks different - that is how the shipped templates
behaved before 0.13.13.
Route at lazysite-auth.pl, not at the processor. The auth wrapper
validates the session cookie and passes a trusted identity through. Pointing
straight at lazysite-processor.pl gives it no usable identity, so every
protected file bounces to the login page for everyone - including the people
entitled to read it.
Verify it before trusting it. With an ACL in place, request a protected file while signed out:
curl -sSI https://example.com/private/brief.pdf
A 302 to the login page (or a 403) means the rule works. A 200 means your
web server is still answering from disk and the ACL is being ignored.
Two questions, two answers.
What is restricted right now - read the store directly:
jq -r 'to_entries[] | select(.value.read != null and (.value.read | length) > 0)
| "\(.key)\t\(.value.read | join(","))"' lazysite/auth/acls.json
Anything listed is refused to everyone outside its list. This matters after an upgrade: an entry originally written to keep other editors out of a file now also keeps anonymous visitors out of it, which is usually the intention and occasionally is not.
What has actually been refused - the access log flags an access refusal with
"ar":1, because no status code can express it (the anonymous case is a 302 to
the login page, identical to any other redirect):
grep -h '"ar":1' lazysite/logs/access-*.jsonl | jq -r .p | sort | uniq -c | sort -rn
The same data appears as auth_refused in the analyse_visitors report, so an
AI assistant with the analytics capability can answer this without shell access.
A path there that you believe is public is the signal to check its ACL entry.
A capability is conferred by turning it on for a group. Two rules govern who may do that:
manage_users but not operator) may confer a
capability only if they hold it, or if an operator has put it in one of
their groups' grant authority.Removing a capability is always allowed - that is de-escalation and needs no authority.
Grant authority exists so a delegate does not have to hold a capability
merely to hand it to someone else. An agency sub-admin who manages an AI agent
should be able to grant the agent mcp without carrying mcp on their own
account, which would enlarge their surface for a purely administrative act:
# operator only
perl tools/lazysite-users.pl --docroot /path/to/public_html \
group-set client-admins grantable mcp,api
Members of client-admins may now confer mcp and api on the groups they
manage, and still do not hold either themselves.
Setting grantable is operator-only, and that is what makes it safe: grant
authority is conferred from above and never self-assumed. A delegate that could
widen its own grant authority would have no ceiling at all. Making a group a
manager group (manager) is operator-only for the same reason.
Upgrading from before 0.10.5: there was no ceiling - manage_users alone
allowed conferring any capability, including on a group the delegate belonged to.
If your delegates rely on that, give them explicit grant authority for the
capabilities they legitimately hand out; otherwise those grants now refuse, and
the refusal names the command that fixes it.
maintenance_access (Access during maintenance) is the capability that gets an
account through a site in maintenance (the maintenance setting; see the
reference). With the switch on, a page request goes to the
maintenance page, a posted form is refused, and MCP, the control API, WebDAV and
the data endpoint answer 503 - unless the request's verified identity holds
this capability. It is assigned to a group on the Groups page like any other and
the operator group and the site-admins role hold it by default, on an upgraded
site as on a new one (site-admins through the cap-maintenance bundle, which
an upgrade adds to an existing site once; take the role out of the bundle on the
Groups page to withhold it). You
need it yourself to switch maintenance on, so the account that closes the site is
one the closed site lets back in. It does nothing while the site is not in
maintenance.
The manager at /manager uses the same auth mechanism. Access is the
ui capability, granted through a group on the manager Groups page
(the seeded lazysite-admins group carries it):
manager: enabled
manager_path: /manager
Capabilities on groups are the mechanism of record. (The legacy
manager_groups: conf key is retired: on upgrade any group it named receives
its capabilities explicitly and the conf line is removed.)
An account without ui gets nothing from the manager API. The exception is
write_content (Content files where a rule names you), the content twin of
write_data. It is for the authors of a member app, such as a blog whose writers
are members and never open /manager. Over their own signed-in session they may
call read, list, save, mkdir, move and delete on the manager API, and
nothing else:
save, mkdir, delete, and both
ends of a move, land only where the access rule on the file or its folder
names the account or one of its groups in its write list. A new file
follows its folder's rule. A path no rule governs is refused. (For a manager it
is open.) A folder is moved only when every rule inside it names them too.lazysite/ tree or another domain's folder, names entries by the site's own
paths (the ones read and save take), and carries no rule lists./post/hello.md writes that domain's
post/hello.md, under the rule keyed by that domain's path. An account whose
domain access is limited stays limited, and a domain whose folder sits inside
this one's is not reached from it.lazysite/) is never reached, and the CSRF token,
the file checks and the audit row are the same as for a manager's save.The capability implies nothing and no shipped role holds it. An account that
holds ui uses the manager as before, and write_content changes nothing for
it. To set up a blog's authors:
perl tools/lazysite-users.pl --docroot /path/to/public_html \
group-set authors write_content on
Then, as the operator, give the posts folder a rule naming the group as a writer
(the Files page's access panel, or acl-set with {"write":["@authors"]} on
post). The app's page script fetches a token from ?action=csrf-token and
sends POST /cgi-bin/lazysite-manager-api.pl?action=save&path=/post/hello.md
with X-CSRF-Token and {"content":"..."}. See
AI briefing - authoring.
These variables are available in page content and the view template:
[% authenticated %] - 1 if user is logged in, 0 otherwise[% auth_user %] - username[% auth_name %] - display name (from users file or proxy header)[% auth_email %] - email (from proxy header)[% auth_groups %] - array of group namesExample in a view template:
[% IF authenticated %]
<span>Signed in as [% auth_user %]</span>
<a href="/logout">Sign out</a>
[% ELSE %]
<a href="/login">Sign in</a>
[% END %]
All five are HTML-escaped where they enter the page, so the example above is
safe with no filter and you should not add | html to them. Doing so
escapes an already-escaped value, and a display name of O'Brien renders as
O'Brien on the page.
The same is true of [% query.<name> %]. One used as a whole link target -
<a href="[% query.next | url_safe %]"> - also goes through | url_safe,
which keeps a path or an http, https, mailto or tel URL and turns any other
scheme into #; see Query parameters.
<script> blockThis does not work, and the way it fails is not obvious:
<script>
var me = '[% auth_name %]'; // WRONG
</script>
A browser decodes no HTML entities inside <script>, so the escaping that makes
these values safe in a page arrives in your JavaScript as literal entity text -
O'Brien rather than O'Brien. | html does not fix it and makes it
worse. There is no filter that is right here.
Put the value in an attribute and read it from there. The HTML parser decodes the escaping when it reads the attribute, so JavaScript receives the real characters:
<div id="viewer" data-user="[% auth_user %]" data-name="[% auth_name %]"></div>
<script>
var el = document.getElementById('viewer');
var me = el.dataset.name || el.dataset.user;
</script>
This is the idiom for any value a page's script needs from the request or
the viewer, and it is what the shipped search-results page and the manager's
editor both use.
A second reason to prefer it: a literal [%` anywhere in a page - including
inside a regular expression in your own script - is read as a template directive
and fails the parse, and the whole page then renders with **every** `[% %]
un-substituted. Keeping template syntax out of your script blocks avoids that
entirely.
Create 403.md in the docroot. These context variables are available:
[% auth_denied_reason %] - insufficient_groups when group check fails[% auth_required_groups %] - array of required group names[% auth_user %] - the authenticated username[% auth_name %] - display nameThe 403 page is never cached.
The OpenID Connect sign-in extension lets people sign in at a provider the site trusts - Authentik, Kanidm, Authelia, Pocket ID or any other OpenID Connect issuer - instead of holding an account on the site. The groups the provider holds for a person become site groups, by a map you set per provider. No account is made on the site, and a person who is in none of the mapped groups is not signed in.
https://<your site>/cgi-bin/lazysite-oidc.pl. Have the provider put the
person's groups in a claim (usually groups), in the ID token or from the
userinfo endpoint.https://; plain http:// only to this host).openid email profile) and Group claim (default
groups)./.well-known/openid-configuration)
is fetched and kept; press Refresh on the extension's card after the
provider changes its endpoints.The settings live in lazysite/oidc.conf (providers: staff, partners, then
staff.issuer: ... and so on), so a sysadmin can repair them there. A secret
can also be set from the command line, as the site's user, reading it from
standard input:
extensions/oidc.pl --secret staff --docroot /var/www/example/public_html
The sign-in page then offers one "Sign in with ..." link per provider, beside
the site's own form. Run lazysite check to see each provider ready, or why
not.
A provider group may map to any site group, including one that carries
capabilities such as operators. Whoever controls membership of that group at
the provider can therefore give manager rights on this site; map such groups
only from a provider you control. Every sign-in through a provider is written
to the audit trail with the provider, the person's subject and the groups it
granted.
Only the operator may map a provider group to a group that carries capabilities (operators, or any group that grants something). Anyone who may change the site's settings may map to a plain group, and keeps a capability mapping the operator already made while editing the rest; adding one is refused, saying why. A control-API token never counts as the operator, so such a mapping is saved in the manager.
A person signed in this way is oidc-<provider>:<subject>, shown by their
email. Their session lasts as long as a local one (session_lifetime). The
groups are read at each sign-in: someone removed from a group at the provider
keeps it until their session ends, until it is signed out, or until you mark it
Require sign-in again on the Sessions page: they then sign in again
through the provider at their next request, which reads their groups afresh, and
are returned to the page they asked for. A map entry you remove takes the group
away at their next request.
The sign-in uses the authorisation code flow with PKCE. The site checks that the answer comes back to the browser that started it (the state), then exchanges the code at the provider's token endpoint over TLS, authenticating with the client secret. The ID token from that answer must name the configured issuer, this site's client id and the nonce the site sent, and must not have expired. Its signature is not checked: the token comes straight from the provider over a verified TLS connection (OpenID Connect Core 3.1.3.7), so there are no signing keys to fetch or rotate.
A provider whose settings are wrong, whose client secret is missing or
unreadable, or whose discovery could not be fetched signs nobody in, and the
sessions it made end at their next request. The other providers are not
affected. The server log and lazysite check name the fault and how to fix it.
If lazysite/oidc.conf itself cannot be read, every provider is refused.
Any reverse proxy that sets HTTP headers works with lazysite; the settings, the conditions that make it safe and a worked example are in Behind an identity provider. The processor reads these headers by default:
X-Remote-User - usernameX-Remote-Name - display nameX-Remote-Email - email addressX-Remote-Groups - comma-separated group listIf your proxy uses different header names, configure them in
lazysite/lazysite.conf:
auth_proxy_trusted: true
auth_header_user: Remote-User
auth_header_name: Remote-Name
auth_header_email: Remote-Email
auth_header_groups: Remote-Groups
A custom name needs auth_proxy_trusted: true. The engine accepts an
identity header only from a proxy it has been told to trust; without the flag a
custom-named header is discarded and logged, because a visitor can send any
header they like. Make sure the proxy strips these headers from incoming
requests before setting its own.
# In Authentik proxy provider - forwarded headers:
# X-Remote-User: %(username)s
# X-Remote-Name: %(name)s
# X-Remote-Email: %(email)s
# X-Remote-Groups: %(groups|join(","))s
Apache with Authentik:
<Location />
RequestHeader set X-Remote-User "%{AUTHENTIK_USERNAME}e"
RequestHeader set X-Remote-Groups "%{AUTHENTIK_GROUPS}e"
</Location>
Configure header names in lazysite.conf to match Authelia:
auth_proxy_trusted: true
auth_header_user: Remote-User
auth_header_name: Remote-Name
auth_header_email: Remote-Email
auth_header_groups: Remote-Groups
nginx with Authelia:
location / {
auth_request /authelia;
auth_request_set $remote_user $upstream_http_remote_user;
auth_request_set $remote_groups $upstream_http_remote_groups;
proxy_set_header X-Remote-User $remote_user;
proxy_set_header X-Remote-Groups $remote_groups;
}
Protected pages (auth: required or with auth_groups:) are never
cached to disk and always include Cache-Control: no-store, private
in the response. This prevents authenticated content from being
served to unauthenticated users.
A public page is cached from a render made for nobody: a signed-in
visitor may be served the cached page, but their own render is never
written to the cache, so a layout that prints auth_user cannot hand
one visitor's name to the next.