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
|
`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.
|
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
|
Status: Accepted
|
||||||
|
|
||||||
Context: A client such as the Router Manager needs the configuration and the resolved state of
|
Context: Clients such as the Router Manager, and people with `curl`, need the configuration,
|
||||||
the router, which `/` and `/status` must not show. The service listens on plain HTTP on the
|
the users and the groups of the router, which `/` and `/status` must not show. Both people and
|
||||||
internal network, and its configuration file is readable by root only. The platform that deploys
|
programs sign in with a user of the directory. A secret shared with the platform that deploys
|
||||||
the router generates one secret and gives it to the router's configuration and to the client.
|
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
|
Decision: `/config`, `/users` and `/groups` need a JWT in an `Authorization: Bearer` header,
|
||||||
tokens with `hmac` and `hashlib`, with the secret `web.jwt_secret`, and accepts no other
|
HS256, verified with `hmac` and `hashlib`; no other algorithm is accepted. The key is
|
||||||
algorithm, because a shared secret is what both sides have; RS256 or ES256 would need a key pair
|
`/etc/vpn-router/web.key`, random, made by `vpn-router-setup` on the router and readable by root
|
||||||
and its distribution. The token must carry `exp`, not more than ten minutes ahead, so that the client makes a fresh token for
|
only; it is never configured or output, and `web.jwt_secret` is ignored. The router itself issues
|
||||||
each use and a leaked one expires. Without a secret the page is not served. The configuration
|
the tokens: `POST /auth` checks a user's password and TOTP code with a bind (ADR-14) and answers
|
||||||
shown has no secret options (ADR-8).
|
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,
|
Consequences: Nothing outside the router can make a token, so a token is either issued to a
|
||||||
so it is long and random; it is checked to have at least 32 characters. The secret is only in the
|
user who signed in or made by root on the router. A client keeps the refresh token to stay
|
||||||
configuration file, readable by root only. The token and the answer cross the network
|
signed in. A token of `vpn-router-token` can be revoked only by replacing the key, which ends
|
||||||
unencrypted, so the page is for a trusted internal network; HTTPS would need a certificate that
|
every token. Tokens, passwords and answers cross the network unencrypted, so the API is for a
|
||||||
the package does not have.
|
trusted internal network; HTTPS would need a certificate that the package does not have.
|
||||||
|
|
||||||
## ADR-4: Users are kept in a local OpenLDAP directory
|
## 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
|
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
|
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
|
configuration module. The API is protected by the tokens of ADR-3, so only members of `admins`
|
||||||
can manage users.
|
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
|
## 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
|
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
|
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
|
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
|
`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
|
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.
|
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
|
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
|
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.
|
seconds, which costs a few `ip` commands.
|
||||||
|
|
||||||
## ADR-8: /config leaves secret options out
|
## 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.
|
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
|
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`,
|
options at all. They are recognised by name: `psk`, `database`, whose connection string may hold
|
||||||
`token` or a `_key` ending in the name.
|
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
|
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.
|
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
|
Status: Accepted
|
||||||
|
|
||||||
Context: The router needs a web application to manage it, which may run on the router or on
|
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
|
another Linux system. The router's web service issues tokens through `/auth` (ADR-3) and sends
|
||||||
`/config` and `/users` and sends no CORS headers, so a browser cannot call it with the secret
|
no CORS headers, so a browser cannot call it directly, and tokens kept in the page could be read
|
||||||
kept away from the page.
|
by its scripts.
|
||||||
|
|
||||||
Decision: `vpn-router-manager` is a Debian package of its own, built in the repository
|
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
|
`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
|
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>`
|
Ubuntu packages `python3-requests` and `python3-psycopg`, serves them, signs users in through the
|
||||||
call to the router's `/<path>`, signing a short-lived token with the router's secret; it holds
|
router's `/auth`, keeps their tokens in a server-side session in PostgreSQL behind an
|
||||||
no logic of the router's own. It reads its settings from `/etc/vpn-router-manager/manager.json`
|
`HttpOnly` cookie, and passes every other `/api/<path>` call to the router's `/<path>` with the
|
||||||
at each request; on the router the settings not given come from `vpn-router.json`, and it
|
session's access token, refreshing it as needed; it holds no logic of the router's own and no
|
||||||
listens on the internal address like the status web service, with the same discovery thread.
|
key of it. It reads its settings from `/etc/vpn-router-manager/manager.json` at each request; on
|
||||||
`vpn-router-setup` opens the manager's port on the internal interface while the manager is
|
the router the settings not given come from `vpn-router.json`, and it listens on the internal
|
||||||
installed, so the firewall stays in one place. The manager asks for no authentication.
|
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
|
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
|
Ubuntu archive. Tokens never reach the browser, and sessions survive a restart of the manager.
|
||||||
answer of the router. Anyone who reaches the manager's port manages the router, so on another
|
The router decides what a user may do; the manager only signs in. The application checks the
|
||||||
system it listens on `127.0.0.1` unless told otherwise. The router's firewall depends on whether
|
shape of every answer of the router. The router's firewall depends on whether the manager is
|
||||||
the manager is installed, and a changed manager port needs `vpn-router-setup` to run again.
|
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
|
## 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
|
Status: Accepted
|
||||||
|
|
||||||
Context: Users are to be enrolled for TOTP one-time passwords, with nothing checking the codes
|
Context: Users are enrolled for TOTP one-time passwords, and enrolled users must give a code
|
||||||
yet. Ubuntu's slapd ships the `otp` module, which holds the OATH-LDAP schema and an overlay that
|
whenever they log in, to OpenVPN and to `/auth`. Ubuntu's slapd ships the `otp` module, which
|
||||||
checks a code appended to the password at bind time. Ubuntu packages `python3-pyotp`. An
|
holds the OATH-LDAP schema and an overlay that checks a code appended to the password at bind
|
||||||
authenticator app is enrolled from a QR code of an `otpauth://` URI.
|
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
|
Decision: The `otp` module is loaded and its overlay is configured on the package's database,
|
||||||
token is the user entry itself: enrolment adds `oathTOTPUser` and `oathTOTPToken` to it, with
|
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
|
`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
|
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
|
(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
|
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.
|
router answers the URI; the client renders the QR code.
|
||||||
|
|
||||||
Consequences: Removing a user removes its token. Configuring the overlay later makes slapd
|
Consequences: Removing a user removes its token. One check covers OpenVPN and `/auth`, and
|
||||||
check the stored tokens without moving data. The secret passes through the client between
|
an enrolled user types the code after the password in the OpenVPN client. The secret passes
|
||||||
`otp_new` and `otp_enrol`, so the router keeps no pending state; enrolling again replaces the
|
through the client between `otp_new` and `otp_enrol`, so the router keeps no pending state;
|
||||||
secret only once a code of the new one verifies.
|
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.
|
||||||
@@ -125,7 +125,8 @@ must have the type shown below; an unknown section or setting is an error.
|
|||||||
| `docker` | `exposed_ports` | list | | TCP ports and port ranges (`8000:8100`) reachable from the external interface; at most 15, a range counting as two |
|
| `docker` | `exposed_ports` | list | | TCP ports and port ranges (`8000:8100`) reachable from the external interface; at most 15, a range counting as two |
|
||||||
| `web` | `enabled` | boolean | `true` | The status web service on the internal address |
|
| `web` | `enabled` | boolean | `true` | The status web service on the internal address |
|
||||||
| `web` | `port` | integer | `7081` | TCP port of the status web service |
|
| `web` | `port` | integer | `7081` | TCP port of the status web service |
|
||||||
| `web` | `jwt_secret` | string | | Secret, at least 32 characters, that signs the tokens for `/config`; without it `/config` is not served |
|
| `web` | `database` | string | | A PostgreSQL connection string for the refresh tokens; without it, the `vpn-router` database on this host that `vpn-router-setup` creates |
|
||||||
|
| `web` | `trusted_proxies` | list | the listening address | Addresses whose `X-Forwarded-For` header names the client for the `/auth` rate limit |
|
||||||
|
|
||||||
A minimal site-to-site configuration in `auto` mode:
|
A minimal site-to-site configuration in `auto` mode:
|
||||||
|
|
||||||
@@ -310,7 +311,7 @@ certificate names:
|
|||||||
- the certificate is checked against the CA and the CRL (`crl-verify`, read at every
|
- the certificate is checked against the CA and the CRL (`crl-verify`, read at every
|
||||||
connection);
|
connection);
|
||||||
- the password is checked by `openvpn-auth-ldap`, which binds to the user directory as the
|
- the password is checked by `openvpn-auth-ldap`, which binds to the user directory as the
|
||||||
user;
|
user; a user enrolled for TOTP types the password followed by the current 6-digit code;
|
||||||
- `/usr/lib/vpn-router/openvpn-same-user` refuses a login whose user name is not the common
|
- `/usr/lib/vpn-router/openvpn-same-user` refuses a login whose user name is not the common
|
||||||
name of the certificate.
|
name of the certificate.
|
||||||
|
|
||||||
@@ -331,11 +332,14 @@ is switched on:
|
|||||||
`/etc/vpn-router/ldap/user-manager.secret` (root only);
|
`/etc/vpn-router/ldap/user-manager.secret` (root only);
|
||||||
- passwords are stored as ARGON2 hashes, and a disabled user is locked with the password policy
|
- passwords are stored as ARGON2 hashes, and a disabled user is locked with the password policy
|
||||||
(`pwdAccountLockedTime`);
|
(`pwdAccountLockedTime`);
|
||||||
- slapd's `otp` module is loaded for the OATH-LDAP schema; a user enrolled for TOTP is also an
|
- the password policy locks an account for 15 minutes after 5 failed binds within 15 minutes,
|
||||||
|
whether they come from OpenVPN or from `/auth`; a successful bind clears the count, and
|
||||||
|
`vpn-router-user unlock` ends a lockout;
|
||||||
|
- slapd's `otp` overlay is configured on the database; a user enrolled for TOTP is also an
|
||||||
`oathTOTPUser` and `oathTOTPToken` whose `oathTOTPToken` names the entry itself, with the
|
`oathTOTPUser` and `oathTOTPToken` whose `oathTOTPToken` names the entry itself, with the
|
||||||
secret in `oathSecret`, which the user manager account may set and no one can read, and
|
secret in `oathSecret`, which the user manager account may set and no one can read, and
|
||||||
`oathTOTPParams` naming `cn=totp,ou=policies` (6 digits, HMAC-SHA1, 30 second steps, one step
|
`oathTOTPParams` naming `cn=totp,ou=policies` (6 digits, HMAC-SHA1, 30 second steps, one step
|
||||||
of tolerance). The module's overlay is not configured, so binds take the password alone;
|
of tolerance). A bind of an enrolled user takes the password followed by a current code;
|
||||||
- slapd listens on its local socket only (`SLAPD_SERVICES` in `/etc/default/slapd`).
|
- slapd listens on its local socket only (`SLAPD_SERVICES` in `/etc/default/slapd`).
|
||||||
|
|
||||||
The directory is kept on purge.
|
The directory is kept on purge.
|
||||||
@@ -361,6 +365,7 @@ vpn-router-user --json list
|
|||||||
echo 'n3w-pass' | vpn-router-user passwd alice
|
echo 'n3w-pass' | vpn-router-user passwd alice
|
||||||
vpn-router-user disable alice
|
vpn-router-user disable alice
|
||||||
vpn-router-user enable alice
|
vpn-router-user enable alice
|
||||||
|
vpn-router-user unlock alice
|
||||||
vpn-router-user remove alice
|
vpn-router-user remove alice
|
||||||
vpn-router-user otp-new alice
|
vpn-router-user otp-new alice
|
||||||
echo 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP' | vpn-router-user otp-enrol alice 123456
|
echo 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP' | vpn-router-user otp-enrol alice 123456
|
||||||
@@ -369,11 +374,12 @@ vpn-router-user otp-remove alice
|
|||||||
|
|
||||||
| Command | Effect |
|
| Command | Effect |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `list` | The users with their names and e-mail addresses, whether each is enabled, has a certificate and is enrolled for TOTP |
|
| `list` | The users with their names and e-mail addresses, whether each is enabled or locked, has a certificate and is enrolled for TOTP |
|
||||||
| `add <name> --first-name F --last-name L --email E` | Adds the user and issues the certificate; all three are required |
|
| `add <name> --first-name F --last-name L --email E` | Adds the user and issues the certificate; all three are required |
|
||||||
| `update <name> [--first-name F] [--last-name L] [--email E]` | Changes the names or the e-mail address |
|
| `update <name> [--first-name F] [--last-name L] [--email E]` | Changes the names or the e-mail address |
|
||||||
| `passwd <name>` | Sets the password |
|
| `passwd <name>` | Sets the password |
|
||||||
| `disable <name>`, `enable <name>` | Locks the user out of OpenVPN, and lets the user in again |
|
| `disable <name>`, `enable <name>` | Locks the user out of OpenVPN and `/auth`, and lets the user in again |
|
||||||
|
| `unlock <name>` | Ends a lockout after failed logins and clears the failure count; a disabled user stays disabled |
|
||||||
| `remove <name>` | Takes the user out of its groups, revokes the certificate, renews the CRL and removes the user; refused while the user is the last member of a group |
|
| `remove <name>` | Takes the user out of its groups, revokes the certificate, renews the CRL and removes the user; refused while the user is the last member of a group |
|
||||||
| `profile <name>` | The OpenVPN client profile, with the CA, the certificate and the key inline |
|
| `profile <name>` | The OpenVPN client profile, with the CA, the certificate and the key inline |
|
||||||
| `otp-new <name>` | A new TOTP secret and its `otpauth://` URI, issued by `wan.local_fqdn`, for an authenticator app; nothing is stored |
|
| `otp-new <name>` | A new TOTP secret and its `otpauth://` URI, issued by `wan.local_fqdn`, for an authenticator app; nothing is stored |
|
||||||
@@ -383,8 +389,8 @@ vpn-router-user otp-remove alice
|
|||||||
A user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter; a
|
A user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter; a
|
||||||
password has 8 to 128 characters; a first or last name has 1 to 64 printable characters; an
|
password has 8 to 128 characters; a first or last name has 1 to 64 printable characters; an
|
||||||
e-mail address has one `@`, no spaces and a dot in its domain; a TOTP secret is 32 base32
|
e-mail address has one `@`, no spaces and a dot in its domain; a TOTP secret is 32 base32
|
||||||
characters, as `otp-new` makes it, and a code 6 digits. Enrolment does not change how users log
|
characters, as `otp-new` makes it, and a code 6 digits. Once enrolled, a user logs in to OpenVPN
|
||||||
in. In the answers a user added
|
and `/auth` with the password followed by a current code. In the answers a user added
|
||||||
without the names or the e-mail address has them as `null`. The exit status is 0 on success, 2 for an invalid request,
|
without the names or the e-mail address has them as `null`. The exit status is 0 on success, 2 for an invalid request,
|
||||||
3 when the user does not exist, 4 when the user exists and 1 for any other failure.
|
3 when the user does not exist, 4 when the user exists and 1 for any other failure.
|
||||||
|
|
||||||
@@ -471,6 +477,7 @@ configuration: no key, identity, address or subnet.
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `/` | The status for people: plain text for `curl` and `wget`, a page styled with Tailwind CSS for a browser, in the browser's light or dark scheme, that reloads itself every minute (the style sheet is loaded from a CDN, so the browser needs internet access; the text is readable without it) |
|
| `/` | The status for people: plain text for `curl` and `wget`, a page styled with Tailwind CSS for a browser, in the browser's light or dark scheme, that reloads itself every minute (the style sheet is loaded from a CDN, so the browser needs internet access; the text is readable without it) |
|
||||||
| `/status` | The same status as JSON |
|
| `/status` | The same status as JSON |
|
||||||
|
| `/auth` | Signing in with a user's password and TOTP code, refreshing and signing out |
|
||||||
| `/config` | The configuration without its secret options, and what the router resolved and runs, as JSON; needs a token |
|
| `/config` | The configuration without its secret options, and what the router resolved and runs, as JSON; needs a token |
|
||||||
| `/users` | The users of OpenVPN and road-warrior access; needs a token |
|
| `/users` | The users of OpenVPN and road-warrior access; needs a token |
|
||||||
| `/groups` | The groups of users; needs a token |
|
| `/groups` | The groups of users; needs a token |
|
||||||
@@ -490,43 +497,70 @@ curl http://10.0.0.4:7081/
|
|||||||
curl http://10.0.0.4:7081/status
|
curl http://10.0.0.4:7081/status
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Tokens and signing in
|
||||||
|
|
||||||
|
`/config`, `/users` and `/groups` need a JWT in an `Authorization: Bearer` header, signed with
|
||||||
|
HS256 by the key `vpn-router-setup` makes on the router, `/etc/vpn-router/web.key` (root only).
|
||||||
|
The key never leaves the router; while it does not exist these paths and `/auth` are not served
|
||||||
|
(404). A missing, expired or wrongly signed token, or one of another algorithm, gets 401 with
|
||||||
|
`WWW-Authenticate: Bearer`. There are two kinds of tokens:
|
||||||
|
|
||||||
|
- an access token of `/auth` names a user in `sub` and is valid for 5 minutes; the user must
|
||||||
|
be a member of the group `admins`, otherwise the request gets 403;
|
||||||
|
- a token of `vpn-router-token`, run as root on the router, has the claim `admin` and is valid
|
||||||
|
for the lifetime given, without a maximum. It may call everything, and only such a token may
|
||||||
|
change the group `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 of `admins` is added on the router:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vpn-router-group add admins --member alice --description 'Router administrators'
|
||||||
|
vpn-router-token --hours 1
|
||||||
|
vpn-router-token --json --days 30
|
||||||
|
```
|
||||||
|
|
||||||
|
`POST /auth` signs a user in. Request and answer bodies are JSON:
|
||||||
|
|
||||||
|
| Request | Body | Answer |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `POST /auth` | `{"name", "password", "code"}`; `code` only for a user enrolled for TOTP | the tokens |
|
||||||
|
| `POST /auth/refresh` | `{"refresh_token"}` | new tokens; the refresh token given is used up |
|
||||||
|
| `POST /auth/logout` | `{"refresh_token"}` | `{"revoked": true}`, or `false` for a token not known |
|
||||||
|
|
||||||
|
The tokens are `{"access_token", "token_type": "Bearer", "expires_in": 300, "refresh_token",
|
||||||
|
"refresh_expires_in": 28800}`. A wrong name, password or code, and a locked or disabled user,
|
||||||
|
get 401 with the same message; a refresh token is refused once its user is disabled, locked or
|
||||||
|
removed. Changing a user's password, disabling, unlocking or removing the user revokes the
|
||||||
|
user's refresh tokens. Five failed logins lock the account for 15 minutes (see the user
|
||||||
|
directory). `/auth` and `/auth/refresh` take at most 10 calls a minute from one client address,
|
||||||
|
and answer 429 with `Retry-After` beyond that; for a call from one of `web.trusted_proxies`, by
|
||||||
|
default the address the service listens on, the client address is the last entry of
|
||||||
|
`X-Forwarded-For`.
|
||||||
|
|
||||||
|
Refresh tokens are kept, as their SHA-256, in PostgreSQL: by default in the database and role
|
||||||
|
`vpn-router` on the router, which `vpn-router-setup` creates with a password in
|
||||||
|
`/etc/vpn-router/database.secret` (root only), reached over TCP on `localhost`; `web.database`
|
||||||
|
names another database. Tokens and passwords travel over plain HTTP on the internal network.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H 'Content-Type: application/json' -d '{"name": "alice", "password": "s3cr3t-pass", "code": "123456"}' \
|
||||||
|
http://10.0.0.4:7081/auth
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:7081/config
|
||||||
|
```
|
||||||
|
|
||||||
### The /config page
|
### The /config page
|
||||||
|
|
||||||
`/config` shows what `/` and `/status` leave out, for a client such as manage-ui:
|
`/config` shows what `/` and `/status` leave out, for a client such as manage-ui:
|
||||||
|
|
||||||
| Key | Content |
|
| Key | Content |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `configuration` | `vpn-router.json` with the defaults applied, without its secret options (`psk`, `jwt_secret`, and any option named like a password, token or key) |
|
| `configuration` | `vpn-router.json` with the defaults applied, without its secret options (`psk`, `database`, and any option named like a secret, password, token or key) |
|
||||||
| `network` | The external and internal interface, their addresses, the internal gateway, the local and remote IKE identities and subnets, and the IPv4 routes as `ip -j -4 route` prints them |
|
| `network` | The external and internal interface, their addresses, the internal gateway, the local and remote IKE identities and subnets, and the IPv4 routes as `ip -j -4 route` prints them |
|
||||||
| `ipsec` | `connections` and `sas`: one object per connection and per IKE SA, read from `swanctl --list-conns --pretty` and `swanctl --list-sas --pretty`, with the connection's name under `name` and swanctl's keys and string values |
|
| `ipsec` | `connections` and `sas`: one object per connection and per IKE SA, read from `swanctl --list-conns --pretty` and `swanctl --list-sas --pretty`, with the connection's name under `name` and swanctl's keys and string values |
|
||||||
| `wireguard` | The public key and port of `wg0`, and each peer's public key, endpoint, allowed IPs, handshake age and traffic; no private or preshared key |
|
| `wireguard` | The public key and port of `wg0`, and each peer's public key, endpoint, allowed IPs, handshake age and traffic; no private or preshared key |
|
||||||
| `docker` | `docker.exposed_ports` and the rules of the `VPN-ROUTER-DOCKER` chain |
|
| `docker` | `docker.exposed_ports` and the rules of the `VPN-ROUTER-DOCKER` chain |
|
||||||
|
|
||||||
The page needs a JWT in an `Authorization: Bearer` header, signed with HS256 by `web.jwt_secret`.
|
|
||||||
The token must have an `exp` claim, not more than ten minutes from now (a minute of clock
|
|
||||||
difference is allowed); `nbf` is checked when present. Other algorithms are refused, with 401 and
|
|
||||||
`WWW-Authenticate: Bearer`. While `web.jwt_secret` is not set the page is not served (404). The
|
|
||||||
token and the page travel over plain HTTP on the internal network, and the token can be used
|
|
||||||
until it expires, so keep the lifetime short. A client makes a token from the secret with only
|
|
||||||
HMAC-SHA256; with Python:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import base64, hashlib, hmac, json, time
|
|
||||||
|
|
||||||
def b64(data):
|
|
||||||
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
|
|
||||||
|
|
||||||
secret = 'the web.jwt_secret value'
|
|
||||||
head = b64(json.dumps({'alg': 'HS256', 'typ': 'JWT'}).encode())
|
|
||||||
body = b64(json.dumps({'exp': int(time.time()) + 300}).encode())
|
|
||||||
sign = b64(hmac.new(secret.encode(), f'{head}.{body}'.encode(), hashlib.sha256).digest())
|
|
||||||
print(f'{head}.{body}.{sign}')
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:7081/config
|
|
||||||
```
|
|
||||||
|
|
||||||
### The /users API
|
### The /users API
|
||||||
|
|
||||||
`/users` manages users with the same token as `/config`, with the module `vpn-router-user` uses
|
`/users` manages users with the same token as `/config`, with the module `vpn-router-user` uses
|
||||||
@@ -535,12 +569,13 @@ and answer bodies are JSON; a request body is at most 4096 bytes.
|
|||||||
|
|
||||||
| Request | Body | Answer |
|
| Request | Body | Answer |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `GET /users` | | `{"users": [{"name", "enabled", "certificate", "otp", "first_name", "last_name", "email"}, ...]}` |
|
| `GET /users` | | `{"users": [{"name", "enabled", "locked", "certificate", "otp", "first_name", "last_name", "email"}, ...]}` |
|
||||||
| `POST /users` | `{"name", "password", "first_name", "last_name", "email"}`, all required | 201 and the user |
|
| `POST /users` | `{"name", "password", "first_name", "last_name", "email"}`, all required | 201 and the user |
|
||||||
| `PUT /users/<name>` | any of `{"first_name", "last_name", "email"}` | the user |
|
| `PUT /users/<name>` | any of `{"first_name", "last_name", "email"}` | the user |
|
||||||
| `PUT /users/<name>/password` | `{"password": ...}` | the user |
|
| `PUT /users/<name>/password` | `{"password": ...}` | the user |
|
||||||
| `POST /users/<name>/disable` | | the user |
|
| `POST /users/<name>/disable` | | the user |
|
||||||
| `POST /users/<name>/enable` | | the user |
|
| `POST /users/<name>/enable` | | the user |
|
||||||
|
| `POST /users/<name>/unlock` | | the user |
|
||||||
| `DELETE /users/<name>` | | `{"name", "removed": true}` |
|
| `DELETE /users/<name>` | | `{"name", "removed": true}` |
|
||||||
| `GET /users/<name>/profile` | | `{"name", "profile"}`, the OpenVPN client profile |
|
| `GET /users/<name>/profile` | | `{"name", "profile"}`, the OpenVPN client profile |
|
||||||
| `POST /users/<name>/otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored |
|
| `POST /users/<name>/otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored |
|
||||||
@@ -580,7 +615,7 @@ The service starts on its own, without waiting for `vpn-router-setup`, and keeps
|
|||||||
30 seconds it works out the internal address from `vpn-router.json` as `general.mode` directs,
|
30 seconds it works out the internal address from `vpn-router.json` as `general.mode` directs,
|
||||||
and reads `web.port` and `web.enabled`; it listens there, moves when the address or the port
|
and reads `web.port` and `web.enabled`; it listens there, moves when the address or the port
|
||||||
changes, and stops listening while `web.enabled` is `false` or the address is not known. Each
|
changes, and stops listening while `web.enabled` is `false` or the address is not known. Each
|
||||||
request reads the token secret from `vpn-router.json`, and the features and the facts for
|
request reads the signing key and `vpn-router.json`, and the features and the facts for
|
||||||
`/config` from `/run/vpn-router/web.json` (readable by root only), which `vpn-router-setup` writes;
|
`/config` from `/run/vpn-router/web.json` (readable by root only), which `vpn-router-setup` writes;
|
||||||
before that file exists the page shows no features. `vpn-router-setup` opens `web.port` on the
|
before that file exists the page shows no features. `vpn-router-setup` opens `web.port` on the
|
||||||
internal interface in the firewall.
|
internal interface in the firewall.
|
||||||
@@ -603,8 +638,9 @@ may be in use by something else.
|
|||||||
|
|
||||||
`apt-get purge` additionally removes the generated files and the firewall rules, restoring
|
`apt-get purge` additionally removes the generated files and the firewall rules, restoring
|
||||||
`/etc/ufw/before.rules` to its original content, and hands `vpn-router.json` back to `ucf`
|
`/etc/ufw/before.rules` to its original content, and hands `vpn-router.json` back to `ucf`
|
||||||
before deleting it so a later reinstall starts clean. Key material and users are never
|
before deleting it so a later reinstall starts clean. It also removes the web service's
|
||||||
deleted: `/etc/vpn-router/pki`, `/etc/vpn-router/ldap`, the user directory in slapd, the
|
signing key, and the refresh tokens' database, its role and their password file when the package
|
||||||
|
created them. Key material and users are never deleted: `/etc/vpn-router/pki`, `/etc/vpn-router/ldap`, the user directory in slapd, the
|
||||||
WireGuard key pair and everything under `/etc/swanctl` are left alone.
|
WireGuard key pair and everything under `/etc/swanctl` are left alone.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|||||||
@@ -1,3 +1,29 @@
|
|||||||
|
vpn-router (0.7.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
feature: users sign in to the web service, and only members of admins manage the router.
|
||||||
|
|
||||||
|
* POST /auth signs a user in with the password, and the TOTP code for an
|
||||||
|
enrolled user, and answers a 5 minute access token and an 8 hour refresh
|
||||||
|
token; /auth/refresh renews them and /auth/logout revokes the refresh
|
||||||
|
token.
|
||||||
|
* /config, /users and /groups take tokens of users in the group admins,
|
||||||
|
and tokens of the new vpn-router-token, which has an admin claim and a
|
||||||
|
chosen lifetime. Only these may change the group admins.
|
||||||
|
* The tokens are signed with /etc/vpn-router/web.key, which
|
||||||
|
vpn-router-setup makes on the router; web.jwt_secret is ignored.
|
||||||
|
* Five failed logins lock an account for 15 minutes, for OpenVPN and
|
||||||
|
/auth. vpn-router-user unlock and POST /users/<name>/unlock end the
|
||||||
|
lockout, and the users show locked.
|
||||||
|
* /auth takes at most 10 calls a minute per client address; for calls from
|
||||||
|
web.trusted_proxies the address is read from X-Forwarded-For.
|
||||||
|
* Users enrolled for TOTP log in to OpenVPN with the password followed by
|
||||||
|
the code.
|
||||||
|
* Refresh tokens are kept in PostgreSQL, in the database web.database
|
||||||
|
names or in the vpn-router database vpn-router-setup creates. The
|
||||||
|
package depends on postgresql and python3-psycopg.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 01:00:00 +0200
|
||||||
|
|
||||||
vpn-router (0.6.0-1) unstable; urgency=medium
|
vpn-router (0.6.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: users can be enrolled for TOTP one-time passwords.
|
feature: users can be enrolled for TOTP one-time passwords.
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ Depends: ${misc:Depends},
|
|||||||
python3-jinja2,
|
python3-jinja2,
|
||||||
python3-ldap,
|
python3-ldap,
|
||||||
python3-pyotp,
|
python3-pyotp,
|
||||||
|
python3-psycopg,
|
||||||
|
postgresql,
|
||||||
slapd,
|
slapd,
|
||||||
openvpn,
|
openvpn,
|
||||||
openvpn-auth-ldap,
|
openvpn-auth-ldap,
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ src/usr/lib/vpn-router/vpnrouter.py usr/lib/vpn-router/
|
|||||||
src/usr/lib/vpn-router/vpnrouter_ldap.py usr/lib/vpn-router/
|
src/usr/lib/vpn-router/vpnrouter_ldap.py usr/lib/vpn-router/
|
||||||
src/usr/lib/vpn-router/vpnrouter_net.py usr/lib/vpn-router/
|
src/usr/lib/vpn-router/vpnrouter_net.py usr/lib/vpn-router/
|
||||||
src/usr/lib/vpn-router/vpnrouter_users.py usr/lib/vpn-router/
|
src/usr/lib/vpn-router/vpnrouter_users.py usr/lib/vpn-router/
|
||||||
|
src/usr/lib/vpn-router/vpnrouter_auth.py usr/lib/vpn-router/
|
||||||
src/usr/lib/vpn-router/openvpn-same-user usr/lib/vpn-router/
|
src/usr/lib/vpn-router/openvpn-same-user usr/lib/vpn-router/
|
||||||
src/usr/sbin/vpn-router-user usr/sbin/
|
src/usr/sbin/vpn-router-user usr/sbin/
|
||||||
src/usr/sbin/vpn-router-group usr/sbin/
|
src/usr/sbin/vpn-router-group usr/sbin/
|
||||||
|
src/usr/sbin/vpn-router-token usr/sbin/
|
||||||
src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/
|
src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/
|
||||||
src/usr/lib/vpn-router/configure usr/lib/vpn-router/
|
src/usr/lib/vpn-router/configure usr/lib/vpn-router/
|
||||||
src/usr/lib/vpn-router/docker-rules usr/lib/vpn-router/
|
src/usr/lib/vpn-router/docker-rules usr/lib/vpn-router/
|
||||||
|
|||||||
@@ -49,7 +49,15 @@ case "$1" in
|
|||||||
/etc/wireguard/wg0.conf \
|
/etc/wireguard/wg0.conf \
|
||||||
/etc/openvpn/server/vpn-router.conf \
|
/etc/openvpn/server/vpn-router.conf \
|
||||||
/etc/openvpn/server/vpn-router-ldap.conf \
|
/etc/openvpn/server/vpn-router-ldap.conf \
|
||||||
/etc/vpn-router/vpn-router.json
|
/etc/vpn-router/vpn-router.json \
|
||||||
|
/etc/vpn-router/web.key
|
||||||
|
|
||||||
|
# The refresh tokens' database and role, when the package created them.
|
||||||
|
if [ -f /etc/vpn-router/database.secret ] && command -v psql >/dev/null 2>&1; then
|
||||||
|
runuser -u postgres -- psql -X -q -c 'DROP DATABASE IF EXISTS "vpn-router"' || true
|
||||||
|
runuser -u postgres -- psql -X -q -c 'DROP ROLE IF EXISTS "vpn-router"' || true
|
||||||
|
fi
|
||||||
|
rm -f /etc/vpn-router/database.secret
|
||||||
|
|
||||||
# Key material and users are not package state: /etc/vpn-router/pki,
|
# Key material and users are not package state: /etc/vpn-router/pki,
|
||||||
# /etc/vpn-router/ldap, the user directory in slapd, the WireGuard key
|
# /etc/vpn-router/ldap, the user directory in slapd, the WireGuard key
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=VPN Router Setup
|
Description=VPN Router Setup
|
||||||
Documentation=file:/usr/share/doc/vpn-router/README.md
|
Documentation=file:/usr/share/doc/vpn-router/README.md
|
||||||
Wants=network-online.target
|
Wants=network-online.target postgresql.service
|
||||||
After=network-online.target docker.service
|
After=network-online.target docker.service postgresql.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=VPN Router status web service
|
Description=VPN Router status web service
|
||||||
Documentation=file:/usr/share/doc/vpn-router/README.md
|
Documentation=file:/usr/share/doc/vpn-router/README.md
|
||||||
After=network-online.target
|
After=network-online.target postgresql.service
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
|
|||||||
import ldap
|
import ldap
|
||||||
|
|
||||||
import vpnrouter
|
import vpnrouter
|
||||||
|
import vpnrouter_auth
|
||||||
import vpnrouter_ldap
|
import vpnrouter_ldap
|
||||||
from vpnrouter import fail
|
from vpnrouter import fail
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ MANAGER_PORT = 7080
|
|||||||
FILTER_ANCHOR = '# End required lines'
|
FILTER_ANCHOR = '# End required lines'
|
||||||
|
|
||||||
#: Configuration options left out wherever the configuration is shown.
|
#: Configuration options left out wherever the configuration is shown.
|
||||||
SECRET_OPTION = re.compile(r'(^psk$)|secret|password|token|_key$')
|
SECRET_OPTION = re.compile(r'(^psk$)|secret|password|token|_key$|^database$')
|
||||||
|
|
||||||
BLOCKS = (
|
BLOCKS = (
|
||||||
('# IPSEC RULES START', '# IPSEC RULES END'),
|
('# IPSEC RULES START', '# IPSEC RULES END'),
|
||||||
@@ -357,6 +358,15 @@ def manage_directory():
|
|||||||
fail(f'the user directory cannot be provisioned: {exc}')
|
fail(f'the user directory cannot be provisioned: {exc}')
|
||||||
|
|
||||||
|
|
||||||
|
def manage_tokens(cfg):
|
||||||
|
"""Make the web service's signing key, and the database of its refresh tokens unless web.database names one."""
|
||||||
|
vpnrouter_auth.ensure_key()
|
||||||
|
try:
|
||||||
|
vpnrouter_auth.provision(cfg)
|
||||||
|
except OSError as exc:
|
||||||
|
fail(f'the token database cannot be provisioned: {exc}')
|
||||||
|
|
||||||
|
|
||||||
def manage_openvpn(ctx, changed):
|
def manage_openvpn(ctx, changed):
|
||||||
"""Run the OpenVPN server when it is configured, and stop it when it is not."""
|
"""Run the OpenVPN server when it is configured, and stop it when it is not."""
|
||||||
unit = 'openvpn-server@vpn-router'
|
unit = 'openvpn-server@vpn-router'
|
||||||
@@ -464,6 +474,7 @@ def main():
|
|||||||
run(['ufw', 'reload'], check=False)
|
run(['ufw', 'reload'], check=False)
|
||||||
|
|
||||||
manage_directory()
|
manage_directory()
|
||||||
|
manage_tokens(cfg)
|
||||||
manage_strongswan(ctx, changed)
|
manage_strongswan(ctx, changed)
|
||||||
manage_wireguard(ctx, changed)
|
manage_wireguard(ctx, changed)
|
||||||
manage_openvpn(ctx, changed)
|
manage_openvpn(ctx, changed)
|
||||||
|
|||||||
@@ -54,12 +54,10 @@ SCHEMA = {
|
|||||||
'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
|
'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
|
||||||
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
|
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
|
||||||
'docker': {'mode': 'auto', 'exposed_ports': []},
|
'docker': {'mode': 'auto', 'exposed_ports': []},
|
||||||
'web': {'enabled': True, 'port': 7081, 'jwt_secret': ''},
|
# web.jwt_secret is read and ignored; the tokens are signed with vpnrouter_auth.WEB_KEY.
|
||||||
|
'web': {'enabled': True, 'port': 7081, 'jwt_secret': '', 'database': '', 'trusted_proxies': []},
|
||||||
}
|
}
|
||||||
|
|
||||||
#: The shortest secret that signs the tokens of the status web service.
|
|
||||||
WEB_SECRET_MIN = 32
|
|
||||||
|
|
||||||
DOCKER_MODES = ('auto', 'on', 'off')
|
DOCKER_MODES = ('auto', 'on', 'off')
|
||||||
|
|
||||||
OPENVPN_PROTOCOLS = ('udp', 'tcp')
|
OPENVPN_PROTOCOLS = ('udp', 'tcp')
|
||||||
@@ -262,9 +260,8 @@ def validate(cfg):
|
|||||||
if isinstance(web_port, bool) or not isinstance(web_port, int) or not 1 <= web_port <= 65535:
|
if isinstance(web_port, bool) or not isinstance(web_port, int) or not 1 <= web_port <= 65535:
|
||||||
raise ConfigError(f'web.port: {web_port!r} is not a valid port')
|
raise ConfigError(f'web.port: {web_port!r} is not a valid port')
|
||||||
|
|
||||||
web_secret = _get(cfg, 'web', 'jwt_secret')
|
for item in _get_list(cfg, 'web', 'trusted_proxies'):
|
||||||
if web_secret and len(web_secret) < WEB_SECRET_MIN:
|
_check_address(item, 'web.trusted_proxies')
|
||||||
raise ConfigError(f'web.jwt_secret: use at least {WEB_SECRET_MIN} characters')
|
|
||||||
|
|
||||||
docker_mode = _get(cfg, 'docker', 'mode')
|
docker_mode = _get(cfg, 'docker', 'mode')
|
||||||
if docker_mode not in DOCKER_MODES:
|
if docker_mode not in DOCKER_MODES:
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""The tokens of the router's web service, for the web service and vpn-router-token.
|
||||||
|
|
||||||
|
Tokens are HS256 JWTs signed with WEB_KEY, a key vpn-router-setup makes on the router and that
|
||||||
|
never leaves it. /auth issues an access token naming the user in sub, valid ACCESS_SECONDS, and
|
||||||
|
a refresh token valid REFRESH_SECONDS; vpn-router-token issues tokens with the admin claim and
|
||||||
|
any lifetime. A token without the admin claim must expire within MAX_LIFETIME seconds.
|
||||||
|
|
||||||
|
Refresh tokens are random strings kept in PostgreSQL as their SHA-256, in the database of
|
||||||
|
web.database, by default the vpn-router database on this host that provision() creates, with
|
||||||
|
the password in DATABASE_SECRET. A refresh token is used once: refreshing replaces it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import secrets
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from psycopg.conninfo import make_conninfo
|
||||||
|
|
||||||
|
WEB_KEY = pathlib.Path('/etc/vpn-router/web.key')
|
||||||
|
DATABASE_SECRET = pathlib.Path('/etc/vpn-router/database.secret')
|
||||||
|
|
||||||
|
#: The role and the database provision() creates when web.database is not set.
|
||||||
|
DATABASE = 'vpn-router'
|
||||||
|
|
||||||
|
#: Seconds an access token of /auth and a refresh token are valid.
|
||||||
|
ACCESS_SECONDS = 300
|
||||||
|
REFRESH_SECONDS = 8 * 3600
|
||||||
|
|
||||||
|
#: The longest a token without the admin claim may be valid, from now, in seconds.
|
||||||
|
MAX_LIFETIME = 600
|
||||||
|
|
||||||
|
#: Seconds of clock difference accepted between a token and this router.
|
||||||
|
LEEWAY = 60
|
||||||
|
|
||||||
|
SCHEMA = 'CREATE TABLE IF NOT EXISTS refresh_tokens (hash text PRIMARY KEY, name text NOT NULL, expires timestamptz NOT NULL)'
|
||||||
|
|
||||||
|
|
||||||
|
def key():
|
||||||
|
"""The signing key, or '' while vpn-router-setup has not made it."""
|
||||||
|
try:
|
||||||
|
return WEB_KEY.read_text(encoding='utf-8').strip()
|
||||||
|
except OSError:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_key():
|
||||||
|
"""Make the signing key when it is missing."""
|
||||||
|
if not key():
|
||||||
|
WEB_KEY.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
WEB_KEY.touch(mode=0o600)
|
||||||
|
WEB_KEY.chmod(0o600)
|
||||||
|
WEB_KEY.write_text(secrets.token_urlsafe(36) + '\n', encoding='utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def b64encode(data):
|
||||||
|
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
|
||||||
|
|
||||||
|
|
||||||
|
def b64decode(part):
|
||||||
|
return base64.urlsafe_b64decode(part + '=' * (-len(part) % 4))
|
||||||
|
|
||||||
|
|
||||||
|
def sign(claims, secret):
|
||||||
|
head = b64encode(json.dumps({'alg': 'HS256', 'typ': 'JWT'}).encode())
|
||||||
|
body = b64encode(json.dumps(claims).encode())
|
||||||
|
signature = hmac.new(secret.encode(), f'{head}.{body}'.encode(), hashlib.sha256).digest()
|
||||||
|
return f'{head}.{body}.{b64encode(signature)}'
|
||||||
|
|
||||||
|
|
||||||
|
def verify(token, secret):
|
||||||
|
"""The claims of a token signed with HS256 by the secret, or None when it is not acceptable.
|
||||||
|
|
||||||
|
Only HS256 is accepted, so a token cannot choose a weaker algorithm. The token must expire
|
||||||
|
(exp); without the admin claim not later than MAX_LIFETIME seconds from now, so that a stolen
|
||||||
|
token is of little use. LEEWAY seconds of clock difference are allowed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
header, payload, signature = token.split('.')
|
||||||
|
if json.loads(b64decode(header)).get('alg') != 'HS256':
|
||||||
|
return None
|
||||||
|
expected = hmac.new(secret.encode(), f'{header}.{payload}'.encode(), hashlib.sha256).digest()
|
||||||
|
if not hmac.compare_digest(expected, b64decode(signature)):
|
||||||
|
return None
|
||||||
|
claims = json.loads(b64decode(payload))
|
||||||
|
now = time.time()
|
||||||
|
expires = claims['exp']
|
||||||
|
if isinstance(expires, bool) or not isinstance(expires, (int, float)) or expires < now - LEEWAY:
|
||||||
|
return None
|
||||||
|
if claims.get('admin') is not True and expires > now + MAX_LIFETIME + LEEWAY:
|
||||||
|
return None
|
||||||
|
not_before = claims.get('nbf')
|
||||||
|
if not_before is not None and (isinstance(not_before, bool) or not isinstance(not_before, (int, float)) or not_before > now + LEEWAY):
|
||||||
|
return None
|
||||||
|
except (ValueError, KeyError, TypeError, AttributeError):
|
||||||
|
return None
|
||||||
|
return claims
|
||||||
|
|
||||||
|
|
||||||
|
def admin_token(seconds):
|
||||||
|
"""A token with the admin claim, valid for the seconds given."""
|
||||||
|
secret = key()
|
||||||
|
if not secret:
|
||||||
|
raise OSError(f'{WEB_KEY} does not exist; vpn-router-setup makes it once a platform is chosen')
|
||||||
|
now = int(time.time())
|
||||||
|
return {'token': sign({'admin': True, 'iat': now, 'exp': now + seconds}, secret), 'expires': now + seconds}
|
||||||
|
|
||||||
|
|
||||||
|
def connection_string(cfg):
|
||||||
|
"""web.database, or the database provision() creates."""
|
||||||
|
configured = cfg['web']['database'].strip()
|
||||||
|
if configured:
|
||||||
|
return configured
|
||||||
|
password = DATABASE_SECRET.read_text(encoding='utf-8').strip()
|
||||||
|
return make_conninfo(host='localhost', dbname=DATABASE, user=DATABASE, password=password)
|
||||||
|
|
||||||
|
|
||||||
|
def connect(cfg):
|
||||||
|
"""A connection to the database of the refresh tokens, with their table."""
|
||||||
|
connection = psycopg.connect(connection_string(cfg), autocommit=True)
|
||||||
|
connection.execute(SCHEMA)
|
||||||
|
return connection
|
||||||
|
|
||||||
|
|
||||||
|
def digest(token):
|
||||||
|
return hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def issue(cfg, name):
|
||||||
|
"""An access token and a refresh token for the user, as /auth answers them."""
|
||||||
|
secret = key()
|
||||||
|
now = int(time.time())
|
||||||
|
refresh = secrets.token_urlsafe(32)
|
||||||
|
with connect(cfg) as connection:
|
||||||
|
connection.execute('DELETE FROM refresh_tokens WHERE expires < now()')
|
||||||
|
connection.execute("INSERT INTO refresh_tokens VALUES (%s, %s, now() + make_interval(secs => %s))",
|
||||||
|
(digest(refresh), name, REFRESH_SECONDS))
|
||||||
|
return {
|
||||||
|
'access_token': sign({'sub': name, 'iat': now, 'exp': now + ACCESS_SECONDS}, secret),
|
||||||
|
'token_type': 'Bearer',
|
||||||
|
'expires_in': ACCESS_SECONDS,
|
||||||
|
'refresh_token': refresh,
|
||||||
|
'refresh_expires_in': REFRESH_SECONDS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def take(cfg, token):
|
||||||
|
"""Remove an unexpired refresh token and return the user it was issued to, or None."""
|
||||||
|
if not isinstance(token, str) or not token:
|
||||||
|
return None
|
||||||
|
with connect(cfg) as connection:
|
||||||
|
row = connection.execute('DELETE FROM refresh_tokens WHERE hash = %s RETURNING name, expires > now()',
|
||||||
|
(digest(token),)).fetchone()
|
||||||
|
return row[0] if row and row[1] else None
|
||||||
|
|
||||||
|
|
||||||
|
def revoke(cfg, name):
|
||||||
|
"""Remove all refresh tokens of the user."""
|
||||||
|
with connect(cfg) as connection:
|
||||||
|
connection.execute('DELETE FROM refresh_tokens WHERE name = %s', (name,))
|
||||||
|
|
||||||
|
|
||||||
|
def psql(sql, name):
|
||||||
|
"""The output of an SQL statement, read from standard input, run by psql as postgres with name as the variable v1."""
|
||||||
|
args = ['runuser', '-u', 'postgres', '--', 'psql', '-X', '-q', '-t', '-A', '-v', 'ON_ERROR_STOP=1', '-v', f'v1={name}']
|
||||||
|
result = subprocess.run(args, input=sql, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise OSError(f'psql failed: {result.stderr.strip()}')
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def provision(cfg):
|
||||||
|
"""Create the role, its password file and the database when web.database is not set and they are missing.
|
||||||
|
|
||||||
|
The password, of URL-safe characters, reaches psql on standard input, not on its command line.
|
||||||
|
"""
|
||||||
|
if cfg['web']['database'].strip():
|
||||||
|
return
|
||||||
|
if not psql("SELECT 1 FROM pg_roles WHERE rolname = :'v1'", DATABASE):
|
||||||
|
DATABASE_SECRET.unlink(missing_ok=True)
|
||||||
|
psql("CREATE ROLE :\"v1\" LOGIN", DATABASE)
|
||||||
|
if not DATABASE_SECRET.is_file():
|
||||||
|
password = secrets.token_urlsafe(32)
|
||||||
|
psql(f"ALTER ROLE :\"v1\" PASSWORD '{password}'", DATABASE)
|
||||||
|
DATABASE_SECRET.touch(mode=0o600)
|
||||||
|
DATABASE_SECRET.chmod(0o600)
|
||||||
|
DATABASE_SECRET.write_text(password + '\n', encoding='utf-8')
|
||||||
|
if not psql("SELECT 1 FROM pg_database WHERE datname = :'v1'", DATABASE):
|
||||||
|
psql("CREATE DATABASE :\"v1\" OWNER :\"v1\"", DATABASE)
|
||||||
@@ -7,9 +7,10 @@ administrator password. Users and groups are changed by the user-manager service
|
|||||||
which the ACLs allow to write ou=users and ou=groups and to set, never read, passwords and
|
which the ACLs allow to write ou=users and ou=groups and to set, never read, passwords and
|
||||||
TOTP secrets.
|
TOTP secrets.
|
||||||
|
|
||||||
slapd's otp module is loaded for the OATH-LDAP schema; its overlay is not configured on the
|
The password policy locks an account for LOCKOUT_SECONDS after LOCKOUT_FAILURES failed binds;
|
||||||
database, so binds do not check one-time passwords. cn=totp,ou=policies holds the TOTP
|
a successful bind clears the count. slapd's otp overlay is configured on the database, so a
|
||||||
parameters the token of every user points to.
|
bind of a user with a TOTP token takes the password followed by a current code.
|
||||||
|
cn=totp,ou=policies holds the TOTP parameters the token of every user points to.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -42,6 +43,13 @@ SLAPD_SERVICES = 'SLAPD_SERVICES="ldapi:///"'
|
|||||||
MODULES = ('argon2', 'ppolicy', 'otp')
|
MODULES = ('argon2', 'ppolicy', 'otp')
|
||||||
PASSWORD_HASH = '{ARGON2}'
|
PASSWORD_HASH = '{ARGON2}'
|
||||||
|
|
||||||
|
#: Failed binds that lock an account, within LOCKOUT_SECONDS, and the seconds it stays locked.
|
||||||
|
LOCKOUT_FAILURES = 5
|
||||||
|
LOCKOUT_SECONDS = 900
|
||||||
|
|
||||||
|
#: The overlays of the database, in the order they are configured.
|
||||||
|
OVERLAYS = ('ppolicy', 'otp')
|
||||||
|
|
||||||
#: The TOTP parameters: digits, the OID of HMAC-SHA1, seconds per time step, and the time steps accepted before and after now.
|
#: The TOTP parameters: digits, the OID of HMAC-SHA1, seconds per time step, and the time steps accepted before and after now.
|
||||||
TOTP_DIGITS = 6
|
TOTP_DIGITS = 6
|
||||||
TOTP_ALGORITHM = '1.2.840.113549.2.7'
|
TOTP_ALGORITHM = '1.2.840.113549.2.7'
|
||||||
@@ -131,13 +139,18 @@ def _ensure_database(connection):
|
|||||||
return dn
|
return dn
|
||||||
|
|
||||||
|
|
||||||
def _ensure_overlay(connection, database):
|
def _ensure_overlays(connection, database):
|
||||||
if not connection.search_s(database, ldap.SCOPE_ONELEVEL, '(objectClass=olcPPolicyConfig)', ['1.1']):
|
"""Add the overlays of OVERLAYS the database lacks; the otp overlay has no settings of its own."""
|
||||||
connection.add_s(f'olcOverlay=ppolicy,{database}', ldap.modlist.addModlist({
|
found = connection.search_s(database, ldap.SCOPE_ONELEVEL, '(objectClass=olcOverlayConfig)', ['olcOverlay'])
|
||||||
'objectClass': [b'olcOverlayConfig', b'olcPPolicyConfig'],
|
present = {ORDER.sub(b'', attributes['olcOverlay'][0]).decode() for _, attributes in found}
|
||||||
'olcOverlay': [b'ppolicy'],
|
for name in OVERLAYS:
|
||||||
'olcPPolicyDefault': [DEFAULT_POLICY.encode()],
|
if name in present:
|
||||||
}))
|
continue
|
||||||
|
record = {'objectClass': [b'olcOverlayConfig'], 'olcOverlay': [name.encode()]}
|
||||||
|
if name == 'ppolicy':
|
||||||
|
record = {'objectClass': [b'olcOverlayConfig', b'olcPPolicyConfig'], 'olcOverlay': [b'ppolicy'],
|
||||||
|
'olcPPolicyDefault': [DEFAULT_POLICY.encode()]}
|
||||||
|
connection.add_s(f'olcOverlay={name},{database}', ldap.modlist.addModlist(record))
|
||||||
|
|
||||||
|
|
||||||
def _ensure_entry(connection, dn, attributes):
|
def _ensure_entry(connection, dn, attributes):
|
||||||
@@ -181,7 +194,7 @@ def provision():
|
|||||||
_ensure_modules(connection)
|
_ensure_modules(connection)
|
||||||
_ensure_password_hash(connection)
|
_ensure_password_hash(connection)
|
||||||
database = _ensure_database(connection)
|
database = _ensure_database(connection)
|
||||||
_ensure_overlay(connection, database)
|
_ensure_overlays(connection, database)
|
||||||
_ensure_entry(connection, BASE, {'objectClass': [b'dcObject', b'organization'], 'dc': [b'vpn-router'], 'o': [b'vpn-router']})
|
_ensure_entry(connection, BASE, {'objectClass': [b'dcObject', b'organization'], 'dc': [b'vpn-router'], 'o': [b'vpn-router']})
|
||||||
for unit in (USERS, GROUPS, SERVICES, POLICIES):
|
for unit in (USERS, GROUPS, SERVICES, POLICIES):
|
||||||
_ensure_entry(connection, unit, {'objectClass': [b'organizationalUnit'], 'ou': [unit.split(',')[0][3:].encode()]})
|
_ensure_entry(connection, unit, {'objectClass': [b'organizationalUnit'], 'ou': [unit.split(',')[0][3:].encode()]})
|
||||||
@@ -190,7 +203,9 @@ def provision():
|
|||||||
'cn': [b'default'],
|
'cn': [b'default'],
|
||||||
'pwdAttribute': [b'userPassword'],
|
'pwdAttribute': [b'userPassword'],
|
||||||
'pwdLockout': [b'TRUE'],
|
'pwdLockout': [b'TRUE'],
|
||||||
'pwdMaxFailure': [b'0'],
|
'pwdMaxFailure': [str(LOCKOUT_FAILURES).encode()],
|
||||||
|
'pwdLockoutDuration': [str(LOCKOUT_SECONDS).encode()],
|
||||||
|
'pwdFailureCountInterval': [str(LOCKOUT_SECONDS).encode()],
|
||||||
})
|
})
|
||||||
_ensure_entry(connection, TOTP_PARAMS, {
|
_ensure_entry(connection, TOTP_PARAMS, {
|
||||||
'objectClass': [b'organizationalRole', b'oathTOTPParams'],
|
'objectClass': [b'organizationalRole', b'oathTOTPParams'],
|
||||||
|
|||||||
@@ -14,15 +14,22 @@ A user may have a TOTP token: the user entry itself is an oathTOTPUser and oathT
|
|||||||
oathTOTPToken points to itself, with the secret in oathSecret and the parameters of
|
oathTOTPToken points to itself, with the secret in oathSecret and the parameters of
|
||||||
vpnrouter_ldap.TOTP_PARAMS. Enrolment stores nothing until a code of the new secret is
|
vpnrouter_ldap.TOTP_PARAMS. Enrolment stores nothing until a code of the new secret is
|
||||||
verified: otp_new makes a secret and its otpauth URI, and otp_enrol stores the secret given a
|
verified: otp_new makes a secret and its otpauth URI, and otp_enrol stores the secret given a
|
||||||
code of it. Nothing on the router checks the codes at login.
|
code of it. slapd's otp overlay then takes the password followed by a code at every bind.
|
||||||
|
|
||||||
|
A user is disabled when pwdAccountLockedTime holds LOCKED, and locked when the password
|
||||||
|
policy has set it after failed binds, for vpnrouter_ldap.LOCKOUT_SECONDS; unlock ends that.
|
||||||
|
login() checks a user's password and code with a bind. Members of ADMIN_GROUP manage the
|
||||||
|
router through the web service. Changing the password, disabling, unlocking and removing a
|
||||||
|
user revoke its refresh tokens.
|
||||||
|
|
||||||
Every operation runs in a session(), which holds the lock that serializes changes and the
|
Every operation runs in a session(), which holds the lock that serializes changes and the
|
||||||
connection of the user manager account. A request that cannot be done raises Failure, whose
|
connection of the user manager account. A request that cannot be done raises Failure, whose
|
||||||
status says why: 2 the request is invalid, 3 the user does not exist, 4 the user exists, 1
|
status says why: 2 the request is invalid, 3 the user does not exist, 4 the user exists, 5 a
|
||||||
anything else. Answers are dicts with the keys of the JSON answers.
|
login failed, 1 anything else. Answers are dicts with the keys of the JSON answers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import calendar
|
||||||
import contextlib
|
import contextlib
|
||||||
import fcntl
|
import fcntl
|
||||||
import pathlib
|
import pathlib
|
||||||
@@ -37,6 +44,7 @@ import ldap.modlist
|
|||||||
import pyotp
|
import pyotp
|
||||||
|
|
||||||
import vpnrouter
|
import vpnrouter
|
||||||
|
import vpnrouter_auth
|
||||||
import vpnrouter_ldap
|
import vpnrouter_ldap
|
||||||
|
|
||||||
#: A user name is one lowercase DNS label, as it is also the common name of the certificate.
|
#: A user name is one lowercase DNS label, as it is also the common name of the certificate.
|
||||||
@@ -61,13 +69,17 @@ TOTP_SECRET = re.compile(r'^[A-Z2-7]{32}$')
|
|||||||
GROUP_ATTRIBUTES = ['cn', 'description', 'member']
|
GROUP_ATTRIBUTES = ['cn', 'description', 'member']
|
||||||
#: The longest group description, in characters.
|
#: The longest group description, in characters.
|
||||||
DESCRIPTION_MAX = 256
|
DESCRIPTION_MAX = 256
|
||||||
#: The value of pwdAccountLockedTime that locks an account until it is unlocked.
|
#: The value of pwdAccountLockedTime that disables an account until it is enabled.
|
||||||
LOCKED = b'000001010000Z'
|
LOCKED = b'000001010000Z'
|
||||||
|
#: The group whose members manage the router through the web service.
|
||||||
|
ADMIN_GROUP = 'admins'
|
||||||
|
#: The digits of a TOTP code given to login().
|
||||||
|
CODE = re.compile(r'^\d{6}$')
|
||||||
LOCK_FILE = pathlib.Path('/run/vpn-router/users.lock')
|
LOCK_FILE = pathlib.Path('/run/vpn-router/users.lock')
|
||||||
|
|
||||||
|
|
||||||
class Failure(Exception):
|
class Failure(Exception):
|
||||||
"""A request that cannot be done; status is 2 invalid, 3 no such user, 4 the user exists, 1 other."""
|
"""A request that cannot be done; status is 2 invalid, 3 no such user, 4 the user exists, 5 failed login, 1 other."""
|
||||||
|
|
||||||
def __init__(self, message, status=1):
|
def __init__(self, message, status=1):
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
@@ -152,12 +164,22 @@ def publish_crl():
|
|||||||
subprocess.run(['swanctl', '--load-creds'], capture_output=True, text=True)
|
subprocess.run(['swanctl', '--load-creds'], capture_output=True, text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def locked_since(value):
|
||||||
|
"""The time a pwdAccountLockedTime value names, in seconds since the epoch, or 0 when it names none."""
|
||||||
|
try:
|
||||||
|
return calendar.timegm(time.strptime(value[:14].decode(), '%Y%m%d%H%M%S'))
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def summary(attributes):
|
def summary(attributes):
|
||||||
"""A user as the answers show it; a person attribute the entry does not have is None."""
|
"""A user as the answers show it; a person attribute the entry does not have is None."""
|
||||||
name = attributes['uid'][0].decode()
|
name = attributes['uid'][0].decode()
|
||||||
|
lock = attributes.get('pwdAccountLockedTime', [None])[0]
|
||||||
user = {
|
user = {
|
||||||
'name': name,
|
'name': name,
|
||||||
'enabled': 'pwdAccountLockedTime' not in attributes,
|
'enabled': lock != LOCKED,
|
||||||
|
'locked': lock not in (None, LOCKED) and locked_since(lock) > time.time() - vpnrouter_ldap.LOCKOUT_SECONDS,
|
||||||
'certificate': certificate(name)[0].is_file(),
|
'certificate': certificate(name)[0].is_file(),
|
||||||
'otp': 'oathTOTPToken' in attributes,
|
'otp': 'oathTOTPToken' in attributes,
|
||||||
}
|
}
|
||||||
@@ -218,21 +240,64 @@ def passwd(connection, name, password):
|
|||||||
check_password(password)
|
check_password(password)
|
||||||
entry(connection, name)
|
entry(connection, name)
|
||||||
connection.passwd_s(dn_of(name), None, password)
|
connection.passwd_s(dn_of(name), None, password)
|
||||||
|
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
|
||||||
return summary(entry(connection, name))
|
return summary(entry(connection, name))
|
||||||
|
|
||||||
|
|
||||||
def disable(connection, name):
|
def disable(connection, name):
|
||||||
if 'pwdAccountLockedTime' not in entry(connection, name):
|
if entry(connection, name).get('pwdAccountLockedTime') != [LOCKED]:
|
||||||
connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'pwdAccountLockedTime', [LOCKED])])
|
connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'pwdAccountLockedTime', [LOCKED])])
|
||||||
|
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
|
||||||
return summary(entry(connection, name))
|
return summary(entry(connection, name))
|
||||||
|
|
||||||
|
|
||||||
def enable(connection, name):
|
def enable(connection, name):
|
||||||
if 'pwdAccountLockedTime' in entry(connection, name):
|
if entry(connection, name).get('pwdAccountLockedTime') == [LOCKED]:
|
||||||
connection.modify_s(dn_of(name), [(ldap.MOD_DELETE, 'pwdAccountLockedTime', None)])
|
connection.modify_s(dn_of(name), [(ldap.MOD_DELETE, 'pwdAccountLockedTime', None)])
|
||||||
return summary(entry(connection, name))
|
return summary(entry(connection, name))
|
||||||
|
|
||||||
|
|
||||||
|
def unlock(connection, name):
|
||||||
|
"""End a lockout after failed binds and clear the failure count; a disabled user stays disabled."""
|
||||||
|
entry(connection, name)
|
||||||
|
current = connection.search_s(dn_of(name), ldap.SCOPE_BASE, attrlist=['pwdAccountLockedTime', 'pwdFailureTime'])[0][1]
|
||||||
|
changes = [(ldap.MOD_DELETE, 'pwdFailureTime', None)] if 'pwdFailureTime' in current else []
|
||||||
|
if 'pwdAccountLockedTime' in current and current['pwdAccountLockedTime'] != [LOCKED]:
|
||||||
|
changes.append((ldap.MOD_DELETE, 'pwdAccountLockedTime', None))
|
||||||
|
if changes:
|
||||||
|
connection.modify_s(dn_of(name), changes)
|
||||||
|
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
|
||||||
|
return summary(entry(connection, name))
|
||||||
|
|
||||||
|
|
||||||
|
def login(name, password, code):
|
||||||
|
"""Check the user's password, and the TOTP code of an enrolled user, with a bind as the user.
|
||||||
|
|
||||||
|
A wrong name, password or code, a locked or a disabled user give the same Failure, status 5.
|
||||||
|
"""
|
||||||
|
refused = Failure('the name, the password or the code is wrong', 5)
|
||||||
|
if not isinstance(name, str) or not NAME.match(name) or not isinstance(password, str) or not password:
|
||||||
|
raise refused
|
||||||
|
if code is not None and code != '' and (not isinstance(code, str) or not CODE.match(code)):
|
||||||
|
raise refused
|
||||||
|
connection = ldap.initialize(vpnrouter_ldap.URI)
|
||||||
|
try:
|
||||||
|
connection.simple_bind_s(dn_of(name), password + (code or ''))
|
||||||
|
except ldap.INVALID_CREDENTIALS:
|
||||||
|
raise refused
|
||||||
|
finally:
|
||||||
|
connection.unbind_s()
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin(connection, name):
|
||||||
|
"""Whether the user exists and is a member of ADMIN_GROUP."""
|
||||||
|
try:
|
||||||
|
entry(connection, name)
|
||||||
|
except Failure:
|
||||||
|
return False
|
||||||
|
return ADMIN_GROUP in groups_of(connection, name)
|
||||||
|
|
||||||
|
|
||||||
def totp(secret):
|
def totp(secret):
|
||||||
return pyotp.TOTP(secret, digits=vpnrouter_ldap.TOTP_DIGITS, interval=vpnrouter_ldap.TOTP_PERIOD)
|
return pyotp.TOTP(secret, digits=vpnrouter_ldap.TOTP_DIGITS, interval=vpnrouter_ldap.TOTP_PERIOD)
|
||||||
|
|
||||||
@@ -296,6 +361,7 @@ def remove(connection, name):
|
|||||||
cert.unlink(missing_ok=True)
|
cert.unlink(missing_ok=True)
|
||||||
key.unlink(missing_ok=True)
|
key.unlink(missing_ok=True)
|
||||||
connection.delete_s(dn_of(name))
|
connection.delete_s(dn_of(name))
|
||||||
|
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
|
||||||
return {'name': name, 'removed': True}
|
return {'name': name, 'removed': True}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,16 @@ Pages:
|
|||||||
profile, and TOTP enrolment (see users())
|
profile, and TOTP enrolment (see users())
|
||||||
/groups the groups of the router's directory: list, add, rename or describe, remove, and
|
/groups the groups of the router's directory: list, add, rename or describe, remove, and
|
||||||
add and take out members (see groups())
|
add and take out members (see groups())
|
||||||
|
/auth sign-in with a user's password and TOTP code, refresh and sign-out (see auth())
|
||||||
|
|
||||||
/config, /users and /groups need an HS256 JWT in an Authorization: Bearer header that is signed
|
/config, /users and /groups need an HS256 JWT in an Authorization: Bearer header signed with
|
||||||
with web.jwt_secret and expires within ten minutes; they are not served (404) while
|
vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names a user in sub, who
|
||||||
web.jwt_secret is not set.
|
must be a member of vpnrouter_users.ADMIN_GROUP; a token of vpn-router-token has the admin
|
||||||
|
claim, and only such a token may change ADMIN_GROUP: create, rename to it, change, remove it,
|
||||||
|
add or take out its members, and remove one of its members. These paths and /auth are not
|
||||||
|
served (404) while the key does not exist. /auth is limited to AUTH_LIMIT calls per client
|
||||||
|
address in AUTH_WINDOW seconds; the address of a call from one of web.trusted_proxies, by
|
||||||
|
default the address the service listens on, is the last entry of its X-Forwarded-For header.
|
||||||
|
|
||||||
/ and /status show only the status: whether the services run, the package version, whether
|
/ and /status show only the status: whether the services run, the package version, whether
|
||||||
Docker is installed, and the state of site-to-site, road-warrior (IPsec P2S), OpenVPN and
|
Docker is installed, and the state of site-to-site, road-warrior (IPsec P2S), OpenVPN and
|
||||||
@@ -28,15 +34,13 @@ The service needs nothing else to start and does not end on its own. A discovery
|
|||||||
out the internal address from /etc/vpn-router/vpn-router.json as general.mode directs, with
|
out the internal address from /etc/vpn-router/vpn-router.json as general.mode directs, with
|
||||||
web.port and web.enabled, every DISCOVERY_SECONDS, and runs the server thread there: it starts
|
web.port and web.enabled, every DISCOVERY_SECONDS, and runs the server thread there: it starts
|
||||||
it, moves it when the address or the port changes, and stops it while the service is disabled
|
it, moves it when the address or the port changes, and stops it while the service is disabled
|
||||||
or the address is not known. Every request reads what it needs when it is made: the token secret
|
or the address is not known. Every request reads what it needs when it is made: the signing
|
||||||
from the configuration, and the features of the router and the facts shown by /config from
|
key, the configuration, and the features of the router and the facts shown by /config from
|
||||||
/run/vpn-router/web.json, which vpn-router-setup writes. Before setup has written that file
|
/run/vpn-router/web.json, which vpn-router-setup writes. Before setup has written that file
|
||||||
the router has no features, and /config shows no configuration or interfaces.
|
the router has no features, and /config shows no configuration or interfaces.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import collections
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
@@ -50,7 +54,10 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|||||||
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
|
||||||
|
|
||||||
import ldap
|
import ldap
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
import vpnrouter
|
||||||
|
import vpnrouter_auth
|
||||||
import vpnrouter_net
|
import vpnrouter_net
|
||||||
import vpnrouter_users
|
import vpnrouter_users
|
||||||
|
|
||||||
@@ -65,11 +72,9 @@ DISCOVERY_SECONDS = 30
|
|||||||
COMMAND_TIMEOUT = 5
|
COMMAND_TIMEOUT = 5
|
||||||
CACHE_SECONDS = 2
|
CACHE_SECONDS = 2
|
||||||
|
|
||||||
#: Seconds of clock difference accepted between a token and this router.
|
#: The calls of /auth allowed per client address in AUTH_WINDOW seconds.
|
||||||
LEEWAY = 60
|
AUTH_LIMIT = 10
|
||||||
|
AUTH_WINDOW = 60
|
||||||
#: The longest a token may be valid, from now, in seconds.
|
|
||||||
MAX_LIFETIME = 600
|
|
||||||
|
|
||||||
TAILWIND = 'https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4'
|
TAILWIND = 'https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4'
|
||||||
|
|
||||||
@@ -82,11 +87,15 @@ OPENVPN_STATUS = '/run/openvpn-server/status-vpn-router.log'
|
|||||||
BODY_LIMIT = 4096
|
BODY_LIMIT = 4096
|
||||||
|
|
||||||
#: The HTTP status for each status of a vpnrouter_users.Failure; any other is 500.
|
#: The HTTP status for each status of a vpnrouter_users.Failure; any other is 500.
|
||||||
USER_STATUS = {2: 400, 3: 404, 4: 409}
|
USER_STATUS = {2: 400, 3: 404, 4: 409, 5: 401}
|
||||||
|
|
||||||
_cache = {'at': 0.0, 'data': None}
|
_cache = {'at': 0.0, 'data': None}
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
#: The times of the recent /auth calls of each client address.
|
||||||
|
_auth_calls = collections.defaultdict(collections.deque)
|
||||||
|
_auth_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def read_json(path):
|
def read_json(path):
|
||||||
"""The object in a JSON file, or {} when the file is missing or holds no object."""
|
"""The object in a JSON file, or {} when the file is missing or holds no object."""
|
||||||
@@ -105,14 +114,12 @@ def section(configuration, name):
|
|||||||
|
|
||||||
|
|
||||||
def current():
|
def current():
|
||||||
"""What a request needs, read now: the features and facts setup resolved, and the token secret.
|
"""What a request needs, read now: the features and facts setup resolved, and the signing key.
|
||||||
|
|
||||||
Before setup has run the router has no features, and without a secret /config and /users are
|
Before setup has run the router has no features, and without a key the protected paths and
|
||||||
not served.
|
/auth are not served.
|
||||||
"""
|
"""
|
||||||
state = read_json(STATE_FILE)
|
return dict(read_json(STATE_FILE), key=vpnrouter_auth.key())
|
||||||
secret = section(read_json(CONFIG_FILE), 'web').get('jwt_secret')
|
|
||||||
return dict(state, jwt_secret=secret if isinstance(secret, str) else '')
|
|
||||||
|
|
||||||
|
|
||||||
def run(*args):
|
def run(*args):
|
||||||
@@ -253,37 +260,6 @@ def collect(state):
|
|||||||
return status
|
return status
|
||||||
|
|
||||||
|
|
||||||
def b64decode(part):
|
|
||||||
return base64.urlsafe_b64decode(part + '=' * (-len(part) % 4))
|
|
||||||
|
|
||||||
|
|
||||||
def verify(token, secret):
|
|
||||||
"""The claims of a token signed with HS256 by the secret, or None when it is not acceptable.
|
|
||||||
|
|
||||||
Only HS256 is accepted, so a token cannot choose a weaker algorithm. The token
|
|
||||||
must expire (exp), and not later than MAX_LIFETIME seconds from now, so that a
|
|
||||||
stolen token is of little use; LEEWAY seconds of clock difference are allowed.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
header, payload, signature = token.split('.')
|
|
||||||
if json.loads(b64decode(header)).get('alg') != 'HS256':
|
|
||||||
return None
|
|
||||||
expected = hmac.new(secret.encode(), f'{header}.{payload}'.encode(), hashlib.sha256).digest()
|
|
||||||
if not hmac.compare_digest(expected, b64decode(signature)):
|
|
||||||
return None
|
|
||||||
claims = json.loads(b64decode(payload))
|
|
||||||
now = time.time()
|
|
||||||
expires = claims['exp']
|
|
||||||
if isinstance(expires, bool) or not isinstance(expires, (int, float)) or not now - LEEWAY <= expires <= now + MAX_LIFETIME + LEEWAY:
|
|
||||||
return None
|
|
||||||
not_before = claims.get('nbf')
|
|
||||||
if not_before is not None and (isinstance(not_before, bool) or not isinstance(not_before, (int, float)) or not_before > now + LEEWAY):
|
|
||||||
return None
|
|
||||||
except (ValueError, KeyError, TypeError, AttributeError):
|
|
||||||
return None
|
|
||||||
return claims
|
|
||||||
|
|
||||||
|
|
||||||
def lines_of(*args):
|
def lines_of(*args):
|
||||||
"""The output lines of a command, or None when it fails."""
|
"""The output lines of a command, or None when it fails."""
|
||||||
result = run(*args)
|
result = run(*args)
|
||||||
@@ -420,6 +396,7 @@ def users(state, method, path, body):
|
|||||||
PUT /users/<name>/password set the password: {"password": ...}
|
PUT /users/<name>/password set the password: {"password": ...}
|
||||||
POST /users/<name>/disable lock the user out of OpenVPN
|
POST /users/<name>/disable lock the user out of OpenVPN
|
||||||
POST /users/<name>/enable let the user in again
|
POST /users/<name>/enable let the user in again
|
||||||
|
POST /users/<name>/unlock end a lockout after failed logins
|
||||||
DELETE /users/<name> remove the user and revoke the certificate
|
DELETE /users/<name> remove the user and revoke the certificate
|
||||||
GET /users/<name>/profile the OpenVPN client profile
|
GET /users/<name>/profile the OpenVPN client profile
|
||||||
POST /users/<name>/otp a new TOTP secret and its otpauth URI; nothing is stored
|
POST /users/<name>/otp a new TOTP secret and its otpauth URI; nothing is stored
|
||||||
@@ -439,6 +416,7 @@ def users(state, method, path, body):
|
|||||||
('PUT', 3, 'password'): lambda connection: vpnrouter_users.passwd(connection, name, body.get('password')),
|
('PUT', 3, 'password'): lambda connection: vpnrouter_users.passwd(connection, name, body.get('password')),
|
||||||
('POST', 3, 'disable'): lambda connection: vpnrouter_users.disable(connection, name),
|
('POST', 3, 'disable'): lambda connection: vpnrouter_users.disable(connection, name),
|
||||||
('POST', 3, 'enable'): lambda connection: vpnrouter_users.enable(connection, name),
|
('POST', 3, 'enable'): lambda connection: vpnrouter_users.enable(connection, name),
|
||||||
|
('POST', 3, 'unlock'): lambda connection: vpnrouter_users.unlock(connection, name),
|
||||||
('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name),
|
('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name),
|
||||||
('GET', 3, 'profile'): lambda connection: vpnrouter_users.profile(connection, name),
|
('GET', 3, 'profile'): lambda connection: vpnrouter_users.profile(connection, name),
|
||||||
('POST', 3, 'otp'): lambda connection: vpnrouter_users.otp_new(connection, name),
|
('POST', 3, 'otp'): lambda connection: vpnrouter_users.otp_new(connection, name),
|
||||||
@@ -454,6 +432,8 @@ def users(state, method, path, body):
|
|||||||
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
|
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||||
except (ldap.LDAPError, OSError) as exc:
|
except (ldap.LDAPError, OSError) as exc:
|
||||||
return 500, {'error': f'the user directory failed: {exc}'}
|
return 500, {'error': f'the user directory failed: {exc}'}
|
||||||
|
except psycopg.Error as exc:
|
||||||
|
return 500, {'error': f'the token database failed: {exc}'}
|
||||||
except SystemExit:
|
except SystemExit:
|
||||||
# vpnrouter ends with SystemExit on a configuration it cannot read; it has printed the reason.
|
# vpnrouter ends with SystemExit on a configuration it cannot read; it has printed the reason.
|
||||||
return 500, {'error': 'the router configuration cannot be read'}
|
return 500, {'error': 'the router configuration cannot be read'}
|
||||||
@@ -498,6 +478,85 @@ def groups(state, method, path, body):
|
|||||||
return (201 if route == ('POST', 1, None) else 200), document
|
return (201 if route == ('POST', 1, None) else 200), document
|
||||||
|
|
||||||
|
|
||||||
|
def changes_admins(connection, method, parts, body):
|
||||||
|
"""Whether a /users or /groups request creates, renames to, changes or removes ADMIN_GROUP, or removes a member of it."""
|
||||||
|
admins = vpnrouter_users.ADMIN_GROUP
|
||||||
|
if parts[0] == 'groups':
|
||||||
|
if method == 'POST' and len(parts) == 1:
|
||||||
|
return body.get('name') == admins
|
||||||
|
if method != 'GET' and len(parts) > 1 and parts[1] == admins:
|
||||||
|
return True
|
||||||
|
return method == 'PUT' and len(parts) == 2 and body.get('name') == admins
|
||||||
|
if parts[0] == 'users' and method == 'DELETE' and len(parts) == 2:
|
||||||
|
return admins in vpnrouter_users.groups_of(connection, parts[1])
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def refusal(claims, method, path, body):
|
||||||
|
"""Why the holder of the claims may not make the request to a protected path, or '' when it may."""
|
||||||
|
if claims.get('admin') is True:
|
||||||
|
return ''
|
||||||
|
name = claims.get('sub')
|
||||||
|
if not isinstance(name, str):
|
||||||
|
return 'the token names no user'
|
||||||
|
admins = vpnrouter_users.ADMIN_GROUP
|
||||||
|
with vpnrouter_users.session() as connection:
|
||||||
|
if not vpnrouter_users.is_admin(connection, name):
|
||||||
|
return f'user {name} is not a member of {admins}'
|
||||||
|
if changes_admins(connection, method, path.strip('/').split('/'), body if isinstance(body, dict) else {}):
|
||||||
|
return f'only a token of vpn-router-token may change the group {admins} or remove its members'
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def auth(path, body):
|
||||||
|
"""Answer a POST to /auth with vpnrouter_users and vpnrouter_auth: the HTTP status and the JSON document.
|
||||||
|
|
||||||
|
POST /auth sign in: {"name", "password", "code"?}; the tokens
|
||||||
|
POST /auth/refresh {"refresh_token"}; new tokens, the refresh token used up
|
||||||
|
POST /auth/logout {"refresh_token"}; the refresh token revoked
|
||||||
|
"""
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
return 400, {'error': 'the body must be a JSON object'}
|
||||||
|
try:
|
||||||
|
cfg = vpnrouter.load_config()
|
||||||
|
if path == '/auth':
|
||||||
|
vpnrouter_users.login(body.get('name'), body.get('password'), body.get('code'))
|
||||||
|
return 200, vpnrouter_auth.issue(cfg, body['name'])
|
||||||
|
name = vpnrouter_auth.take(cfg, body.get('refresh_token'))
|
||||||
|
if path == '/auth/logout':
|
||||||
|
return 200, {'revoked': name is not None}
|
||||||
|
if name is None:
|
||||||
|
return 401, {'error': 'the refresh token is not valid'}
|
||||||
|
with vpnrouter_users.session() as connection:
|
||||||
|
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
|
||||||
|
if not user['enabled'] or user['locked']:
|
||||||
|
return 401, {'error': f'user {name} is disabled or locked'}
|
||||||
|
return 200, vpnrouter_auth.issue(cfg, name)
|
||||||
|
except vpnrouter_users.Failure as failure:
|
||||||
|
return (401 if failure.status == 3 else USER_STATUS.get(failure.status, 500)), {'error': str(failure)}
|
||||||
|
except (ldap.LDAPError, OSError) as exc:
|
||||||
|
return 500, {'error': f'the user directory failed: {exc}'}
|
||||||
|
except psycopg.Error as exc:
|
||||||
|
return 500, {'error': f'the token database failed: {exc}'}
|
||||||
|
except SystemExit:
|
||||||
|
return 500, {'error': 'the router configuration cannot be read'}
|
||||||
|
|
||||||
|
|
||||||
|
def auth_wait(address):
|
||||||
|
"""Seconds until the client address may call /auth again, 0 when it may now; a call that may is counted."""
|
||||||
|
now = time.monotonic()
|
||||||
|
with _auth_lock:
|
||||||
|
for idle in [key for key, times in _auth_calls.items() if times[-1] <= now - AUTH_WINDOW]:
|
||||||
|
del _auth_calls[idle]
|
||||||
|
calls = _auth_calls[address]
|
||||||
|
while calls and calls[0] <= now - AUTH_WINDOW:
|
||||||
|
calls.popleft()
|
||||||
|
if len(calls) >= AUTH_LIMIT:
|
||||||
|
return int(calls[0] + AUTH_WINDOW - now) + 1
|
||||||
|
calls.append(now)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
protocol_version = 'HTTP/1.1'
|
protocol_version = 'HTTP/1.1'
|
||||||
|
|
||||||
@@ -508,29 +567,65 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
_cache['at'] = time.monotonic()
|
_cache['at'] = time.monotonic()
|
||||||
return _cache['data']
|
return _cache['data']
|
||||||
|
|
||||||
def authorized(self, secret):
|
def claims(self, secret):
|
||||||
"""Whether the request carries a token signed with the secret; False also while no secret is set."""
|
"""The claims of the request's token signed with the secret, or None without an acceptable one."""
|
||||||
header = self.headers.get('Authorization', '')
|
header = self.headers.get('Authorization', '')
|
||||||
return bool(secret) and header.startswith('Bearer ') and verify(header[7:].strip(), secret) is not None
|
return vpnrouter_auth.verify(header[7:].strip(), secret) if header.startswith('Bearer ') else None
|
||||||
|
|
||||||
|
def client(self):
|
||||||
|
"""The client address: the last X-Forwarded-For entry when the peer is a trusted proxy, else the peer."""
|
||||||
|
peer = self.client_address[0]
|
||||||
|
trusted = section(read_json(CONFIG_FILE), 'web').get('trusted_proxies') or [self.server.server_address[0]]
|
||||||
|
forwarded = self.headers.get('X-Forwarded-For', '')
|
||||||
|
if isinstance(trusted, list) and peer in trusted and forwarded.strip():
|
||||||
|
return forwarded.split(',')[-1].strip()
|
||||||
|
return peer
|
||||||
|
|
||||||
|
def refusal(self, claims, path, payload):
|
||||||
|
"""The HTTP status and the message that refuse a request to a protected path, or None when it may be made."""
|
||||||
|
try:
|
||||||
|
reason = refusal(claims, self.command, path, payload)
|
||||||
|
except (ldap.LDAPError, OSError) as exc:
|
||||||
|
return 500, f'the user directory failed: {exc}'
|
||||||
|
return (403, reason) if reason else None
|
||||||
|
|
||||||
def answer(self, send_body):
|
def answer(self, send_body):
|
||||||
path = self.path.split('?', 1)[0]
|
path = self.path.split('?', 1)[0]
|
||||||
extra = []
|
extra = []
|
||||||
|
self.principal = None
|
||||||
# A body that is not read must not be taken for the next request.
|
# A body that is not read must not be taken for the next request.
|
||||||
if self.command not in ('GET', 'HEAD'):
|
if self.command not in ('GET', 'HEAD'):
|
||||||
self.close_connection = True
|
self.close_connection = True
|
||||||
directory = next((name for name in ('users', 'groups') if path == f'/{name}' or path.startswith(f'/{name}/')), None)
|
directory = next((name for name in ('users', 'groups') if path == f'/{name}' or path.startswith(f'/{name}/')), None)
|
||||||
protected = path == '/config' or directory is not None
|
protected = path == '/config' or directory is not None
|
||||||
state = current() if protected else {}
|
signing = path in ('/auth', '/auth/refresh', '/auth/logout')
|
||||||
if protected and not state['jwt_secret']:
|
state = current() if protected or signing else {}
|
||||||
|
claims = self.claims(state['key']) if protected and state['key'] else None
|
||||||
|
payload = self.request_body() if (protected or signing) and self.command not in ('GET', 'HEAD') else None
|
||||||
|
if claims is not None:
|
||||||
|
self.principal = 'admin' if claims.get('admin') is True else claims.get('sub')
|
||||||
|
if (protected or signing) and not state['key']:
|
||||||
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
|
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
|
||||||
elif protected and not self.authorized(state['jwt_secret']):
|
elif signing and self.command != 'POST':
|
||||||
|
body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
|
||||||
|
elif signing:
|
||||||
|
wait = auth_wait(self.client()) if path != '/auth/logout' else 0
|
||||||
|
if wait:
|
||||||
|
code, document = 429, {'error': f'too many sign-in calls; retry in {wait} seconds'}
|
||||||
|
extra.append(('Retry-After', str(wait)))
|
||||||
|
else:
|
||||||
|
code, document = auth(path, payload)
|
||||||
|
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||||
|
elif protected and claims is None:
|
||||||
body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401
|
body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401
|
||||||
extra.append(('WWW-Authenticate', 'Bearer'))
|
extra.append(('WWW-Authenticate', 'Bearer'))
|
||||||
|
elif protected and (refused := self.refusal(claims, path, payload)):
|
||||||
|
code, kind = refused[0], 'application/json'
|
||||||
|
body = json.dumps({'error': refused[1]}, indent=2).encode() + b'\n'
|
||||||
elif path == '/config' and self.command in ('GET', 'HEAD'):
|
elif path == '/config' and self.command in ('GET', 'HEAD'):
|
||||||
body, kind, code = json.dumps(details(state), indent=2).encode() + b'\n', 'application/json', 200
|
body, kind, code = json.dumps(details(state), indent=2).encode() + b'\n', 'application/json', 200
|
||||||
elif protected and path != '/config':
|
elif protected and path != '/config':
|
||||||
code, document = (users if directory == 'users' else groups)(state, self.command, path, self.request_body())
|
code, document = (users if directory == 'users' else groups)(state, self.command, path, payload)
|
||||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||||
elif self.command not in ('GET', 'HEAD'):
|
elif self.command not in ('GET', 'HEAD'):
|
||||||
body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
|
body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
|
||||||
@@ -584,7 +679,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.answer(True)
|
self.answer(True)
|
||||||
|
|
||||||
def log_message(self, format, *args):
|
def log_message(self, format, *args):
|
||||||
sys.stderr.write('%s - - [%s] %s\n' % (self.client_address[0], self.log_date_time_string(), format % args))
|
principal = getattr(self, 'principal', None) or '-'
|
||||||
|
sys.stderr.write('%s - %s [%s] %s\n' % (self.client_address[0], principal, self.log_date_time_string(), format % args))
|
||||||
|
|
||||||
|
|
||||||
def wanted():
|
def wanted():
|
||||||
|
|||||||
Executable
+79
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
"""Make a token with the admin claim for the router's web service, with vpnrouter_auth.
|
||||||
|
|
||||||
|
The token is signed with the router's key and is valid for the lifetime given, without a
|
||||||
|
maximum. It may call every protected path, and it is the only token that may change the
|
||||||
|
admins group. The answer is the token on standard output, and a failure is a message on
|
||||||
|
standard error. With --json the answer is {"token", "expires"} on standard output, and a
|
||||||
|
failure is {"error": ...} there too. The exit status is 2 for an invalid request and 1 for
|
||||||
|
any other failure. -h or --help shows the options.
|
||||||
|
|
||||||
|
vpn-router-token [--json] (--minutes N | --hours N | --days N)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, '/usr/lib/vpn-router')
|
||||||
|
|
||||||
|
import vpnrouter_auth
|
||||||
|
|
||||||
|
#: The seconds of each unit of the lifetime.
|
||||||
|
UNITS = {'minutes': 60, 'hours': 3600, 'days': 86400}
|
||||||
|
|
||||||
|
|
||||||
|
class Failure(Exception):
|
||||||
|
"""A request that cannot be done; status is 2 invalid, 1 other."""
|
||||||
|
|
||||||
|
def __init__(self, message, status=1):
|
||||||
|
super().__init__(message)
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
|
||||||
|
class Parser(argparse.ArgumentParser):
|
||||||
|
"""An argument parser whose errors are Failures with status 2, so that --json answers them as JSON."""
|
||||||
|
|
||||||
|
def error(self, message):
|
||||||
|
raise Failure(f'{message}; see {self.prog} --help', 2)
|
||||||
|
|
||||||
|
|
||||||
|
def positive(value):
|
||||||
|
try:
|
||||||
|
number = int(value)
|
||||||
|
except ValueError:
|
||||||
|
number = 0
|
||||||
|
if number < 1:
|
||||||
|
raise argparse.ArgumentTypeError(f'{value!r} is not a positive whole number')
|
||||||
|
return number
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
# A request that cannot be parsed is answered in JSON when --json is among the arguments.
|
||||||
|
as_json = '--json' in argv
|
||||||
|
try:
|
||||||
|
parser = Parser(prog='vpn-router-token', description='Make a token with the admin claim for the web service.')
|
||||||
|
parser.add_argument('--json', action='store_true', help='answer in JSON')
|
||||||
|
lifetime = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
for unit in UNITS:
|
||||||
|
lifetime.add_argument(f'--{unit}', type=positive, metavar='N', help=f'valid for N {unit}')
|
||||||
|
options = parser.parse_args(argv)
|
||||||
|
as_json = options.json
|
||||||
|
seconds = next(getattr(options, unit) * factor for unit, factor in UNITS.items() if getattr(options, unit))
|
||||||
|
answer = vpnrouter_auth.admin_token(seconds)
|
||||||
|
except Failure as failure:
|
||||||
|
message, status = str(failure), failure.status
|
||||||
|
except OSError as exc:
|
||||||
|
message, status = str(exc), 1
|
||||||
|
else:
|
||||||
|
print(json.dumps(answer) if as_json else answer['token'])
|
||||||
|
return 0
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps({'error': message}))
|
||||||
|
else:
|
||||||
|
print(f'vpn-router-token: {message}', file=sys.stderr)
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main(sys.argv[1:]))
|
||||||
@@ -14,6 +14,7 @@ user exists, 1 anything else. -h or --help shows the commands and their options.
|
|||||||
vpn-router-user [--json] passwd <name> (password on stdin)
|
vpn-router-user [--json] passwd <name> (password on stdin)
|
||||||
vpn-router-user [--json] disable <name>
|
vpn-router-user [--json] disable <name>
|
||||||
vpn-router-user [--json] enable <name>
|
vpn-router-user [--json] enable <name>
|
||||||
|
vpn-router-user [--json] unlock <name>
|
||||||
vpn-router-user [--json] remove <name>
|
vpn-router-user [--json] remove <name>
|
||||||
vpn-router-user [--json] profile <name>
|
vpn-router-user [--json] profile <name>
|
||||||
vpn-router-user [--json] otp-new <name>
|
vpn-router-user [--json] otp-new <name>
|
||||||
@@ -28,6 +29,7 @@ import sys
|
|||||||
sys.path.insert(0, '/usr/lib/vpn-router')
|
sys.path.insert(0, '/usr/lib/vpn-router')
|
||||||
|
|
||||||
import ldap
|
import ldap
|
||||||
|
import psycopg
|
||||||
|
|
||||||
import vpnrouter_users
|
import vpnrouter_users
|
||||||
from vpnrouter_users import Failure
|
from vpnrouter_users import Failure
|
||||||
@@ -40,6 +42,7 @@ HELP = {
|
|||||||
'passwd': 'set the password, read from standard input',
|
'passwd': 'set the password, read from standard input',
|
||||||
'disable': 'lock the user out of OpenVPN',
|
'disable': 'lock the user out of OpenVPN',
|
||||||
'enable': 'let the user in again',
|
'enable': 'let the user in again',
|
||||||
|
'unlock': 'end a lockout after failed logins',
|
||||||
'remove': 'revoke the certificate and remove the user',
|
'remove': 'revoke the certificate and remove the user',
|
||||||
'profile': 'print the OpenVPN client profile',
|
'profile': 'print the OpenVPN client profile',
|
||||||
'otp-new': 'make a TOTP secret and its otpauth URI; nothing is stored',
|
'otp-new': 'make a TOTP secret and its otpauth URI; nothing is stored',
|
||||||
@@ -109,7 +112,8 @@ def text_of(answer):
|
|||||||
if not users:
|
if not users:
|
||||||
return 'no users'
|
return 'no users'
|
||||||
head = ('USER', 'STATE', 'CERTIFICATE', 'OTP', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
|
head = ('USER', 'STATE', 'CERTIFICATE', 'OTP', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
|
||||||
rows = [(user['name'], 'enabled' if user['enabled'] else 'disabled', 'issued' if user['certificate'] else 'missing',
|
rows = [(user['name'], 'disabled' if not user['enabled'] else 'locked' if user['locked'] else 'enabled',
|
||||||
|
'issued' if user['certificate'] else 'missing',
|
||||||
'enrolled' if user['otp'] else '-', user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-')
|
'enrolled' if user['otp'] else '-', user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-')
|
||||||
for user in users]
|
for user in users]
|
||||||
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
|
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
|
||||||
@@ -131,6 +135,8 @@ def main(argv):
|
|||||||
message, status = str(failure), failure.status
|
message, status = str(failure), failure.status
|
||||||
except (ldap.LDAPError, OSError) as exc:
|
except (ldap.LDAPError, OSError) as exc:
|
||||||
message, status = f'the user directory failed: {exc}', 1
|
message, status = f'the user directory failed: {exc}', 1
|
||||||
|
except psycopg.Error as exc:
|
||||||
|
message, status = f'the token database failed: {exc}', 1
|
||||||
else:
|
else:
|
||||||
print(json.dumps(answer) if as_json else text_of(answer))
|
print(json.dumps(answer) if as_json else text_of(answer))
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
"web": {
|
"web": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"port": 7081,
|
"port": 7081,
|
||||||
"jwt_secret": ""
|
"database": "",
|
||||||
|
"trusted_proxies": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user