Compare commits

...
5 Commits
18 changed files with 1659 additions and 120 deletions

No files matched your search

+80 -6
View File
@@ -98,8 +98,8 @@ Context: OpenVPN needs user accounts with passwords, and the users are meant to
system of the router, including IKEv2 EAP-TLS and WireGuard, which an OIDC provider cannot
authenticate. Everything the router runs comes from the Ubuntu archive. slapd, its Python module
`python3-ldap` and the OpenVPN plugin `openvpn-auth-ldap` are all in the archive; the plugin
checks a password by binding as the user, so the hash scheme is slapd's alone. An OIDC provider
can read the directory through its LDAP connector.
checks a password by binding as the user, so the hash scheme is slapd's alone. The router's own
OpenID Provider (ADR-37) signs the directory's users in to other systems.
Decision: The users are entries in slapd. The package keeps its own database,
`dc=vpn-router,dc=local` in `/var/lib/ldap/vpn-router` (the path the slapd AppArmor profile
@@ -909,7 +909,10 @@ Manager cannot set it. The shell runs as the console account of the signed-in us
it is never root and each administrator's shell is the administrator's own; a user without
console login gets none. `POST /shell` signs the user in again with the password
and TOTP code and gives a one-time ticket valid 30 seconds; a WebSocket on `GET /shell` with that
ticket gets the shell. The WebSocket of RFC 6455 is implemented on the standard library inside the
ticket gets the shell. Asking for the password and code at every opening of the page is tiresome,
so within `web.shell_auth_timeout` minutes of the user's last sign-in or keystroke a ticket is given
without them; the times are kept in the web service's memory only, so a restart asks again, and 0
keeps the sign-in at every opening. The WebSocket of RFC 6455 is implemented on the standard library inside the
web service's `http.server`, server side only, without extensions. `systemd-run --pty` starts the
shell in a transient unit with a PAM login session, outside the sandbox, and `setsid --ctty`
makes the terminal its controlling terminal so window sizes reach the shell. A shell idle for
@@ -942,12 +945,18 @@ so slapd's otp overlay and password policy apply as for the Router Manager: the
by the code, and the lockout. Members of `vpn-router-console` get sudo with that password. SSH
accepts it only while `ssh.ldap_login` is set, in a `Match Group` drop-in of sshd. Switching console
login off keeps the `posixAccount`, hidden by nslcd's filter, so the uid and the home stay the
user's; leaving `admins` switches it off.
user's; leaving `admins` switches it off. A console reaches root through sudo, so a password alone
must not open one: console login needs a TOTP token. Switching it on is refused without one, and
the token is checked at use rather than only when it is switched on, because an administrator can
remove it later: the shell's ticket and every minute of an open shell check it, nslcd's
`pam_authz_search` requires `oathTOTPUser`, and removing the token ends the sessions of the user's
own POSIX account.
Consequences: Each administrator's shell, SSH session and sudo run as the administrator, logged
under the administrator's name. The directory becomes part of the system's name service through
nslcd, and the system knows only the accounts with console login on. A user enrolled for TOTP types
the code after the password at the SSH and sudo prompts. Homes are created at the first login and
nslcd, and the system knows only the accounts with console login on. The user types the code
after the password at the SSH and sudo prompts. An administrator whose console login was switched
on before the token was required is refused until enrolling. Homes are created at the first login and
are not removed with the user. A password is changed through the directory, not with `passwd`.
## ADR-35: WireGuard peers are the devices of the VPN users, kept in the directory
@@ -969,3 +978,68 @@ endpoint is, choosing the user for each.
Consequences: Peers follow their users: disabling a user drops the user's devices, and the
directory's backup holds them. A peer cannot exist without a VPN user. A change restarts
`wg-quick@wg0` through setup, which briefly interrupts the other peers.
## ADR-36: Passkeys sign in to the Router Manager and the OpenID Provider, verified by the router with python3-fido2
Context: Typing the password and a TOTP code at every sign-in to the Router Manager is tedious.
WebAuthn needs a browser, HTTPS and a domain name; OpenVPN, IKEv2, WireGuard, SSH and sudo have no
browser, and the sudo PAM module for security keys needs the key at the router. The router signs
users in (`/auth`) and keeps them in its directory. The standard library cannot read CBOR or COSE
keys; Ubuntu packages Yubico's python-fido2 in noble (1.1) and resolute (2.0), whose server API is
the same once the JSON mapping of WebAuthn Level 3 is switched on, which 2.0 always uses.
Decision: A passkey is a discoverable credential with user verification of the relying party
`wan.local_fqdn`, with the user name as its user handle, so the sign-in needs no name. The router
registers and verifies passkeys with python3-fido2 in `vpnrouter_passkeys`; the origin must be
HTTPS on that name, which python-fido2 checks. Each passkey is a JSON value of the multi-valued
`vpnRouterPasskey` on the user's entry, with the public key only. Registration needs the current
password and code, so a session alone cannot add a lasting way in; removal needs neither. A
passkey sign-in gives the same tokens as `/auth`; as it binds no password, the router checks that
the user is enabled, not locked and not a service account. The begun steps are kept in the web
service's memory for 2 minutes. The browser converts the options and answers with
`PublicKeyCredential.parseCreationOptionsFromJSON`, `parseRequestOptionsFromJSON` and `toJSON`.
The OpenID Provider's sign-in page (ADR-37) takes a passkey the same way.
Consequences: Passkeys are a convenience for the Router Manager and the OpenID Provider; the
password and code stay for everything else, the shell included. A changed `wan.local_fqdn` orphans every passkey. The
Router Manager on another name, or in development on `localhost`, cannot use them. Failed passkey
sign-ins do not count towards the directory's lockout, but the sign-in calls are rate limited.
Browsers without the JSON methods of WebAuthn Level 3 cannot add or use passkeys.
## ADR-37: The web service is an OpenID Provider built on python3-oauthlib; its clients are service accounts
Status: Accepted
Context: Other systems, such as dashboards, should sign the router's users in with the directory's
passwords, TOTP codes and passkeys, without a second user store. The Ubuntu archive has no
packaged identity provider. `python3-oauthlib` and `python3-jwt` are in main for noble and
resolute; oauthlib implements the OpenID Connect flows and leaves the checks, the storage and the
ID token's signature to the application. `python3-authlib` and `python3-jwcrypto` are in universe
only. The web service already checks passwords and passkeys, rate limits sign-ins and keeps
tokens in PostgreSQL, and Caddy serves it over HTTPS on `wan.local_fqdn`. ADR-3's HS256 key must
not leave the router, but a relying party verifies an ID token itself.
Decision: The web service is the OpenID Provider, in `vpnrouter_oidc`, while `oidc.enabled` is
true; its issuer is its HTTPS site, `https://<wan.local_fqdn>:<https.web_port>`, without the port
on 443, and it answers the provider's paths only on `127.0.0.1`, where Caddy passes them. Only
the authorization code flow with PKCE S256 and the refresh token grant are allowed. A client is a
service account with redirect URIs, kept with the account in the new attributes
`vpnRouterRedirectUri`, `vpnRouterPostLogoutRedirectUri` and `vpnRouterAllowedGroup` of
`vpnRouterAccount`: its name is the client ID and its password the secret, checked with a bind.
Allowed groups limit who may sign in; without them every user may. A registered client is
trusted, so there is no consent page. The sign-in page is the web service's own, a Jinja2
template with the password, the code and a passkey, so the provider works without the Router
Manager. ID tokens are RS256, signed with an RSA key the web service makes on its first run
(`/etc/vpn-router/oidc.key`), published as a JWK named by its RFC 7638 thumbprint; `sub` is the
entry's `entryUUID`. Codes, access and refresh tokens and sessions are kept as their SHA-256 in
the database of the refresh tokens, defined in `/usr/share/vpn-router/sql/oidc.sql` with
procedures and functions the module calls; a process loads the file at its first connection. The
operations that revoke the refresh tokens of `/auth` also revoke them, and every use of a code or
token checks the user and the client's allowed groups again.
Consequences: The package depends on `python3-oauthlib` and `python3-jwt`. The provider needs
HTTPS, a public name and the web service. Clients are managed like service accounts, by the CLI,
`/service-accounts` and the Router Manager; a group a client allows cannot be removed, and renaming
it renames it in the clients. The signing key does not rotate; replacing it invalidates every ID
token issued. Wrong client secrets lock the client like any account. The implicit and hybrid
flows, dynamic client registration and public clients without a secret are not offered.
+155 -37
View File
@@ -130,12 +130,14 @@ must have the type shown below; an unknown section or setting is an error.
| `web` | `status_content` | string | `basic` | What `/` and `/status` show: `basic`, `standard` or `full` (see Status web service) |
| `web` | `shell` | boolean | `false` | `/shell` opens a login shell for a member of `admins` with console login (see The shell) |
| `web` | `shell_idle_minutes` | integer | `15` | Minutes without a keystroke after which a shell is closed, 1 to 1440 |
| `web` | `shell_auth_timeout` | integer | `15` | Minutes after the last use of a shell in which a new shell opens without signing in again, 0 (always sign in) to 1440 |
| `ssh` | `ldap_login` | boolean | `false` | sshd accepts the directory password of the console accounts in the directory (see Console login and SSH) |
| `https` | `enabled` | boolean | `true` | Caddy serves the status web service and the Router Manager over HTTPS for `wan.local_fqdn` |
| `https` | `email` | string | | The e-mail address of the Let's Encrypt account |
| `https` | `web_port` | integer | `7444` | HTTPS port of the status web service |
| `https` | `manager_port` | integer | `7443` | HTTPS port of the Router Manager |
| `https` | `caddy_source` | string | `ubuntu` | Where Caddy is installed from: `ubuntu`, the Ubuntu archive, or `upstream`, Caddy's own repository (see HTTPS) |
| `oidc` | `enabled` | boolean | `false` | The OpenID Provider at the status web service's HTTPS site, which needs `web.enabled`, `https.enabled` and `wan.local_fqdn` (see OpenID Provider) |
| `pki` | `root_name` | string | `VPN Router Root CA` | Common name of the root CA, when the router creates it |
| `pki` | `root_days` | integer | `7300` | Days the root CA's certificate is valid |
| `pki` | `ca_days` | integer | `3650` | Days an issuing CA's certificate is valid |
@@ -626,10 +628,12 @@ is switched on:
`account` for a Manager-only account (see Managing users);
- the package's own schema, `cn=vpn-router,cn=schema,cn=config`, under the OID
`2.25.98477352595735834532404256531620013001` of the UUID arc: the auxiliary class
`vpnRouterAccount` with `MAY ( mail $ vpnRouterScope $ displayName $ description )`, which
Manager-only and service accounts have, and the attribute `vpnRouterScope`, authorization
scopes, of which `service` marks a service account; `displayName` and `description` are the
display name and the description of a service account;
`vpnRouterAccount`, which Manager-only and service accounts have, and the attribute
`vpnRouterScope`, authorization scopes, of which `service` marks a service account;
`displayName` and `description` are the display name and the description of a service
account, and `vpnRouterRedirectUri`, `vpnRouterPostLogoutRedirectUri` and
`vpnRouterAllowedGroup` the settings of a service account that is a client of the OpenID
Provider;
- `ou=groups` holds one `groupOfNames` per group, named by `cn`, with its members' DNs in
`member` and an optional `description`;
- `cn=user-manager,ou=services` is the account that changes users and groups; it may write
@@ -664,7 +668,8 @@ it is added:
- a service account, a Manager-only account whose `vpnRouterScope` is `service`, which another
system signs in with to use a router service, such as an OpenTelemetry Collector reading
`/metrics`; it cannot sign in to `/auth`. Besides its name, it has a display name and a
description. `add --service` adds one, and the web service manages them at `/service-accounts`,
description, and a service account with redirect URIs is a client of the OpenID Provider (see
OpenID Provider). `add --service` adds one, and the web service manages them at `/service-accounts`,
not at `/users`. Internal service accounts are those the router creates and manages for its own
services, display name and description included; at present that is the metrics service's, the
account `metrics.service_account` names while metrics are enabled (see Metrics), which
@@ -699,12 +704,12 @@ vpn-router-user otp-remove alice
| Command | Effect |
| --- | --- |
| `list` | The users with their kind, names and e-mail addresses, whether each is enabled or locked, has a certificate and is enrolled for TOTP |
| `list` | The users with their kind, names and e-mail addresses, whether each is enabled or locked, has a certificate and is enrolled for TOTP, the number of passkeys and the console login |
| `add <name> --first-name F --last-name L --email E` | Adds a VPN user and issues the certificate |
| `add <name> [--email E]` | Adds a Manager-only account, with the e-mail address when given; one name without the other two options is refused |
| `add <name> --service [--display-name N] [--description D] [--email E]` | Adds a service account, with its display name, description and e-mail address when given; the names are refused |
| `add <name> --service [--display-name N] [--description D] [--email E] [--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]` | Adds a service account, with its display name, description, e-mail address and OpenID Provider client settings when given; the names are refused |
| `update <name> [--first-name F] [--last-name L] [--email E]` | Changes the names or the e-mail address |
| `update <name> [--display-name N] [--description D] [--email E]` | Changes the display name, the description or the e-mail address of a service account; `''` removes one |
| `update <name> [--display-name N] [--description D] [--email E] [--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]` | Changes the display name, the description, the e-mail address or the OpenID Provider client settings of a service account; `''` removes one; a repeated client option gives all values of its kind |
| `passwd <name>` | Sets the password |
| `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 |
@@ -712,17 +717,20 @@ vpn-router-user otp-remove alice
| `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-enrol <name> <code>` | Stores the secret read from standard input when the code is a current code of it, replacing a secret the user has |
| `otp-remove <name>` | Removes the user's TOTP secret |
| `console <name> (--posix \| --account LOCAL \| --off)` | Switches the console login of a member of `admins` on, as the user's own POSIX account or a local account, or off (see Console login and SSH) |
| `otp-remove <name>` | Removes the user's TOTP secret and ends the sessions of the user's own POSIX account |
| `console <name> (--posix \| --account LOCAL \| --off)` | Switches the console login of a member of `admins` enrolled for TOTP on, as the user's own POSIX account or a local account, or off (see Console login and SSH) |
| `passkeys-remove <name>` | Removes all the user's passkeys (see Passkeys) |
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, and a
service account may instead be named by a GUID in lowercase, such as
`3f2b8c1e-9a4d-4e6f-8b2a-1c5d7e9f0a3b`; a
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
characters, as `otp-new` makes it, and a code 6 digits. Once enrolled, a user logs in to OpenVPN
and `/auth` with the password followed by a current code. In the answers `manager_only`, `service`
and `internal` tell the kind, and a Manager-only account has its names as `null`; `update` changes only its e-mail
address, and `--email ''` removes it. Changing a user's password, disabling, unlocking or removing the user also revokes its
refresh tokens of `/auth`. The exit status is 0 on success, 2 for an invalid request, 3 when the
refresh tokens of `/auth` and its codes, tokens and sessions of the OpenID Provider. 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.
When the Router Manager is installed with an administrator (its debconf questions
@@ -736,9 +744,12 @@ certificates only, so `disable` does not affect it; `remove` revokes the certifi
### Console login and SSH
A member of `admins` may have console login: the Router Manager's Shell page (see The shell),
and, for the user's own POSIX account, SSH and sudo. Only root on the router switches it, with
`vpn-router-user console` or a token of `vpn-router-token`, as for the group `admins`:
A member of `admins` enrolled for TOTP may have console login: the Router Manager's Shell page
(see The shell), and, for the user's own POSIX account, SSH and sudo. Only root on the router
switches it, with `vpn-router-user console` or a token of `vpn-router-token`, as for the group
`admins`. Switching it on is refused without the enrolment, and a user whose enrolment is removed
is refused the shell, SSH and sudo until enrolling again; the user's open shells end within a
minute, and removing the enrolment ends the sessions of the user's own POSIX account:
```sh
vpn-router-user console alice --posix
@@ -750,9 +761,8 @@ vpn-config-edit set ssh.ldap_login true
- **Own POSIX account** (`--posix`): the user's entry becomes a `posixAccount` of the directory
with the user's name, a uid from 20000 up, the local group `vpn-router-console` as its group,
`/home/<name>` and `/bin/bash`. `nslcd` makes it known to the system over slapd's local socket
and checks its password by binding as the user, so a user enrolled for TOTP types the password
followed by a current code, failed logins count towards the lockout, and a disabled user cannot
sign in. The home is created at the first login and kept when the user is removed. Members of
and checks its password by binding as the user, so the user types the password followed by a
current code, failed logins count towards the lockout, and a disabled user cannot sign in. The home is created at the first login and kept when the user is removed. Members of
`vpn-router-console` may run any command with sudo, with the same password. A local account of
the same name is refused.
- **Local account** (`--account`): the console runs as an existing local account with a uid from
@@ -766,12 +776,34 @@ While `ssh.ldap_login` is `true`, setup lets sshd accept the password of the mem
settings. A password change is made in the Router Manager or with `vpn-router-user passwd`, not
with `passwd`.
### Passkeys
A user who is not a service account may sign in to the Router Manager and to the OpenID Provider
with a passkey instead of the password and code. Passkeys are for those sign-ins only: OpenVPN,
the shell, SSH and sudo keep the password and code. A passkey belongs to `wan.local_fqdn`, so the
browser offers it on the HTTPS addresses on that name, and passkeys need the name set; a passkey made
before the name changes is no longer offered. The authenticator checks the user (a PIN or a
fingerprint), and the passkey is kept by it or by the user's password manager.
A user adds a passkey on the Router Manager's Account page with the current password and code,
and removes it there. Each passkey is a value of `vpnRouterPasskey` on the user's entry, with its
public key, label and the time it was added; a user has at most 10. An administrator removes all
passkeys of a user on the Users page, through `/users`, or with:
```sh
vpn-router-user passkeys-remove alice
```
A disabled or locked user is refused a passkey sign-in too.
### Managing groups
`vpn-router-group` manages the groups. A group has a name, with the rules of a user name, an
optional description of at most 256 printable characters, and at least one member, each a user
of the directory. Groups record membership only; nothing on the router reads them. The output,
`--json`, `-h` and `--help` work as for `vpn-router-user`.
of the directory. The group `admins` grants the management of the router, and the allowed groups
of a client of the OpenID Provider admit their members to it; other groups record membership
only. Renaming a group renames it in the clients that allow it, and a group a client allows
cannot be removed. The output, `--json`, `-h` and `--help` work as for `vpn-router-user`.
```sh
vpn-router-group add ops --member alice --member bob --description 'Operations team'
@@ -788,7 +820,7 @@ vpn-router-group remove netops
| `list` | The groups with their members and descriptions |
| `add <name> --member U [--member U ...] [--description D]` | Adds the group with its members |
| `update <name> [--name N] [--description D]` | Renames the group or sets its description; `''` removes it |
| `remove <name>` | Removes the group; its members are kept |
| `remove <name>` | Removes the group; its members are kept; refused while a client of the OpenID Provider allows it |
| `member-add <name> <user>` | Adds the user to the group |
| `member-remove <name> <user>` | Takes the user out of the group; refused for the last member |
@@ -855,8 +887,9 @@ request from one of `web.trusted_proxies`, by default the address the request ca
behind Caddy or a Router Manager on the router it is the browser's. `/status` names the content in
`content` and the Client IP in `client_ip`; `/` keeps the time and the link to `/status`.
The service first makes its signing key and the refresh tokens' database, retrying every 15
seconds, for example while PostgreSQL is not running yet, and logs each failure; then it listens.
The service first makes its signing key, the OpenID Provider's key and the refresh tokens'
database, retrying every 15 seconds, for example while PostgreSQL is not running yet, and logs
each failure; then it listens.
### Bootstrap diagnostics
@@ -887,6 +920,7 @@ curl http://10.0.0.4:17081/
| `/groups` | The groups of users; needs a token |
| `/site-to-site` | The site-to-site connections: list, add, change and remove; needs a token |
| `/account` | The account of the signed-in user: its details, its password and its TOTP enrolment; needs a user's token |
| `/.well-known/openid-configuration`, `/oidc/...` | The OpenID Provider, on `127.0.0.1` only, where Caddy passes the requests (see OpenID Provider) |
The status holds the state of `vpn-router-setup`, `slapd` and each PostgreSQL cluster unit
(`postgresql@<version>-<cluster>`, or `postgresql` as `no cluster` without one), of
@@ -936,7 +970,9 @@ vpn-router-token --json --days 30
| Request | Body | Answer |
| --- | --- | --- |
| `POST /auth` | `{"name", "password", "code"}`; `code` only for a user enrolled for TOTP | the tokens |
| `POST /auth` | `{"name", "password", "code"}`; `code` only for a user enrolled for TOTP | the tokens and `"name"` |
| `POST /auth/passkey` | `{}` | `{"id", "options"}`: a pending sign-in, valid 2 minutes, and the options of `navigator.credentials.get` in their JSON form |
| `POST /auth/passkey/complete` | `{"id", "credential"}`, `credential` the browser's answer as its `toJSON()` gives it | the tokens and `"name"`, the user whose passkey signed |
| `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 |
@@ -945,7 +981,9 @@ The tokens are `{"access_token", "token_type": "Bearer", "expires_in": 300, "ref
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,
directory). A passkey that does not sign, is not the user's, or of a disabled, locked or service
account gets 401 too (see Passkeys). `/auth`, `/auth/passkey/complete` 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 call came in on, the client address is the last entry of
`X-Forwarded-For`.
@@ -969,8 +1007,8 @@ every service and tunnel while the public status page shows less.
### The shell
While `web.shell` is `true`, `/shell` gives a member of `admins` whose console login is on (see
Console login and SSH) a login shell on the router, as the user's console account, for the
While `web.shell` is `true`, `/shell` gives a member of `admins` whose console login is on and
who is enrolled for TOTP (see Console login and SSH) a login shell on the router, as the user's console account, for the
Router Manager's Shell page. It is off by default and switched on and off on the router only:
```sh
@@ -980,17 +1018,20 @@ vpn-config-edit set web.shell true
| Request | Body | Answer |
| --- | --- | --- |
| `POST /shell` | `{"password", "code"}`; `code` only for a user enrolled for TOTP | `{"ticket", "expires_in": 30}` |
| `POST /shell` | `{"password", "code"}`; `{}` within `web.shell_auth_timeout` | `{"ticket", "expires_in": 30}` |
| `GET /shell?ticket=<ticket>` | a WebSocket handshake | the terminal |
`POST /shell` needs an access token of `/auth`, not one of `vpn-router-token`, and signs its user
in again: a wrong password or code gets 403 and counts as a failed login; it gets 403 too while
`web.shell` is `false` or the user's console login is off. The ticket opens one
`web.shell` is `false`, the user's console login is off or the user is not enrolled for TOTP. Without a password it gives a ticket
while the user's last sign-in of `POST /shell` or keystroke in a shell is less than
`web.shell_auth_timeout` minutes ago, and `{"ticket": null}` otherwise. The ticket opens one
shell for the same user within 30 seconds. Over the WebSocket, binary messages of the client
are keystrokes, a text message `{"cols", "rows"}` sets the window size, and binary messages of
the router are the terminal's output. The shell is a PAM login session of the console account,
run by `systemd-run` in a transient unit `vpn-router-shell-<id>`; sudo works as for SSH. It is closed after `web.shell_idle_minutes` without a keystroke, when the shell
exits, or when the connection closes; the router then sends a close frame with the reason. The
exits, when the connection closes, or within a minute of the user's console login being switched
off or the TOTP enrolment removed; the router then sends a close frame with the reason. The
journal of `vpn-router-web` records who opened each shell, from which address, in which unit,
when it ended and why, and each refused ticket or shell with the reason.
@@ -1025,7 +1066,7 @@ and answer bodies are JSON; a request body is at most 65536 bytes.
| Request | Body | Answer |
| --- | --- | --- |
| `GET /users` | | `{"users": [{"name", "manager_only", "service", "internal", "enabled", "locked", "certificate", "otp", "console", "wireguard_peers", "first_name", "last_name", "email"}, ...], "wireguard": {"enabled", "address"}}`; `console` is `null` or `{"enabled", "account", "local", "uid"}`, `wireguard_peers` a list of `{"public_key", "ips"}`, and `wireguard` the endpoint's settings, whose network the peers' addresses come from |
| `GET /users` | | `{"users": [{"name", "manager_only", "service", "internal", "enabled", "locked", "certificate", "otp", "console", "wireguard_peers", "passkeys", "first_name", "last_name", "email"}, ...], "wireguard": {"enabled", "address"}}`; `console` is `null` or `{"enabled", "account", "local", "uid"}`, `wireguard_peers` a list of `{"public_key", "ips"}`, `passkeys` a list of `{"id", "label", "created"}`, and `wireguard` the endpoint's settings, whose network the peers' addresses come from |
| `POST /users` | `{"name", "password", "first_name", "last_name", "email"}` for a VPN user; `{"name", "password", "email"?}` for a Manager-only account | 201 and the user |
| `PUT /users/<name>` | any of `{"first_name", "last_name", "email", "wireguard_peers"}`; `wireguard_peers`, `[{"public_key", "ips"}]`, replaces a VPN user's WireGuard peers (see Configuration); for a Manager-only account only `{"email"}`, `""` removing it | the user |
| `PUT /users/<name>/password` | `{"password": ...}` | the user |
@@ -1037,6 +1078,7 @@ and answer bodies are JSON; a request body is at most 65536 bytes.
| `POST /users/<name>/otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored |
| `PUT /users/<name>/otp` | `{"secret", "code"}` | the user, once the code matches the secret |
| `DELETE /users/<name>/otp` | | the user |
| `DELETE /users/<name>/passkeys` | | the user, without passkeys |
| `PUT /users/<name>/console` | `{"enabled", "account"?}`; only with a token of `vpn-router-token` | the user |
A failure is `{"error": ...}` with 400 for an invalid request, 404 for a user that does not
@@ -1058,16 +1100,19 @@ curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
| Request | Body | Answer |
| --- | --- | --- |
| `GET /service-accounts` | | `{"service_accounts": [{"name", "display_name", "description", "internal", "enabled", "locked", "email", ...}, ...]}`, each as `/users` shows a user |
| `POST /service-accounts` | `{"name", "password", "display_name"?, "description"?, "email"?}` | 201 and the account |
| `PUT /service-accounts/<name>` | any of `{"display_name", "description", "email"}`; `""` removes one | the account |
| `GET /service-accounts` | | `{"service_accounts": [{"name", "display_name", "description", "internal", "enabled", "locked", "email", "redirect_uris", "post_logout_redirect_uris", "allowed_groups", ...}, ...], "oidc": {"enabled", "issuer"}}`, each as `/users` shows a user, and the issuer of the OpenID Provider, `null` while it is disabled |
| `POST /service-accounts` | `{"name", "password", "display_name"?, "description"?, "email"?, "redirect_uris"?, "post_logout_redirect_uris"?, "allowed_groups"?}` | 201 and the account |
| `PUT /service-accounts/<name>` | any of `{"display_name", "description", "email", "redirect_uris", "post_logout_redirect_uris", "allowed_groups"}`; `""` removes one of the first three, `[]` the values of the others | the account |
| `PUT /service-accounts/<name>/password` | `{"password": ...}` | the account |
| `POST /service-accounts/<name>/disable` | | the account |
| `POST /service-accounts/<name>/enable` | | the account |
| `POST /service-accounts/<name>/unlock` | | the account |
| `DELETE /service-accounts/<name>` | | `{"name", "removed": true}` |
A display name has at most 64 printable characters and a description at most 256. The internal
A display name has at most 64 printable characters and a description at most 256. The client
settings are lists of at most 16 values: URIs of `https`, or of `http` for `localhost`,
`127.0.0.1` or `::1`, without a fragment and of at most 1024 characters, and names of existing
groups (see OpenID Provider). The internal
service accounts, `internal` true, are only listed: every other request on one gets 403, as the
router manages them, their display name and description included. A failure is otherwise as for
`/users`, with 404 also for a name that is not a service account.
@@ -1083,8 +1128,11 @@ router manages them, their display name and description included. A failure is o
| `PUT /account/password` | `{"current_password", "current_code", "password"}` | the user |
| `POST /account/otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored |
| `PUT /account/otp` | `{"current_password", "current_code", "secret", "code"}` | the user, once the code matches the secret |
| `POST /account/passkeys` | `{"current_password", "current_code"}` | `{"id", "options"}`: a pending registration, valid 2 minutes, and the options of `navigator.credentials.create` in their JSON form |
| `PUT /account/passkeys` | `{"id", "credential", "label"?}`, `credential` the browser's answer as its `toJSON()` gives it; `label` at most 64 characters, `Passkey` without one | the user |
| `DELETE /account/passkeys/<id>` | | the user |
`current_code` is needed only for a user enrolled for TOTP. A wrong current password or code gets
`current_code` is needed only for a user enrolled for TOTP; removing a passkey needs neither. A wrong current password or code gets
403 and counts as a failed login; slapd accepts each code once, so a code used to sign in is not
accepted again, and a change right after signing in waits for the next code. A user enrols or
replaces the enrolment; removing it is left to `/users` and `vpn-router-user`. Changing the
@@ -1265,6 +1313,76 @@ does on its own: remove the source and the key, run `apt-get update`, and instal
with `apt-get install --allow-downgrades caddy=<version>`, the version `apt-cache policy caddy`
lists for the archive.
## OpenID Provider
While `oidc.enabled` is `true`, the status web service is an OpenID Provider (OpenID Connect
Core 1.0) for the users of the directory. Its issuer is the web service's HTTPS site,
`https://<wan.local_fqdn>:<https.web_port>`, without the port when it is 443; the web service
answers it only on `127.0.0.1`, where Caddy passes the requests, so the provider is never reached
over plain HTTP. The setting needs `web.enabled`, `https.enabled` and `wan.local_fqdn`.
```sh
vpn-config-edit set oidc.enabled true
systemctl restart vpn-router-setup
curl https://router.example.com:7444/.well-known/openid-configuration
```
| Endpoint | Purpose |
| --- | --- |
| `GET /.well-known/openid-configuration` | The discovery document |
| `GET /oidc/jwks` | The public key ID tokens are signed with |
| `GET`, `POST /oidc/authorize` | The authorization code flow; the sign-in page while the browser has no session |
| `POST /oidc/token` | Exchanges a code or a refresh token for tokens |
| `GET`, `POST /oidc/userinfo` | The claims of the user of an access token |
| `GET /oidc/logout` | Ends the session (RP-initiated logout) |
A client is a service account with at least one redirect URI: its name is the `client_id` and its
password the `client_secret`, sent with HTTP Basic authentication (`client_secret_basic`) or in
the body of the token request (`client_secret_post`). The password is checked with a bind, so 5
wrong secrets lock the client for 15 minutes. Its settings are:
- redirect URIs, where the provider sends the browser back with a code, matched exactly; a URI
uses `https`, or `http` for `localhost`, `127.0.0.1` or `::1`;
- post-logout redirect URIs, where `/oidc/logout` may send the browser afterwards;
- allowed groups: only their members may sign in to the client; without any, every user who is
not a service account may.
```sh
echo 'cl1ent-s3cret' | vpn-router-user add 3f2b8c1e-9a4d-4e6f-8b2a-1c5d7e9f0a3b --service \
--display-name Grafana --redirect-uri https://grafana.example.com/login/generic_oauth \
--post-logout-redirect-uri https://grafana.example.com/login --allowed-group ops
vpn-router-user update 3f2b8c1e-9a4d-4e6f-8b2a-1c5d7e9f0a3b --allowed-group ''
```
The flow is the authorization code flow with PKCE, whose `code_challenge_method` must be `S256`;
the implicit and hybrid flows, and the password and client credentials grants, are refused. A
registered client is trusted, so a user is not asked for consent. A user signs in on the
provider's page with the name, the password and, when enrolled, the TOTP code, or with a passkey
(see Passkeys); failed sign-ins count towards the lockout, and `POST /oidc/login` takes at most
10 calls a minute from one client address, as `/auth` does. A
disabled, locked or removed user, a service account, and a user outside the client's allowed
groups cannot sign in, and the codes and tokens already issued to such a user stop working. The
sign-in starts a session, a cookie of the issuer's `/oidc/` path valid for 8 hours, so later
requests of any client go on without the page; `prompt=login` and `max_age` ask for a new sign-in,
and `prompt=none` answers `login_required` without a session.
| Scope | Claims |
| --- | --- |
| `openid` (required) | `sub`, the `entryUUID` of the user's entry |
| `profile` | `preferred_username` (the user name), `name`, `given_name`, `family_name` |
| `email` | `email` |
| `groups` | `groups`, the names of the user's groups |
The claims of the scopes granted are in the ID token and in the answer of `/oidc/userinfo`. ID
tokens are RS256 JWTs, valid for 5 minutes, with `auth_time` and `amr` (`pwd`, `pwd` and `otp`, or
`hwk` and `user` for a passkey), signed with an RSA key of 3072 bits that the web service makes on
its first run, `/etc/vpn-router/oidc.key` (root only), and named in the JWKS by its RFC 7638
thumbprint. A code is valid for 1 minute and used once, an access token for 5 minutes, and a
refresh token for 8 hours and used once: each refresh answers a new one with a new ID token. The
codes, tokens and sessions are kept, as their SHA-256, in the database of the refresh tokens of
`/auth`. Changing a user's password, disabling, unlocking or removing the user ends its sessions
and revokes its codes and tokens.
## Metrics
While `metrics.enabled` is `true`, `vpn-router-setup` runs `vpn-router-metrics`, which every minute
@@ -1487,7 +1605,7 @@ may be in use by something else.
`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`
before deleting it so a later reinstall starts clean. It also removes the web service's
signing key, the password file of the metrics service account, and the database of the refresh
signing key and the OpenID Provider's, the password file of the metrics service account, and the database of the refresh
tokens and the metrics, its role and their password
file when the package created them, and Caddy's configuration `/etc/caddy/vpn-router.Caddyfile`; the certificate in
`/var/lib/caddy` belongs to the `caddy` package. It removes the backup timer's schedule, the
+82
View File
@@ -1,3 +1,85 @@
vpn-router (0.27.0-1) unstable; urgency=medium
feature: the web service is an OpenID Provider, which signs the directory's users in to other systems, the clients being service accounts with redirect URIs.
* oidc.enabled turns on the OpenID Provider at the issuer
https://<wan.local_fqdn>:<https.web_port>, with discovery, JWKS,
authorize, token, userinfo and RP-initiated logout under /oidc/,
answered only through Caddy. The authorization code flow with PKCE
S256 and refresh tokens are offered; ID tokens are RS256, signed with
/etc/vpn-router/oidc.key, which the web service makes on its first run.
* Users sign in on the provider's page with the password and the TOTP
code or with a passkey; the session lasts 8 hours. The scopes openid,
profile, email and groups give sub (the entry's entryUUID), the names,
the e-mail address and the user's groups.
* A service account with redirect URIs is a client: its name is the
client ID and its password the secret. Its redirect URIs,
post-logout redirect URIs and allowed groups are set with
vpn-router-user add/update --redirect-uri, --post-logout-redirect-uri
and --allowed-group, and in /service-accounts as redirect_uris,
post_logout_redirect_uris and allowed_groups; GET /service-accounts
also answers the issuer. vpn-router-user list shows them.
* Changing a user's password, disabling, unlocking or removing the user
also revokes the user's codes, tokens and sessions of the provider.
* Renaming a group renames it in the clients that allow it, and a group
a client allows cannot be removed.
* The package depends on python3-oauthlib and python3-jwt.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 14:00:00 +0100
vpn-router (0.26.2-1) unstable; urgency=medium
fix: a service account may be named by a GUID in lowercase, as the Router Manager proposes for a new one.
* vpn-router-user add --service and POST /service-accounts take a GUID
in lowercase as the name, besides a user name; users and groups keep
the rules of user names.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 10:00:00 +0100
vpn-router (0.26.1-1) unstable; urgency=medium
fix: the package installs vpnrouter_passkeys, without which the web service and vpn-router-user do not start.
* /usr/lib/vpn-router/vpnrouter_passkeys.py is part of the package.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 08:45:00 +0100
vpn-router (0.26.0-1) unstable; urgency=medium
feature: users sign in to the Router Manager with passkeys, and console login needs a TOTP token, so a user without one is refused the shell, SSH and sudo.
* Console login is switched on only for a user enrolled for TOTP. A user
whose console login is on without a token is refused POST /shell, the
shell's WebSocket, SSH and sudo; an open shell ends within a minute of
the token's removal or of console login being switched off, and
removing the token ends the sessions of the user's own POSIX account.
* POST /auth/passkey and /auth/passkey/complete sign a user in with a
passkey of wan.local_fqdn, giving the tokens of /auth; POST /auth also
answers the user's name.
* POST, PUT /account/passkeys and DELETE /account/passkeys/<id> add, with
the current password and code, and remove the user's passkeys, at most
10, kept in the new attribute vpnRouterPasskey; DELETE
/users/<name>/passkeys and vpn-router-user passkeys-remove remove all
passkeys of a user.
* Users have passkeys, [{"id", "label", "created"}], in /users and
/account; vpn-router-user list shows their number and a console login
refused for want of a TOTP token.
* The package depends on python3-fido2.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 08:00:00 +0100
vpn-router (0.25.4-1) unstable; urgency=medium
fix: a shell opens without the password and code again while the user's last use of a shell is within web.shell_auth_timeout minutes.
* New setting web.shell_auth_timeout, 15 minutes by default, 0 to 1440:
POST /shell without a password gives a ticket while the user's last
sign-in of POST /shell or keystroke in a shell is within it, and a
null ticket otherwise; 0 asks for the password and code every time.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 06:00:00 +0100
vpn-router (0.25.3-1) unstable; urgency=medium
fix: /config gives the state of caddy.service with the installed Caddy, for the Router Manager's Web page.
+5 -1
View File
@@ -22,6 +22,9 @@ Depends: ${misc:Depends},
python3-jinja2,
python3-ldap,
python3-pyotp,
python3-fido2,
python3-jwt,
python3-oauthlib,
python3-psycopg,
python3-psutil,
python3-prometheus-client,
@@ -48,7 +51,8 @@ Description: Linux VPN router with IPSec, road-warrior, OpenVPN and WireGuard su
needs. Its two-level CA, whose root may be kept offline, has issuing CAs for
the VPN clients, for site-to-site and for general use, and publishes their
certificates and CRLs. Caddy, installed by the package from the Ubuntu
archive or from Caddy's repository, serves HTTPS. It keeps metrics of the
archive or from Caddy's repository, serves HTTPS, and an optional OpenID
Provider signs the directory's users in to other systems. It keeps metrics of the
router in PostgreSQL and serves them for an OpenTelemetry Collector, and
makes scheduled backups to a local folder, copied to the cloud with rclone,
or to a Proxmox Backup Server. Nothing in the package is specific to a cloud
+3
View File
@@ -4,7 +4,9 @@ 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_serve.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_users.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_passkeys.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_auth.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_oidc.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_metrics.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_status.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_backup.py usr/lib/vpn-router/
@@ -28,5 +30,6 @@ src/usr/lib/vpn-router/wait-for-libpq usr/lib/vpn-router/
src/usr/lib/vpn-router/generate-config usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_platforms/*.py usr/lib/vpn-router/vpnrouter_platforms/
src/usr/share/vpn-router/templates/* usr/share/vpn-router/templates/
src/usr/share/vpn-router/sql/* usr/share/vpn-router/sql/
src/usr/lib/systemd/system/caddy.service.d/vpn-router.conf usr/lib/systemd/system/caddy.service.d/
src/usr/share/doc/vpn-router/vpn-router.json.example usr/share/doc/vpn-router/
+1
View File
@@ -52,6 +52,7 @@ case "$1" in
/etc/caddy/vpn-router.Caddyfile \
/etc/vpn-router/vpn-router.json \
/etc/vpn-router/web.key \
/etc/vpn-router/oidc.key \
/etc/vpn-router/metrics.secret \
/etc/systemd/system/vpn-router-backup.timer.d/schedule.conf \
/etc/sudoers.d/vpn-router \
+1 -1
View File
@@ -587,7 +587,7 @@ def manage_console(cfg):
f'base {vpnrouter_ldap.BASE}',
'filter passwd (&(objectClass=posixAccount)(vpnRouterConsoleLogin=TRUE))',
f'nss_min_uid {vpnrouter_ldap.CONSOLE_UID_MIN}',
'pam_authz_search (&(objectClass=posixAccount)(uid=$username)(vpnRouterConsoleLogin=TRUE))',
'pam_authz_search (&(objectClass=posixAccount)(objectClass=oathTOTPUser)(uid=$username)(vpnRouterConsoleLogin=TRUE))',
'pam_password_prohibit_message The password is changed in the Router Manager or with vpn-router-user.',
'',
])
@@ -58,9 +58,10 @@ SCHEMA = {
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
'docker': {'mode': 'auto', 'exposed_ports': []},
'web': {'enabled': True, 'listen': DEFAULT_LISTEN, 'port': 7081, 'database': '', 'trusted_proxies': [],
'status_content': 'basic', 'shell': False, 'shell_idle_minutes': 15},
'status_content': 'basic', 'shell': False, 'shell_idle_minutes': 15, 'shell_auth_timeout': 15},
'ssh': {'ldap_login': False},
'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443, 'caddy_source': 'ubuntu'},
'oidc': {'enabled': False},
'pki': {'root_name': 'VPN Router Root CA', 'root_days': 7300, 'ca_days': 3650, 'key': 'rsa-4096',
'crl_days': 7, 'root_crl_days': 180, 'publish': ['http'], 'p2s_name': 'VPN Router P2S CA',
's2s_name': 'VPN Router Site-to-Site CA', 'issuing_cas': {}, 'templates': {},
@@ -201,7 +202,7 @@ CA_NAME_MAX = 64
#: What / and /status of the web service show, web.status_content, from the least to the most.
STATUS_CONTENTS = ('basic', 'standard', 'full')
#: The longest web.shell_idle_minutes, a day.
#: The longest web.shell_idle_minutes and web.shell_auth_timeout, a day.
SHELL_IDLE_MAX = 1440
DOCKER_MODES = ('auto', 'on', 'off')
@@ -499,6 +500,9 @@ def validate(cfg):
shell_idle = _get(cfg, 'web', 'shell_idle_minutes')
if not 1 <= shell_idle <= SHELL_IDLE_MAX:
raise ConfigError(f'web.shell_idle_minutes: {shell_idle} is not between 1 and {SHELL_IDLE_MAX}')
shell_auth = _get(cfg, 'web', 'shell_auth_timeout')
if not 0 <= shell_auth <= SHELL_IDLE_MAX:
raise ConfigError(f'web.shell_auth_timeout: {shell_auth} is not between 0 and {SHELL_IDLE_MAX}')
https_ports = []
for option in ('web_port', 'manager_port'):
@@ -518,6 +522,12 @@ def validate(cfg):
if caddy_source not in CADDY_SOURCES:
raise ConfigError(f'https.caddy_source: {caddy_source!r} is not one of {", ".join(CADDY_SOURCES)}')
if _get(cfg, 'oidc', 'enabled') and not (_get(cfg, 'web', 'enabled') and _get(cfg, 'https', 'enabled')
and _get(cfg, 'wan', 'local_fqdn')):
raise ConfigError('oidc.enabled: the OpenID Provider is the web service served by Caddy at '
'https://<wan.local_fqdn>:<https.web_port>, which needs web.enabled, https.enabled and '
'wan.local_fqdn')
for option in ('root_name', 'p2s_name', 's2s_name'):
value = _get(cfg, 'pki', option)
if not value or len(value) > CA_NAME_MAX or not value.isprintable():
@@ -743,6 +753,12 @@ def pki_base_urls(cfg):
return [bases[item] for item in _get_list(cfg, 'pki', 'publish') if item in bases] if fqdn else []
def oidc_issuer(cfg):
"""The issuer of the OpenID Provider: the web service's HTTPS site, without the port when it is HTTPS_DEFAULT_PORT."""
port = _get(cfg, 'https', 'web_port')
return f'https://{_get(cfg, "wan", "local_fqdn")}' + ('' if port == HTTPS_DEFAULT_PORT else f':{port}')
def resolve_psk(connection, name):
"""Return the pre-shared key of the site_to_site connection: psk_file wins, else psk; '' when none is configured."""
psk_file = connection['psk_file'].strip()
@@ -10,6 +10,9 @@ Every failure of the database raises DatabaseError, the only database exception
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 on the
web service's first run, with the password in DATABASE_SECRET. A refresh token is used once: refreshing replaces it.
The same database holds the state of the OpenID Provider (vpnrouter_oidc), whose tables and procedures OIDC_SQL
defines; the first connection of a process loads it. revoke() removes a user's refresh tokens and OpenID Provider
codes, tokens and sessions.
"""
import base64
@@ -26,6 +29,7 @@ import psycopg
WEB_KEY = pathlib.Path('/etc/vpn-router/web.key')
DATABASE_SECRET = pathlib.Path('/etc/vpn-router/database.secret')
OIDC_SQL = pathlib.Path('/usr/share/vpn-router/sql/oidc.sql')
#: The role and the database provision() creates when web.database is not set.
DATABASE = 'vpn-router'
@@ -45,6 +49,9 @@ LEEWAY = 60
SCHEMA = 'CREATE TABLE IF NOT EXISTS refresh_tokens (hash text PRIMARY KEY, name text NOT NULL, expires timestamptz NOT NULL)'
#: Whether this process has loaded OIDC_SQL.
_oidc_loaded = False
class DatabaseError(Exception):
"""The database of the refresh tokens failed."""
@@ -131,10 +138,19 @@ def connection_string(cfg):
@contextlib.contextmanager
def database(cfg):
"""A connection to the database of the refresh tokens, with their table, closed at the end; DatabaseError on any failure."""
"""A connection to the database of the refresh tokens, with their table and the OpenID Provider's, closed at the end;
DatabaseError on any failure."""
global _oidc_loaded
try:
oidc = None if _oidc_loaded else OIDC_SQL.read_text(encoding='utf-8')
except OSError as exc:
raise DatabaseError(f'{OIDC_SQL} cannot be read: {exc}') from exc
try:
with psycopg.connect(connection_string(cfg), autocommit=True) as connection:
connection.execute(SCHEMA)
if oidc is not None:
connection.execute(oidc)
_oidc_loaded = True
yield connection
except psycopg.Error as exc:
raise DatabaseError(f'the token database failed: {exc}') from exc
@@ -173,9 +189,10 @@ def take(cfg, token):
def revoke(cfg, name):
"""Remove all refresh tokens of the user."""
"""Remove all refresh tokens of the user, and its codes, tokens and sessions of the OpenID Provider."""
with database(cfg) as connection:
connection.execute('DELETE FROM refresh_tokens WHERE name = %s', (name,))
connection.execute('CALL oidc_revoke(%s)', (name,))
def psql(sql, name):
@@ -67,7 +67,11 @@ OVERLAYS = ('ppolicy', 'otp')
#: description, the labels of a service account, and vpnRouterConsoleLogin and
#: vpnRouterConsoleAccount, whether the user may use a console and the local account it runs as,
#: without which it runs as the user's own posixAccount, and vpnRouterWireGuardPeer, each a
#: WireGuard peer of the user as "<public key> <IP>[,<IP>...]". vpnRouterAuthority is a CA of the PKI: its
#: WireGuard peer of the user as "<public key> <IP>[,<IP>...]", and vpnRouterPasskey, each a
#: passkey of the user as JSON (vpnrouter_passkeys). A service account with vpnRouterRedirectUri
#: is a client of the OpenID Provider (vpnrouter_oidc): the URIs it is sent back to after a sign-in
#: and, vpnRouterPostLogoutRedirectUri, after a sign-out, and vpnRouterAllowedGroup, the DNs of the
#: groups whose members may sign in to it. vpnRouterAuthority is a CA of the PKI: its
#: certificate and CRL (cACertificate and certificateRevocationList of RFC 4523), its key, the
#: CSR it waits with for the offline root, the number of its last CRL, where the CRL came from,
#: and when it was retired. vpnRouterCertificate is a certificate a CA issued, named by its
@@ -100,11 +104,20 @@ SCHEMA_ATTRIBUTE_TYPES = (
f"{_STRING} )",
f"( {SCHEMA_OID}.1.15 NAME 'vpnRouterWireGuardPeer' DESC 'VPN router: a WireGuard peer of the user, its public key and IPs' "
"EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )",
f"( {SCHEMA_OID}.1.16 NAME 'vpnRouterPasskey' DESC 'VPN router: a passkey of the user, its credential as JSON' "
"EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{4096} )",
f"( {SCHEMA_OID}.1.17 NAME 'vpnRouterRedirectUri' DESC 'VPN router: a redirect URI of an OpenID Connect client' "
"EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )",
f"( {SCHEMA_OID}.1.18 NAME 'vpnRouterPostLogoutRedirectUri' DESC 'VPN router: a post-logout redirect URI of an "
"OpenID Connect client' EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )",
f"( {SCHEMA_OID}.1.19 NAME 'vpnRouterAllowedGroup' DESC 'VPN router: a group whose members may sign in to an "
"OpenID Connect client' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 )",
)
SCHEMA_OBJECT_CLASSES = (
f"( {SCHEMA_OID}.2.1 NAME 'vpnRouterAccount' DESC 'VPN router: properties of a user or a Manager-only account' "
"SUP top AUXILIARY MAY ( mail $ vpnRouterScope $ displayName $ description $ vpnRouterConsoleLogin $ "
"vpnRouterConsoleAccount $ vpnRouterWireGuardPeer ) )",
"vpnRouterConsoleAccount $ vpnRouterWireGuardPeer $ vpnRouterPasskey $ vpnRouterRedirectUri $ "
"vpnRouterPostLogoutRedirectUri $ vpnRouterAllowedGroup ) )",
f"( {SCHEMA_OID}.2.2 NAME 'vpnRouterAuthority' DESC 'VPN router: a CA of the PKI' SUP top STRUCTURAL MUST cn "
"MAY ( cACertificate $ certificateRevocationList $ vpnRouterPrivateKey $ vpnRouterCertificateRequest $ "
"vpnRouterCRLNumber $ vpnRouterCRLSource $ vpnRouterRetired ) )",
@@ -0,0 +1,584 @@
"""The router's OpenID Provider, for the web service, with python3-oauthlib and python3-jwt.
While oidc.enabled is true, the web service answers OpenID Connect at the issuer vpnrouter.oidc_issuer, its HTTPS site
that Caddy serves; the web service answers it only on the loopback address, where Caddy passes the requests. The users
of the router's directory sign in, with the password and the TOTP code or with a passkey (vpnrouter_passkeys), to its
clients, the service accounts with redirect URIs (vpnrouter_users.client_settings): a client's ID is its name and its
secret its password, sent with HTTP Basic authentication or in the body of the token request. A client with allowed
groups admits only their members; a service account never signs in as a user.
GET /.well-known/openid-configuration the discovery document
GET /oidc/jwks the public key the ID tokens are signed with
GET, POST /oidc/authorize the authorization code flow, PKCE with S256 required; the sign-in page while
the browser has no session, or with prompt=login or an older one than max_age
POST /oidc/login the sign-in page's form: the name, the password and the code, or a passkey
POST /oidc/passkey begin a sign-in with a passkey: {"id", "options"}
POST /oidc/token exchange a code or a refresh token for tokens
GET, POST /oidc/userinfo the claims of the user an access token was issued for
GET /oidc/logout end the session, then go to a post-logout redirect URI of the client
ID tokens are RS256 JWTs signed with KEY, an RSA key the web service makes on its first run on the router, named by
the RFC 7638 thumbprint of its public key. sub is the entryUUID of the user's entry, so a user added again under the
same name is another subject. The scopes: openid, required; profile, preferred_username and the names; email; groups,
the names of the user's groups; their claims are in the ID token and the userinfo answer. A code is valid
CODE_SECONDS, an access token and an ID token ACCESS_SECONDS, a refresh token REFRESH_SECONDS, used once. A session,
the cookie COOKIE of the issuer's /oidc/ path, lasts SESSION_SECONDS. Codes, tokens and sessions are kept in the
database of vpnrouter_auth as their SHA-256 (vpnrouter_auth.OIDC_SQL); vpnrouter_auth.revoke removes a user's. A
client is registered by an administrator, so a user is not asked for consent.
"""
import base64
import collections
import contextlib
import datetime
import hashlib
import http.cookies
import json
import pathlib
import secrets
import time
import types
import urllib.parse
import jinja2
import jwt
import ldap
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from oauthlib.common import add_params_to_uri
from oauthlib.oauth2 import AccessDeniedError, FatalClientError, InvalidRequestError, OAuth2Error
from oauthlib.openid import RequestValidator, Server
from oauthlib.openid.connect.core.exceptions import LoginRequired
import vpnrouter
import vpnrouter_auth
import vpnrouter_ldap
import vpnrouter_passkeys
import vpnrouter_users
KEY = pathlib.Path('/etc/vpn-router/oidc.key')
KEY_BITS = 3072
CODE_SECONDS = 60
ACCESS_SECONDS = 300
REFRESH_SECONDS = 8 * 3600
SESSION_SECONDS = 8 * 3600
COOKIE = 'vpn_router_oidc'
#: The claims of each scope but openid, which gives sub.
SCOPE_CLAIMS = {
'profile': ('preferred_username', 'name', 'given_name', 'family_name'),
'email': ('email',),
'groups': ('groups',),
}
SCOPES = ('openid', *SCOPE_CLAIMS)
#: The authentication methods (RFC 8176) of a sign-in with a password, with a password and a TOTP code, and with a
#: passkey, which verifies the user.
PASSWORD_AMR = ['pwd']
OTP_AMR = ['pwd', 'otp']
PASSKEY_AMR = ['hwk', 'user']
TEMPLATE = 'oidc-page.html.j2'
#: The headers of the pages: no framing, and nothing loaded but from the page itself and the router.
PAGE_HEADERS = [('Content-Security-Policy', "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; "
"connect-src 'self'; frame-ancestors 'none'")]
#: The headers of oauthlib's answers that the web service sets itself.
OWN_HEADERS = ('content-type', 'cache-control', 'content-length')
#: A user's sign-in at the OpenID Provider: the user name, when, a datetime, and how (amr).
Session = collections.namedtuple('Session', 'name auth_time amr')
_pages = jinja2.Environment(loader=jinja2.FileSystemLoader(str(vpnrouter.TEMPLATE_DIR)), autoescape=True,
undefined=jinja2.StrictUndefined)
def ensure_key():
"""Make the signing key when it is missing."""
if KEY.is_file() and KEY.stat().st_size:
return
key = rsa.generate_private_key(public_exponent=65537, key_size=KEY_BITS)
KEY.parent.mkdir(parents=True, exist_ok=True)
KEY.touch(mode=0o600)
KEY.chmod(0o600)
KEY.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
serialization.NoEncryption()))
def private_key():
return serialization.load_pem_private_key(KEY.read_bytes(), None)
def public_jwk(key):
"""The JWK of the key's public key, its kid the RFC 7638 thumbprint."""
numbers = key.public_key().public_numbers()
jwk = {'e': numbers.e, 'kty': 'RSA', 'n': numbers.n}
jwk = {name: vpnrouter_auth.b64encode(value.to_bytes((value.bit_length() + 7) // 8, 'big')) if isinstance(value, int)
else value for name, value in jwk.items()}
thumbprint = hashlib.sha256(json.dumps(jwk, sort_keys=True, separators=(',', ':')).encode()).digest()
return {**jwk, 'kid': vpnrouter_auth.b64encode(thumbprint), 'use': 'sig', 'alg': 'RS256'}
def sign(claims):
key = private_key()
return jwt.encode(claims, key, algorithm='RS256', headers={'kid': public_jwk(key)['kid']})
def discovery(issuer):
"""The discovery document of OpenID Connect Discovery 1.0."""
return {
'issuer': issuer,
'authorization_endpoint': f'{issuer}/oidc/authorize',
'token_endpoint': f'{issuer}/oidc/token',
'userinfo_endpoint': f'{issuer}/oidc/userinfo',
'jwks_uri': f'{issuer}/oidc/jwks',
'end_session_endpoint': f'{issuer}/oidc/logout',
'scopes_supported': list(SCOPES),
'response_types_supported': ['code'],
'response_modes_supported': ['query'],
'grant_types_supported': ['authorization_code', 'refresh_token'],
'subject_types_supported': ['public'],
'id_token_signing_alg_values_supported': ['RS256'],
'token_endpoint_auth_methods_supported': ['client_secret_basic', 'client_secret_post'],
'code_challenge_methods_supported': ['S256'],
'claims_supported': ['iss', 'sub', 'aud', 'exp', 'iat', 'auth_time', 'nonce', 'amr', 'at_hash',
*(claim for claims in SCOPE_CLAIMS.values() for claim in claims)],
}
@contextlib.contextmanager
def directory():
"""A connection to the router's directory as the user manager account, for reading."""
connection = vpnrouter_ldap.manager()
try:
yield connection
finally:
connection.unbind_s()
def client_of(client_id):
"""The client of the ID, a namespace of its vpnrouter_users.summary with client_id, when it is an enabled service
account with redirect URIs; else None."""
if not isinstance(client_id, str):
return None
with directory() as connection:
try:
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, client_id))
except vpnrouter_users.Failure:
return None
if not user['service'] or not user['enabled'] or not user['redirect_uris']:
return None
return types.SimpleNamespace(client_id=client_id, **user)
def subject(name, client):
"""The claims of every scope of the user, with sub, when the user may sign in to the client: an enabled, unlocked
user who is not a service account, a member of one of the client's allowed groups when it has any; else None."""
with directory() as connection:
try:
attributes = vpnrouter_users.entry(connection, name)
except vpnrouter_users.Failure:
return None
user = vpnrouter_users.summary(attributes)
groups = sorted(vpnrouter_users.groups_of(connection, name))
uuid = connection.search_s(vpnrouter_users.dn_of(name), ldap.SCOPE_BASE, attrlist=['entryUUID'])[0][1]['entryUUID']
if user['service'] or not user['enabled'] or user['locked']:
return None
if client.allowed_groups and not set(client.allowed_groups) & set(groups):
return None
full = ' '.join(part for part in (user['first_name'], user['last_name']) if part)
claims = {'sub': uuid[0].decode(), 'preferred_username': name, 'name': full or None, 'given_name': user['first_name'],
'family_name': user['last_name'], 'email': user['email'], 'groups': groups}
return {claim: value for claim, value in claims.items() if value is not None}
def claims_of(person, scopes):
"""The claims of the scopes of a subject's claims (subject)."""
return {'sub': person['sub'], **{claim: person[claim] for scope in scopes for claim in SCOPE_CLAIMS.get(scope, ())
if claim in person}}
def cookie_value(cookies):
"""The value of the session cookie in a Cookie header, or ''."""
try:
morsel = http.cookies.SimpleCookie(cookies or '').get(COOKIE)
except http.cookies.CookieError:
return ''
return morsel.value if morsel is not None else ''
def session_of(cfg, cookies):
"""The unexpired Session the session cookie in a Cookie header names, or None."""
value = cookie_value(cookies)
if not value:
return None
with vpnrouter_auth.database(cfg) as connection:
row = connection.execute('SELECT * FROM oidc_session_get(%s)', (vpnrouter_auth.digest(value),)).fetchone()
return Session(*row) if row else None
class Validator(RequestValidator):
"""The checks and the storage of oauthlib's server for the router's clients, users and database.
Only the authorization code grant with PKCE S256 and the refresh token grant are allowed. The values of a code, a
token or a session found are kept on the request as oidc_code, oidc_scopes and oidc_person.
"""
def __init__(self, cfg):
super().__init__()
self.cfg = cfg
def fetch(self, sql, values):
with vpnrouter_auth.database(self.cfg) as connection:
return connection.execute(sql, values).fetchone()
def call(self, *statements):
with vpnrouter_auth.database(self.cfg) as connection:
for sql, values in statements:
connection.execute(sql, values)
def session(self, request):
return session_of(self.cfg, request.headers.get('Cookie'))
# Clients.
def validate_client_id(self, client_id, request, *args, **kwargs):
request.client = client_of(client_id)
return request.client is not None
def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs):
return redirect_uri in request.client.redirect_uris
def get_default_redirect_uri(self, client_id, request, *args, **kwargs):
return None
def validate_response_type(self, client_id, response_type, client, request, *args, **kwargs):
return response_type == 'code'
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
return 'openid' in scopes and set(scopes) <= set(SCOPES)
def get_default_scopes(self, client_id, request, *args, **kwargs):
return ['openid']
def is_pkce_required(self, client_id, request):
return True
def authenticate_client(self, request, *args, **kwargs):
"""Check the client's secret, the password of its service account, given with HTTP Basic authentication, its
ID and secret form-encoded (RFC 6749 2.3.1), or in the body."""
name, secret = request.client_id, request.client_secret
header = request.headers.get('Authorization', '')
if header[:6].lower() == 'basic ':
try:
name, _, secret = base64.b64decode(header[6:].strip(), validate=True).decode().partition(':')
except (ValueError, UnicodeDecodeError):
return False
name, secret = urllib.parse.unquote_plus(name), urllib.parse.unquote_plus(secret)
client = client_of(name)
if client is None:
return False
try:
vpnrouter_users.login(name, secret, None)
except vpnrouter_users.Failure:
return False
request.client = client
return True
def authenticate_client_id(self, client_id, request, *args, **kwargs):
return False
def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs):
return grant_type in ('authorization_code', 'refresh_token')
def validate_user(self, username, password, client, request, *args, **kwargs):
return False
# Codes.
def save_authorization_code(self, client_id, code, request, *args, **kwargs):
self.call(('CALL oidc_code_save(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)',
(vpnrouter_auth.digest(code['code']), client_id, request.user, request.redirect_uri, request.scopes,
request.nonce, request.code_challenge, request.code_challenge_method, request.auth_time, request.amr,
CODE_SECONDS)))
def code(self, code):
row = self.fetch('SELECT * FROM oidc_code_get(%s)', (vpnrouter_auth.digest(code),)) if isinstance(code, str) else None
return dict(zip(('client_id', 'name', 'redirect_uri', 'scopes', 'nonce', 'code_challenge', 'code_challenge_method',
'auth_time', 'amr'), row)) if row else None
def get_authorization_code_scopes(self, client_id, code, redirect_uri, request):
found = self.code(code)
return found['scopes'] if found else []
def validate_code(self, client_id, code, client, request, *args, **kwargs):
found = self.code(code)
if found is None or found['client_id'] != client_id:
return False
person = subject(found['name'], client)
if person is None:
return False
request.user, request.scopes, request.auth_time, request.amr = found['name'], found['scopes'], found['auth_time'], found['amr']
request.oidc_code, request.oidc_person = found, person
return True
def get_code_challenge(self, code, request):
return request.oidc_code['code_challenge']
def get_code_challenge_method(self, code, request):
return request.oidc_code['code_challenge_method']
def confirm_redirect_uri(self, client_id, code, redirect_uri, client, request, *args, **kwargs):
return redirect_uri == request.oidc_code['redirect_uri']
def get_authorization_code_nonce(self, client_id, code, redirect_uri, request):
return request.oidc_code['nonce']
def invalidate_authorization_code(self, client_id, code, request, *args, **kwargs):
self.call(('CALL oidc_code_delete(%s)', (vpnrouter_auth.digest(code),)))
# Tokens.
def token(self, token, kind):
row = self.fetch('SELECT * FROM oidc_token_get(%s, %s)', (vpnrouter_auth.digest(token), kind)) \
if isinstance(token, str) and token else None
return dict(zip(('client_id', 'name', 'scopes', 'auth_time', 'amr'), row)) if row else None
def save_bearer_token(self, token, request, *args, **kwargs):
"""Keep the access token and the refresh token; a refresh token that was exchanged is used up."""
values = (request.client.client_id, request.user, request.scopes, request.auth_time, request.amr)
statements = [('CALL oidc_token_save(%s, %s, %s, %s, %s, %s, %s, %s)',
(vpnrouter_auth.digest(token['access_token']), 'access', *values, ACCESS_SECONDS))]
if token.get('refresh_token'):
statements.append(('CALL oidc_token_save(%s, %s, %s, %s, %s, %s, %s, %s)',
(vpnrouter_auth.digest(token['refresh_token']), 'refresh', *values, REFRESH_SECONDS)))
if request.grant_type == 'refresh_token':
statements.append(('CALL oidc_token_delete(%s)', (vpnrouter_auth.digest(request.refresh_token),)))
self.call(*statements)
def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs):
found = self.token(refresh_token, 'refresh')
if found is None or found['client_id'] != client.client_id:
return False
person = subject(found['name'], client)
if person is None:
return False
request.user, request.auth_time, request.amr = found['name'], found['auth_time'], found['amr']
request.oidc_scopes, request.oidc_person = found['scopes'], person
return True
def get_original_scopes(self, refresh_token, request, *args, **kwargs):
return request.oidc_scopes
def validate_bearer_token(self, token, scopes, request):
found = self.token(token, 'access')
if found is None or not set(scopes) <= set(found['scopes']):
return False
client = client_of(found['client_id'])
person = subject(found['name'], client) if client else None
if person is None:
return False
request.client, request.user, request.oidc_scopes, request.oidc_person = client, found['name'], found['scopes'], person
return True
def get_userinfo_claims(self, request):
return claims_of(request.oidc_person, request.oidc_scopes)
# OpenID Connect.
def finalize_id_token(self, id_token, token, token_handler, request):
now = int(time.time())
id_token.update(claims_of(request.oidc_person, request.scopes))
id_token.update({'iss': vpnrouter.oidc_issuer(self.cfg), 'exp': now + ACCESS_SECONDS,
'auth_time': int(request.auth_time.timestamp()), 'amr': request.amr})
return sign(id_token)
def validate_silent_login(self, request):
session = self.session(request)
return session is not None and subject(session.name, request.client) is not None
def validate_silent_authorization(self, request):
return True
def validate_user_match(self, id_token_hint, scopes, claims, request):
"""Whether the user of the session, when there is one, is the subject of an ID token the router signed."""
session = self.session(request) if id_token_hint else None
if session is None:
return True
try:
hint = jwt.decode(id_token_hint, private_key().public_key(), algorithms=['RS256'],
options={'verify_exp': False, 'verify_aud': False})
except jwt.InvalidTokenError:
return False
person = subject(session.name, request.client)
return person is not None and hint.get('sub') == person['sub']
def page(status, cfg, title, message='', error='', form=False, request='', name='', headers=()):
"""An HTML page of TEMPLATE: a message, an error, and with form the sign-in form for the authorization request."""
body = _pages.get_template(TEMPLATE).render(title=title, message=message, error=error, form=form, request=request,
name=name, issuer=vpnrouter.oidc_issuer(cfg))
return status, 'text/html; charset=utf-8', body.encode(), [*PAGE_HEADERS, *headers]
def document(status, value, headers=()):
return status, 'application/json', json.dumps(value, indent=2).encode() + b'\n', list(headers)
def relayed(headers, body, status):
"""An answer of oauthlib's server, without the headers the web service sets."""
kind = next((value for name, value in headers.items() if name.lower() == 'content-type'), 'application/json')
return status, kind, (body or '').encode(), [(name, value) for name, value in headers.items()
if name.lower() not in OWN_HEADERS]
def redirect(location, headers=()):
return 302, 'text/plain; charset=utf-8', b'', [('Location', location), *headers]
def cookie(value, seconds):
return 'Set-Cookie', f'{COOKIE}={value}; Path=/oidc/; Max-Age={seconds}; Secure; HttpOnly; SameSite=Lax'
def authorize(server, cfg, params, headers, fresh=None, name='', error=''):
"""Answer an authorization request, the query or the form params: the sign-in page while the browser has no
session, or with prompt=login or max_age older than the session, else a redirect to the client with a code.
fresh is the session of a sign-in just made, which counts whatever prompt and max_age say; the redirect then sets
its cookie, fresh.cookie. error is why a sign-in failed: the sign-in page shows it, filled with the name.
"""
uri = f'{vpnrouter.oidc_issuer(cfg)}/oidc/authorize?{params}'
try:
scopes, info = server.validate_authorization_request(uri, 'GET', None, headers)
except FatalClientError as error:
return page(error.status_code, cfg, 'The sign-in request is refused', error=error.description or error.error)
except OAuth2Error as error:
if not error.redirect_uri:
return page(error.status_code, cfg, 'The sign-in request is refused', error=error.description or error.error)
return redirect(error.in_uri(error.redirect_uri))
request = info['request']
title = f'Sign in to {request.client.display_name or request.client.client_id}'
if info.get('code_challenge_method') != 'S256':
return redirect(InvalidRequestError(description='PKCE with code_challenge_method S256 is required',
request=request).in_uri(request.redirect_uri))
if error:
return page(401, cfg, title, error=error, form=True, request=params, name=name)
session = fresh.session if fresh else session_of(cfg, headers.get('Cookie'))
max_age = getattr(request, 'max_age', None)
stale = session is not None and isinstance(max_age, str) and max_age.isdigit() and \
time.time() - session.auth_time.timestamp() > int(max_age)
prompt = info.get('prompt') or set()
if session is None or (not fresh and ('login' in prompt or stale)):
if 'none' in prompt:
return redirect(LoginRequired(request=request).in_uri(request.redirect_uri))
return page(200, cfg, title, form=True, request=params, name=name)
if subject(session.name, request.client) is None:
if fresh:
return redirect(AccessDeniedError(description=f'{session.name} may not sign in to this client',
request=request).in_uri(request.redirect_uri), [fresh.cookie])
return page(403, cfg, title, error=f'{session.name} may not sign in here; sign in as another user.', form=True,
request=params)
answer, _, _ = server.create_authorization_response(uri, 'GET', None, headers, scopes, credentials={
'user': session.name, 'auth_time': session.auth_time, 'amr': session.amr})
return redirect(answer['Location'], [fresh.cookie] if fresh else [])
def login(server, cfg, form, headers):
"""Sign a user in with the sign-in page's form, start the session and go on with its authorization request."""
params = form.get('request', '')
name = form.get('name', '').strip()
try:
if form.get('passkey_id'):
with vpnrouter_users.session() as connection:
name = vpnrouter_passkeys.login_complete(connection, form['passkey_id'], json.loads(form.get('credential', '')))
amr = PASSKEY_AMR
else:
code = form.get('code', '').strip()
vpnrouter_users.login(name, form.get('password', ''), code or None)
with directory() as connection:
if vpnrouter_users.service(vpnrouter_users.entry(connection, name)):
raise vpnrouter_users.Failure('the name, the password or the code is wrong', 5)
amr = OTP_AMR if code else PASSWORD_AMR
except vpnrouter_users.Failure as failure:
return authorize(server, cfg, params, headers, name=name, error=str(failure))
except ValueError:
return authorize(server, cfg, params, headers, name=name, error='the passkey is not accepted')
value = secrets.token_urlsafe(32)
with vpnrouter_auth.database(cfg) as connection:
connection.execute('CALL oidc_session_save(%s, %s, %s, %s)', (vpnrouter_auth.digest(value), name, amr, SESSION_SECONDS))
fresh = types.SimpleNamespace(session=Session(name, datetime.datetime.now(datetime.timezone.utc), amr),
cookie=cookie(value, SESSION_SECONDS))
return authorize(server, cfg, params, headers, fresh=fresh)
def logout(cfg, query, headers):
"""End the session the cookie names, then go to post_logout_redirect_uri, with state, when it is a post-logout
redirect URI of the client that client_id or the audience of id_token_hint names; else show that the user is
signed out."""
values = dict(urllib.parse.parse_qsl(query))
value = cookie_value(headers.get('Cookie'))
if value:
with vpnrouter_auth.database(cfg) as connection:
connection.execute('CALL oidc_session_delete(%s)', (vpnrouter_auth.digest(value),))
client_id = values.get('client_id')
if not client_id and values.get('id_token_hint'):
try:
audience = jwt.decode(values['id_token_hint'], private_key().public_key(), algorithms=['RS256'],
options={'verify_exp': False, 'verify_aud': False}).get('aud')
except jwt.InvalidTokenError:
audience = None
client_id = audience[0] if isinstance(audience, list) and audience else audience
client = client_of(client_id)
target = values.get('post_logout_redirect_uri')
expired = cookie('', 0)
if client is not None and target in client.post_logout_redirect_uris:
return redirect(add_params_to_uri(target, [('state', values['state'])]) if values.get('state') else target, [expired])
return page(200, cfg, 'Signed out', message='You are signed out of the router.', headers=[expired])
def answer(method, target, headers, body):
"""Answer a request of the OpenID Provider: the HTTP status, the content type, the body and [(header, value)]; 404
while oidc.enabled is false. target is the path with the query, headers a mapping of the request's headers and
body its bytes; HEAD is answered as GET."""
path, _, query = target.partition('?')
method = 'GET' if method == 'HEAD' else method
try:
cfg = vpnrouter.load_config()
if not cfg['oidc']['enabled']:
return 404, 'text/plain; charset=utf-8', b'Not found\n', []
issuer = vpnrouter.oidc_issuer(cfg)
headers = dict(headers.items())
text = body.decode('utf-8', 'replace') if body else ''
server = Server(Validator(cfg), token_expires_in=ACCESS_SECONDS)
if (method, path) == ('GET', '/.well-known/openid-configuration'):
return document(200, discovery(issuer))
if (method, path) == ('GET', '/oidc/jwks'):
return document(200, {'keys': [public_jwk(private_key())]})
if path == '/oidc/authorize' and method in ('GET', 'POST'):
return authorize(server, cfg, query if method == 'GET' else text, headers)
if (method, path) == ('POST', '/oidc/login'):
return login(server, cfg, dict(urllib.parse.parse_qsl(text)), headers)
if (method, path) == ('POST', '/oidc/passkey'):
return document(200, vpnrouter_passkeys.login_begin())
if (method, path) == ('POST', '/oidc/token'):
return relayed(*server.create_token_response(f'{issuer}{path}', 'POST', text, headers))
if path == '/oidc/userinfo' and method in ('GET', 'POST'):
return relayed(*server.create_userinfo_response(f'{issuer}{target}', method, text or None, headers))
if (method, path) == ('GET', '/oidc/logout'):
return logout(cfg, query, headers)
return 404, 'text/plain; charset=utf-8', b'Not found\n', []
except OAuth2Error as error:
return error.status_code, 'application/json', error.json.encode(), list(error.headers.items())
except vpnrouter_users.Failure as failure:
return document(400 if failure.status == 2 else 500, {'error': str(failure)})
except (ldap.LDAPError, OSError) as exc:
return document(500, {'error': f'the OpenID Provider failed: {exc}'})
except vpnrouter_auth.DatabaseError as exc:
return document(500, {'error': str(exc)})
except SystemExit as exc:
return document(500, {'error': str(exc.code)})
except ValueError as exc:
return document(400, {'error': 'invalid_request', 'error_description': str(exc)})
@@ -0,0 +1,174 @@
"""The passkeys of the router's users, for signing in to the Router Manager and the OpenID Provider (vpnrouter_oidc),
with python3-fido2.
A passkey is a discoverable WebAuthn credential with user verification, of the relying party
wan.local_fqdn, so the browser offers it at the HTTPS addresses on that name.
Its user handle is the user name. Each passkey of a user is a value of vpnRouterPasskey on the
user entry: a JSON object {"id", "credential", "label", "created"}, id the credential ID and
credential the attested credential data (with the public key), both base64url. A user has at
most PASSKEYS_MAX passkeys; service accounts have none.
Registration and sign-in are two steps each: begin answers the options for the browser and a
pending id, whose state the web service keeps in memory for PENDING_SECONDS; complete takes the
id and the browser's answer. A request that cannot be done raises vpnrouter_users.Failure, with
the statuses of that module; a sign-in that fails gives status 5 whatever the reason.
"""
import json
import secrets
import threading
import time
import fido2.features
import ldap
from fido2.server import Fido2Server
from fido2.utils import websafe_decode, websafe_encode
from fido2.webauthn import (AttestedCredentialData, PublicKeyCredentialRpEntity, PublicKeyCredentialUserEntity,
ResidentKeyRequirement, UserVerificationRequirement)
import vpnrouter
import vpnrouter_users
# The options and the browser's answers are in the JSON mapping of WebAuthn Level 3; python3-fido2 2.0 always uses it.
_json_mapping = getattr(fido2.features, 'webauthn_json_mapping', None)
if _json_mapping is not None:
_json_mapping.enabled = True
#: The most passkeys of one user.
PASSKEYS_MAX = 10
#: The longest label of a passkey, in characters.
LABEL_MAX = 64
#: Seconds a begun registration or sign-in may be completed.
PENDING_SECONDS = 120
#: The most begun registrations and sign-ins kept at once.
PENDING_MAX = 1000
#: The begun registrations and sign-ins: pending id -> (kind, user name or None, state, monotonic time of expiry).
_pending = {}
_lock = threading.Lock()
def server():
"""The Fido2Server of the relying party wan.local_fqdn; passkeys need the name."""
fqdn = vpnrouter.load_config()['wan']['local_fqdn'].strip()
if not fqdn:
raise vpnrouter_users.Failure('passkeys need wan.local_fqdn, the name the Router Manager is reached at', 2)
return Fido2Server(rp=PublicKeyCredentialRpEntity(name=fqdn, id=fqdn))
def keep(kind, name, state):
"""Keep the state of a begun step and answer its pending id."""
pending = secrets.token_urlsafe(24)
now = time.monotonic()
with _lock:
for old in [key for key, (*_, expires) in _pending.items() if expires <= now]:
del _pending[old]
if len(_pending) >= PENDING_MAX:
raise vpnrouter_users.Failure('too many passkey requests at once; retry in a moment')
_pending[pending] = (kind, name, state, now + PENDING_SECONDS)
return pending
def take(kind, name, pending):
"""The state of a begun step of the kind and user, used up; None when it is unknown or expired."""
with _lock:
found = _pending.pop(pending, None) if isinstance(pending, str) else None
if found is None or found[0] != kind or found[1] != name or found[3] <= time.monotonic():
return None
return found[2]
def credentials(attributes):
"""The attested credential data of the passkeys of an entry."""
return [AttestedCredentialData(websafe_decode(passkey['credential']))
for _, passkey in vpnrouter_users.passkeys_of(attributes)]
def register_begin(connection, name):
"""Begin the registration of a passkey of the user: {"id", "options"}, options those of navigator.credentials.create."""
current = vpnrouter_users.entry(connection, name)
if vpnrouter_users.service(current):
raise vpnrouter_users.Failure(f'{name} is a service account, which has no passkeys', 2)
if len(vpnrouter_users.passkeys_of(current)) >= PASSKEYS_MAX:
raise vpnrouter_users.Failure(f'user {name} has {PASSKEYS_MAX} passkeys, the most; remove one first', 2)
options, state = server().register_begin(
PublicKeyCredentialUserEntity(id=name.encode(), name=name, display_name=name),
credentials(current),
resident_key_requirement=ResidentKeyRequirement.REQUIRED,
user_verification=UserVerificationRequirement.REQUIRED,
)
return {'id': keep('register', name, state), 'options': dict(options)}
def register_complete(connection, name, pending, response, label):
"""Store the passkey the browser created for a begun registration, with its label; the user (vpnrouter_users.summary)."""
state = take('register', name, pending)
if state is None:
raise vpnrouter_users.Failure('the registration is unknown or expired; begin it again', 2)
if label is None or (isinstance(label, str) and not label.strip()):
label = 'Passkey'
if not isinstance(label, str) or len(label.strip()) > LABEL_MAX:
raise vpnrouter_users.Failure(f'the label must be text of at most {LABEL_MAX} characters', 2)
if not isinstance(response, dict):
raise vpnrouter_users.Failure('credential must be the answer of navigator.credentials.create as JSON', 2)
try:
data = server().register_complete(state, response).credential_data
except (ValueError, KeyError, TypeError) as exc:
raise vpnrouter_users.Failure(f'the passkey is refused: {exc}', 2)
current = vpnrouter_users.entry(connection, name)
value = json.dumps({
'id': websafe_encode(data.credential_id),
'credential': websafe_encode(bytes(data)),
'label': label.strip(),
'created': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
}, separators=(',', ':'))
changes = [] if 'vpnrouteraccount' in vpnrouter_users.classes(current) else [(ldap.MOD_ADD, 'objectClass', [b'vpnRouterAccount'])]
changes.append((ldap.MOD_ADD, 'vpnRouterPasskey', [value.encode()]))
connection.modify_s(vpnrouter_users.dn_of(name), changes)
return vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
def remove(connection, name, passkey_id=None):
"""Remove the user's passkey of the id, or all its passkeys without one; the user (vpnrouter_users.summary)."""
current = vpnrouter_users.entry(connection, name)
values = current.get('vpnRouterPasskey', [])
kept = [value for value, passkey in vpnrouter_users.passkeys_of(current)
if passkey_id is not None and passkey['id'] != passkey_id]
if passkey_id is not None and len(kept) == len(vpnrouter_users.passkeys_of(current)):
raise vpnrouter_users.Failure(f'user {name} has no passkey {passkey_id}', 3)
if len(kept) != len(values):
connection.modify_s(vpnrouter_users.dn_of(name), [(ldap.MOD_REPLACE, 'vpnRouterPasskey', kept)])
return vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
def login_begin():
"""Begin a sign-in with a passkey: {"id", "options"}, options those of navigator.credentials.get, for any user."""
options, state = server().authenticate_begin(user_verification=UserVerificationRequirement.REQUIRED)
return {'id': keep('login', None, state), 'options': dict(options)}
def login_complete(connection, pending, response):
"""The name of the user whose passkey signed a begun sign-in, when the user is enabled, not locked and not a
service account."""
refused = vpnrouter_users.Failure('the passkey is not accepted', 5)
state = take('login', None, pending)
if state is None or not isinstance(response, dict):
raise refused
try:
name = websafe_decode(response['response']['userHandle']).decode()
except (KeyError, TypeError, ValueError):
raise refused
if not vpnrouter_users.NAME.match(name):
raise refused
try:
current = vpnrouter_users.entry(connection, name)
except vpnrouter_users.Failure:
raise refused
user = vpnrouter_users.summary(current)
if user['service'] or not user['enabled'] or user['locked']:
raise refused
try:
server().authenticate_complete(state, credentials(current), response)
except (ValueError, KeyError, TypeError):
raise refused
return name
@@ -15,10 +15,16 @@ internal service accounts are those the router creates and manages for its own s
included (internal_accounts); vpn-router-setup keeps the metrics service's.
Users exist whether or not a P2S system is enabled.
A service account with redirect URIs is a client of the OpenID Provider (vpnrouter_oidc), its name the client ID and
its password the client secret (client_settings): redirect_uris, where the provider sends a user back after a sign-in,
post_logout_redirect_uris, after a sign-out, and allowed_groups, the groups whose members may sign in to it, every
user who is not a service account when there are none.
A group is a groupOfNames in ou=groups with a name, which follows the rules of user names, an
optional description and at least one member; a group cannot be empty, so the last member
cannot leave it and a user who is the last member of a group cannot be removed. Groups only
record membership; nothing on the router reads them.
cannot leave it and a user who is the last member of a group cannot be removed. ADMIN_GROUP grants the management of
the router, and the allowed groups of the OpenID Provider's clients who may sign in to them; renaming a group renames it
in those, and a group a client allows cannot be removed.
A VPN user may have WireGuard peers, each a device with its public key and its IPs, in
vpnRouterWireGuardPeer; vpn-router-setup writes a [Peer] of wg0.conf for each peer of a user who
@@ -30,13 +36,17 @@ vpnrouter_ldap.TOTP_PARAMS. Enrolment stores nothing until a code of the new sec
verified: otp_new makes a secret and its otpauth URI, and otp_enrol stores the secret given a
code of it. slapd's otp overlay then takes the password followed by a code at every bind.
A user who is not a service account may have passkeys for signing in to the Router Manager, in
vpnRouterPasskey (vpnrouter_passkeys registers and checks them).
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.
A member of ADMIN_GROUP may have console login (console): the Shell page of the Router Manager,
and SSH and sudo with the directory's password, followed by the code for an enrolled user. The
A member of ADMIN_GROUP with a TOTP token may have console login (console): the Shell page of the
Router Manager, and SSH and sudo with the directory's password followed by the code; without the
token the console is refused, also for one whose console login was switched on before. The
console runs as a local account, or as the user's own posixAccount, which the user entry becomes
and nslcd serves to the system. Leaving ADMIN_GROUP switches it off. The module knows only the directory; the callers revoke a
user's refresh tokens after the operations of vpnrouter_auth.REVOKING.
@@ -53,10 +63,12 @@ import contextlib
import fcntl
import grp
import ipaddress
import json
import pwd
import re
import subprocess
import time
import urllib.parse
import ldap
import ldap.filter
@@ -69,6 +81,8 @@ import vpnrouter_pki
#: A user name is one lowercase DNS label, as it is also the common name of the certificate.
NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}[a-z0-9]$|^[a-z]$')
#: The other name of a service account: a GUID in lowercase hexadecimal, as the Router Manager proposes one.
GUID = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')
PASSWORD_MIN = 8
PASSWORD_MAX = 128
#: The longest first or last name, in characters.
@@ -83,7 +97,15 @@ LABELS = (('display_name', 'displayName'), ('description', 'description'))
#: The attributes read for a user.
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope',
'displayName', 'description', 'userCertificate;binary', 'vpnRouterConsoleLogin', 'vpnRouterConsoleAccount',
'uidNumber', 'vpnRouterWireGuardPeer']
'uidNumber', 'vpnRouterWireGuardPeer', 'vpnRouterPasskey', 'vpnRouterRedirectUri',
'vpnRouterPostLogoutRedirectUri', 'vpnRouterAllowedGroup']
#: The keys of the URIs of an OpenID Provider client and their LDAP attributes.
CLIENT_URIS = (('redirect_uris', 'vpnRouterRedirectUri'), ('post_logout_redirect_uris', 'vpnRouterPostLogoutRedirectUri'))
#: The most URIs of each kind and allowed groups of one client, and the longest URI.
CLIENT_VALUES_MAX = 16
URI_MAX = 1024
#: The hosts a client's URI may name over plain HTTP: those of the system it runs on.
LOOPBACK_HOSTS = ('localhost', '127.0.0.1', '::1')
#: The most WireGuard peers of one user.
WIREGUARD_PEERS_MAX = 16
#: The vpnRouterScope of a service account.
@@ -130,9 +152,11 @@ def session():
connection.unbind_s()
def check_name(name):
if not isinstance(name, str) or not NAME.match(name):
raise Failure('a user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter', 2)
def check_name(name, service=True):
"""Refuse a name that is not a user name (NAME), or, with service, the GUID of a service account either."""
if not isinstance(name, str) or not (NAME.match(name) or service and GUID.match(name)):
raise Failure('a user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter'
+ (', or, for a service account, a GUID in lowercase' if service else ''), 2)
def check_password(value):
@@ -186,6 +210,47 @@ def labels(values):
return found
def client_settings(connection, values):
"""The OpenID Provider client settings of a service account in values ({redirect_uris, post_logout_redirect_uris,
allowed_groups}), checked, as {LDAP attribute: [value, ...]}; a key whose value is None is not given, [] means none.
A URI is absolute, has no fragment and uses https, or http for a host of LOOPBACK_HOSTS; an allowed group exists. Each
list has at most CLIENT_VALUES_MAX values.
"""
found = {}
for key, attribute in CLIENT_URIS:
uris = values.get(key)
if uris is None:
continue
if not isinstance(uris, list) or len(uris) > CLIENT_VALUES_MAX or not all(isinstance(uri, str) for uri in uris):
raise Failure(f'{key} must be a list of at most {CLIENT_VALUES_MAX} URIs', 2)
for uri in uris:
try:
parts = urllib.parse.urlsplit(uri)
host = parts.hostname
except ValueError:
host = None
if len(uri) > URI_MAX or not host or parts.fragment or any(character.isspace() for character in uri) or \
not (parts.scheme == 'https' or parts.scheme == 'http' and host in LOOPBACK_HOSTS):
raise Failure(f'{uri!r} is not an https URI, or an http URI of localhost, without a fragment and of at '
f'most {URI_MAX} characters', 2)
found[attribute] = [uri.encode() for uri in dict.fromkeys(uris)]
allowed = values.get('allowed_groups')
if allowed is not None:
if not isinstance(allowed, list) or len(allowed) > CLIENT_VALUES_MAX:
raise Failure(f'allowed_groups must be a list of at most {CLIENT_VALUES_MAX} group names', 2)
for group in allowed:
group_entry(connection, group)
found['vpnRouterAllowedGroup'] = [group_dn(group).encode() for group in sorted(set(allowed))]
return found
def refuse_client_settings(name, values):
"""Refuse OpenID Provider client settings for a user who is not a service account."""
if any(values.get(key) is not None for key in ('redirect_uris', 'post_logout_redirect_uris', 'allowed_groups')):
raise Failure(f'{name} is not a service account; only a service account is a client of the OpenID Provider', 2)
def dn_of(name):
return f'uid={name},{vpnrouter_ldap.USERS}'
@@ -237,7 +302,8 @@ def internal_accounts():
def summary(attributes):
"""A user as the answers show it; a person attribute or a label the entry does not have is None.
internal is whether it is an internal service account (internal_accounts).
internal is whether it is an internal service account (internal_accounts). A service account also has its OpenID
Provider client settings (client_settings), allowed_groups as group names.
"""
name = attributes['uid'][0].decode()
lock = attributes.get('pwdAccountLockedTime', [None])[0]
@@ -252,10 +318,17 @@ def summary(attributes):
'otp': 'oathTOTPToken' in attributes,
'console': console_of(attributes),
'wireguard_peers': peers_of(attributes),
'passkeys': [{key: passkey[key] for key in ('id', 'label', 'created')} for _, passkey in passkeys_of(attributes)],
}
for key, attribute in PERSON + LABELS:
values = attributes.get(attribute)
user[key] = values[0].decode() if values else None
if user['service']:
for key, attribute in CLIENT_URIS:
user[key] = [value.decode() for value in attributes.get(attribute, [])]
suffix = f',{vpnrouter_ldap.GROUPS}'
user['allowed_groups'] = sorted(value.decode()[3:-len(suffix)] for value in attributes.get('vpnRouterAllowedGroup', [])
if value.decode().startswith('cn=') and value.decode().endswith(suffix))
return user
@@ -284,6 +357,20 @@ def peers_of(attributes):
return peers
def passkeys_of(attributes):
"""The passkeys of an entry (vpnrouter_passkeys) with their stored values: [(value, {id, credential, label,
created})]; values that cannot be read are left out."""
passkeys = []
for value in attributes.get('vpnRouterPasskey', []):
try:
passkey = json.loads(value)
except ValueError:
continue
if isinstance(passkey, dict) and all(isinstance(passkey.get(key), str) for key in ('id', 'credential', 'label', 'created')):
passkeys.append((value, passkey))
return passkeys
def checked_peers(connection, name, peers):
"""The values of vpnRouterWireGuardPeer for the user's WireGuard peers, [{public_key, ips}], when they are valid.
@@ -349,8 +436,8 @@ def free_uid(connection):
def console(connection, name, enabled, account=None):
"""Switch the console login of a member of ADMIN_GROUP on, as the local account given or, without one, as the
user's own posixAccount, or off.
"""Switch the console login of a member of ADMIN_GROUP with a TOTP token on, as the local account given or, without
one, as the user's own posixAccount, or off.
The posixAccount is added the first time and kept when the console login is switched off, so its uidNumber and the
home it owns stay the user's. A local account must exist in the system's passwd with a uid from 1000 to below
@@ -373,6 +460,8 @@ def console(connection, name, enabled, account=None):
raise Failure(f'{name} is a service account, which has no console', 2)
if ADMIN_GROUP not in groups_of(connection, name):
raise Failure(f'user {name} is not a member of {ADMIN_GROUP}', 2)
if 'oathTOTPToken' not in current:
raise Failure(f'user {name} has no TOTP token; console login needs one', 2)
if account is not None:
try:
local = pwd.getpwnam(account)
@@ -440,14 +529,19 @@ def list_users(connection):
def add(connection, name, password, values, service=False):
"""Add a user: a VPN user with its certificate when values gives all three person attributes, a Manager-only account
with at most the e-mail address when it gives neither name, and, with service, a service account, with at most the
e-mail address and its labels (labels)."""
check_name(name)
e-mail address, its labels (labels) and its OpenID Provider client settings (client_settings); only a service
account may be named by a GUID."""
check_name(name, service)
details = person(values, required=False)
vpn_user = 'givenName' in details or 'sn' in details
if service and vpn_user:
raise Failure('a service account has no first or last name', 2)
client = {}
if service:
details.update({attribute: value for attribute, value in labels(values).items() if value})
client = {attribute: found for attribute, found in client_settings(connection, values).items() if found}
else:
refuse_client_settings(name, values)
if vpn_user and len(details) != len(PERSON):
raise Failure('give first_name, last_name and email for a VPN user, or at most email for a Manager-only account', 2)
check_password(password)
@@ -462,6 +556,7 @@ def add(connection, name, password, values, service=False):
if service:
record['vpnRouterScope'] = [SERVICE_SCOPE.encode()]
record.update({attribute: [value.encode()] for attribute, value in details.items()})
record.update(client)
try:
connection.add_s(dn_of(name), ldap.modlist.addModlist(record))
except ldap.ALREADY_EXISTS:
@@ -485,6 +580,7 @@ def update(connection, name, values):
current = entry(connection, name)
if service(current):
return update_service(connection, name, values)
refuse_client_settings(name, values)
if manager_only(current):
if values.get('wireguard_peers') is not None:
raise Failure(f'user {name} is a Manager-only account, which has no VPN access and so no WireGuard peers', 2)
@@ -525,16 +621,18 @@ def apply_wireguard():
def update_service(connection, name, values):
"""Change any of the e-mail address and the labels (labels) of a service account that values gives; '' removes one."""
"""Change any of the e-mail address, the labels (labels) and the OpenID Provider client settings (client_settings) of
a service account that values gives; '' removes the e-mail address or a label, [] the values of a client setting."""
if not service(entry(connection, name)):
raise Failure(f'{name} is not a service account', 3)
changes = [(ldap.MOD_REPLACE, attribute, [value.encode()] if value else []) for attribute, value in labels(values).items()]
changes += [(ldap.MOD_REPLACE, attribute, found) for attribute, found in client_settings(connection, values).items()]
email = values.get('email')
if email is not None:
address = '' if isinstance(email, str) and email.strip() == '' else person({'email': email}, required=False)['mail']
changes.append((ldap.MOD_REPLACE, 'mail', [address.encode()] if address else []))
if not changes:
raise Failure('give email, display_name or description', 2)
raise Failure('give email, display_name, description, redirect_uris, post_logout_redirect_uris or allowed_groups', 2)
connection.modify_s(dn_of(name), changes)
return summary(entry(connection, name))
@@ -579,12 +677,13 @@ def unlock(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.
"""Check the user's password, and the TOTP code of an enrolled user, with a bind as the user; a service account may be
named by a GUID.
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:
if not isinstance(name, str) or not (NAME.match(name) or GUID.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
@@ -639,13 +738,15 @@ def otp_enrol(connection, name, secret, code):
def otp_remove(connection, name):
"""Remove the user's TOTP token, when the user has one."""
if 'oathTOTPToken' in entry(connection, name):
"""Remove the user's TOTP token, when the user has one, and end the console sessions it needed (end_sessions)."""
current = entry(connection, name)
if 'oathTOTPToken' in current:
token = connection.search_s(dn_of(name), ldap.SCOPE_BASE, attrlist=TOKEN_ATTRIBUTES)[0][1]
changes = [(ldap.MOD_DELETE, 'objectClass', [b'oathTOTPUser', b'oathTOTPToken']),
(ldap.MOD_DELETE, 'oathSecret', None)]
changes += [(ldap.MOD_DELETE, attribute, None) for attribute in token]
connection.modify_s(dn_of(name), changes)
end_sessions(current)
return summary(entry(connection, name))
@@ -761,6 +862,13 @@ def groups_of(connection, name):
return {attributes['cn'][0].decode(): member_names(attributes) for _, attributes in found}
def allowing(connection, name):
"""The names of the OpenID Provider's clients whose allowed groups have the group, sorted."""
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL,
f'(vpnRouterAllowedGroup={ldap.filter.escape_filter_chars(group_dn(name))})', ['uid'])
return sorted(attributes['uid'][0].decode() for _, attributes in found)
def list_groups(connection):
found = connection.search_s(vpnrouter_ldap.GROUPS, ldap.SCOPE_ONELEVEL, '(objectClass=groupOfNames)', GROUP_ATTRIBUTES)
return {'groups': sorted((group_summary(attributes) for _, attributes in found), key=lambda group: group['name'])}
@@ -787,7 +895,8 @@ def group_add(connection, name, description, members):
def group_update(connection, name, new_name=None, description=None):
"""Rename the group or change its description ('' removes it); at least one must be given. Renaming ADMIN_GROUP
switches the console login of its members off."""
switches the console login of its members off; renaming a group a client of the OpenID Provider allows renames it
there."""
if new_name is None and description is None:
raise Failure('give a new name or a description', 2)
members = member_names(group_entry(connection, name))
@@ -802,6 +911,9 @@ def group_update(connection, name, new_name=None, description=None):
connection.rename_s(group_dn(name), f'cn={new_name}', delold=1)
except ldap.ALREADY_EXISTS:
raise Failure(f'group {new_name} exists', 4)
for client in allowing(connection, name):
connection.modify_s(dn_of(client), [(ldap.MOD_DELETE, 'vpnRouterAllowedGroup', [group_dn(name).encode()]),
(ldap.MOD_ADD, 'vpnRouterAllowedGroup', [group_dn(new_name).encode()])])
if name == ADMIN_GROUP:
console_off(connection, members)
name = new_name
@@ -809,8 +921,12 @@ def group_update(connection, name, new_name=None, description=None):
def group_remove(connection, name):
"""Remove the group; removing ADMIN_GROUP switches the console login of its members off."""
"""Remove the group, unless a client of the OpenID Provider allows it; removing ADMIN_GROUP switches the console login
of its members off."""
members = member_names(group_entry(connection, name))
clients = allowing(connection, name)
if clients:
raise Failure(f'group {name} is an allowed group of the clients {", ".join(clients)}; take it out of them first', 2)
connection.delete_s(group_dn(name))
if name == ADMIN_GROUP:
console_off(connection, members)
+146 -38
View File
@@ -21,8 +21,8 @@ Pages:
service_accounts())
/groups the groups of the router's directory: list, add, rename or describe, remove, and
add and take out members (see groups())
/account the account of the signed-in user: its details, its password and its TOTP
enrolment (see account())
/account the account of the signed-in user: its details, its password, its TOTP
enrolment and its passkeys (see account())
/site-to-site
the site-to-site connections of the configuration: list, add, change and remove,
applied by vpn-router-setup (see site_to_site())
@@ -38,16 +38,22 @@ Pages:
the DER certificate and CRL of the root (root) and of each issuing CA, which the
certificates name in their AIA and CRL distribution points; no authentication
/shell while web.shell is true, a login shell of the user's console account on a terminal, for
a member of admins whose console login is on: POST signs the
user in again for a one-time ticket (see shell_ticket()), GET with it is a WebSocket
a member of admins whose console login is on and who has a TOTP token: POST signs the user in again, unless
the user used a shell within web.shell_auth_timeout minutes, for a one-time ticket
(see shell_ticket()), GET with it is a WebSocket
that carries the terminal (see Handler.shell())
/auth sign-in with a user's password and TOTP code, refresh and sign-out (see auth())
/auth sign-in with a user's password and TOTP code or with a passkey, refresh and sign-out
(see auth())
/metrics/history
the metrics the metrics service keeps, for a view: hour, day, month or year (see
metrics_history())
/postgresql
the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see
postgresql_databases()); /config has the clusters' settings
/.well-known/openid-configuration, /oidc/...
while oidc.enabled is true, the OpenID Provider (vpnrouter_oidc), answered only on the
loopback address, where Caddy passes the requests of the HTTPS site; POST /oidc/login counts
towards the limit of /auth calls
/status/full, /config, /metrics/history, /postgresql, /users, /service-accounts, /groups, /site-to-site, /backup, /remote-access, /ca, /shell and /account need an HS256 JWT in an Authorization: Bearer header signed
with vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names a user in sub;
@@ -56,7 +62,8 @@ token of vpn-router-token has the admin claim and names no user; it may call eve
/account, 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, and switch a user's console login.
These paths and /auth are not
served (404) while the key does not exist. /auth is limited to AUTH_LIMIT calls per client
served (404) while the key does not exist. /auth but its sign-out and the beginning of a passkey
sign-in 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 call came in on, is the last entry of its X-Forwarded-For header.
@@ -68,8 +75,8 @@ whether each service runs, whether Docker is installed, how many of the site-to-
(site_to_site: connections and established, null when swanctl cannot be asked), and road-warrior (IPsec P2S), OpenVPN
and WireGuard. No configuration data, key or connection name is shown, and no address but the client's.
The service first initializes, without listening: it makes the signing key and the refresh
tokens' database (vpnrouter_auth), retrying every INIT_RETRY_SECONDS until both are ready and
The service first initializes, without listening: it makes the signing key, the OpenID Provider's key
(vpnrouter_oidc) and the refresh tokens' database (vpnrouter_auth), retrying every INIT_RETRY_SECONDS until all are ready and
logging each failure. Then it listens. Its unit starts it only once wait-for-libpq has found libpq
in the linker cache, where psycopg looks it up.
@@ -117,6 +124,8 @@ import vpnrouter_auth
import vpnrouter_backup
import vpnrouter_metrics
import vpnrouter_net
import vpnrouter_oidc
import vpnrouter_passkeys
import vpnrouter_pki
import vpnrouter_serve
import vpnrouter_users
@@ -169,6 +178,9 @@ PUBLISHED = {'.crt': 'application/pkix-cert', '.crl': 'application/pkix-crl'}
#: Seconds a ticket of POST /shell may open a shell.
SHELL_TICKET_SECONDS = 30
#: Seconds between the checks of an open shell's user, which end the shell once its console is refused.
SHELL_CHECK_SECONDS = 60
#: The largest WebSocket message accepted from a client, in bytes.
MESSAGE_LIMIT = 65536
@@ -189,6 +201,9 @@ _auth_lock = threading.Lock()
_shell_tickets = {}
_shell_lock = threading.Lock()
#: The last use of a shell of each user, a sign-in of POST /shell or a keystroke: user -> monotonic time.
_shell_used = {}
def read_json(path):
"""The object in a JSON file, or {} when the file is missing or holds no object."""
@@ -523,6 +538,7 @@ def users(state, method, path, body):
POST /users/<name>/otp a new TOTP secret and its otpauth URI; nothing is stored
PUT /users/<name>/otp store a TOTP secret: {"secret", "code"}, code a code of the secret
DELETE /users/<name>/otp remove the TOTP token
DELETE /users/<name>/passkeys remove all the user's passkeys
PUT /users/<name>/console switch console login on or off: {"enabled", "account"?}, account a local account,
without which the user's own posixAccount; only a token of vpn-router-token
@@ -549,6 +565,7 @@ def users(state, method, path, body):
('POST', 3, 'otp'): lambda connection: vpnrouter_users.otp_new(connection, name),
('PUT', 3, 'otp'): lambda connection: vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code')),
('DELETE', 3, 'otp'): lambda connection: vpnrouter_users.otp_remove(connection, name),
('DELETE', 3, 'passkeys'): lambda connection: vpnrouter_passkeys.remove(connection, name),
('PUT', 3, 'console'): lambda connection: vpnrouter_users.console(connection, name, body.get('enabled'), body.get('account')),
}
# The vpnrouter_users operation of each route that may revoke refresh tokens.
@@ -575,20 +592,32 @@ def users(state, method, path, body):
return (201 if route == ('POST', 1, None) else 200), document
def oidc_settings():
"""{"enabled", "issuer"} of the OpenID Provider, issuer null while it is disabled."""
cfg = vpnrouter.load_config()
enabled = cfg['oidc']['enabled']
return {'enabled': enabled, 'issuer': vpnrouter.oidc_issuer(cfg) if enabled else None}
def service_accounts(method, path, body):
"""Answer a /service-accounts request with vpnrouter_users: the HTTP status and the JSON document.
GET /service-accounts the service accounts: {"service_accounts": [...]}
POST /service-accounts add one: {"name", "password", "display_name"?, "description"?, "email"?}
PUT /service-accounts/<name> change any of {"display_name", "description", "email"}; "" removes one
GET /service-accounts the service accounts: {"service_accounts": [...], "oidc": {"enabled",
"issuer"}}, issuer that of the OpenID Provider, null while it is disabled
POST /service-accounts add one: {"name", "password", "display_name"?, "description"?, "email"?,
"redirect_uris"?, "post_logout_redirect_uris"?, "allowed_groups"?}
PUT /service-accounts/<name> change any of {"display_name", "description", "email", "redirect_uris",
"post_logout_redirect_uris", "allowed_groups"}; "" removes one of the
first three, [] the values of the others
PUT /service-accounts/<name>/password set the password: {"password": ...}
POST /service-accounts/<name>/disable lock it out
POST /service-accounts/<name>/enable let it in again
POST /service-accounts/<name>/unlock end a lockout after failed logins
DELETE /service-accounts/<name> remove it
A name that is not a service account is 404. The internal service accounts (vpnrouter_users.internal_accounts),
which the router manages, are only listed: every other request on one is refused with 403.
A service account with redirect URIs is a client of the OpenID Provider (vpnrouter_users.client_settings). A name
that is not a service account is 404. The internal service accounts (vpnrouter_users.internal_accounts), which
the router manages, are only listed: every other request on one is refused with 403.
"""
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
return 400, {'error': 'the body must be a JSON object'}
@@ -596,10 +625,12 @@ def service_accounts(method, path, body):
parts = path.strip('/').split('/')
route = (method, len(parts), parts[2] if len(parts) == 3 else None)
name = parts[1] if len(parts) > 1 else None
settings = {key: body.get(key) for key in ('email', 'display_name', 'description')}
settings = {key: body.get(key) for key in ('email', 'display_name', 'description', 'redirect_uris',
'post_logout_redirect_uris', 'allowed_groups')}
actions = {
('GET', 1, None): lambda connection: {
'service_accounts': [user for user in vpnrouter_users.list_users(connection)['users'] if user['service']]},
'service_accounts': [user for user in vpnrouter_users.list_users(connection)['users'] if user['service']],
'oidc': oidc_settings()},
('POST', 1, None): lambda connection: vpnrouter_users.add(connection, body.get('name'), body.get('password'), settings,
service=True),
('PUT', 2, None): lambda connection: vpnrouter_users.update_service(connection, name, settings),
@@ -622,6 +653,8 @@ def service_accounts(method, path, body):
return 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 SystemExit as exc:
return 500, {'error': str(exc.code)}
return (201 if route == ('POST', 1, None) else 200), document
@@ -972,20 +1005,27 @@ def account(name, method, path, body):
POST /account/otp a new TOTP secret and its otpauth URI; nothing is stored
PUT /account/otp store a TOTP secret: {"current_password", "current_code"?, "secret", "code"},
code a code of the secret
POST /account/passkeys begin the registration of a passkey: {"current_password", "current_code"?};
{"id", "options"} for navigator.credentials.create
PUT /account/passkeys store the passkey: {"id", "credential", "label"?}, credential the browser's answer
DELETE /account/passkeys/<id>
remove a passkey
A change needs the current password and, for an enrolled user, a current code; a wrong one is 403 and counts as
a failed login. A password change revokes the user's refresh tokens.
A change but the removal of a passkey needs the current password and, for an enrolled user, a current code; a wrong
one is 403 and counts as a failed login. A password change revokes the user's refresh tokens.
"""
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
return 400, {'error': 'the body must be a JSON object'}
body = body or {}
route = (method, path.rstrip('/'))
if route not in (('GET', '/account'), ('PUT', '/account/password'), ('POST', '/account/otp'), ('PUT', '/account/otp')):
removing = method == 'DELETE' and path.startswith('/account/passkeys/') and len(path.strip('/').split('/')) == 3
if not removing and route not in (('GET', '/account'), ('PUT', '/account/password'), ('POST', '/account/otp'),
('PUT', '/account/otp'), ('POST', '/account/passkeys'), ('PUT', '/account/passkeys')):
return 404, {'error': f'no route {method} {path}'}
try:
if route == ('PUT', '/account/password'):
vpnrouter_users.check_password(body.get('password'))
if method == 'PUT':
if route in (('PUT', '/account/password'), ('PUT', '/account/otp'), ('POST', '/account/passkeys')):
vpnrouter_users.login(name, body.get('current_password'), body.get('current_code'))
with vpnrouter_users.session() as connection:
if route == ('GET', '/account'):
@@ -995,6 +1035,13 @@ def account(name, method, path, body):
document = vpnrouter_users.passwd(connection, name, body['password'])
elif route == ('POST', '/account/otp'):
document = vpnrouter_users.otp_new(connection, name)
elif route == ('POST', '/account/passkeys'):
document = vpnrouter_passkeys.register_begin(connection, name)
elif route == ('PUT', '/account/passkeys'):
document = vpnrouter_passkeys.register_complete(connection, name, body.get('id'), body.get('credential'),
body.get('label'))
elif removing:
document = vpnrouter_passkeys.remove(connection, name, urllib.parse.unquote(path.strip('/').split('/')[2]))
else:
document = vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code'))
if route == ('PUT', '/account/password'):
@@ -1067,7 +1114,10 @@ def refusal(claims, method, path, body):
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 sign in: {"name", "password", "code"?}; the tokens and the user's name
POST /auth/passkey begin a sign-in with a passkey: {"id", "options"} for navigator.credentials.get
POST /auth/passkey/complete
{"id", "credential"}, credential the browser's answer; the tokens and the user's name
POST /auth/refresh {"refresh_token"}; new tokens, the refresh token used up
POST /auth/logout {"refresh_token"}; the refresh token revoked
"""
@@ -1080,7 +1130,13 @@ def auth(path, body):
with vpnrouter_users.session() as connection:
if vpnrouter_users.service(vpnrouter_users.entry(connection, body['name'])):
raise vpnrouter_users.Failure('the name, the password or the code is wrong', 5)
return 200, vpnrouter_auth.issue(cfg, body['name'])
return 200, {**vpnrouter_auth.issue(cfg, body['name']), 'name': body['name']}
if path == '/auth/passkey':
return 200, vpnrouter_passkeys.login_begin()
if path == '/auth/passkey/complete':
with vpnrouter_users.session() as connection:
name = vpnrouter_passkeys.login_complete(connection, body.get('id'), body.get('credential'))
return 200, {**vpnrouter_auth.issue(cfg, name), 'name': name}
name = vpnrouter_auth.take(cfg, body.get('refresh_token'))
if path == '/auth/logout':
return 200, {'revoked': name is not None}
@@ -1105,19 +1161,29 @@ def shell_ticket(name, body):
"""Answer POST /shell: the HTTP status and the JSON document.
The user named by the token signs in again with {"password", "code"?}; a wrong password or code is 403 and counts
as a failed login. The answer is {"ticket", "expires_in"}: a ticket that opens one shell (GET /shell) for the
same user within SHELL_TICKET_SECONDS. 403 while web.shell is false or the user's console login is not on.
as a failed login. Without a password, the user is not asked to sign in while its last use of a shell (see
_shell_used) is less than web.shell_auth_timeout minutes ago. The answer is {"ticket", "expires_in"}: a ticket that
opens one shell (GET /shell) for the same user within SHELL_TICKET_SECONDS, or a null ticket when the user must
sign in. 403 while web.shell is false, the user's console login is not on or the user has no TOTP token.
"""
if not isinstance(name, str):
return 403, {'error': 'the shell needs a token of a signed-in user'}
if not isinstance(body, dict):
return 400, {'error': 'the body must be a JSON object'}
try:
if not vpnrouter.load_config()['web']['shell']:
options = vpnrouter.load_config()['web']
if not options['shell']:
return 403, {'error': 'the shell is disabled; vpn-config-edit set web.shell true enables it'}
if console_account(name) is None:
return 403, {'error': f'console login is not enabled for {name}; vpn-router-user console sets it'}
vpnrouter_users.login(name, body.get('password'), body.get('code'))
refusal = console_account(name)[1]
if refusal:
return 403, {'error': refusal}
if 'password' not in body:
with _shell_lock:
used = _shell_used.get(name)
if used is None or time.monotonic() - used >= options['shell_auth_timeout'] * 60:
return 200, {'ticket': None, 'expires_in': 0}
else:
vpnrouter_users.login(name, body.get('password'), body.get('code'))
except vpnrouter_users.Failure as failure:
if failure.status == 5:
return 403, {'error': 'the password or the code is wrong'}
@@ -1132,14 +1198,20 @@ def shell_ticket(name, body):
for unused in [key for key, (_, expires) in _shell_tickets.items() if expires <= now]:
del _shell_tickets[unused]
_shell_tickets[ticket] = (name, now + SHELL_TICKET_SECONDS)
_shell_used[name] = now
return 200, {'ticket': ticket, 'expires_in': SHELL_TICKET_SECONDS}
def console_account(name):
"""The system account the console of the user runs as, or None while its console login is not on."""
"""The system account the console of the user runs as and None, or None and the reason the user may not use a
console: its console login is off, or it has no TOTP token."""
with vpnrouter_users.session() as connection:
console = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))['console']
return console['account'] if console and console['enabled'] else None
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
if not user['console'] or not user['console']['enabled']:
return None, f'console login is not enabled for {name}; vpn-router-user console sets it'
if not user['otp']:
return None, f'{name} has no TOTP token; the shell needs one, enrolled on the Account page'
return user['console']['account'], None
def take_ticket(name, ticket):
@@ -1264,8 +1336,9 @@ class Handler(BaseHTTPRequestHandler):
The ticket must come from POST /shell for the user named by the token. Binary messages of the client are its
keystrokes, a text message {"cols", "rows"} sets the window size, and binary messages of the server are the
terminal's output. The shell ends after web.shell_idle_minutes without a keystroke, when it exits, or when the
client closes the connection; the server then closes it with the reason. Each opening, end and refusal is logged
terminal's output. The shell ends after web.shell_idle_minutes without a keystroke, when it exits, when the
client closes the connection, or when a check of the user (SHELL_CHECK_SECONDS) finds its console login off or
its TOTP token gone; the server then closes it with the reason. Each opening, end and refusal is logged
with its reason.
"""
def refuse(code, message):
@@ -1291,11 +1364,11 @@ class Handler(BaseHTTPRequestHandler):
if not options['shell']:
return refuse(403, 'the shell is disabled; vpn-config-edit set web.shell true enables it')
try:
login = console_account(name)
login, refusal = console_account(name)
except (vpnrouter_users.Failure, ldap.LDAPError, OSError) as exc:
return refuse(500, f'the user directory failed: {exc}')
if login is None:
return refuse(403, f'console login is not enabled for {name}')
if refusal:
return refuse(403, refusal)
try:
account = pwd.getpwnam(login)
except KeyError:
@@ -1336,6 +1409,8 @@ class Handler(BaseHTTPRequestHandler):
if opcode == BINARY:
os.write(master, payload)
typed[0] = time.monotonic()
with _shell_lock:
_shell_used[name] = typed[0]
elif opcode == TEXT:
size = json.loads(payload)
fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack('HHHH', int(size['rows']), int(size['cols']), 0, 0))
@@ -1347,12 +1422,23 @@ class Handler(BaseHTTPRequestHandler):
reader = threading.Thread(target=receive, name=f'{unit} input', daemon=True)
reader.start()
reason = 'the shell ended'
checked = started
try:
while not ended.is_set():
left = typed[0] + idle - time.monotonic()
if left <= 0:
reason = f'no keystroke for {options["shell_idle_minutes"]} minutes'
break
# The user's console login and TOTP token are checked again every SHELL_CHECK_SECONDS.
if time.monotonic() - checked >= SHELL_CHECK_SECONDS:
checked = time.monotonic()
try:
refusal = console_account(name)[1]
except (vpnrouter_users.Failure, ldap.LDAPError, OSError) as exc:
refusal = f'the user directory failed: {exc}'
if refusal:
reason = refusal
break
if select.select([master], [], [], min(left, 1))[0]:
try:
output = os.read(master, 65536)
@@ -1394,7 +1480,9 @@ class Handler(BaseHTTPRequestHandler):
'ca', 'shell', 'remote-access')
if path == f'/{name}' or path.startswith(f'/{name}/')), None)
protected = path in ('/status/full', '/config', '/metrics/history', '/postgresql') or directory is not None
signing = path in ('/auth', '/auth/refresh', '/auth/logout')
signing = path in ('/auth', '/auth/passkey', '/auth/passkey/complete', '/auth/refresh', '/auth/logout')
oidc = (path == '/.well-known/openid-configuration' or path.startswith('/oidc/')) and \
self.server.server_address[0] == vpnrouter_net.LOOPBACK
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
@@ -1405,7 +1493,7 @@ class Handler(BaseHTTPRequestHandler):
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
wait = auth_wait(self.client()) if path not in ('/auth/logout', '/auth/passkey') else 0
if wait:
code, document = 429, {'error': f'too many sign-in calls; retry in {wait} seconds'}
extra.append(('Retry-After', str(wait)))
@@ -1458,6 +1546,15 @@ class Handler(BaseHTTPRequestHandler):
elif directory is not None:
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'
elif oidc:
wait = auth_wait(self.client()) if (self.command, path) == ('POST', '/oidc/login') else 0
if wait:
code, kind = 429, 'application/json'
body = json.dumps({'error': f'too many sign-in calls; retry in {wait} seconds'}).encode() + b'\n'
extra.append(('Retry-After', str(wait)))
else:
code, kind, body, headers = vpnrouter_oidc.answer(self.command, self.path, self.headers, self.raw_body())
extra += headers
elif self.command not in ('GET', 'HEAD'):
body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
elif path.startswith('/pki/') and vpnrouter.PKI_NAME.match(path[5:-4]) and path[-4:] in PUBLISHED and \
@@ -1485,6 +1582,14 @@ class Handler(BaseHTTPRequestHandler):
if send_body:
self.wfile.write(body)
def raw_body(self):
"""The request body's bytes, b'' without one or over BODY_LIMIT."""
try:
size = int(self.headers.get('Content-Length') or 0)
except ValueError:
return b''
return self.rfile.read(size) if 0 < size <= BODY_LIMIT else b''
def request_body(self):
"""The request body as JSON, None when there is none, or the string 'invalid'."""
length = self.headers.get('Content-Length')
@@ -1523,7 +1628,8 @@ def log(message):
def initialize():
"""Make the signing key and the refresh tokens' database, retrying every INIT_RETRY_SECONDS until both are ready."""
"""Make the signing key, the OpenID Provider's key and the refresh tokens' database, retrying every
INIT_RETRY_SECONDS until all are ready."""
reported = None
while True:
phase = 'reading the configuration'
@@ -1531,6 +1637,8 @@ def initialize():
cfg = vpnrouter.load_config()
phase = 'making the signing key'
vpnrouter_auth.ensure_key()
phase = 'making the OpenID Provider key'
vpnrouter_oidc.ensure_key()
phase = 'creating the token database'
vpnrouter_auth.provision(cfg)
phase = 'connecting to the token database'
+47 -9
View File
@@ -12,9 +12,15 @@ user exists, 1 anything else. -h or --help shows the commands and their options.
vpn-router-user [--json] add <name> --first-name F --last-name L --email E (password on stdin)
vpn-router-user [--json] add <name> [--email E] (a Manager-only account; password on stdin)
vpn-router-user [--json] add <name> --service [--display-name N] [--description D] [--email E]
[--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]
(a service account; password on stdin)
vpn-router-user [--json] update <name> [--first-name F] [--last-name L] [--email E]
vpn-router-user [--json] update <name> [--display-name N] [--description D] [--email E] (a service account)
vpn-router-user [--json] update <name> [--display-name N] [--description D] [--email E]
[--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]
(a service account)
A service account with a redirect URI is a client of the OpenID Provider; each of the three options may be repeated,
and replaces all values of its kind; one '' removes them.
vpn-router-user [--json] passwd <name> (password on stdin)
vpn-router-user [--json] disable <name>
vpn-router-user [--json] enable <name>
@@ -25,6 +31,7 @@ user exists, 1 anything else. -h or --help shows the commands and their options.
vpn-router-user [--json] otp-enrol <name> <code> (secret on stdin)
vpn-router-user [--json] otp-remove <name>
vpn-router-user [--json] console <name> (--posix | --account LOCAL | --off)
vpn-router-user [--json] passkeys-remove <name>
"""
import argparse
@@ -37,6 +44,7 @@ import ldap
import vpnrouter
import vpnrouter_auth
import vpnrouter_passkeys
import vpnrouter_users
from vpnrouter_users import Failure
@@ -57,8 +65,9 @@ HELP = {
'otp-new': 'make a TOTP secret and its otpauth URI; nothing is stored',
'otp-enrol': 'store the TOTP secret read from standard input, when the code is a code of it',
'otp-remove': 'remove the TOTP token',
'console': "switch the console login of a member of admins on, as the user's own POSIX account in the directory "
'or as a local account, or off',
'console': "switch the console login of a member of admins with a TOTP token on, as the user's own POSIX account "
'in the directory or as a local account, or off',
'passkeys-remove': "remove all the user's passkeys",
}
#: The help of the person options.
@@ -66,6 +75,13 @@ PERSON_HELP = {'first_name': 'the first name', 'last_name': 'the last name', 'em
'display_name': "a service account's display name; '' removes it",
'description': "a service account's description; '' removes it"}
#: The OpenID Provider client settings of a service account, their options and help.
CLIENT_OPTIONS = (
('redirect_uris', 'redirect-uri', 'a redirect URI of the OpenID Provider client'),
('post_logout_redirect_uris', 'post-logout-redirect-uri', 'a post-logout redirect URI of the OpenID Provider client'),
('allowed_groups', 'allowed-group', 'a group whose members may sign in to the OpenID Provider client'),
)
class Parser(argparse.ArgumentParser):
"""An argument parser whose errors are Failures with status 2, so that --json answers them as JSON."""
@@ -85,6 +101,9 @@ def parse(argv):
if command in ('add', 'update'):
for key, _ in vpnrouter_users.PERSON + vpnrouter_users.LABELS:
sub.add_argument(f'--{key.replace("_", "-")}', dest=key, metavar='TEXT', help=PERSON_HELP[key])
for key, option, text in CLIENT_OPTIONS:
sub.add_argument(f'--{option}', dest=key, action='append', metavar='VALUE',
help=f"{text}, of a service account; repeat it for more, '' for none")
if command == 'add':
sub.add_argument('--service', action='store_true', help='add a service account, which cannot sign in to the web service')
if command == 'otp-enrol':
@@ -108,6 +127,10 @@ def run(connection, options, secret):
if command == 'list':
return vpnrouter_users.list_users(connection)
values = {key: getattr(options, key, None) for key, _ in vpnrouter_users.PERSON + vpnrouter_users.LABELS}
for key, _, _ in CLIENT_OPTIONS:
given = getattr(options, key, None)
if given is not None:
values[key] = [value for value in given if value]
if command == 'add':
return vpnrouter_users.add(connection, options.name, secret, values, service=options.service)
if command == 'update':
@@ -118,14 +141,28 @@ def run(connection, options, secret):
return vpnrouter_users.otp_enrol(connection, options.name, secret, options.code)
if command == 'console':
return vpnrouter_users.console(connection, options.name, not options.off, options.account)
if command == 'passkeys-remove':
return vpnrouter_passkeys.remove(connection, options.name)
return getattr(vpnrouter_users, command.replace('-', '_'))(connection, options.name)
def console_text(console):
"""The console login of a user as a cell: -, off, posix (uid N) or local and the account."""
def console_text(user):
"""The console login of a user as a cell: -, off, posix (uid N) or local and the account, refused without a TOTP
token."""
console = user['console']
if console is None or not console['enabled']:
return '-' if console is None else 'off'
return f'local {console["account"]}' if console['local'] else f'posix (uid {console["uid"]})'
text = f'local {console["account"]}' if console['local'] else f'posix (uid {console["uid"]})'
return text if user['otp'] else f'{text}, refused: no OTP'
def client_text(user):
"""The OpenID Provider client settings of a service account as a cell: -, or the numbers of its URIs and its allowed
groups."""
if not user.get('redirect_uris'):
return '-'
groups = ','.join(user['allowed_groups']) or 'all users'
return f'{len(user["redirect_uris"])} redirect, {len(user["post_logout_redirect_uris"])} logout, {groups}'
def text_of(answer):
@@ -139,12 +176,13 @@ def text_of(answer):
users = answer['users'] if 'users' in answer else [answer]
if not users:
return 'no users'
head = ('USER', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'CONSOLE', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
head = ('USER', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'PASSKEYS', 'CONSOLE', 'FIRST NAME', 'LAST NAME', 'E-MAIL',
'OIDC CLIENT')
rows = [(user['name'], 'internal' if user['internal'] else 'service' if user['service'] else 'manager' if user['manager_only'] else 'vpn',
'disabled' if not user['enabled'] else 'locked' if user['locked'] else 'enabled',
'issued' if user['certificate'] else 'missing',
'enrolled' if user['otp'] else '-', console_text(user['console']),
user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-')
'enrolled' if user['otp'] else '-', str(len(user['passkeys'])) if user['passkeys'] else '-', console_text(user),
user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-', client_text(user))
for user in users]
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
return '\n'.join(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows))
@@ -63,7 +63,8 @@
"trusted_proxies": [],
"status_content": "basic",
"shell": false,
"shell_idle_minutes": 15
"shell_idle_minutes": 15,
"shell_auth_timeout": 15
},
"ssh": {
"ldap_login": false
@@ -75,6 +76,9 @@
"manager_port": 7443,
"caddy_source": "ubuntu"
},
"oidc": {
"enabled": false
},
"pki": {
"root_name": "VPN Router Root CA",
"root_days": 7300,
@@ -0,0 +1,113 @@
-- The state of the router's OpenID Provider (vpnrouter_oidc) in the database of web.database.
-- Codes, tokens and sessions are kept as the SHA-256 of their values; expired rows are purged when new ones are saved.
-- The whole file runs as one transaction, serialized by an advisory lock, so that two processes can load it at once.
SELECT pg_advisory_xact_lock(hashtext('vpn-router oidc schema'));
-- An authorization code, valid until it is exchanged for tokens or expires.
CREATE TABLE IF NOT EXISTS oidc_codes (
hash text PRIMARY KEY,
client_id text NOT NULL,
name text NOT NULL,
redirect_uri text NOT NULL,
scopes text[] NOT NULL,
nonce text,
code_challenge text NOT NULL,
code_challenge_method text NOT NULL,
auth_time timestamptz NOT NULL,
amr text[] NOT NULL,
expires timestamptz NOT NULL
);
-- An access token or a refresh token (kind) a client was issued for a user.
CREATE TABLE IF NOT EXISTS oidc_tokens (
hash text PRIMARY KEY,
kind text NOT NULL CHECK (kind IN ('access', 'refresh')),
client_id text NOT NULL,
name text NOT NULL,
scopes text[] NOT NULL,
auth_time timestamptz NOT NULL,
amr text[] NOT NULL,
expires timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS oidc_tokens_name ON oidc_tokens (name);
-- A user's sign-in at the OpenID Provider, which the session cookie names.
CREATE TABLE IF NOT EXISTS oidc_sessions (
hash text PRIMARY KEY,
name text NOT NULL,
auth_time timestamptz NOT NULL,
amr text[] NOT NULL,
expires timestamptz NOT NULL
);
CREATE OR REPLACE PROCEDURE oidc_code_save(p_hash text, p_client_id text, p_name text, p_redirect_uri text,
p_scopes text[], p_nonce text, p_code_challenge text,
p_code_challenge_method text, p_auth_time timestamptz, p_amr text[],
p_seconds integer)
LANGUAGE sql AS $$
DELETE FROM oidc_codes WHERE expires < now();
INSERT INTO oidc_codes VALUES (p_hash, p_client_id, p_name, p_redirect_uri, p_scopes, p_nonce, p_code_challenge,
p_code_challenge_method, p_auth_time, p_amr, now() + make_interval(secs => p_seconds));
$$;
-- The unexpired code.
CREATE OR REPLACE FUNCTION oidc_code_get(p_hash text)
RETURNS TABLE (client_id text, name text, redirect_uri text, scopes text[], nonce text, code_challenge text,
code_challenge_method text, auth_time timestamptz, amr text[])
LANGUAGE sql STABLE AS $$
SELECT client_id, name, redirect_uri, scopes, nonce, code_challenge, code_challenge_method, auth_time, amr
FROM oidc_codes WHERE hash = p_hash AND expires > now();
$$;
CREATE OR REPLACE PROCEDURE oidc_code_delete(p_hash text)
LANGUAGE sql AS $$
DELETE FROM oidc_codes WHERE hash = p_hash;
$$;
CREATE OR REPLACE PROCEDURE oidc_token_save(p_hash text, p_kind text, p_client_id text, p_name text, p_scopes text[],
p_auth_time timestamptz, p_amr text[], p_seconds integer)
LANGUAGE sql AS $$
DELETE FROM oidc_tokens WHERE expires < now();
INSERT INTO oidc_tokens VALUES (p_hash, p_kind, p_client_id, p_name, p_scopes, p_auth_time, p_amr,
now() + make_interval(secs => p_seconds));
$$;
-- The unexpired token of the kind.
CREATE OR REPLACE FUNCTION oidc_token_get(p_hash text, p_kind text)
RETURNS TABLE (client_id text, name text, scopes text[], auth_time timestamptz, amr text[])
LANGUAGE sql STABLE AS $$
SELECT client_id, name, scopes, auth_time, amr FROM oidc_tokens
WHERE hash = p_hash AND kind = p_kind AND expires > now();
$$;
CREATE OR REPLACE PROCEDURE oidc_token_delete(p_hash text)
LANGUAGE sql AS $$
DELETE FROM oidc_tokens WHERE hash = p_hash;
$$;
CREATE OR REPLACE PROCEDURE oidc_session_save(p_hash text, p_name text, p_amr text[], p_seconds integer)
LANGUAGE sql AS $$
DELETE FROM oidc_sessions WHERE expires < now();
INSERT INTO oidc_sessions VALUES (p_hash, p_name, now(), p_amr, now() + make_interval(secs => p_seconds));
$$;
-- The unexpired session.
CREATE OR REPLACE FUNCTION oidc_session_get(p_hash text)
RETURNS TABLE (name text, auth_time timestamptz, amr text[])
LANGUAGE sql STABLE AS $$
SELECT name, auth_time, amr FROM oidc_sessions WHERE hash = p_hash AND expires > now();
$$;
CREATE OR REPLACE PROCEDURE oidc_session_delete(p_hash text)
LANGUAGE sql AS $$
DELETE FROM oidc_sessions WHERE hash = p_hash;
$$;
-- Remove every code, token and session of the user.
CREATE OR REPLACE PROCEDURE oidc_revoke(p_name text)
LANGUAGE sql AS $$
DELETE FROM oidc_codes WHERE name = p_name;
DELETE FROM oidc_tokens WHERE name = p_name;
DELETE FROM oidc_sessions WHERE name = p_name;
$$;
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>{{ title }}</title>
<style>
body { font-family: system-ui, sans-serif; background: #f4f5f7; color: #1f2937; margin: 0; }
main { max-width: 22rem; margin: 4rem auto; background: #fff; padding: 2rem; border-radius: 0.5rem; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); }
h1 { font-size: 1.25rem; margin: 0 0 0.25rem; }
p { margin: 0 0 1rem; color: #4b5563; }
label { display: block; font-size: 0.875rem; margin: 0.75rem 0 0.25rem; }
input { box-sizing: border-box; width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.25rem; font-size: 1rem; }
button { width: 100%; margin-top: 1.25rem; padding: 0.6rem; border: 0; border-radius: 0.25rem; font-size: 1rem; cursor: pointer; background: #1d4ed8; color: #fff; }
button.secondary { background: #fff; color: #1d4ed8; border: 1px solid #1d4ed8; margin-top: 0.75rem; }
.error { background: #fee2e2; color: #991b1b; padding: 0.5rem 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem; }
.issuer { font-size: 0.75rem; color: #6b7280; margin: 1.5rem 0 0; }
</style>
</head>
<body>
<main>
<h1>{{ title }}</h1>
{%- if message %}
<p>{{ message }}</p>
{%- endif %}
{%- if error %}
<div class="error" role="alert">{{ error }}</div>
{%- endif %}
{%- if form %}
<form method="post" action="/oidc/login">
<input type="hidden" name="request" value="{{ request }}">
<label for="name">Name</label>
<input id="name" name="name" value="{{ name }}" autocomplete="username webauthn" autocapitalize="none" spellcheck="false" required autofocus>
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
<label for="code">Code from the authenticator, when you have one</label>
<input id="code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code">
<button type="submit">Sign in</button>
</form>
<form id="passkey-form" method="post" action="/oidc/login" hidden>
<input type="hidden" name="request" value="{{ request }}">
<input type="hidden" name="passkey_id">
<input type="hidden" name="credential">
</form>
<button id="passkey" class="secondary" type="button" hidden>Sign in with a passkey</button>
<script>
// A passkey sign-in begins at /oidc/passkey and posts the browser's answer to /oidc/login with the authorization request.
(function () {
var button = document.getElementById('passkey');
if (!window.PublicKeyCredential || !PublicKeyCredential.parseRequestOptionsFromJSON) {
return;
}
button.hidden = false;
button.addEventListener('click', async function () {
button.disabled = true;
try {
var begun = await (await fetch('/oidc/passkey', { method: 'POST' })).json();
var credential = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(begun.options.publicKey) });
var form = document.getElementById('passkey-form');
form.elements.passkey_id.value = begun.id;
form.elements.credential.value = JSON.stringify(credential.toJSON());
form.submit();
} catch (error) {
button.disabled = false;
}
});
})();
</script>
{%- endif %}
<p class="issuer">{{ issuer }}</p>
</main>
</body>
</html>