feature: users sign in to the web service, and only members of admins manage the router.
Release / release (push) Failing after 16m57s
Release / release (push) Failing after 16m57s
This commit is contained in:
1 parent
791fc7fddd
commit
caf0e334f0
17 files changed
+792
-177
No files matched your search
@@ -52,27 +52,31 @@ configuration. It runs as root, because `swanctl` and `wg` need it, with `NoNewP
|
||||
`ProtectSystem=strict`, `ProtectHome` and `PrivateTmp`. The Tailwind style comes from a CDN, so a
|
||||
browser on a network without internet shows the unstyled page, and `curl` is unaffected.
|
||||
|
||||
## ADR-3: /config is authenticated with a short-lived JWT signed with a shared secret
|
||||
## ADR-3: The protected paths take JWTs that only the router signs
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: A client such as the Router Manager needs the configuration and the resolved state of
|
||||
the router, which `/` and `/status` must not show. The service listens on plain HTTP on the
|
||||
internal network, and its configuration file is readable by root only. The platform that deploys
|
||||
the router generates one secret and gives it to the router's configuration and to the client.
|
||||
Context: Clients such as the Router Manager, and people with `curl`, need the configuration,
|
||||
the users and the groups of the router, which `/` and `/status` must not show. Both people and
|
||||
programs sign in with a user of the directory. A secret shared with the platform that deploys
|
||||
the router would let whoever holds it, and every copy of the deployment's state, make tokens.
|
||||
The service listens on plain HTTP on the internal network.
|
||||
|
||||
Decision: `/config` needs a JWT in an `Authorization: Bearer` header. The service verifies HS256
|
||||
tokens with `hmac` and `hashlib`, with the secret `web.jwt_secret`, and accepts no other
|
||||
algorithm, because a shared secret is what both sides have; RS256 or ES256 would need a key pair
|
||||
and its distribution. The token must carry `exp`, not more than ten minutes ahead, so that the client makes a fresh token for
|
||||
each use and a leaked one expires. Without a secret the page is not served. The configuration
|
||||
shown has no secret options (ADR-8).
|
||||
Decision: `/config`, `/users` and `/groups` need a JWT in an `Authorization: Bearer` header,
|
||||
HS256, verified with `hmac` and `hashlib`; no other algorithm is accepted. The key is
|
||||
`/etc/vpn-router/web.key`, random, made by `vpn-router-setup` on the router and readable by root
|
||||
only; it is never configured or output, and `web.jwt_secret` is ignored. The router itself issues
|
||||
the tokens: `POST /auth` checks a user's password and TOTP code with a bind (ADR-14) and answers
|
||||
an access token naming the user in `sub`, valid for 5 minutes, and a refresh token valid for 8
|
||||
hours (ADR-15); `vpn-router-token` makes tokens with the `admin` claim (ADR-13). A token without
|
||||
`admin` must expire within ten minutes, so a leaked one is of little use. Without the key the
|
||||
paths and `/auth` are not served. The configuration shown has no secret options (ADR-8).
|
||||
|
||||
Consequences: The client and the router share one secret, and whoever holds it can make tokens,
|
||||
so it is long and random; it is checked to have at least 32 characters. The secret is only in the
|
||||
configuration file, readable by root only. The token and the answer cross the network
|
||||
unencrypted, so the page is for a trusted internal network; HTTPS would need a certificate that
|
||||
the package does not have.
|
||||
Consequences: Nothing outside the router can make a token, so a token is either issued to a
|
||||
user who signed in or made by root on the router. A client keeps the refresh token to stay
|
||||
signed in. A token of `vpn-router-token` can be revoked only by replacing the key, which ends
|
||||
every token. Tokens, passwords and answers cross the network unencrypted, so the API is for a
|
||||
trusted internal network; HTTPS would need a certificate that the package does not have.
|
||||
|
||||
## ADR-4: Users are kept in a local OpenLDAP directory
|
||||
|
||||
@@ -144,8 +148,8 @@ the CA's state, and `/etc/swanctl/x509crl` for the published CRL.
|
||||
|
||||
Consequences: The rules exist once. A request starts no process, and a password never leaves
|
||||
the process that received it. The web service imports `python3-ldap` and the package's
|
||||
configuration module. The API is protected by the token of `/config`, so whoever has the secret
|
||||
can manage users.
|
||||
configuration module. The API is protected by the tokens of ADR-3, so only members of `admins`
|
||||
and the holders of an admin token manage users.
|
||||
|
||||
## ADR-7: The web service starts on its own and reads its inputs at each request
|
||||
|
||||
@@ -161,14 +165,14 @@ thread works out, every 30 seconds, the internal address from `vpn-router.json`
|
||||
too, and `web.port` and `web.enabled` from the same file. It runs the HTTP server in a thread of
|
||||
its own at that address and port, stops it and starts a new one when they change, and runs none
|
||||
while the service is disabled, the address is not known or it cannot be bound. Each request
|
||||
reads the token secret from `vpn-router.json` and the features and resolved facts from
|
||||
reads the signing key and `vpn-router.json`, and the features and resolved facts from
|
||||
`web.json`, which setup writes when it applies the configuration, so the service shows what setup
|
||||
applied; while that file is missing the router shows no features. The unit is not
|
||||
ordered after `vpn-router-setup`, and setup does not restart it.
|
||||
|
||||
Consequences: The status page answers as soon as the internal address exists, whatever state
|
||||
setup is in, and follows a changed address, port or `web.enabled` within 30 seconds without a
|
||||
restart. A change of the secret applies to the next request. The interfaces are read every 30
|
||||
restart. A new signing key applies to the next request. The interfaces are read every 30
|
||||
seconds, which costs a few `ip` commands.
|
||||
|
||||
## ADR-8: /config leaves secret options out
|
||||
@@ -179,8 +183,8 @@ Context: A secret has no use outside the router, so a client never needs it, and
|
||||
that a secret is set tells a reader more than it needs.
|
||||
|
||||
Decision: The configuration that `vpn-router-setup` writes for the web service has no secret
|
||||
options at all. They are recognised by name: `psk`, and options with `secret`, `password`,
|
||||
`token` or a `_key` ending in the name.
|
||||
options at all. They are recognised by name: `psk`, `database`, whose connection string may hold
|
||||
a password, and options with `secret`, `password`, `token` or a `_key` ending in the name.
|
||||
|
||||
Consequences: No secret reaches the web service's state file or any answer of the API. A client
|
||||
cannot tell whether a secret is set. A new secret option needs a name that matches.
|
||||
@@ -190,26 +194,28 @@ cannot tell whether a secret is set. A new secret option needs a name that match
|
||||
Status: Accepted
|
||||
|
||||
Context: The router needs a web application to manage it, which may run on the router or on
|
||||
another Linux system. The router's web service needs a token signed with `web.jwt_secret` for
|
||||
`/config` and `/users` and sends no CORS headers, so a browser cannot call it with the secret
|
||||
kept away from the page.
|
||||
another Linux system. The router's web service issues tokens through `/auth` (ADR-3) and sends
|
||||
no CORS headers, so a browser cannot call it directly, and tokens kept in the page could be read
|
||||
by its scripts.
|
||||
|
||||
Decision: `vpn-router-manager` is a Debian package of its own, built in the repository
|
||||
`linux-cloud-router-manager`. A React and MUI application in the style of manage-ui is built
|
||||
with Vite when the package is built and shipped as static files. A Python server, using the
|
||||
Ubuntu packages `python3-jwt` and `python3-requests`, serves them and passes every `/api/<path>`
|
||||
call to the router's `/<path>`, signing a short-lived token with the router's secret; it holds
|
||||
no logic of the router's own. It reads its settings from `/etc/vpn-router-manager/manager.json`
|
||||
at each request; on the router the settings not given come from `vpn-router.json`, and it
|
||||
listens on the internal address like the status web service, with the same discovery thread.
|
||||
`vpn-router-setup` opens the manager's port on the internal interface while the manager is
|
||||
installed, so the firewall stays in one place. The manager asks for no authentication.
|
||||
Ubuntu packages `python3-requests` and `python3-psycopg`, serves them, signs users in through the
|
||||
router's `/auth`, keeps their tokens in a server-side session in PostgreSQL behind an
|
||||
`HttpOnly` cookie, and passes every other `/api/<path>` call to the router's `/<path>` with the
|
||||
session's access token, refreshing it as needed; it holds no logic of the router's own and no
|
||||
key of it. It reads its settings from `/etc/vpn-router-manager/manager.json` at each request; on
|
||||
the router the settings not given come from `vpn-router.json`, and it listens on the internal
|
||||
address like the status web service, with the same discovery thread. `vpn-router-setup` opens
|
||||
the manager's port on the internal interface while the manager is installed, so the firewall
|
||||
stays in one place.
|
||||
|
||||
Consequences: The router needs no Node.js; the package needs only `python3` and packages of the
|
||||
Ubuntu archive. The secret never reaches the browser. The application checks the shape of every
|
||||
answer of the router. Anyone who reaches the manager's port manages the router, so on another
|
||||
system it listens on `127.0.0.1` unless told otherwise. The router's firewall depends on whether
|
||||
the manager is installed, and a changed manager port needs `vpn-router-setup` to run again.
|
||||
Ubuntu archive. Tokens never reach the browser, and sessions survive a restart of the manager.
|
||||
The router decides what a user may do; the manager only signs in. The application checks the
|
||||
shape of every answer of the router. The router's firewall depends on whether the manager is
|
||||
installed, and a changed manager port needs `vpn-router-setup` to run again.
|
||||
|
||||
## ADR-10: Users and their certificates do not depend on a P2S system being enabled
|
||||
|
||||
@@ -252,13 +258,15 @@ groups, such as allowing OpenVPN only to some, can read them as they are.
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: Users are to be enrolled for TOTP one-time passwords, with nothing checking the codes
|
||||
yet. Ubuntu's slapd ships the `otp` module, which holds the OATH-LDAP schema and an overlay that
|
||||
checks a code appended to the password at bind time. Ubuntu packages `python3-pyotp`. An
|
||||
authenticator app is enrolled from a QR code of an `otpauth://` URI.
|
||||
Context: Users are enrolled for TOTP one-time passwords, and enrolled users must give a code
|
||||
whenever they log in, to OpenVPN and to `/auth`. Ubuntu's slapd ships the `otp` module, which
|
||||
holds the OATH-LDAP schema and an overlay that checks a code appended to the password at bind
|
||||
time; both OpenVPN (`openvpn-auth-ldap`) and `/auth` check passwords with a bind. Ubuntu
|
||||
packages `python3-pyotp`. An authenticator app is enrolled from a QR code of an `otpauth://` URI.
|
||||
|
||||
Decision: The `otp` module is loaded for its schema; its overlay is not configured. The TOTP
|
||||
token is the user entry itself: enrolment adds `oathTOTPUser` and `oathTOTPToken` to it, with
|
||||
Decision: The `otp` module is loaded and its overlay is configured on the package's database,
|
||||
after ppolicy, so every bind of an enrolled user takes the password followed by a current code
|
||||
and slapd records the time step used. The TOTP token is the user entry itself: enrolment adds `oathTOTPUser` and `oathTOTPToken` to it, with
|
||||
`oathTOTPToken` naming the entry, the secret in `oathSecret` and `oathTOTPParams` naming one
|
||||
shared parameter entry, `cn=totp,ou=policies`, with the parameters authenticator apps assume
|
||||
(6 digits, HMAC-SHA1, 30 seconds). An ACL lets the user manager account set `oathSecret` and
|
||||
@@ -267,7 +275,75 @@ with its URI, storing nothing, and `otp_enrol` stores the secret it is given bac
|
||||
code of it verifies, so a secret is never stored that the user's app does not produce. The
|
||||
router answers the URI; the client renders the QR code.
|
||||
|
||||
Consequences: Removing a user removes its token. Configuring the overlay later makes slapd
|
||||
check the stored tokens without moving data. The secret passes through the client between
|
||||
`otp_new` and `otp_enrol`, so the router keeps no pending state; enrolling again replaces the
|
||||
secret only once a code of the new one verifies.
|
||||
Consequences: Removing a user removes its token. One check covers OpenVPN and `/auth`, and
|
||||
an enrolled user types the code after the password in the OpenVPN client. The secret passes
|
||||
through the client between `otp_new` and `otp_enrol`, so the router keeps no pending state;
|
||||
enrolling again replaces the secret only once a code of the new one verifies.
|
||||
|
||||
## ADR-13: Members of admins manage the router; only CLI tokens change admins
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: Signing in through `/auth` (ADR-3) is open to every user of the directory, most of
|
||||
whom only use OpenVPN. Finer permissions per operation are not needed. Somebody must be able to
|
||||
manage the router before any administrator exists and after every administrator lost access,
|
||||
and administrators must not be able to grant themselves or others the administrator role over
|
||||
the API.
|
||||
|
||||
Decision: A token naming a user in `sub` may call the protected paths only while the user is a
|
||||
member of the group `admins`, read at each request; other users get 403. `vpn-router-token`, run
|
||||
as root on the router, makes tokens with the claim `admin: true` and a lifetime given at
|
||||
creation, without a maximum, signed with the router's key; `/auth` never issues the claim. An
|
||||
admin token may call everything, and only an admin token may change `admins`: create it, rename a
|
||||
group to it, change or remove it, add or take out its members, or remove a user who is a member
|
||||
of it. The first member is added with `vpn-router-group` on the router, which acts as root.
|
||||
|
||||
Consequences: Removing a user from `admins` takes effect at the next request, without waiting
|
||||
for the user's tokens to expire. Membership of `admins` changes only through root on the router.
|
||||
An admin token is as powerful as root over the API and is revoked only by replacing the key, so
|
||||
it is given a short lifetime unless it is meant for automation.
|
||||
|
||||
## ADR-14: Failed logins lock the account in slapd; /auth is rate limited per client
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: `/auth` is open to the internal network and checks passwords, so it invites guessing;
|
||||
OpenVPN checks passwords from the internet. Both check with a bind, so slapd sees every attempt.
|
||||
ppolicy locks an account after a number of failed binds for a fixed time; it has no growing
|
||||
lockout. A Router Manager signs all its users in from one address.
|
||||
|
||||
Decision: The default password policy locks an account for 15 minutes after 5 failed binds
|
||||
within 15 minutes; a successful bind clears the count; `vpn-router-user unlock` and
|
||||
`POST /users/<name>/unlock` end a lockout and clear the count, and leave a disabled user disabled.
|
||||
`/auth` answers the same 401 for a wrong name, password or code and for a locked or disabled
|
||||
user. `/auth` and `/auth/refresh` allow 10 calls a minute per client address, counted in memory,
|
||||
and answer 429 with `Retry-After` beyond that. The client address is the peer, or the last
|
||||
`X-Forwarded-For` entry for a peer in `web.trusted_proxies`, by default the address the service
|
||||
listens on, where a manager on the router calls from.
|
||||
|
||||
Consequences: One lockout covers OpenVPN and `/auth`, kept by slapd alone. Someone who knows a
|
||||
user name can lock the user out for 15 minutes at a time. The rate limit restarts with the
|
||||
service. A manager elsewhere than on the router has its address added to `web.trusted_proxies`,
|
||||
or its users share one limit.
|
||||
|
||||
## ADR-15: Refresh tokens are kept in PostgreSQL
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: An access token lives 5 minutes, and a client stays signed in with a refresh token for
|
||||
8 hours; refresh tokens must be revocable when a user signs out, is disabled, changes the
|
||||
password or is removed, and must survive a restart of the service. The Router Manager keeps its
|
||||
sessions in PostgreSQL too.
|
||||
|
||||
Decision: Refresh tokens are random strings, stored as their SHA-256 with the user name and the
|
||||
expiry in the table `refresh_tokens`, through psycopg 3 (`python3-psycopg`). The database is
|
||||
`web.database`, or by default the database and role `vpn-router` on the router, which
|
||||
`vpn-router-setup` creates with `runuser -u postgres -- psql` and a random password in
|
||||
`/etc/vpn-router/database.secret`, reached over TCP on `localhost`, as Ubuntu's `pg_hba.conf`
|
||||
takes peer authentication on the local socket. A refresh token is used once: refreshing deletes
|
||||
it and issues a new one, after checking that the user is neither disabled nor locked.
|
||||
|
||||
Consequences: The package depends on `postgresql` and `python3-psycopg`. A stolen database holds
|
||||
no usable token. Changing a user's password, disabling, unlocking or removing the user, and
|
||||
signing out, end the user's sessions at once; an access token already issued lives out its
|
||||
5 minutes.
|
||||
Reference in new issue
Block a user