diff --git a/ADR.md b/ADR.md index a6e3423..72221fe 100644 --- a/ADR.md +++ b/ADR.md @@ -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 @@ -979,7 +979,7 @@ Consequences: Peers follow their users: disabling a user drops the user's device 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 only, verified by the router with python3-fido2 +## 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 @@ -998,9 +998,48 @@ passkey sign-in gives the same tokens as `/auth`; as it binds no password, the r 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; the password and code stay for -everything else, the shell included. A changed `wan.local_fqdn` orphans every passkey. The +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://:`, 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. diff --git a/README.md b/README.md index b0374b8..b9d54d9 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ must have the type shown below; an unknown section or setting is an error. | `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 | @@ -627,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 @@ -665,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 @@ -703,9 +707,9 @@ vpn-router-user otp-remove alice | `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 --first-name F --last-name L --email E` | Adds a VPN user and issues the certificate | | `add [--email E]` | Adds a Manager-only account, with the e-mail address when given; one name without the other two options is refused | -| `add --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 --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 [--first-name F] [--last-name L] [--email E]` | Changes the names or the e-mail address | -| `update [--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 [--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 ` | Sets the password | | `disable `, `enable ` | Locks the user out of OpenVPN and `/auth`, and lets the user in again | | `unlock ` | Ends a lockout after failed logins and clears the failure count; a disabled user stays disabled | @@ -726,7 +730,7 @@ characters, as `otp-new` makes it, and a code 6 digits. Once enrolled, a user lo 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 @@ -774,10 +778,10 @@ with `passwd`. ### Passkeys -A user who is not a service account may sign in to the Router Manager with a passkey instead of -the password and code. Passkeys are for that sign-in 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 -Router Manager's HTTPS address on that name, and passkeys need the name set; a passkey made +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. @@ -796,8 +800,10 @@ A disabled or locked user is refused a passkey sign-in too. `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' @@ -814,7 +820,7 @@ vpn-router-group remove netops | `list` | The groups with their members and descriptions | | `add --member U [--member U ...] [--description D]` | Adds the group with its members | | `update [--name N] [--description D]` | Renames the group or sets its description; `''` removes it | -| `remove ` | Removes the group; its members are kept | +| `remove ` | Removes the group; its members are kept; refused while a client of the OpenID Provider allows it | | `member-add ` | Adds the user to the group | | `member-remove ` | Takes the user out of the group; refused for the last member | @@ -881,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 @@ -913,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@-`, or `postgresql` as `no cluster` without one), of @@ -1092,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/` | 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/` | 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//password` | `{"password": ...}` | the account | | `POST /service-accounts//disable` | | the account | | `POST /service-accounts//enable` | | the account | | `POST /service-accounts//unlock` | | the account | | `DELETE /service-accounts/` | | `{"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. @@ -1302,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=`, 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://:`, 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 @@ -1524,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 diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index b4888f9..da0462a 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -1,3 +1,32 @@ +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://:, 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 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. diff --git a/debian-package/debian/control b/debian-package/debian/control index 39c6c99..bfbaf1e 100644 --- a/debian-package/debian/control +++ b/debian-package/debian/control @@ -23,6 +23,8 @@ Depends: ${misc:Depends}, python3-ldap, python3-pyotp, python3-fido2, + python3-jwt, + python3-oauthlib, python3-psycopg, python3-psutil, python3-prometheus-client, @@ -49,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 diff --git a/debian-package/debian/install b/debian-package/debian/install index ae25cd3..878046a 100644 --- a/debian-package/debian/install +++ b/debian-package/debian/install @@ -6,6 +6,7 @@ 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/ @@ -29,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/ diff --git a/debian-package/debian/postrm b/debian-package/debian/postrm index e20e2bd..930a5c7 100755 --- a/debian-package/debian/postrm +++ b/debian-package/debian/postrm @@ -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 \ diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter.py b/debian-package/src/usr/lib/vpn-router/vpnrouter.py index a29a524..c607a9b 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter.py @@ -61,6 +61,7 @@ SCHEMA = { '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': {}, @@ -521,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://:, 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(): @@ -746,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() diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_auth.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_auth.py index be3b9c4..4f51a59 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_auth.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_auth.py @@ -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): diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py index 73fc5c3..29d7d1d 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py @@ -68,7 +68,10 @@ OVERLAYS = ('ppolicy', 'otp') #: 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 " [,...]", and vpnRouterPasskey, each a -#: passkey of the user as JSON (vpnrouter_passkeys). vpnRouterAuthority is a CA of the PKI: its +#: 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 @@ -103,11 +106,18 @@ SCHEMA_ATTRIBUTE_TYPES = ( "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 $ vpnRouterPasskey ) )", + "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 ) )", diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_oidc.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_oidc.py new file mode 100644 index 0000000..c4a7552 --- /dev/null +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_oidc.py @@ -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)}) diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_passkeys.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_passkeys.py index a029246..3200d4b 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_passkeys.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_passkeys.py @@ -1,7 +1,8 @@ -"""The passkeys of the router's users, for signing in to the Router Manager, with python3-fido2. +"""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 Router Manager's address on that name over HTTPS. +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 diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py index 9880daa..0578783 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py @@ -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 @@ -62,6 +68,7 @@ import pwd import re import subprocess import time +import urllib.parse import ldap import ldap.filter @@ -90,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', 'vpnRouterPasskey'] + '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. @@ -195,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}' @@ -246,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] @@ -266,6 +323,12 @@ def summary(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 @@ -466,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); only a service account may be named by a GUID.""" + 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) @@ -488,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: @@ -511,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) @@ -551,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)) @@ -605,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 @@ -789,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'])} @@ -815,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)) @@ -830,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 @@ -837,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) diff --git a/debian-package/src/usr/lib/vpn-router/web b/debian-package/src/usr/lib/vpn-router/web index b8a82c5..bc576b5 100644 --- a/debian-package/src/usr/lib/vpn-router/web +++ b/debian-package/src/usr/lib/vpn-router/web @@ -50,6 +50,10 @@ Pages: /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; @@ -71,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. @@ -120,6 +124,7 @@ import vpnrouter_auth import vpnrouter_backup import vpnrouter_metrics import vpnrouter_net +import vpnrouter_oidc import vpnrouter_passkeys import vpnrouter_pki import vpnrouter_serve @@ -587,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/ 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/ 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//password set the password: {"password": ...} POST /service-accounts//disable lock it out POST /service-accounts//enable let it in again POST /service-accounts//unlock end a lockout after failed logins DELETE /service-accounts/ 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'} @@ -608,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), @@ -634,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 @@ -1460,6 +1481,8 @@ class Handler(BaseHTTPRequestHandler): 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/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 @@ -1523,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 \ @@ -1550,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') @@ -1588,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' @@ -1596,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' diff --git a/debian-package/src/usr/sbin/vpn-router-user b/debian-package/src/usr/sbin/vpn-router-user index 99338d8..e661395 100755 --- a/debian-package/src/usr/sbin/vpn-router-user +++ b/debian-package/src/usr/sbin/vpn-router-user @@ -12,9 +12,15 @@ user exists, 1 anything else. -h or --help shows the commands and their options. vpn-router-user [--json] add --first-name F --last-name L --email E (password on stdin) vpn-router-user [--json] add [--email E] (a Manager-only account; password on stdin) vpn-router-user [--json] add --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 [--first-name F] [--last-name L] [--email E] - vpn-router-user [--json] update [--display-name N] [--description D] [--email E] (a service account) + vpn-router-user [--json] update [--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 (password on stdin) vpn-router-user [--json] disable vpn-router-user [--json] enable @@ -69,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.""" @@ -88,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': @@ -111,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': @@ -136,6 +156,15 @@ def console_text(user): 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): """The answer as text for people.""" if 'profile' in answer: @@ -147,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', 'PASSKEYS', '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 '-', str(len(user['passkeys'])) if user['passkeys'] else '-', console_text(user), - user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-') + 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)) diff --git a/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example b/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example index 1a65772..c363063 100644 --- a/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example +++ b/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example @@ -76,6 +76,9 @@ "manager_port": 7443, "caddy_source": "ubuntu" }, + "oidc": { + "enabled": false + }, "pki": { "root_name": "VPN Router Root CA", "root_days": 7300, diff --git a/debian-package/src/usr/share/vpn-router/sql/oidc.sql b/debian-package/src/usr/share/vpn-router/sql/oidc.sql new file mode 100644 index 0000000..79721ca --- /dev/null +++ b/debian-package/src/usr/share/vpn-router/sql/oidc.sql @@ -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; +$$; diff --git a/debian-package/src/usr/share/vpn-router/templates/oidc-page.html.j2 b/debian-package/src/usr/share/vpn-router/templates/oidc-page.html.j2 new file mode 100644 index 0000000..dca4b25 --- /dev/null +++ b/debian-package/src/usr/share/vpn-router/templates/oidc-page.html.j2 @@ -0,0 +1,74 @@ + + + + + + +{{ title }} + + + +
+

{{ title }}

+{%- if message %} +

{{ message }}

+{%- endif %} +{%- if error %} + +{%- endif %} +{%- if form %} +
+ + + + + + + + +
+ + + +{%- endif %} +

{{ issuer }}

+
+ +