Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81b7f4cac6 | ||
|
|
8cac21845a | ||
|
|
d1d565b1d9 |
No files matched your search
@@ -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
|
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
|
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
|
`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
|
checks a password by binding as the user, so the hash scheme is slapd's alone. The router's own
|
||||||
can read the directory through its LDAP connector.
|
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,
|
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
|
`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
|
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.
|
`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.
|
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
|
WebAuthn needs a browser, HTTPS and a domain name; OpenVPN, IKEv2, WireGuard, SSH and sudo have no
|
||||||
@@ -998,9 +998,70 @@ 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
|
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
|
service's memory for 2 minutes. The browser converts the options and answers with
|
||||||
`PublicKeyCredential.parseCreationOptionsFromJSON`, `parseRequestOptionsFromJSON` and `toJSON`.
|
`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
|
Consequences: Passkeys are a convenience for the Router Manager and the OpenID Provider; the
|
||||||
everything else, the shell included. A changed `wan.local_fqdn` orphans every passkey. 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
|
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.
|
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.
|
Browsers without the JSON methods of WebAuthn Level 3 cannot add or use passkeys.
|
||||||
|
|
||||||
|
## ADR-37: The web service is an OpenID Provider built on python3-oauthlib; its clients are service accounts
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
Context: Other systems, such as dashboards, should sign the router's users in with the directory's
|
||||||
|
passwords, TOTP codes and passkeys, without a second user store. The Ubuntu archive has no
|
||||||
|
packaged identity provider. `python3-oauthlib` and `python3-jwt` are in main for noble and
|
||||||
|
resolute; oauthlib implements the OpenID Connect flows and leaves the checks, the storage and the
|
||||||
|
ID token's signature to the application. `python3-authlib` and `python3-jwcrypto` are in universe
|
||||||
|
only. The web service already checks passwords and passkeys, rate limits sign-ins and keeps
|
||||||
|
tokens in PostgreSQL, and Caddy serves it over HTTPS on `wan.local_fqdn`. ADR-3's HS256 key must
|
||||||
|
not leave the router, but a relying party verifies an ID token itself.
|
||||||
|
|
||||||
|
Decision: The web service is the OpenID Provider, in `vpnrouter_oidc`, while `oidc.enabled` is
|
||||||
|
true; its issuer is its HTTPS site, `https://<wan.local_fqdn>:<https.web_port>`, without the port
|
||||||
|
on 443, and it answers the provider's paths only on `127.0.0.1`, where Caddy passes them. Only
|
||||||
|
the authorization code flow with PKCE S256 and the refresh token grant are allowed. A client is a
|
||||||
|
service account with redirect URIs, kept with the account in the new attributes
|
||||||
|
`vpnRouterRedirectUri`, `vpnRouterPostLogoutRedirectUri` and `vpnRouterAllowedGroup` of
|
||||||
|
`vpnRouterAccount`: its name is the client ID and its password the secret, checked with a bind.
|
||||||
|
Allowed groups limit who may sign in; without them every user may. A registered client is
|
||||||
|
trusted, so there is no consent page. The sign-in page is the web service's own, a Jinja2
|
||||||
|
template with the password, the code and a passkey, so the provider works without the Router
|
||||||
|
Manager. ID tokens are RS256, signed with an RSA key the web service makes on its first run
|
||||||
|
(`/etc/vpn-router/oidc.key`), published as a JWK named by its RFC 7638 thumbprint; `sub` is the
|
||||||
|
entry's `entryUUID`. Codes, access and refresh tokens and sessions are kept as their SHA-256 in
|
||||||
|
the database of the refresh tokens, defined in `/usr/share/vpn-router/sql/oidc.sql` with
|
||||||
|
procedures and functions the module calls; a process loads the file at its first connection. The
|
||||||
|
operations that revoke the refresh tokens of `/auth` also revoke them, and every use of a code or
|
||||||
|
token checks the user and the client's allowed groups again.
|
||||||
|
|
||||||
|
Consequences: The package depends on `python3-oauthlib` and `python3-jwt`. The provider needs
|
||||||
|
HTTPS, a public name and the web service. Clients are managed like service accounts, by the CLI,
|
||||||
|
`/service-accounts` and the Router Manager; a group a client allows cannot be removed, and renaming
|
||||||
|
it renames it in the clients. The signing key does not rotate; replacing it invalidates every ID
|
||||||
|
token issued. Wrong client secrets lock the client like any account. The implicit and hybrid
|
||||||
|
flows, dynamic client registration and public clients without a secret are not offered.
|
||||||
|
|
||||||
|
## ADR-38: Bash completions are files per command in bash-completion's completions directory
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
Context: The `vpn-*` commands have many subcommands, options and names (users, groups, issuing
|
||||||
|
CAs, templates, settings) that are hard to remember. bash-completion's README recommends its
|
||||||
|
`completionsdir`, `/usr/share/bash-completion/completions` on Ubuntu, whose files it loads on the
|
||||||
|
first Tab of their command, and calls the eagerly loaded `/etc/bash_completion.d` not
|
||||||
|
recommended; it prefers the name `<command>.bash`, which noble's 2.11 and resolute's 2.16 both
|
||||||
|
look up.
|
||||||
|
|
||||||
|
Decision: The package installs `<command>.bash` for each `vpn-*` command into
|
||||||
|
`/usr/share/bash-completion/completions` and recommends `bash-completion`. The completions read
|
||||||
|
the names they offer from the commands themselves (`--json list`) or the package's Python
|
||||||
|
modules (the schema, the configuration, the PKI's templates and reasons), not from copies, and
|
||||||
|
send errors to `/dev/null`, so a user who may not read them gets the fixed words only.
|
||||||
|
|
||||||
|
Consequences: Completions follow the code without being edited for each new setting or name. A
|
||||||
|
Tab that offers names runs a command or Python, which takes a fraction of a second. The files
|
||||||
|
are not conffiles; an administrator overrides one in `/etc/bash_completion.d` or the user's own
|
||||||
|
completions directory.
|
||||||
@@ -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` | `web_port` | integer | `7444` | HTTPS port of the status web service |
|
||||||
| `https` | `manager_port` | integer | `7443` | HTTPS port of the Router Manager |
|
| `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) |
|
| `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_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` | `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 |
|
| `pki` | `ca_days` | integer | `3650` | Days an issuing CA's certificate is valid |
|
||||||
@@ -354,6 +355,23 @@ indented, with mode 0600, and reminds that `vpn-router-setup` applies it. The st
|
|||||||
writes the file the same way, and a lock keeps the two from overwriting each other's change. A
|
writes the file the same way, and a lock keeps the two from overwriting each other's change. A
|
||||||
refused request exits with status 2, any other failure with 1.
|
refused request exits with status 2, any other failure with 1.
|
||||||
|
|
||||||
|
## Bash completion
|
||||||
|
|
||||||
|
Bash completes the commands, options and arguments of `vpn-router-user`, `vpn-router-group`,
|
||||||
|
`vpn-router-token`, `vpn-config-edit`, `vpn-router-backup`, `vpn-router-ca` and
|
||||||
|
`vpn-router-caddy` with the Tab key. The package installs a file per command in
|
||||||
|
`/usr/share/bash-completion/completions`, which the `bash-completion` package, recommended by
|
||||||
|
`vpn-router`, loads on the first Tab after the command. For root, who can read them, the
|
||||||
|
completions also offer the users and groups of the directory, the settings and site-to-site
|
||||||
|
connections of `vpn-router.json` with `true` and `false` for a boolean setting, and the issuing CAs and
|
||||||
|
certificate templates; for other users only the fixed words.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo -i
|
||||||
|
vpn-config-edit set oidc.<Tab>
|
||||||
|
vpn-router-user passwd <Tab>
|
||||||
|
```
|
||||||
|
|
||||||
## Applying changes
|
## Applying changes
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -627,10 +645,12 @@ is switched on:
|
|||||||
`account` for a Manager-only account (see Managing users);
|
`account` for a Manager-only account (see Managing users);
|
||||||
- the package's own schema, `cn=vpn-router,cn=schema,cn=config`, under the OID
|
- 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
|
`2.25.98477352595735834532404256531620013001` of the UUID arc: the auxiliary class
|
||||||
`vpnRouterAccount` with `MAY ( mail $ vpnRouterScope $ displayName $ description )`, which
|
`vpnRouterAccount`, which Manager-only and service accounts have, and the attribute
|
||||||
Manager-only and service accounts have, and the attribute `vpnRouterScope`, authorization
|
`vpnRouterScope`, authorization scopes, of which `service` marks a service account;
|
||||||
scopes, of which `service` marks a service account; `displayName` and `description` are the
|
`displayName` and `description` are the display name and the description of a service
|
||||||
display name and the description of a service account;
|
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
|
- `ou=groups` holds one `groupOfNames` per group, named by `cn`, with its members' DNs in
|
||||||
`member` and an optional `description`;
|
`member` and an optional `description`;
|
||||||
- `cn=user-manager,ou=services` is the account that changes users and groups; it may write
|
- `cn=user-manager,ou=services` is the account that changes users and groups; it may write
|
||||||
@@ -665,7 +685,8 @@ it is added:
|
|||||||
- a service account, a Manager-only account whose `vpnRouterScope` is `service`, which another
|
- 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
|
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
|
`/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
|
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
|
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
|
account `metrics.service_account` names while metrics are enabled (see Metrics), which
|
||||||
@@ -703,9 +724,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 |
|
| `list` | The users with their kind, names and e-mail addresses, whether each is enabled or locked, has a certificate and is enrolled for TOTP, the number of passkeys and the console login |
|
||||||
| `add <name> --first-name F --last-name L --email E` | Adds a VPN user and issues the certificate |
|
| `add <name> --first-name F --last-name L --email E` | Adds a VPN user and issues the certificate |
|
||||||
| `add <name> [--email E]` | Adds a Manager-only account, with the e-mail address when given; one name without the other two options is refused |
|
| `add <name> [--email E]` | Adds a Manager-only account, with the e-mail address when given; one name without the other two options is refused |
|
||||||
| `add <name> --service [--display-name N] [--description D] [--email E]` | Adds a service account, with its display name, description and e-mail address when given; the names are refused |
|
| `add <name> --service [--display-name N] [--description D] [--email E] [--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]` | Adds a service account, with its display name, description, e-mail address and OpenID Provider client settings when given; the names are refused |
|
||||||
| `update <name> [--first-name F] [--last-name L] [--email E]` | Changes the names or the e-mail address |
|
| `update <name> [--first-name F] [--last-name L] [--email E]` | Changes the names or the e-mail address |
|
||||||
| `update <name> [--display-name N] [--description D] [--email E]` | Changes the display name, the description or the e-mail address of a service account; `''` removes one |
|
| `update <name> [--display-name N] [--description D] [--email E] [--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]` | Changes the display name, the description, the e-mail address or the OpenID Provider client settings of a service account; `''` removes one; a repeated client option gives all values of its kind |
|
||||||
| `passwd <name>` | Sets the password |
|
| `passwd <name>` | Sets the password |
|
||||||
| `disable <name>`, `enable <name>` | Locks the user out of OpenVPN and `/auth`, and lets the user in again |
|
| `disable <name>`, `enable <name>` | Locks the user out of OpenVPN and `/auth`, and lets the user in again |
|
||||||
| `unlock <name>` | Ends a lockout after failed logins and clears the failure count; a disabled user stays disabled |
|
| `unlock <name>` | Ends a lockout after failed logins and clears the failure count; a disabled user stays disabled |
|
||||||
@@ -717,14 +738,16 @@ vpn-router-user otp-remove alice
|
|||||||
| `console <name> (--posix \| --account LOCAL \| --off)` | Switches the console login of a member of `admins` enrolled for TOTP on, as the user's own POSIX account or a local account, or off (see Console login and SSH) |
|
| `console <name> (--posix \| --account LOCAL \| --off)` | Switches the console login of a member of `admins` enrolled for TOTP on, as the user's own POSIX account or a local account, or off (see Console login and SSH) |
|
||||||
| `passkeys-remove <name>` | Removes all the user's passkeys (see Passkeys) |
|
| `passkeys-remove <name>` | Removes all the user's passkeys (see Passkeys) |
|
||||||
|
|
||||||
A user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter; a
|
A user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter, and a
|
||||||
|
service account may instead be named by a GUID in lowercase, such as
|
||||||
|
`3f2b8c1e-9a4d-4e6f-8b2a-1c5d7e9f0a3b`; a
|
||||||
password has 8 to 128 characters; a first or last name has 1 to 64 printable characters; an
|
password has 8 to 128 characters; a first or last name has 1 to 64 printable characters; an
|
||||||
e-mail address has one `@`, no spaces and a dot in its domain; a TOTP secret is 32 base32
|
e-mail address has one `@`, no spaces and a dot in its domain; a TOTP secret is 32 base32
|
||||||
characters, as `otp-new` makes it, and a code 6 digits. Once enrolled, a user logs in to OpenVPN
|
characters, as `otp-new` makes it, and a code 6 digits. Once enrolled, a user logs in to OpenVPN
|
||||||
and `/auth` with the password followed by a current code. In the answers `manager_only`, `service`
|
and `/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
|
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
|
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.
|
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
|
When the Router Manager is installed with an administrator (its debconf questions
|
||||||
@@ -772,10 +795,10 @@ with `passwd`.
|
|||||||
|
|
||||||
### Passkeys
|
### Passkeys
|
||||||
|
|
||||||
A user who is not a service account may sign in to the Router Manager with a passkey instead of
|
A user who is not a service account may sign in to the Router Manager and to the OpenID Provider
|
||||||
the password and code. Passkeys are for that sign-in only: OpenVPN, the shell, SSH and sudo keep
|
with a passkey instead of the password and code. Passkeys are for those sign-ins only: OpenVPN,
|
||||||
the password and code. A passkey belongs to `wan.local_fqdn`, so the browser offers it on the
|
the shell, SSH and sudo keep the password and code. A passkey belongs to `wan.local_fqdn`, so the
|
||||||
Router Manager's HTTPS address on that name, and passkeys need the name set; a passkey made
|
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
|
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.
|
fingerprint), and the passkey is kept by it or by the user's password manager.
|
||||||
|
|
||||||
@@ -794,8 +817,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
|
`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
|
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,
|
of the directory. The group `admins` grants the management of the router, and the allowed groups
|
||||||
`--json`, `-h` and `--help` work as for `vpn-router-user`.
|
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
|
```sh
|
||||||
vpn-router-group add ops --member alice --member bob --description 'Operations team'
|
vpn-router-group add ops --member alice --member bob --description 'Operations team'
|
||||||
@@ -812,7 +837,7 @@ vpn-router-group remove netops
|
|||||||
| `list` | The groups with their members and descriptions |
|
| `list` | The groups with their members and descriptions |
|
||||||
| `add <name> --member U [--member U ...] [--description D]` | Adds the group with its members |
|
| `add <name> --member U [--member U ...] [--description D]` | Adds the group with its members |
|
||||||
| `update <name> [--name N] [--description D]` | Renames the group or sets its description; `''` removes it |
|
| `update <name> [--name N] [--description D]` | Renames the group or sets its description; `''` removes it |
|
||||||
| `remove <name>` | Removes the group; its members are kept |
|
| `remove <name>` | Removes the group; its members are kept; refused while a client of the OpenID Provider allows it |
|
||||||
| `member-add <name> <user>` | Adds the user to the group |
|
| `member-add <name> <user>` | Adds the user to the group |
|
||||||
| `member-remove <name> <user>` | Takes the user out of the group; refused for the last member |
|
| `member-remove <name> <user>` | Takes the user out of the group; refused for the last member |
|
||||||
|
|
||||||
@@ -879,8 +904,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
|
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`.
|
`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
|
The service first makes its signing key, the OpenID Provider's key and the refresh tokens'
|
||||||
seconds, for example while PostgreSQL is not running yet, and logs each failure; then it listens.
|
database, retrying every 15 seconds, for example while PostgreSQL is not running yet, and logs
|
||||||
|
each failure; then it listens.
|
||||||
|
|
||||||
### Bootstrap diagnostics
|
### Bootstrap diagnostics
|
||||||
|
|
||||||
@@ -911,6 +937,7 @@ curl http://10.0.0.4:17081/
|
|||||||
| `/groups` | The groups of users; needs a token |
|
| `/groups` | The groups of users; needs a token |
|
||||||
| `/site-to-site` | The site-to-site connections: list, add, change and remove; 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 |
|
| `/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
|
The status holds the state of `vpn-router-setup`, `slapd` and each PostgreSQL cluster unit
|
||||||
(`postgresql@<version>-<cluster>`, or `postgresql` as `no cluster` without one), of
|
(`postgresql@<version>-<cluster>`, or `postgresql` as `no cluster` without one), of
|
||||||
@@ -1090,16 +1117,19 @@ curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
|||||||
|
|
||||||
| Request | Body | Answer |
|
| Request | Body | Answer |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `GET /service-accounts` | | `{"service_accounts": [{"name", "display_name", "description", "internal", "enabled", "locked", "email", ...}, ...]}`, each as `/users` shows a user |
|
| `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"?}` | 201 and the account |
|
| `POST /service-accounts` | `{"name", "password", "display_name"?, "description"?, "email"?, "redirect_uris"?, "post_logout_redirect_uris"?, "allowed_groups"?}` | 201 and the account |
|
||||||
| `PUT /service-accounts/<name>` | any of `{"display_name", "description", "email"}`; `""` removes one | the account |
|
| `PUT /service-accounts/<name>` | any of `{"display_name", "description", "email", "redirect_uris", "post_logout_redirect_uris", "allowed_groups"}`; `""` removes one of the first three, `[]` the values of the others | the account |
|
||||||
| `PUT /service-accounts/<name>/password` | `{"password": ...}` | the account |
|
| `PUT /service-accounts/<name>/password` | `{"password": ...}` | the account |
|
||||||
| `POST /service-accounts/<name>/disable` | | the account |
|
| `POST /service-accounts/<name>/disable` | | the account |
|
||||||
| `POST /service-accounts/<name>/enable` | | the account |
|
| `POST /service-accounts/<name>/enable` | | the account |
|
||||||
| `POST /service-accounts/<name>/unlock` | | the account |
|
| `POST /service-accounts/<name>/unlock` | | the account |
|
||||||
| `DELETE /service-accounts/<name>` | | `{"name", "removed": true}` |
|
| `DELETE /service-accounts/<name>` | | `{"name", "removed": true}` |
|
||||||
|
|
||||||
A display name has at most 64 printable characters and a description at most 256. The internal
|
A display name has at most 64 printable characters and a description at most 256. The client
|
||||||
|
settings are lists of at most 16 values: URIs of `https`, or of `http` for `localhost`,
|
||||||
|
`127.0.0.1` or `::1`, without a fragment and of at most 1024 characters, and names of existing
|
||||||
|
groups (see OpenID Provider). The internal
|
||||||
service accounts, `internal` true, are only listed: every other request on one gets 403, as the
|
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
|
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.
|
`/users`, with 404 also for a name that is not a service account.
|
||||||
@@ -1300,6 +1330,76 @@ does on its own: remove the source and the key, run `apt-get update`, and instal
|
|||||||
with `apt-get install --allow-downgrades caddy=<version>`, the version `apt-cache policy caddy`
|
with `apt-get install --allow-downgrades caddy=<version>`, the version `apt-cache policy caddy`
|
||||||
lists for the archive.
|
lists for the archive.
|
||||||
|
|
||||||
|
## OpenID Provider
|
||||||
|
|
||||||
|
While `oidc.enabled` is `true`, the status web service is an OpenID Provider (OpenID Connect
|
||||||
|
Core 1.0) for the users of the directory. Its issuer is the web service's HTTPS site,
|
||||||
|
`https://<wan.local_fqdn>:<https.web_port>`, without the port when it is 443; the web service
|
||||||
|
answers it only on `127.0.0.1`, where Caddy passes the requests, so the provider is never reached
|
||||||
|
over plain HTTP. The setting needs `web.enabled`, `https.enabled` and `wan.local_fqdn`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vpn-config-edit set oidc.enabled true
|
||||||
|
systemctl restart vpn-router-setup
|
||||||
|
curl https://router.example.com:7444/.well-known/openid-configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
| Endpoint | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /.well-known/openid-configuration` | The discovery document |
|
||||||
|
| `GET /oidc/jwks` | The public key ID tokens are signed with |
|
||||||
|
| `GET`, `POST /oidc/authorize` | The authorization code flow; the sign-in page while the browser has no session |
|
||||||
|
| `POST /oidc/token` | Exchanges a code or a refresh token for tokens |
|
||||||
|
| `GET`, `POST /oidc/userinfo` | The claims of the user of an access token |
|
||||||
|
| `GET /oidc/logout` | Ends the session (RP-initiated logout) |
|
||||||
|
|
||||||
|
A client is a service account with at least one redirect URI: its name is the `client_id` and its
|
||||||
|
password the `client_secret`, sent with HTTP Basic authentication (`client_secret_basic`) or in
|
||||||
|
the body of the token request (`client_secret_post`). The password is checked with a bind, so 5
|
||||||
|
wrong secrets lock the client for 15 minutes. Its settings are:
|
||||||
|
|
||||||
|
- redirect URIs, where the provider sends the browser back with a code, matched exactly; a URI
|
||||||
|
uses `https`, or `http` for `localhost`, `127.0.0.1` or `::1`;
|
||||||
|
- post-logout redirect URIs, where `/oidc/logout` may send the browser afterwards;
|
||||||
|
- allowed groups: only their members may sign in to the client; without any, every user who is
|
||||||
|
not a service account may.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
echo 'cl1ent-s3cret' | vpn-router-user add 3f2b8c1e-9a4d-4e6f-8b2a-1c5d7e9f0a3b --service \
|
||||||
|
--display-name Grafana --redirect-uri https://grafana.example.com/login/generic_oauth \
|
||||||
|
--post-logout-redirect-uri https://grafana.example.com/login --allowed-group ops
|
||||||
|
vpn-router-user update 3f2b8c1e-9a4d-4e6f-8b2a-1c5d7e9f0a3b --allowed-group ''
|
||||||
|
```
|
||||||
|
|
||||||
|
The flow is the authorization code flow with PKCE, whose `code_challenge_method` must be `S256`;
|
||||||
|
the implicit and hybrid flows, and the password and client credentials grants, are refused. A
|
||||||
|
registered client is trusted, so a user is not asked for consent. A user signs in on the
|
||||||
|
provider's page with the name, the password and, when enrolled, the TOTP code, or with a passkey
|
||||||
|
(see Passkeys); failed sign-ins count towards the lockout, and `POST /oidc/login` takes at most
|
||||||
|
10 calls a minute from one client address, as `/auth` does. A
|
||||||
|
disabled, locked or removed user, a service account, and a user outside the client's allowed
|
||||||
|
groups cannot sign in, and the codes and tokens already issued to such a user stop working. The
|
||||||
|
sign-in starts a session, a cookie of the issuer's `/oidc/` path valid for 8 hours, so later
|
||||||
|
requests of any client go on without the page; `prompt=login` and `max_age` ask for a new sign-in,
|
||||||
|
and `prompt=none` answers `login_required` without a session.
|
||||||
|
|
||||||
|
| Scope | Claims |
|
||||||
|
| --- | --- |
|
||||||
|
| `openid` (required) | `sub`, the `entryUUID` of the user's entry |
|
||||||
|
| `profile` | `preferred_username` (the user name), `name`, `given_name`, `family_name` |
|
||||||
|
| `email` | `email` |
|
||||||
|
| `groups` | `groups`, the names of the user's groups |
|
||||||
|
|
||||||
|
The claims of the scopes granted are in the ID token and in the answer of `/oidc/userinfo`. ID
|
||||||
|
tokens are RS256 JWTs, valid for 5 minutes, with `auth_time` and `amr` (`pwd`, `pwd` and `otp`, or
|
||||||
|
`hwk` and `user` for a passkey), signed with an RSA key of 3072 bits that the web service makes on
|
||||||
|
its first run, `/etc/vpn-router/oidc.key` (root only), and named in the JWKS by its RFC 7638
|
||||||
|
thumbprint. A code is valid for 1 minute and used once, an access token for 5 minutes, and a
|
||||||
|
refresh token for 8 hours and used once: each refresh answers a new one with a new ID token. The
|
||||||
|
codes, tokens and sessions are kept, as their SHA-256, in the database of the refresh tokens of
|
||||||
|
`/auth`. Changing a user's password, disabling, unlocking or removing the user ends its sessions
|
||||||
|
and revokes its codes and tokens.
|
||||||
|
|
||||||
## Metrics
|
## Metrics
|
||||||
|
|
||||||
While `metrics.enabled` is `true`, `vpn-router-setup` runs `vpn-router-metrics`, which every minute
|
While `metrics.enabled` is `true`, `vpn-router-setup` runs `vpn-router-metrics`, which every minute
|
||||||
@@ -1522,7 +1622,7 @@ may be in use by something else.
|
|||||||
`apt-get purge` additionally removes the generated files and the firewall rules, restoring
|
`apt-get purge` additionally removes the generated files and the firewall rules, restoring
|
||||||
`/etc/ufw/before.rules` to its original content, and hands `vpn-router.json` back to `ucf`
|
`/etc/ufw/before.rules` to its original content, and hands `vpn-router.json` back to `ucf`
|
||||||
before deleting it so a later reinstall starts clean. It also removes the web service's
|
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
|
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
|
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
|
`/var/lib/caddy` belongs to the `caddy` package. It removes the backup timer's schedule, the
|
||||||
|
|||||||
@@ -1,3 +1,55 @@
|
|||||||
|
vpn-router (0.28.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
feature: bash completes the vpn-* commands, their options and the names they take.
|
||||||
|
|
||||||
|
* Bash completes the commands, options and arguments of vpn-router-user,
|
||||||
|
vpn-router-group, vpn-router-token, vpn-config-edit, vpn-router-backup,
|
||||||
|
vpn-router-ca and vpn-router-caddy, from
|
||||||
|
/usr/share/bash-completion/completions; for root also the users,
|
||||||
|
groups, issuing CAs, templates, settings and site-to-site connections.
|
||||||
|
* The package recommends bash-completion.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 16:00:00 +0100
|
||||||
|
|
||||||
|
vpn-router (0.27.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
feature: the web service is an OpenID Provider, which signs the directory's users in to other systems, the clients being service accounts with redirect URIs.
|
||||||
|
|
||||||
|
* oidc.enabled turns on the OpenID Provider at the issuer
|
||||||
|
https://<wan.local_fqdn>:<https.web_port>, with discovery, JWKS,
|
||||||
|
authorize, token, userinfo and RP-initiated logout under /oidc/,
|
||||||
|
answered only through Caddy. The authorization code flow with PKCE
|
||||||
|
S256 and refresh tokens are offered; ID tokens are RS256, signed with
|
||||||
|
/etc/vpn-router/oidc.key, which the web service makes on its first run.
|
||||||
|
* Users sign in on the provider's page with the password and the TOTP
|
||||||
|
code or with a passkey; the session lasts 8 hours. The scopes openid,
|
||||||
|
profile, email and groups give sub (the entry's entryUUID), the names,
|
||||||
|
the e-mail address and the user's groups.
|
||||||
|
* A service account with redirect URIs is a client: its name is the
|
||||||
|
client ID and its password the secret. Its redirect URIs,
|
||||||
|
post-logout redirect URIs and allowed groups are set with
|
||||||
|
vpn-router-user add/update --redirect-uri, --post-logout-redirect-uri
|
||||||
|
and --allowed-group, and in /service-accounts as redirect_uris,
|
||||||
|
post_logout_redirect_uris and allowed_groups; GET /service-accounts
|
||||||
|
also answers the issuer. vpn-router-user list shows them.
|
||||||
|
* Changing a user's password, disabling, unlocking or removing the user
|
||||||
|
also revokes the user's codes, tokens and sessions of the provider.
|
||||||
|
* Renaming a group renames it in the clients that allow it, and a group
|
||||||
|
a client allows cannot be removed.
|
||||||
|
* The package depends on python3-oauthlib and python3-jwt.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 14:00:00 +0100
|
||||||
|
|
||||||
|
vpn-router (0.26.2-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
fix: a service account may be named by a GUID in lowercase, as the Router Manager proposes for a new one.
|
||||||
|
|
||||||
|
* vpn-router-user add --service and POST /service-accounts take a GUID
|
||||||
|
in lowercase as the name, besides a user name; users and groups keep
|
||||||
|
the rules of user names.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 10:00:00 +0100
|
||||||
|
|
||||||
vpn-router (0.26.1-1) unstable; urgency=medium
|
vpn-router (0.26.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: the package installs vpnrouter_passkeys, without which the web service and vpn-router-user do not start.
|
fix: the package installs vpnrouter_passkeys, without which the web service and vpn-router-user do not start.
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ Depends: ${misc:Depends},
|
|||||||
python3-ldap,
|
python3-ldap,
|
||||||
python3-pyotp,
|
python3-pyotp,
|
||||||
python3-fido2,
|
python3-fido2,
|
||||||
|
python3-jwt,
|
||||||
|
python3-oauthlib,
|
||||||
python3-psycopg,
|
python3-psycopg,
|
||||||
python3-psutil,
|
python3-psutil,
|
||||||
python3-prometheus-client,
|
python3-prometheus-client,
|
||||||
@@ -37,6 +39,7 @@ Depends: ${misc:Depends},
|
|||||||
iproute2,
|
iproute2,
|
||||||
systemd-resolved,
|
systemd-resolved,
|
||||||
zstd
|
zstd
|
||||||
|
Recommends: bash-completion
|
||||||
Suggests: caddy,
|
Suggests: caddy,
|
||||||
rclone,
|
rclone,
|
||||||
proxmox-backup-client-static
|
proxmox-backup-client-static
|
||||||
@@ -49,7 +52,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
|
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
|
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
|
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
|
router in PostgreSQL and serves them for an OpenTelemetry Collector, and
|
||||||
makes scheduled backups to a local folder, copied to the cloud with rclone,
|
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
|
or to a Proxmox Backup Server. Nothing in the package is specific to a cloud
|
||||||
|
|||||||
@@ -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_users.py usr/lib/vpn-router/
|
||||||
src/usr/lib/vpn-router/vpnrouter_passkeys.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_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_metrics.py usr/lib/vpn-router/
|
||||||
src/usr/lib/vpn-router/vpnrouter_status.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/
|
src/usr/lib/vpn-router/vpnrouter_backup.py usr/lib/vpn-router/
|
||||||
@@ -29,5 +30,7 @@ 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/generate-config usr/lib/vpn-router/
|
||||||
src/usr/lib/vpn-router/vpnrouter_platforms/*.py usr/lib/vpn-router/vpnrouter_platforms/
|
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/templates/* usr/share/vpn-router/templates/
|
||||||
|
src/usr/share/vpn-router/sql/* usr/share/vpn-router/sql/
|
||||||
|
src/usr/share/bash-completion/completions/*.bash usr/share/bash-completion/completions/
|
||||||
src/usr/lib/systemd/system/caddy.service.d/vpn-router.conf usr/lib/systemd/system/caddy.service.d/
|
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/
|
src/usr/share/doc/vpn-router/vpn-router.json.example usr/share/doc/vpn-router/
|
||||||
@@ -52,6 +52,7 @@ case "$1" in
|
|||||||
/etc/caddy/vpn-router.Caddyfile \
|
/etc/caddy/vpn-router.Caddyfile \
|
||||||
/etc/vpn-router/vpn-router.json \
|
/etc/vpn-router/vpn-router.json \
|
||||||
/etc/vpn-router/web.key \
|
/etc/vpn-router/web.key \
|
||||||
|
/etc/vpn-router/oidc.key \
|
||||||
/etc/vpn-router/metrics.secret \
|
/etc/vpn-router/metrics.secret \
|
||||||
/etc/systemd/system/vpn-router-backup.timer.d/schedule.conf \
|
/etc/systemd/system/vpn-router-backup.timer.d/schedule.conf \
|
||||||
/etc/sudoers.d/vpn-router \
|
/etc/sudoers.d/vpn-router \
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ SCHEMA = {
|
|||||||
'status_content': 'basic', 'shell': False, 'shell_idle_minutes': 15, 'shell_auth_timeout': 15},
|
'status_content': 'basic', 'shell': False, 'shell_idle_minutes': 15, 'shell_auth_timeout': 15},
|
||||||
'ssh': {'ldap_login': False},
|
'ssh': {'ldap_login': False},
|
||||||
'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443, 'caddy_source': 'ubuntu'},
|
'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',
|
'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',
|
'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': {},
|
'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:
|
if caddy_source not in CADDY_SOURCES:
|
||||||
raise ConfigError(f'https.caddy_source: {caddy_source!r} is not one of {", ".join(CADDY_SOURCES)}')
|
raise ConfigError(f'https.caddy_source: {caddy_source!r} is not one of {", ".join(CADDY_SOURCES)}')
|
||||||
|
|
||||||
|
if _get(cfg, 'oidc', 'enabled') and not (_get(cfg, 'web', 'enabled') and _get(cfg, 'https', 'enabled')
|
||||||
|
and _get(cfg, 'wan', 'local_fqdn')):
|
||||||
|
raise ConfigError('oidc.enabled: the OpenID Provider is the web service served by Caddy at '
|
||||||
|
'https://<wan.local_fqdn>:<https.web_port>, which needs web.enabled, https.enabled and '
|
||||||
|
'wan.local_fqdn')
|
||||||
|
|
||||||
for option in ('root_name', 'p2s_name', 's2s_name'):
|
for option in ('root_name', 'p2s_name', 's2s_name'):
|
||||||
value = _get(cfg, 'pki', option)
|
value = _get(cfg, 'pki', option)
|
||||||
if not value or len(value) > CA_NAME_MAX or not value.isprintable():
|
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 []
|
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):
|
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."""
|
"""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()
|
psk_file = connection['psk_file'].strip()
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ Every failure of the database raises DatabaseError, the only database exception
|
|||||||
Refresh tokens are random strings kept in PostgreSQL as their SHA-256, in the database of
|
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.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.
|
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
|
import base64
|
||||||
@@ -26,6 +29,7 @@ import psycopg
|
|||||||
|
|
||||||
WEB_KEY = pathlib.Path('/etc/vpn-router/web.key')
|
WEB_KEY = pathlib.Path('/etc/vpn-router/web.key')
|
||||||
DATABASE_SECRET = pathlib.Path('/etc/vpn-router/database.secret')
|
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.
|
#: The role and the database provision() creates when web.database is not set.
|
||||||
DATABASE = 'vpn-router'
|
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)'
|
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):
|
class DatabaseError(Exception):
|
||||||
"""The database of the refresh tokens failed."""
|
"""The database of the refresh tokens failed."""
|
||||||
@@ -131,10 +138,19 @@ def connection_string(cfg):
|
|||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def database(cfg):
|
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:
|
try:
|
||||||
with psycopg.connect(connection_string(cfg), autocommit=True) as connection:
|
with psycopg.connect(connection_string(cfg), autocommit=True) as connection:
|
||||||
connection.execute(SCHEMA)
|
connection.execute(SCHEMA)
|
||||||
|
if oidc is not None:
|
||||||
|
connection.execute(oidc)
|
||||||
|
_oidc_loaded = True
|
||||||
yield connection
|
yield connection
|
||||||
except psycopg.Error as exc:
|
except psycopg.Error as exc:
|
||||||
raise DatabaseError(f'the token database failed: {exc}') from exc
|
raise DatabaseError(f'the token database failed: {exc}') from exc
|
||||||
@@ -173,9 +189,10 @@ def take(cfg, token):
|
|||||||
|
|
||||||
|
|
||||||
def revoke(cfg, name):
|
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:
|
with database(cfg) as connection:
|
||||||
connection.execute('DELETE FROM refresh_tokens WHERE name = %s', (name,))
|
connection.execute('DELETE FROM refresh_tokens WHERE name = %s', (name,))
|
||||||
|
connection.execute('CALL oidc_revoke(%s)', (name,))
|
||||||
|
|
||||||
|
|
||||||
def psql(sql, name):
|
def psql(sql, name):
|
||||||
|
|||||||
@@ -68,7 +68,10 @@ OVERLAYS = ('ppolicy', 'otp')
|
|||||||
#: vpnRouterConsoleAccount, whether the user may use a console and the local account it runs as,
|
#: 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
|
#: without which it runs as the user's own posixAccount, and vpnRouterWireGuardPeer, each a
|
||||||
#: WireGuard peer of the user as "<public key> <IP>[,<IP>...]", and vpnRouterPasskey, each a
|
#: WireGuard peer of the user as "<public key> <IP>[,<IP>...]", 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
|
#: 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,
|
#: 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
|
#: 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} )",
|
"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' "
|
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} )",
|
"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 = (
|
SCHEMA_OBJECT_CLASSES = (
|
||||||
f"( {SCHEMA_OID}.2.1 NAME 'vpnRouterAccount' DESC 'VPN router: properties of a user or a Manager-only account' "
|
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 $ "
|
"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 "
|
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 $ "
|
"MAY ( cACertificate $ certificateRevocationList $ vpnRouterPrivateKey $ vpnRouterCertificateRequest $ "
|
||||||
"vpnRouterCRLNumber $ vpnRouterCRLSource $ vpnRouterRetired ) )",
|
"vpnRouterCRLNumber $ vpnRouterCRLSource $ vpnRouterRetired ) )",
|
||||||
|
|||||||
@@ -0,0 +1,584 @@
|
|||||||
|
"""The router's OpenID Provider, for the web service, with python3-oauthlib and python3-jwt.
|
||||||
|
|
||||||
|
While oidc.enabled is true, the web service answers OpenID Connect at the issuer vpnrouter.oidc_issuer, its HTTPS site
|
||||||
|
that Caddy serves; the web service answers it only on the loopback address, where Caddy passes the requests. The users
|
||||||
|
of the router's directory sign in, with the password and the TOTP code or with a passkey (vpnrouter_passkeys), to its
|
||||||
|
clients, the service accounts with redirect URIs (vpnrouter_users.client_settings): a client's ID is its name and its
|
||||||
|
secret its password, sent with HTTP Basic authentication or in the body of the token request. A client with allowed
|
||||||
|
groups admits only their members; a service account never signs in as a user.
|
||||||
|
|
||||||
|
GET /.well-known/openid-configuration the discovery document
|
||||||
|
GET /oidc/jwks the public key the ID tokens are signed with
|
||||||
|
GET, POST /oidc/authorize the authorization code flow, PKCE with S256 required; the sign-in page while
|
||||||
|
the browser has no session, or with prompt=login or an older one than max_age
|
||||||
|
POST /oidc/login the sign-in page's form: the name, the password and the code, or a passkey
|
||||||
|
POST /oidc/passkey begin a sign-in with a passkey: {"id", "options"}
|
||||||
|
POST /oidc/token exchange a code or a refresh token for tokens
|
||||||
|
GET, POST /oidc/userinfo the claims of the user an access token was issued for
|
||||||
|
GET /oidc/logout end the session, then go to a post-logout redirect URI of the client
|
||||||
|
|
||||||
|
ID tokens are RS256 JWTs signed with KEY, an RSA key the web service makes on its first run on the router, named by
|
||||||
|
the RFC 7638 thumbprint of its public key. sub is the entryUUID of the user's entry, so a user added again under the
|
||||||
|
same name is another subject. The scopes: openid, required; profile, preferred_username and the names; email; groups,
|
||||||
|
the names of the user's groups; their claims are in the ID token and the userinfo answer. A code is valid
|
||||||
|
CODE_SECONDS, an access token and an ID token ACCESS_SECONDS, a refresh token REFRESH_SECONDS, used once. A session,
|
||||||
|
the cookie COOKIE of the issuer's /oidc/ path, lasts SESSION_SECONDS. Codes, tokens and sessions are kept in the
|
||||||
|
database of vpnrouter_auth as their SHA-256 (vpnrouter_auth.OIDC_SQL); vpnrouter_auth.revoke removes a user's. A
|
||||||
|
client is registered by an administrator, so a user is not asked for consent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import collections
|
||||||
|
import contextlib
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
|
import http.cookies
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
import types
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
import jinja2
|
||||||
|
import jwt
|
||||||
|
import ldap
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from oauthlib.common import add_params_to_uri
|
||||||
|
from oauthlib.oauth2 import AccessDeniedError, FatalClientError, InvalidRequestError, OAuth2Error
|
||||||
|
from oauthlib.openid import RequestValidator, Server
|
||||||
|
from oauthlib.openid.connect.core.exceptions import LoginRequired
|
||||||
|
|
||||||
|
import vpnrouter
|
||||||
|
import vpnrouter_auth
|
||||||
|
import vpnrouter_ldap
|
||||||
|
import vpnrouter_passkeys
|
||||||
|
import vpnrouter_users
|
||||||
|
|
||||||
|
KEY = pathlib.Path('/etc/vpn-router/oidc.key')
|
||||||
|
KEY_BITS = 3072
|
||||||
|
|
||||||
|
CODE_SECONDS = 60
|
||||||
|
ACCESS_SECONDS = 300
|
||||||
|
REFRESH_SECONDS = 8 * 3600
|
||||||
|
SESSION_SECONDS = 8 * 3600
|
||||||
|
|
||||||
|
COOKIE = 'vpn_router_oidc'
|
||||||
|
|
||||||
|
#: The claims of each scope but openid, which gives sub.
|
||||||
|
SCOPE_CLAIMS = {
|
||||||
|
'profile': ('preferred_username', 'name', 'given_name', 'family_name'),
|
||||||
|
'email': ('email',),
|
||||||
|
'groups': ('groups',),
|
||||||
|
}
|
||||||
|
SCOPES = ('openid', *SCOPE_CLAIMS)
|
||||||
|
|
||||||
|
#: The authentication methods (RFC 8176) of a sign-in with a password, with a password and a TOTP code, and with a
|
||||||
|
#: passkey, which verifies the user.
|
||||||
|
PASSWORD_AMR = ['pwd']
|
||||||
|
OTP_AMR = ['pwd', 'otp']
|
||||||
|
PASSKEY_AMR = ['hwk', 'user']
|
||||||
|
|
||||||
|
TEMPLATE = 'oidc-page.html.j2'
|
||||||
|
|
||||||
|
#: The headers of the pages: no framing, and nothing loaded but from the page itself and the router.
|
||||||
|
PAGE_HEADERS = [('Content-Security-Policy', "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; "
|
||||||
|
"connect-src 'self'; frame-ancestors 'none'")]
|
||||||
|
|
||||||
|
#: The headers of oauthlib's answers that the web service sets itself.
|
||||||
|
OWN_HEADERS = ('content-type', 'cache-control', 'content-length')
|
||||||
|
|
||||||
|
#: A user's sign-in at the OpenID Provider: the user name, when, a datetime, and how (amr).
|
||||||
|
Session = collections.namedtuple('Session', 'name auth_time amr')
|
||||||
|
|
||||||
|
_pages = jinja2.Environment(loader=jinja2.FileSystemLoader(str(vpnrouter.TEMPLATE_DIR)), autoescape=True,
|
||||||
|
undefined=jinja2.StrictUndefined)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_key():
|
||||||
|
"""Make the signing key when it is missing."""
|
||||||
|
if KEY.is_file() and KEY.stat().st_size:
|
||||||
|
return
|
||||||
|
key = rsa.generate_private_key(public_exponent=65537, key_size=KEY_BITS)
|
||||||
|
KEY.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
KEY.touch(mode=0o600)
|
||||||
|
KEY.chmod(0o600)
|
||||||
|
KEY.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
|
||||||
|
serialization.NoEncryption()))
|
||||||
|
|
||||||
|
|
||||||
|
def private_key():
|
||||||
|
return serialization.load_pem_private_key(KEY.read_bytes(), None)
|
||||||
|
|
||||||
|
|
||||||
|
def public_jwk(key):
|
||||||
|
"""The JWK of the key's public key, its kid the RFC 7638 thumbprint."""
|
||||||
|
numbers = key.public_key().public_numbers()
|
||||||
|
jwk = {'e': numbers.e, 'kty': 'RSA', 'n': numbers.n}
|
||||||
|
jwk = {name: vpnrouter_auth.b64encode(value.to_bytes((value.bit_length() + 7) // 8, 'big')) if isinstance(value, int)
|
||||||
|
else value for name, value in jwk.items()}
|
||||||
|
thumbprint = hashlib.sha256(json.dumps(jwk, sort_keys=True, separators=(',', ':')).encode()).digest()
|
||||||
|
return {**jwk, 'kid': vpnrouter_auth.b64encode(thumbprint), 'use': 'sig', 'alg': 'RS256'}
|
||||||
|
|
||||||
|
|
||||||
|
def sign(claims):
|
||||||
|
key = private_key()
|
||||||
|
return jwt.encode(claims, key, algorithm='RS256', headers={'kid': public_jwk(key)['kid']})
|
||||||
|
|
||||||
|
|
||||||
|
def discovery(issuer):
|
||||||
|
"""The discovery document of OpenID Connect Discovery 1.0."""
|
||||||
|
return {
|
||||||
|
'issuer': issuer,
|
||||||
|
'authorization_endpoint': f'{issuer}/oidc/authorize',
|
||||||
|
'token_endpoint': f'{issuer}/oidc/token',
|
||||||
|
'userinfo_endpoint': f'{issuer}/oidc/userinfo',
|
||||||
|
'jwks_uri': f'{issuer}/oidc/jwks',
|
||||||
|
'end_session_endpoint': f'{issuer}/oidc/logout',
|
||||||
|
'scopes_supported': list(SCOPES),
|
||||||
|
'response_types_supported': ['code'],
|
||||||
|
'response_modes_supported': ['query'],
|
||||||
|
'grant_types_supported': ['authorization_code', 'refresh_token'],
|
||||||
|
'subject_types_supported': ['public'],
|
||||||
|
'id_token_signing_alg_values_supported': ['RS256'],
|
||||||
|
'token_endpoint_auth_methods_supported': ['client_secret_basic', 'client_secret_post'],
|
||||||
|
'code_challenge_methods_supported': ['S256'],
|
||||||
|
'claims_supported': ['iss', 'sub', 'aud', 'exp', 'iat', 'auth_time', 'nonce', 'amr', 'at_hash',
|
||||||
|
*(claim for claims in SCOPE_CLAIMS.values() for claim in claims)],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def directory():
|
||||||
|
"""A connection to the router's directory as the user manager account, for reading."""
|
||||||
|
connection = vpnrouter_ldap.manager()
|
||||||
|
try:
|
||||||
|
yield connection
|
||||||
|
finally:
|
||||||
|
connection.unbind_s()
|
||||||
|
|
||||||
|
|
||||||
|
def client_of(client_id):
|
||||||
|
"""The client of the ID, a namespace of its vpnrouter_users.summary with client_id, when it is an enabled service
|
||||||
|
account with redirect URIs; else None."""
|
||||||
|
if not isinstance(client_id, str):
|
||||||
|
return None
|
||||||
|
with directory() as connection:
|
||||||
|
try:
|
||||||
|
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, client_id))
|
||||||
|
except vpnrouter_users.Failure:
|
||||||
|
return None
|
||||||
|
if not user['service'] or not user['enabled'] or not user['redirect_uris']:
|
||||||
|
return None
|
||||||
|
return types.SimpleNamespace(client_id=client_id, **user)
|
||||||
|
|
||||||
|
|
||||||
|
def subject(name, client):
|
||||||
|
"""The claims of every scope of the user, with sub, when the user may sign in to the client: an enabled, unlocked
|
||||||
|
user who is not a service account, a member of one of the client's allowed groups when it has any; else None."""
|
||||||
|
with directory() as connection:
|
||||||
|
try:
|
||||||
|
attributes = vpnrouter_users.entry(connection, name)
|
||||||
|
except vpnrouter_users.Failure:
|
||||||
|
return None
|
||||||
|
user = vpnrouter_users.summary(attributes)
|
||||||
|
groups = sorted(vpnrouter_users.groups_of(connection, name))
|
||||||
|
uuid = connection.search_s(vpnrouter_users.dn_of(name), ldap.SCOPE_BASE, attrlist=['entryUUID'])[0][1]['entryUUID']
|
||||||
|
if user['service'] or not user['enabled'] or user['locked']:
|
||||||
|
return None
|
||||||
|
if client.allowed_groups and not set(client.allowed_groups) & set(groups):
|
||||||
|
return None
|
||||||
|
full = ' '.join(part for part in (user['first_name'], user['last_name']) if part)
|
||||||
|
claims = {'sub': uuid[0].decode(), 'preferred_username': name, 'name': full or None, 'given_name': user['first_name'],
|
||||||
|
'family_name': user['last_name'], 'email': user['email'], 'groups': groups}
|
||||||
|
return {claim: value for claim, value in claims.items() if value is not None}
|
||||||
|
|
||||||
|
|
||||||
|
def claims_of(person, scopes):
|
||||||
|
"""The claims of the scopes of a subject's claims (subject)."""
|
||||||
|
return {'sub': person['sub'], **{claim: person[claim] for scope in scopes for claim in SCOPE_CLAIMS.get(scope, ())
|
||||||
|
if claim in person}}
|
||||||
|
|
||||||
|
|
||||||
|
def cookie_value(cookies):
|
||||||
|
"""The value of the session cookie in a Cookie header, or ''."""
|
||||||
|
try:
|
||||||
|
morsel = http.cookies.SimpleCookie(cookies or '').get(COOKIE)
|
||||||
|
except http.cookies.CookieError:
|
||||||
|
return ''
|
||||||
|
return morsel.value if morsel is not None else ''
|
||||||
|
|
||||||
|
|
||||||
|
def session_of(cfg, cookies):
|
||||||
|
"""The unexpired Session the session cookie in a Cookie header names, or None."""
|
||||||
|
value = cookie_value(cookies)
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
with vpnrouter_auth.database(cfg) as connection:
|
||||||
|
row = connection.execute('SELECT * FROM oidc_session_get(%s)', (vpnrouter_auth.digest(value),)).fetchone()
|
||||||
|
return Session(*row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
class Validator(RequestValidator):
|
||||||
|
"""The checks and the storage of oauthlib's server for the router's clients, users and database.
|
||||||
|
|
||||||
|
Only the authorization code grant with PKCE S256 and the refresh token grant are allowed. The values of a code, a
|
||||||
|
token or a session found are kept on the request as oidc_code, oidc_scopes and oidc_person.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, cfg):
|
||||||
|
super().__init__()
|
||||||
|
self.cfg = cfg
|
||||||
|
|
||||||
|
def fetch(self, sql, values):
|
||||||
|
with vpnrouter_auth.database(self.cfg) as connection:
|
||||||
|
return connection.execute(sql, values).fetchone()
|
||||||
|
|
||||||
|
def call(self, *statements):
|
||||||
|
with vpnrouter_auth.database(self.cfg) as connection:
|
||||||
|
for sql, values in statements:
|
||||||
|
connection.execute(sql, values)
|
||||||
|
|
||||||
|
def session(self, request):
|
||||||
|
return session_of(self.cfg, request.headers.get('Cookie'))
|
||||||
|
|
||||||
|
# Clients.
|
||||||
|
|
||||||
|
def validate_client_id(self, client_id, request, *args, **kwargs):
|
||||||
|
request.client = client_of(client_id)
|
||||||
|
return request.client is not None
|
||||||
|
|
||||||
|
def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs):
|
||||||
|
return redirect_uri in request.client.redirect_uris
|
||||||
|
|
||||||
|
def get_default_redirect_uri(self, client_id, request, *args, **kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def validate_response_type(self, client_id, response_type, client, request, *args, **kwargs):
|
||||||
|
return response_type == 'code'
|
||||||
|
|
||||||
|
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
|
||||||
|
return 'openid' in scopes and set(scopes) <= set(SCOPES)
|
||||||
|
|
||||||
|
def get_default_scopes(self, client_id, request, *args, **kwargs):
|
||||||
|
return ['openid']
|
||||||
|
|
||||||
|
def is_pkce_required(self, client_id, request):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def authenticate_client(self, request, *args, **kwargs):
|
||||||
|
"""Check the client's secret, the password of its service account, given with HTTP Basic authentication, its
|
||||||
|
ID and secret form-encoded (RFC 6749 2.3.1), or in the body."""
|
||||||
|
name, secret = request.client_id, request.client_secret
|
||||||
|
header = request.headers.get('Authorization', '')
|
||||||
|
if header[:6].lower() == 'basic ':
|
||||||
|
try:
|
||||||
|
name, _, secret = base64.b64decode(header[6:].strip(), validate=True).decode().partition(':')
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
return False
|
||||||
|
name, secret = urllib.parse.unquote_plus(name), urllib.parse.unquote_plus(secret)
|
||||||
|
client = client_of(name)
|
||||||
|
if client is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
vpnrouter_users.login(name, secret, None)
|
||||||
|
except vpnrouter_users.Failure:
|
||||||
|
return False
|
||||||
|
request.client = client
|
||||||
|
return True
|
||||||
|
|
||||||
|
def authenticate_client_id(self, client_id, request, *args, **kwargs):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs):
|
||||||
|
return grant_type in ('authorization_code', 'refresh_token')
|
||||||
|
|
||||||
|
def validate_user(self, username, password, client, request, *args, **kwargs):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Codes.
|
||||||
|
|
||||||
|
def save_authorization_code(self, client_id, code, request, *args, **kwargs):
|
||||||
|
self.call(('CALL oidc_code_save(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)',
|
||||||
|
(vpnrouter_auth.digest(code['code']), client_id, request.user, request.redirect_uri, request.scopes,
|
||||||
|
request.nonce, request.code_challenge, request.code_challenge_method, request.auth_time, request.amr,
|
||||||
|
CODE_SECONDS)))
|
||||||
|
|
||||||
|
def code(self, code):
|
||||||
|
row = self.fetch('SELECT * FROM oidc_code_get(%s)', (vpnrouter_auth.digest(code),)) if isinstance(code, str) else None
|
||||||
|
return dict(zip(('client_id', 'name', 'redirect_uri', 'scopes', 'nonce', 'code_challenge', 'code_challenge_method',
|
||||||
|
'auth_time', 'amr'), row)) if row else None
|
||||||
|
|
||||||
|
def get_authorization_code_scopes(self, client_id, code, redirect_uri, request):
|
||||||
|
found = self.code(code)
|
||||||
|
return found['scopes'] if found else []
|
||||||
|
|
||||||
|
def validate_code(self, client_id, code, client, request, *args, **kwargs):
|
||||||
|
found = self.code(code)
|
||||||
|
if found is None or found['client_id'] != client_id:
|
||||||
|
return False
|
||||||
|
person = subject(found['name'], client)
|
||||||
|
if person is None:
|
||||||
|
return False
|
||||||
|
request.user, request.scopes, request.auth_time, request.amr = found['name'], found['scopes'], found['auth_time'], found['amr']
|
||||||
|
request.oidc_code, request.oidc_person = found, person
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_code_challenge(self, code, request):
|
||||||
|
return request.oidc_code['code_challenge']
|
||||||
|
|
||||||
|
def get_code_challenge_method(self, code, request):
|
||||||
|
return request.oidc_code['code_challenge_method']
|
||||||
|
|
||||||
|
def confirm_redirect_uri(self, client_id, code, redirect_uri, client, request, *args, **kwargs):
|
||||||
|
return redirect_uri == request.oidc_code['redirect_uri']
|
||||||
|
|
||||||
|
def get_authorization_code_nonce(self, client_id, code, redirect_uri, request):
|
||||||
|
return request.oidc_code['nonce']
|
||||||
|
|
||||||
|
def invalidate_authorization_code(self, client_id, code, request, *args, **kwargs):
|
||||||
|
self.call(('CALL oidc_code_delete(%s)', (vpnrouter_auth.digest(code),)))
|
||||||
|
|
||||||
|
# Tokens.
|
||||||
|
|
||||||
|
def token(self, token, kind):
|
||||||
|
row = self.fetch('SELECT * FROM oidc_token_get(%s, %s)', (vpnrouter_auth.digest(token), kind)) \
|
||||||
|
if isinstance(token, str) and token else None
|
||||||
|
return dict(zip(('client_id', 'name', 'scopes', 'auth_time', 'amr'), row)) if row else None
|
||||||
|
|
||||||
|
def save_bearer_token(self, token, request, *args, **kwargs):
|
||||||
|
"""Keep the access token and the refresh token; a refresh token that was exchanged is used up."""
|
||||||
|
values = (request.client.client_id, request.user, request.scopes, request.auth_time, request.amr)
|
||||||
|
statements = [('CALL oidc_token_save(%s, %s, %s, %s, %s, %s, %s, %s)',
|
||||||
|
(vpnrouter_auth.digest(token['access_token']), 'access', *values, ACCESS_SECONDS))]
|
||||||
|
if token.get('refresh_token'):
|
||||||
|
statements.append(('CALL oidc_token_save(%s, %s, %s, %s, %s, %s, %s, %s)',
|
||||||
|
(vpnrouter_auth.digest(token['refresh_token']), 'refresh', *values, REFRESH_SECONDS)))
|
||||||
|
if request.grant_type == 'refresh_token':
|
||||||
|
statements.append(('CALL oidc_token_delete(%s)', (vpnrouter_auth.digest(request.refresh_token),)))
|
||||||
|
self.call(*statements)
|
||||||
|
|
||||||
|
def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs):
|
||||||
|
found = self.token(refresh_token, 'refresh')
|
||||||
|
if found is None or found['client_id'] != client.client_id:
|
||||||
|
return False
|
||||||
|
person = subject(found['name'], client)
|
||||||
|
if person is None:
|
||||||
|
return False
|
||||||
|
request.user, request.auth_time, request.amr = found['name'], found['auth_time'], found['amr']
|
||||||
|
request.oidc_scopes, request.oidc_person = found['scopes'], person
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_original_scopes(self, refresh_token, request, *args, **kwargs):
|
||||||
|
return request.oidc_scopes
|
||||||
|
|
||||||
|
def validate_bearer_token(self, token, scopes, request):
|
||||||
|
found = self.token(token, 'access')
|
||||||
|
if found is None or not set(scopes) <= set(found['scopes']):
|
||||||
|
return False
|
||||||
|
client = client_of(found['client_id'])
|
||||||
|
person = subject(found['name'], client) if client else None
|
||||||
|
if person is None:
|
||||||
|
return False
|
||||||
|
request.client, request.user, request.oidc_scopes, request.oidc_person = client, found['name'], found['scopes'], person
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_userinfo_claims(self, request):
|
||||||
|
return claims_of(request.oidc_person, request.oidc_scopes)
|
||||||
|
|
||||||
|
# OpenID Connect.
|
||||||
|
|
||||||
|
def finalize_id_token(self, id_token, token, token_handler, request):
|
||||||
|
now = int(time.time())
|
||||||
|
id_token.update(claims_of(request.oidc_person, request.scopes))
|
||||||
|
id_token.update({'iss': vpnrouter.oidc_issuer(self.cfg), 'exp': now + ACCESS_SECONDS,
|
||||||
|
'auth_time': int(request.auth_time.timestamp()), 'amr': request.amr})
|
||||||
|
return sign(id_token)
|
||||||
|
|
||||||
|
def validate_silent_login(self, request):
|
||||||
|
session = self.session(request)
|
||||||
|
return session is not None and subject(session.name, request.client) is not None
|
||||||
|
|
||||||
|
def validate_silent_authorization(self, request):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def validate_user_match(self, id_token_hint, scopes, claims, request):
|
||||||
|
"""Whether the user of the session, when there is one, is the subject of an ID token the router signed."""
|
||||||
|
session = self.session(request) if id_token_hint else None
|
||||||
|
if session is None:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
hint = jwt.decode(id_token_hint, private_key().public_key(), algorithms=['RS256'],
|
||||||
|
options={'verify_exp': False, 'verify_aud': False})
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
return False
|
||||||
|
person = subject(session.name, request.client)
|
||||||
|
return person is not None and hint.get('sub') == person['sub']
|
||||||
|
|
||||||
|
|
||||||
|
def page(status, cfg, title, message='', error='', form=False, request='', name='', headers=()):
|
||||||
|
"""An HTML page of TEMPLATE: a message, an error, and with form the sign-in form for the authorization request."""
|
||||||
|
body = _pages.get_template(TEMPLATE).render(title=title, message=message, error=error, form=form, request=request,
|
||||||
|
name=name, issuer=vpnrouter.oidc_issuer(cfg))
|
||||||
|
return status, 'text/html; charset=utf-8', body.encode(), [*PAGE_HEADERS, *headers]
|
||||||
|
|
||||||
|
|
||||||
|
def document(status, value, headers=()):
|
||||||
|
return status, 'application/json', json.dumps(value, indent=2).encode() + b'\n', list(headers)
|
||||||
|
|
||||||
|
|
||||||
|
def relayed(headers, body, status):
|
||||||
|
"""An answer of oauthlib's server, without the headers the web service sets."""
|
||||||
|
kind = next((value for name, value in headers.items() if name.lower() == 'content-type'), 'application/json')
|
||||||
|
return status, kind, (body or '').encode(), [(name, value) for name, value in headers.items()
|
||||||
|
if name.lower() not in OWN_HEADERS]
|
||||||
|
|
||||||
|
|
||||||
|
def redirect(location, headers=()):
|
||||||
|
return 302, 'text/plain; charset=utf-8', b'', [('Location', location), *headers]
|
||||||
|
|
||||||
|
|
||||||
|
def cookie(value, seconds):
|
||||||
|
return 'Set-Cookie', f'{COOKIE}={value}; Path=/oidc/; Max-Age={seconds}; Secure; HttpOnly; SameSite=Lax'
|
||||||
|
|
||||||
|
|
||||||
|
def authorize(server, cfg, params, headers, fresh=None, name='', error=''):
|
||||||
|
"""Answer an authorization request, the query or the form params: the sign-in page while the browser has no
|
||||||
|
session, or with prompt=login or max_age older than the session, else a redirect to the client with a code.
|
||||||
|
|
||||||
|
fresh is the session of a sign-in just made, which counts whatever prompt and max_age say; the redirect then sets
|
||||||
|
its cookie, fresh.cookie. error is why a sign-in failed: the sign-in page shows it, filled with the name.
|
||||||
|
"""
|
||||||
|
uri = f'{vpnrouter.oidc_issuer(cfg)}/oidc/authorize?{params}'
|
||||||
|
try:
|
||||||
|
scopes, info = server.validate_authorization_request(uri, 'GET', None, headers)
|
||||||
|
except FatalClientError as error:
|
||||||
|
return page(error.status_code, cfg, 'The sign-in request is refused', error=error.description or error.error)
|
||||||
|
except OAuth2Error as error:
|
||||||
|
if not error.redirect_uri:
|
||||||
|
return page(error.status_code, cfg, 'The sign-in request is refused', error=error.description or error.error)
|
||||||
|
return redirect(error.in_uri(error.redirect_uri))
|
||||||
|
request = info['request']
|
||||||
|
title = f'Sign in to {request.client.display_name or request.client.client_id}'
|
||||||
|
if info.get('code_challenge_method') != 'S256':
|
||||||
|
return redirect(InvalidRequestError(description='PKCE with code_challenge_method S256 is required',
|
||||||
|
request=request).in_uri(request.redirect_uri))
|
||||||
|
if error:
|
||||||
|
return page(401, cfg, title, error=error, form=True, request=params, name=name)
|
||||||
|
session = fresh.session if fresh else session_of(cfg, headers.get('Cookie'))
|
||||||
|
max_age = getattr(request, 'max_age', None)
|
||||||
|
stale = session is not None and isinstance(max_age, str) and max_age.isdigit() and \
|
||||||
|
time.time() - session.auth_time.timestamp() > int(max_age)
|
||||||
|
prompt = info.get('prompt') or set()
|
||||||
|
if session is None or (not fresh and ('login' in prompt or stale)):
|
||||||
|
if 'none' in prompt:
|
||||||
|
return redirect(LoginRequired(request=request).in_uri(request.redirect_uri))
|
||||||
|
return page(200, cfg, title, form=True, request=params, name=name)
|
||||||
|
if subject(session.name, request.client) is None:
|
||||||
|
if fresh:
|
||||||
|
return redirect(AccessDeniedError(description=f'{session.name} may not sign in to this client',
|
||||||
|
request=request).in_uri(request.redirect_uri), [fresh.cookie])
|
||||||
|
return page(403, cfg, title, error=f'{session.name} may not sign in here; sign in as another user.', form=True,
|
||||||
|
request=params)
|
||||||
|
answer, _, _ = server.create_authorization_response(uri, 'GET', None, headers, scopes, credentials={
|
||||||
|
'user': session.name, 'auth_time': session.auth_time, 'amr': session.amr})
|
||||||
|
return redirect(answer['Location'], [fresh.cookie] if fresh else [])
|
||||||
|
|
||||||
|
|
||||||
|
def login(server, cfg, form, headers):
|
||||||
|
"""Sign a user in with the sign-in page's form, start the session and go on with its authorization request."""
|
||||||
|
params = form.get('request', '')
|
||||||
|
name = form.get('name', '').strip()
|
||||||
|
try:
|
||||||
|
if form.get('passkey_id'):
|
||||||
|
with vpnrouter_users.session() as connection:
|
||||||
|
name = vpnrouter_passkeys.login_complete(connection, form['passkey_id'], json.loads(form.get('credential', '')))
|
||||||
|
amr = PASSKEY_AMR
|
||||||
|
else:
|
||||||
|
code = form.get('code', '').strip()
|
||||||
|
vpnrouter_users.login(name, form.get('password', ''), code or None)
|
||||||
|
with directory() as connection:
|
||||||
|
if vpnrouter_users.service(vpnrouter_users.entry(connection, name)):
|
||||||
|
raise vpnrouter_users.Failure('the name, the password or the code is wrong', 5)
|
||||||
|
amr = OTP_AMR if code else PASSWORD_AMR
|
||||||
|
except vpnrouter_users.Failure as failure:
|
||||||
|
return authorize(server, cfg, params, headers, name=name, error=str(failure))
|
||||||
|
except ValueError:
|
||||||
|
return authorize(server, cfg, params, headers, name=name, error='the passkey is not accepted')
|
||||||
|
value = secrets.token_urlsafe(32)
|
||||||
|
with vpnrouter_auth.database(cfg) as connection:
|
||||||
|
connection.execute('CALL oidc_session_save(%s, %s, %s, %s)', (vpnrouter_auth.digest(value), name, amr, SESSION_SECONDS))
|
||||||
|
fresh = types.SimpleNamespace(session=Session(name, datetime.datetime.now(datetime.timezone.utc), amr),
|
||||||
|
cookie=cookie(value, SESSION_SECONDS))
|
||||||
|
return authorize(server, cfg, params, headers, fresh=fresh)
|
||||||
|
|
||||||
|
|
||||||
|
def logout(cfg, query, headers):
|
||||||
|
"""End the session the cookie names, then go to post_logout_redirect_uri, with state, when it is a post-logout
|
||||||
|
redirect URI of the client that client_id or the audience of id_token_hint names; else show that the user is
|
||||||
|
signed out."""
|
||||||
|
values = dict(urllib.parse.parse_qsl(query))
|
||||||
|
value = cookie_value(headers.get('Cookie'))
|
||||||
|
if value:
|
||||||
|
with vpnrouter_auth.database(cfg) as connection:
|
||||||
|
connection.execute('CALL oidc_session_delete(%s)', (vpnrouter_auth.digest(value),))
|
||||||
|
client_id = values.get('client_id')
|
||||||
|
if not client_id and values.get('id_token_hint'):
|
||||||
|
try:
|
||||||
|
audience = jwt.decode(values['id_token_hint'], private_key().public_key(), algorithms=['RS256'],
|
||||||
|
options={'verify_exp': False, 'verify_aud': False}).get('aud')
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
audience = None
|
||||||
|
client_id = audience[0] if isinstance(audience, list) and audience else audience
|
||||||
|
client = client_of(client_id)
|
||||||
|
target = values.get('post_logout_redirect_uri')
|
||||||
|
expired = cookie('', 0)
|
||||||
|
if client is not None and target in client.post_logout_redirect_uris:
|
||||||
|
return redirect(add_params_to_uri(target, [('state', values['state'])]) if values.get('state') else target, [expired])
|
||||||
|
return page(200, cfg, 'Signed out', message='You are signed out of the router.', headers=[expired])
|
||||||
|
|
||||||
|
|
||||||
|
def answer(method, target, headers, body):
|
||||||
|
"""Answer a request of the OpenID Provider: the HTTP status, the content type, the body and [(header, value)]; 404
|
||||||
|
while oidc.enabled is false. target is the path with the query, headers a mapping of the request's headers and
|
||||||
|
body its bytes; HEAD is answered as GET."""
|
||||||
|
path, _, query = target.partition('?')
|
||||||
|
method = 'GET' if method == 'HEAD' else method
|
||||||
|
try:
|
||||||
|
cfg = vpnrouter.load_config()
|
||||||
|
if not cfg['oidc']['enabled']:
|
||||||
|
return 404, 'text/plain; charset=utf-8', b'Not found\n', []
|
||||||
|
issuer = vpnrouter.oidc_issuer(cfg)
|
||||||
|
headers = dict(headers.items())
|
||||||
|
text = body.decode('utf-8', 'replace') if body else ''
|
||||||
|
server = Server(Validator(cfg), token_expires_in=ACCESS_SECONDS)
|
||||||
|
if (method, path) == ('GET', '/.well-known/openid-configuration'):
|
||||||
|
return document(200, discovery(issuer))
|
||||||
|
if (method, path) == ('GET', '/oidc/jwks'):
|
||||||
|
return document(200, {'keys': [public_jwk(private_key())]})
|
||||||
|
if path == '/oidc/authorize' and method in ('GET', 'POST'):
|
||||||
|
return authorize(server, cfg, query if method == 'GET' else text, headers)
|
||||||
|
if (method, path) == ('POST', '/oidc/login'):
|
||||||
|
return login(server, cfg, dict(urllib.parse.parse_qsl(text)), headers)
|
||||||
|
if (method, path) == ('POST', '/oidc/passkey'):
|
||||||
|
return document(200, vpnrouter_passkeys.login_begin())
|
||||||
|
if (method, path) == ('POST', '/oidc/token'):
|
||||||
|
return relayed(*server.create_token_response(f'{issuer}{path}', 'POST', text, headers))
|
||||||
|
if path == '/oidc/userinfo' and method in ('GET', 'POST'):
|
||||||
|
return relayed(*server.create_userinfo_response(f'{issuer}{target}', method, text or None, headers))
|
||||||
|
if (method, path) == ('GET', '/oidc/logout'):
|
||||||
|
return logout(cfg, query, headers)
|
||||||
|
return 404, 'text/plain; charset=utf-8', b'Not found\n', []
|
||||||
|
except OAuth2Error as error:
|
||||||
|
return error.status_code, 'application/json', error.json.encode(), list(error.headers.items())
|
||||||
|
except vpnrouter_users.Failure as failure:
|
||||||
|
return document(400 if failure.status == 2 else 500, {'error': str(failure)})
|
||||||
|
except (ldap.LDAPError, OSError) as exc:
|
||||||
|
return document(500, {'error': f'the OpenID Provider failed: {exc}'})
|
||||||
|
except vpnrouter_auth.DatabaseError as exc:
|
||||||
|
return document(500, {'error': str(exc)})
|
||||||
|
except SystemExit as exc:
|
||||||
|
return document(500, {'error': str(exc.code)})
|
||||||
|
except ValueError as exc:
|
||||||
|
return document(400, {'error': 'invalid_request', 'error_description': str(exc)})
|
||||||
@@ -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
|
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
|
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
|
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
|
credential the attested credential data (with the public key), both base64url. A user has at
|
||||||
|
|||||||
@@ -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.
|
included (internal_accounts); vpn-router-setup keeps the metrics service's.
|
||||||
Users exist whether or not a P2S system is enabled.
|
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
|
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
|
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
|
cannot leave it and a user who is the last member of a group cannot be removed. ADMIN_GROUP grants the management of
|
||||||
record membership; nothing on the router reads them.
|
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
|
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
|
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 re
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
import ldap
|
import ldap
|
||||||
import ldap.filter
|
import ldap.filter
|
||||||
@@ -74,6 +81,8 @@ import vpnrouter_pki
|
|||||||
|
|
||||||
#: A user name is one lowercase DNS label, as it is also the common name of the certificate.
|
#: A user name is one lowercase DNS label, as it is also the common name of the certificate.
|
||||||
NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}[a-z0-9]$|^[a-z]$')
|
NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}[a-z0-9]$|^[a-z]$')
|
||||||
|
#: The other name of a service account: a GUID in lowercase hexadecimal, as the Router Manager proposes one.
|
||||||
|
GUID = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')
|
||||||
PASSWORD_MIN = 8
|
PASSWORD_MIN = 8
|
||||||
PASSWORD_MAX = 128
|
PASSWORD_MAX = 128
|
||||||
#: The longest first or last name, in characters.
|
#: The longest first or last name, in characters.
|
||||||
@@ -88,7 +97,15 @@ LABELS = (('display_name', 'displayName'), ('description', 'description'))
|
|||||||
#: The attributes read for a user.
|
#: The attributes read for a user.
|
||||||
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope',
|
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope',
|
||||||
'displayName', 'description', 'userCertificate;binary', 'vpnRouterConsoleLogin', 'vpnRouterConsoleAccount',
|
'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.
|
#: The most WireGuard peers of one user.
|
||||||
WIREGUARD_PEERS_MAX = 16
|
WIREGUARD_PEERS_MAX = 16
|
||||||
#: The vpnRouterScope of a service account.
|
#: The vpnRouterScope of a service account.
|
||||||
@@ -135,9 +152,11 @@ def session():
|
|||||||
connection.unbind_s()
|
connection.unbind_s()
|
||||||
|
|
||||||
|
|
||||||
def check_name(name):
|
def check_name(name, service=True):
|
||||||
if not isinstance(name, str) or not NAME.match(name):
|
"""Refuse a name that is not a user name (NAME), or, with service, the GUID of a service account either."""
|
||||||
raise Failure('a user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter', 2)
|
if not isinstance(name, str) or not (NAME.match(name) or service and GUID.match(name)):
|
||||||
|
raise Failure('a user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter'
|
||||||
|
+ (', or, for a service account, a GUID in lowercase' if service else ''), 2)
|
||||||
|
|
||||||
|
|
||||||
def check_password(value):
|
def check_password(value):
|
||||||
@@ -191,6 +210,47 @@ def labels(values):
|
|||||||
return found
|
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):
|
def dn_of(name):
|
||||||
return f'uid={name},{vpnrouter_ldap.USERS}'
|
return f'uid={name},{vpnrouter_ldap.USERS}'
|
||||||
|
|
||||||
@@ -242,7 +302,8 @@ def internal_accounts():
|
|||||||
def summary(attributes):
|
def summary(attributes):
|
||||||
"""A user as the answers show it; a person attribute or a label the entry does not have is None.
|
"""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()
|
name = attributes['uid'][0].decode()
|
||||||
lock = attributes.get('pwdAccountLockedTime', [None])[0]
|
lock = attributes.get('pwdAccountLockedTime', [None])[0]
|
||||||
@@ -262,6 +323,12 @@ def summary(attributes):
|
|||||||
for key, attribute in PERSON + LABELS:
|
for key, attribute in PERSON + LABELS:
|
||||||
values = attributes.get(attribute)
|
values = attributes.get(attribute)
|
||||||
user[key] = values[0].decode() if values else None
|
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
|
return user
|
||||||
|
|
||||||
|
|
||||||
@@ -462,14 +529,19 @@ def list_users(connection):
|
|||||||
def add(connection, name, password, values, service=False):
|
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
|
"""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
|
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)."""
|
e-mail address, its labels (labels) and its OpenID Provider client settings (client_settings); only a service
|
||||||
check_name(name)
|
account may be named by a GUID."""
|
||||||
|
check_name(name, service)
|
||||||
details = person(values, required=False)
|
details = person(values, required=False)
|
||||||
vpn_user = 'givenName' in details or 'sn' in details
|
vpn_user = 'givenName' in details or 'sn' in details
|
||||||
if service and vpn_user:
|
if service and vpn_user:
|
||||||
raise Failure('a service account has no first or last name', 2)
|
raise Failure('a service account has no first or last name', 2)
|
||||||
|
client = {}
|
||||||
if service:
|
if service:
|
||||||
details.update({attribute: value for attribute, value in labels(values).items() if value})
|
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):
|
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)
|
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)
|
check_password(password)
|
||||||
@@ -484,6 +556,7 @@ def add(connection, name, password, values, service=False):
|
|||||||
if service:
|
if service:
|
||||||
record['vpnRouterScope'] = [SERVICE_SCOPE.encode()]
|
record['vpnRouterScope'] = [SERVICE_SCOPE.encode()]
|
||||||
record.update({attribute: [value.encode()] for attribute, value in details.items()})
|
record.update({attribute: [value.encode()] for attribute, value in details.items()})
|
||||||
|
record.update(client)
|
||||||
try:
|
try:
|
||||||
connection.add_s(dn_of(name), ldap.modlist.addModlist(record))
|
connection.add_s(dn_of(name), ldap.modlist.addModlist(record))
|
||||||
except ldap.ALREADY_EXISTS:
|
except ldap.ALREADY_EXISTS:
|
||||||
@@ -507,6 +580,7 @@ def update(connection, name, values):
|
|||||||
current = entry(connection, name)
|
current = entry(connection, name)
|
||||||
if service(current):
|
if service(current):
|
||||||
return update_service(connection, name, values)
|
return update_service(connection, name, values)
|
||||||
|
refuse_client_settings(name, values)
|
||||||
if manager_only(current):
|
if manager_only(current):
|
||||||
if values.get('wireguard_peers') is not None:
|
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)
|
raise Failure(f'user {name} is a Manager-only account, which has no VPN access and so no WireGuard peers', 2)
|
||||||
@@ -547,16 +621,18 @@ def apply_wireguard():
|
|||||||
|
|
||||||
|
|
||||||
def update_service(connection, name, values):
|
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)):
|
if not service(entry(connection, name)):
|
||||||
raise Failure(f'{name} is not a service account', 3)
|
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, [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')
|
email = values.get('email')
|
||||||
if email is not None:
|
if email is not None:
|
||||||
address = '' if isinstance(email, str) and email.strip() == '' else person({'email': email}, required=False)['mail']
|
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 []))
|
changes.append((ldap.MOD_REPLACE, 'mail', [address.encode()] if address else []))
|
||||||
if not changes:
|
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)
|
connection.modify_s(dn_of(name), changes)
|
||||||
return summary(entry(connection, name))
|
return summary(entry(connection, name))
|
||||||
|
|
||||||
@@ -601,12 +677,13 @@ def unlock(connection, name):
|
|||||||
|
|
||||||
|
|
||||||
def login(name, password, code):
|
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.
|
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)
|
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
|
raise refused
|
||||||
if code is not None and code != '' and (not isinstance(code, str) or not CODE.match(code)):
|
if code is not None and code != '' and (not isinstance(code, str) or not CODE.match(code)):
|
||||||
raise refused
|
raise refused
|
||||||
@@ -785,6 +862,13 @@ def groups_of(connection, name):
|
|||||||
return {attributes['cn'][0].decode(): member_names(attributes) for _, attributes in found}
|
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):
|
def list_groups(connection):
|
||||||
found = connection.search_s(vpnrouter_ldap.GROUPS, ldap.SCOPE_ONELEVEL, '(objectClass=groupOfNames)', GROUP_ATTRIBUTES)
|
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'])}
|
return {'groups': sorted((group_summary(attributes) for _, attributes in found), key=lambda group: group['name'])}
|
||||||
@@ -811,7 +895,8 @@ def group_add(connection, name, description, members):
|
|||||||
|
|
||||||
def group_update(connection, name, new_name=None, description=None):
|
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
|
"""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:
|
if new_name is None and description is None:
|
||||||
raise Failure('give a new name or a description', 2)
|
raise Failure('give a new name or a description', 2)
|
||||||
members = member_names(group_entry(connection, name))
|
members = member_names(group_entry(connection, name))
|
||||||
@@ -826,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)
|
connection.rename_s(group_dn(name), f'cn={new_name}', delold=1)
|
||||||
except ldap.ALREADY_EXISTS:
|
except ldap.ALREADY_EXISTS:
|
||||||
raise Failure(f'group {new_name} exists', 4)
|
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:
|
if name == ADMIN_GROUP:
|
||||||
console_off(connection, members)
|
console_off(connection, members)
|
||||||
name = new_name
|
name = new_name
|
||||||
@@ -833,8 +921,12 @@ def group_update(connection, name, new_name=None, description=None):
|
|||||||
|
|
||||||
|
|
||||||
def group_remove(connection, name):
|
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))
|
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))
|
connection.delete_s(group_dn(name))
|
||||||
if name == ADMIN_GROUP:
|
if name == ADMIN_GROUP:
|
||||||
console_off(connection, members)
|
console_off(connection, members)
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ Pages:
|
|||||||
/postgresql
|
/postgresql
|
||||||
the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see
|
the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see
|
||||||
postgresql_databases()); /config has the clusters' settings
|
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
|
/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;
|
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
|
(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.
|
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
|
The service first initializes, without listening: it makes the signing key, the OpenID Provider's key
|
||||||
tokens' database (vpnrouter_auth), retrying every INIT_RETRY_SECONDS until both are ready and
|
(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
|
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.
|
in the linker cache, where psycopg looks it up.
|
||||||
|
|
||||||
@@ -120,6 +124,7 @@ import vpnrouter_auth
|
|||||||
import vpnrouter_backup
|
import vpnrouter_backup
|
||||||
import vpnrouter_metrics
|
import vpnrouter_metrics
|
||||||
import vpnrouter_net
|
import vpnrouter_net
|
||||||
|
import vpnrouter_oidc
|
||||||
import vpnrouter_passkeys
|
import vpnrouter_passkeys
|
||||||
import vpnrouter_pki
|
import vpnrouter_pki
|
||||||
import vpnrouter_serve
|
import vpnrouter_serve
|
||||||
@@ -587,20 +592,32 @@ def users(state, method, path, body):
|
|||||||
return (201 if route == ('POST', 1, None) else 200), document
|
return (201 if route == ('POST', 1, None) else 200), document
|
||||||
|
|
||||||
|
|
||||||
|
def 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):
|
def service_accounts(method, path, body):
|
||||||
"""Answer a /service-accounts request with vpnrouter_users: the HTTP status and the JSON document.
|
"""Answer a /service-accounts request with vpnrouter_users: the HTTP status and the JSON document.
|
||||||
|
|
||||||
GET /service-accounts the service accounts: {"service_accounts": [...]}
|
GET /service-accounts the service accounts: {"service_accounts": [...], "oidc": {"enabled",
|
||||||
POST /service-accounts add one: {"name", "password", "display_name"?, "description"?, "email"?}
|
"issuer"}}, issuer that of the OpenID Provider, null while it is disabled
|
||||||
PUT /service-accounts/<name> change any of {"display_name", "description", "email"}; "" removes one
|
POST /service-accounts add one: {"name", "password", "display_name"?, "description"?, "email"?,
|
||||||
|
"redirect_uris"?, "post_logout_redirect_uris"?, "allowed_groups"?}
|
||||||
|
PUT /service-accounts/<name> change any of {"display_name", "description", "email", "redirect_uris",
|
||||||
|
"post_logout_redirect_uris", "allowed_groups"}; "" removes one of the
|
||||||
|
first three, [] the values of the others
|
||||||
PUT /service-accounts/<name>/password set the password: {"password": ...}
|
PUT /service-accounts/<name>/password set the password: {"password": ...}
|
||||||
POST /service-accounts/<name>/disable lock it out
|
POST /service-accounts/<name>/disable lock it out
|
||||||
POST /service-accounts/<name>/enable let it in again
|
POST /service-accounts/<name>/enable let it in again
|
||||||
POST /service-accounts/<name>/unlock end a lockout after failed logins
|
POST /service-accounts/<name>/unlock end a lockout after failed logins
|
||||||
DELETE /service-accounts/<name> remove it
|
DELETE /service-accounts/<name> remove it
|
||||||
|
|
||||||
A name that is not a service account is 404. The internal service accounts (vpnrouter_users.internal_accounts),
|
A service account with redirect URIs is a client of the OpenID Provider (vpnrouter_users.client_settings). A name
|
||||||
which the router manages, are only listed: every other request on one is refused with 403.
|
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)):
|
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||||
return 400, {'error': 'the body must be a JSON object'}
|
return 400, {'error': 'the body must be a JSON object'}
|
||||||
@@ -608,10 +625,12 @@ def service_accounts(method, path, body):
|
|||||||
parts = path.strip('/').split('/')
|
parts = path.strip('/').split('/')
|
||||||
route = (method, len(parts), parts[2] if len(parts) == 3 else None)
|
route = (method, len(parts), parts[2] if len(parts) == 3 else None)
|
||||||
name = parts[1] if len(parts) > 1 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 = {
|
actions = {
|
||||||
('GET', 1, None): lambda connection: {
|
('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,
|
('POST', 1, None): lambda connection: vpnrouter_users.add(connection, body.get('name'), body.get('password'), settings,
|
||||||
service=True),
|
service=True),
|
||||||
('PUT', 2, None): lambda connection: vpnrouter_users.update_service(connection, name, settings),
|
('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)}
|
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||||
except (ldap.LDAPError, OSError) as exc:
|
except (ldap.LDAPError, OSError) as exc:
|
||||||
return 500, {'error': f'the user directory failed: {exc}'}
|
return 500, {'error': f'the user directory failed: {exc}'}
|
||||||
|
except SystemExit as exc:
|
||||||
|
return 500, {'error': str(exc.code)}
|
||||||
return (201 if route == ('POST', 1, None) else 200), document
|
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)
|
if path == f'/{name}' or path.startswith(f'/{name}/')), None)
|
||||||
protected = path in ('/status/full', '/config', '/metrics/history', '/postgresql') or directory is not 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')
|
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 {}
|
state = current() if protected or signing else {}
|
||||||
claims = self.claims(state['key']) if protected and state['key'] else None
|
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
|
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:
|
elif directory is not None:
|
||||||
code, document = (users if directory == 'users' else groups)(state, self.command, path, payload)
|
code, document = (users if directory == 'users' else groups)(state, self.command, path, payload)
|
||||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||||
|
elif 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'):
|
elif self.command not in ('GET', 'HEAD'):
|
||||||
body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
|
body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
|
||||||
elif path.startswith('/pki/') and vpnrouter.PKI_NAME.match(path[5:-4]) and path[-4:] in PUBLISHED and \
|
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:
|
if send_body:
|
||||||
self.wfile.write(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):
|
def request_body(self):
|
||||||
"""The request body as JSON, None when there is none, or the string 'invalid'."""
|
"""The request body as JSON, None when there is none, or the string 'invalid'."""
|
||||||
length = self.headers.get('Content-Length')
|
length = self.headers.get('Content-Length')
|
||||||
@@ -1588,7 +1628,8 @@ def log(message):
|
|||||||
|
|
||||||
|
|
||||||
def initialize():
|
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
|
reported = None
|
||||||
while True:
|
while True:
|
||||||
phase = 'reading the configuration'
|
phase = 'reading the configuration'
|
||||||
@@ -1596,6 +1637,8 @@ def initialize():
|
|||||||
cfg = vpnrouter.load_config()
|
cfg = vpnrouter.load_config()
|
||||||
phase = 'making the signing key'
|
phase = 'making the signing key'
|
||||||
vpnrouter_auth.ensure_key()
|
vpnrouter_auth.ensure_key()
|
||||||
|
phase = 'making the OpenID Provider key'
|
||||||
|
vpnrouter_oidc.ensure_key()
|
||||||
phase = 'creating the token database'
|
phase = 'creating the token database'
|
||||||
vpnrouter_auth.provision(cfg)
|
vpnrouter_auth.provision(cfg)
|
||||||
phase = 'connecting to the token database'
|
phase = 'connecting to the token database'
|
||||||
|
|||||||
@@ -12,9 +12,15 @@ user exists, 1 anything else. -h or --help shows the commands and their options.
|
|||||||
vpn-router-user [--json] add <name> --first-name F --last-name L --email E (password on stdin)
|
vpn-router-user [--json] add <name> --first-name F --last-name L --email E (password on stdin)
|
||||||
vpn-router-user [--json] add <name> [--email E] (a Manager-only account; password on stdin)
|
vpn-router-user [--json] add <name> [--email E] (a Manager-only account; password on stdin)
|
||||||
vpn-router-user [--json] add <name> --service [--display-name N] [--description D] [--email E]
|
vpn-router-user [--json] add <name> --service [--display-name N] [--description D] [--email E]
|
||||||
|
[--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]
|
||||||
(a service account; password on stdin)
|
(a service account; password on stdin)
|
||||||
vpn-router-user [--json] update <name> [--first-name F] [--last-name L] [--email E]
|
vpn-router-user [--json] update <name> [--first-name F] [--last-name L] [--email E]
|
||||||
vpn-router-user [--json] update <name> [--display-name N] [--description D] [--email E] (a service account)
|
vpn-router-user [--json] update <name> [--display-name N] [--description D] [--email E]
|
||||||
|
[--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]
|
||||||
|
(a service account)
|
||||||
|
|
||||||
|
A service account with a redirect URI is a client of the OpenID Provider; each of the three options may be repeated,
|
||||||
|
and replaces all values of its kind; one '' removes them.
|
||||||
vpn-router-user [--json] passwd <name> (password on stdin)
|
vpn-router-user [--json] passwd <name> (password on stdin)
|
||||||
vpn-router-user [--json] disable <name>
|
vpn-router-user [--json] disable <name>
|
||||||
vpn-router-user [--json] enable <name>
|
vpn-router-user [--json] enable <name>
|
||||||
@@ -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",
|
'display_name': "a service account's display name; '' removes it",
|
||||||
'description': "a service account's description; '' 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):
|
class Parser(argparse.ArgumentParser):
|
||||||
"""An argument parser whose errors are Failures with status 2, so that --json answers them as JSON."""
|
"""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'):
|
if command in ('add', 'update'):
|
||||||
for key, _ in vpnrouter_users.PERSON + vpnrouter_users.LABELS:
|
for key, _ in vpnrouter_users.PERSON + vpnrouter_users.LABELS:
|
||||||
sub.add_argument(f'--{key.replace("_", "-")}', dest=key, metavar='TEXT', help=PERSON_HELP[key])
|
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':
|
if command == 'add':
|
||||||
sub.add_argument('--service', action='store_true', help='add a service account, which cannot sign in to the web service')
|
sub.add_argument('--service', action='store_true', help='add a service account, which cannot sign in to the web service')
|
||||||
if command == 'otp-enrol':
|
if command == 'otp-enrol':
|
||||||
@@ -111,6 +127,10 @@ def run(connection, options, secret):
|
|||||||
if command == 'list':
|
if command == 'list':
|
||||||
return vpnrouter_users.list_users(connection)
|
return vpnrouter_users.list_users(connection)
|
||||||
values = {key: getattr(options, key, None) for key, _ in vpnrouter_users.PERSON + vpnrouter_users.LABELS}
|
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':
|
if command == 'add':
|
||||||
return vpnrouter_users.add(connection, options.name, secret, values, service=options.service)
|
return vpnrouter_users.add(connection, options.name, secret, values, service=options.service)
|
||||||
if command == 'update':
|
if command == 'update':
|
||||||
@@ -136,6 +156,15 @@ def console_text(user):
|
|||||||
return text if user['otp'] else f'{text}, refused: no OTP'
|
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):
|
def text_of(answer):
|
||||||
"""The answer as text for people."""
|
"""The answer as text for people."""
|
||||||
if 'profile' in answer:
|
if 'profile' in answer:
|
||||||
@@ -147,12 +176,13 @@ def text_of(answer):
|
|||||||
users = answer['users'] if 'users' in answer else [answer]
|
users = answer['users'] if 'users' in answer else [answer]
|
||||||
if not users:
|
if not users:
|
||||||
return 'no 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',
|
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',
|
'disabled' if not user['enabled'] else 'locked' if user['locked'] else 'enabled',
|
||||||
'issued' if user['certificate'] else 'missing',
|
'issued' if user['certificate'] else 'missing',
|
||||||
'enrolled' if user['otp'] else '-', str(len(user['passkeys'])) if user['passkeys'] else '-', console_text(user),
|
'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]
|
for user in users]
|
||||||
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
|
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
|
||||||
return '\n'.join(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows))
|
return '\n'.join(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows))
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# bash completion for vpn-config-edit -*- shell-script -*-
|
||||||
|
|
||||||
|
# Python of the package that answers from its schema (vpnrouter.SCHEMA, vpnrouter.CONNECTION) and, read as root, the
|
||||||
|
# site-to-site connections of the configuration: settings prints SECTION.OPTION of every setting and
|
||||||
|
# site_to_site.NAME.OPTION of every connection, connections site_to_site.NAME, and values the values of a boolean
|
||||||
|
# setting, $1.
|
||||||
|
_vpn_config_edit_python()
|
||||||
|
{
|
||||||
|
python3 -c '
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "/usr/lib/vpn-router")
|
||||||
|
import vpnrouter
|
||||||
|
try:
|
||||||
|
connections = vpnrouter.read_settings().get("site_to_site", {})
|
||||||
|
except (OSError, vpnrouter.ConfigError):
|
||||||
|
connections = {}
|
||||||
|
if sys.argv[1] == "settings":
|
||||||
|
print("\n".join(f"{section}.{option}" for section, options in vpnrouter.SCHEMA.items() for option in options))
|
||||||
|
print("\n".join(f"site_to_site.{name}.{option}" for name in connections for option in vpnrouter.CONNECTION))
|
||||||
|
elif sys.argv[1] == "connections":
|
||||||
|
print("\n".join(f"site_to_site.{name}" for name in connections))
|
||||||
|
else:
|
||||||
|
section, _, option = sys.argv[2].rpartition(".")
|
||||||
|
defaults = vpnrouter.CONNECTION if section.startswith("site_to_site.") else vpnrouter.SCHEMA.get(section, {})
|
||||||
|
if isinstance(defaults.get(option), bool):
|
||||||
|
print("true\nfalse")
|
||||||
|
' "$@" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
_vpn_config_edit()
|
||||||
|
{
|
||||||
|
local cur prev words cword split
|
||||||
|
_init_completion -s || return
|
||||||
|
|
||||||
|
# The command is the first word that is not an option; the arguments are the words after it.
|
||||||
|
local i command='' args=()
|
||||||
|
for ((i = 1; i < cword; i++)); do
|
||||||
|
if [[ -z $command ]]; then
|
||||||
|
[[ ${words[i]} != -* ]] && command=${words[i]}
|
||||||
|
elif [[ ${words[i]} != -* ]]; then
|
||||||
|
args+=("${words[i]}")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ $cur == -* ]]; then
|
||||||
|
COMPREPLY=($(compgen -W '--help' -- "$cur"))
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
case $command,${#args[@]} in
|
||||||
|
,*) COMPREPLY=($(compgen -W 'show set remove' -- "$cur")) ;;
|
||||||
|
set,0) COMPREPLY=($(compgen -W "$(_vpn_config_edit_python settings)" -- "$cur")) ;;
|
||||||
|
set,1) COMPREPLY=($(compgen -W "$(_vpn_config_edit_python values "${args[0]}")" -- "$cur")) ;;
|
||||||
|
remove,0) COMPREPLY=($(compgen -W "$(_vpn_config_edit_python connections)" -- "$cur")) ;;
|
||||||
|
esac
|
||||||
|
} &&
|
||||||
|
complete -F _vpn_config_edit vpn-config-edit
|
||||||
|
|
||||||
|
# ex: filetype=sh
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# bash completion for vpn-router-backup -*- shell-script -*-
|
||||||
|
|
||||||
|
_vpn_router_backup()
|
||||||
|
{
|
||||||
|
local cur prev words cword split
|
||||||
|
_init_completion -s || return
|
||||||
|
|
||||||
|
# The command is the first word that is not an option; the arguments are the words after it.
|
||||||
|
local i command='' args=0 pbs=false
|
||||||
|
for ((i = 1; i < cword; i++)); do
|
||||||
|
if [[ -z $command ]]; then
|
||||||
|
[[ ${words[i]} != -* ]] && command=${words[i]}
|
||||||
|
elif [[ ${words[i]} == --pbs ]]; then
|
||||||
|
pbs=true
|
||||||
|
elif [[ ${words[i]} != -* ]]; then
|
||||||
|
((args++))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z $command ]]; then
|
||||||
|
if [[ $cur == -* ]]; then
|
||||||
|
COMPREPLY=($(compgen -W '--help' -- "$cur"))
|
||||||
|
else
|
||||||
|
COMPREPLY=($(compgen -W 'run list restore' -- "$cur"))
|
||||||
|
fi
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $cur == -* ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "--help$([[ $command == restore ]] && echo ' --pbs --yes')" -- "$cur"))
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# restore takes a local archive, or with --pbs a snapshot name, which vpn-router-backup list shows.
|
||||||
|
if [[ $command == restore ]] && ((args == 0)) && ! $pbs; then
|
||||||
|
_filedir
|
||||||
|
fi
|
||||||
|
} &&
|
||||||
|
complete -F _vpn_router_backup vpn-router-backup
|
||||||
|
|
||||||
|
# ex: filetype=sh
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# bash completion for vpn-router-ca -*- shell-script -*-
|
||||||
|
|
||||||
|
# Python of the package that prints the revocation reasons (reasons) or, from the configuration read as root, the
|
||||||
|
# issuing CAs (cas) or the certificate templates (templates), which other users do not get.
|
||||||
|
_vpn_router_ca_python()
|
||||||
|
{
|
||||||
|
python3 -c '
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "/usr/lib/vpn-router")
|
||||||
|
import vpnrouter
|
||||||
|
import vpnrouter_pki
|
||||||
|
if sys.argv[1] == "reasons":
|
||||||
|
print("\n".join(vpnrouter_pki.REASONS))
|
||||||
|
else:
|
||||||
|
cfg = vpnrouter.load_config()
|
||||||
|
print("\n".join(vpnrouter.issuing_cas(cfg) if sys.argv[1] == "cas" else vpnrouter.pki_templates(cfg)))
|
||||||
|
' "$@" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
_vpn_router_ca()
|
||||||
|
{
|
||||||
|
local cur prev words cword split
|
||||||
|
_init_completion -s || return
|
||||||
|
|
||||||
|
# The command and, for ca and root, the action are the first words that are not options; the arguments are the
|
||||||
|
# words after them but option values.
|
||||||
|
local i command='' action='' args=0
|
||||||
|
for ((i = 1; i < cword; i++)); do
|
||||||
|
case ${words[i]} in
|
||||||
|
--ca | --template | --csr | --subject | --san | --pfx | --reason | --out) ((i++)) ;;
|
||||||
|
-*) ;;
|
||||||
|
*)
|
||||||
|
if [[ -z $command ]]; then
|
||||||
|
command=${words[i]}
|
||||||
|
elif [[ $command == @(ca|root) && -z $action ]]; then
|
||||||
|
action=${words[i]}
|
||||||
|
else
|
||||||
|
((args++))
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
case $prev in
|
||||||
|
--ca)
|
||||||
|
COMPREPLY=($(compgen -W "$(_vpn_router_ca_python cas)" -- "$cur"))
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
--template)
|
||||||
|
COMPREPLY=($(compgen -W "$(_vpn_router_ca_python templates)" -- "$cur"))
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
--reason)
|
||||||
|
COMPREPLY=($(compgen -W "$(_vpn_router_ca_python reasons)" -- "$cur"))
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
--csr | --pfx | --out)
|
||||||
|
_filedir
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
--subject | --san)
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
$split && return
|
||||||
|
|
||||||
|
if [[ -z $command ]]; then
|
||||||
|
if [[ $cur == -* ]]; then
|
||||||
|
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
|
||||||
|
else
|
||||||
|
COMPREPLY=($(compgen -W 'status list issue revoke maintain ca root' -- "$cur"))
|
||||||
|
fi
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $cur == -* ]]; then
|
||||||
|
local options='--help'
|
||||||
|
case $command$action in
|
||||||
|
list) options+=' --ca' ;;
|
||||||
|
issue) options+=' --ca --template --csr --subject --san --pfx' ;;
|
||||||
|
revoke) options+=' --reason' ;;
|
||||||
|
rootexport-key) options+=' --out' ;;
|
||||||
|
rootremove-key) options+=' --yes' ;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
case $command,$action,$args in
|
||||||
|
ca,,*) COMPREPLY=($(compgen -W 'csr import' -- "$cur")) ;;
|
||||||
|
root,,*) COMPREPLY=($(compgen -W 'export-key remove-key import-key import-crl' -- "$cur")) ;;
|
||||||
|
ca,*,0) COMPREPLY=($(compgen -W "$(_vpn_router_ca_python cas)" -- "$cur")) ;;
|
||||||
|
ca,import,1 | root,import-key,0 | root,import-crl,0) _filedir ;;
|
||||||
|
esac
|
||||||
|
} &&
|
||||||
|
complete -F _vpn_router_ca vpn-router-ca
|
||||||
|
|
||||||
|
# ex: filetype=sh
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# bash completion for vpn-router-caddy -*- shell-script -*-
|
||||||
|
|
||||||
|
_vpn_router_caddy()
|
||||||
|
{
|
||||||
|
local cur prev words cword
|
||||||
|
_init_completion || return
|
||||||
|
|
||||||
|
# After the command only --help is left.
|
||||||
|
local word
|
||||||
|
for word in "${words[@]:1:cword-1}"; do
|
||||||
|
if [[ $word != -* ]]; then
|
||||||
|
[[ $cur == -* ]] && COMPREPLY=($(compgen -W '--help' -- "$cur"))
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [[ $cur == -* ]]; then
|
||||||
|
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
|
||||||
|
else
|
||||||
|
COMPREPLY=($(compgen -W 'status apply use-upstream' -- "$cur"))
|
||||||
|
fi
|
||||||
|
} &&
|
||||||
|
complete -F _vpn_router_caddy vpn-router-caddy
|
||||||
|
|
||||||
|
# ex: filetype=sh
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# bash completion for vpn-router-group -*- shell-script -*-
|
||||||
|
|
||||||
|
# The names of the users and of the groups of the router's directory, read as root; nothing for other users.
|
||||||
|
_vpn_router_group_users()
|
||||||
|
{
|
||||||
|
vpn-router-user --json list 2>/dev/null |
|
||||||
|
python3 -c 'import json, sys; print("\n".join(user["name"] for user in json.load(sys.stdin)["users"]))' 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
_vpn_router_group_names()
|
||||||
|
{
|
||||||
|
vpn-router-group --json list 2>/dev/null |
|
||||||
|
python3 -c 'import json, sys; print("\n".join(group["name"] for group in json.load(sys.stdin)["groups"]))' 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
_vpn_router_group()
|
||||||
|
{
|
||||||
|
local cur prev words cword split
|
||||||
|
_init_completion -s || return
|
||||||
|
|
||||||
|
# The command is the first word that is not an option; the arguments are the words after it but option values.
|
||||||
|
local i command='' args=0
|
||||||
|
for ((i = 1; i < cword; i++)); do
|
||||||
|
if [[ -z $command ]]; then
|
||||||
|
[[ ${words[i]} != -* ]] && command=${words[i]}
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
case ${words[i]} in
|
||||||
|
--member | --description | --name) ((i++)) ;;
|
||||||
|
-*) ;;
|
||||||
|
*) ((args++)) ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z $command ]]; then
|
||||||
|
if [[ $cur == -* ]]; then
|
||||||
|
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
|
||||||
|
else
|
||||||
|
COMPREPLY=($(compgen -W 'list add update remove member-add member-remove' -- "$cur"))
|
||||||
|
fi
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
case $prev in
|
||||||
|
--member)
|
||||||
|
COMPREPLY=($(compgen -W "$(_vpn_router_group_users)" -- "$cur"))
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
--description | --name)
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
$split && return
|
||||||
|
|
||||||
|
if [[ $cur == -* ]]; then
|
||||||
|
local options='--help'
|
||||||
|
case $command in
|
||||||
|
add) options+=' --member --description' ;;
|
||||||
|
update) options+=' --name --description' ;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The group, which add takes new, then the user of member-add and member-remove.
|
||||||
|
if ((args == 0)) && [[ $command != @(list|add) ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "$(_vpn_router_group_names)" -- "$cur"))
|
||||||
|
elif ((args == 1)) && [[ $command == member-@(add|remove) ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "$(_vpn_router_group_users)" -- "$cur"))
|
||||||
|
fi
|
||||||
|
} &&
|
||||||
|
complete -F _vpn_router_group vpn-router-group
|
||||||
|
|
||||||
|
# ex: filetype=sh
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# bash completion for vpn-router-token -*- shell-script -*-
|
||||||
|
|
||||||
|
_vpn_router_token()
|
||||||
|
{
|
||||||
|
local cur prev words cword split
|
||||||
|
_init_completion -s || return
|
||||||
|
|
||||||
|
case $prev in
|
||||||
|
--minutes | --hours | --days)
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
$split && return
|
||||||
|
|
||||||
|
# The lifetime is one of --minutes, --hours and --days.
|
||||||
|
local options='--json --help' word
|
||||||
|
for word in "${words[@]:1:cword-1}"; do
|
||||||
|
[[ $word == --@(minutes|hours|days)* ]] && break
|
||||||
|
done
|
||||||
|
[[ $word == --@(minutes|hours|days)* ]] || options+=' --minutes --hours --days'
|
||||||
|
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
||||||
|
} &&
|
||||||
|
complete -F _vpn_router_token vpn-router-token
|
||||||
|
|
||||||
|
# ex: filetype=sh
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# bash completion for vpn-router-user -*- shell-script -*-
|
||||||
|
|
||||||
|
# The names of the users and of the groups of the router's directory, read as root; nothing for other users.
|
||||||
|
_vpn_router_user_names()
|
||||||
|
{
|
||||||
|
vpn-router-user --json list 2>/dev/null |
|
||||||
|
python3 -c 'import json, sys; print("\n".join(user["name"] for user in json.load(sys.stdin)["users"]))' 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
_vpn_router_user_groups()
|
||||||
|
{
|
||||||
|
vpn-router-group --json list 2>/dev/null |
|
||||||
|
python3 -c 'import json, sys; print("\n".join(group["name"] for group in json.load(sys.stdin)["groups"]))' 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
_vpn_router_user()
|
||||||
|
{
|
||||||
|
local cur prev words cword split
|
||||||
|
_init_completion -s || return
|
||||||
|
|
||||||
|
local commands='list add update passwd disable enable unlock remove profile otp-new otp-enrol otp-remove console
|
||||||
|
passkeys-remove'
|
||||||
|
|
||||||
|
# The command is the first word that is not an option; the arguments are the words after it but option values.
|
||||||
|
local i command='' args=0
|
||||||
|
for ((i = 1; i < cword; i++)); do
|
||||||
|
if [[ -z $command ]]; then
|
||||||
|
[[ ${words[i]} != -* ]] && command=${words[i]}
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
case ${words[i]} in
|
||||||
|
--first-name | --last-name | --email | --display-name | --description | --redirect-uri | \
|
||||||
|
--post-logout-redirect-uri | --allowed-group | --account) ((i++)) ;;
|
||||||
|
-*) ;;
|
||||||
|
*) ((args++)) ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z $command ]]; then
|
||||||
|
if [[ $cur == -* ]]; then
|
||||||
|
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
|
||||||
|
else
|
||||||
|
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
|
||||||
|
fi
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
case $prev in
|
||||||
|
--allowed-group)
|
||||||
|
COMPREPLY=($(compgen -W "$(_vpn_router_user_groups)" -- "$cur"))
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
--account)
|
||||||
|
# The local accounts console --account takes: a uid from 1000 to 19999, not nobody.
|
||||||
|
COMPREPLY=($(compgen -W "$(getent passwd | awk -F: '$3 >= 1000 && $3 < 20000 && $1 != "nobody" { print $1 }')" -- "$cur"))
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
--first-name | --last-name | --email | --display-name | --description | --redirect-uri | --post-logout-redirect-uri)
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
$split && return
|
||||||
|
|
||||||
|
if [[ $cur == -* ]]; then
|
||||||
|
local options='--help'
|
||||||
|
case $command in
|
||||||
|
add) options+=' --first-name --last-name --email --service --display-name --description --redirect-uri
|
||||||
|
--post-logout-redirect-uri --allowed-group' ;;
|
||||||
|
update) options+=' --first-name --last-name --email --display-name --description --redirect-uri
|
||||||
|
--post-logout-redirect-uri --allowed-group' ;;
|
||||||
|
console) options+=' --posix --account --off' ;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The user name, which add takes new; otp-enrol then takes a code.
|
||||||
|
if ((args == 0)) && [[ $command != @(list|add) ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "$(_vpn_router_user_names)" -- "$cur"))
|
||||||
|
fi
|
||||||
|
} &&
|
||||||
|
complete -F _vpn_router_user vpn-router-user
|
||||||
|
|
||||||
|
# ex: filetype=sh
|
||||||
@@ -76,6 +76,9 @@
|
|||||||
"manager_port": 7443,
|
"manager_port": 7443,
|
||||||
"caddy_source": "ubuntu"
|
"caddy_source": "ubuntu"
|
||||||
},
|
},
|
||||||
|
"oidc": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
"pki": {
|
"pki": {
|
||||||
"root_name": "VPN Router Root CA",
|
"root_name": "VPN Router Root CA",
|
||||||
"root_days": 7300,
|
"root_days": 7300,
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
-- The state of the router's OpenID Provider (vpnrouter_oidc) in the database of web.database.
|
||||||
|
-- Codes, tokens and sessions are kept as the SHA-256 of their values; expired rows are purged when new ones are saved.
|
||||||
|
-- The whole file runs as one transaction, serialized by an advisory lock, so that two processes can load it at once.
|
||||||
|
|
||||||
|
SELECT pg_advisory_xact_lock(hashtext('vpn-router oidc schema'));
|
||||||
|
|
||||||
|
-- An authorization code, valid until it is exchanged for tokens or expires.
|
||||||
|
CREATE TABLE IF NOT EXISTS oidc_codes (
|
||||||
|
hash text PRIMARY KEY,
|
||||||
|
client_id text NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
redirect_uri text NOT NULL,
|
||||||
|
scopes text[] NOT NULL,
|
||||||
|
nonce text,
|
||||||
|
code_challenge text NOT NULL,
|
||||||
|
code_challenge_method text NOT NULL,
|
||||||
|
auth_time timestamptz NOT NULL,
|
||||||
|
amr text[] NOT NULL,
|
||||||
|
expires timestamptz NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- An access token or a refresh token (kind) a client was issued for a user.
|
||||||
|
CREATE TABLE IF NOT EXISTS oidc_tokens (
|
||||||
|
hash text PRIMARY KEY,
|
||||||
|
kind text NOT NULL CHECK (kind IN ('access', 'refresh')),
|
||||||
|
client_id text NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
scopes text[] NOT NULL,
|
||||||
|
auth_time timestamptz NOT NULL,
|
||||||
|
amr text[] NOT NULL,
|
||||||
|
expires timestamptz NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS oidc_tokens_name ON oidc_tokens (name);
|
||||||
|
|
||||||
|
-- A user's sign-in at the OpenID Provider, which the session cookie names.
|
||||||
|
CREATE TABLE IF NOT EXISTS oidc_sessions (
|
||||||
|
hash text PRIMARY KEY,
|
||||||
|
name text NOT NULL,
|
||||||
|
auth_time timestamptz NOT NULL,
|
||||||
|
amr text[] NOT NULL,
|
||||||
|
expires timestamptz NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE OR REPLACE PROCEDURE oidc_code_save(p_hash text, p_client_id text, p_name text, p_redirect_uri text,
|
||||||
|
p_scopes text[], p_nonce text, p_code_challenge text,
|
||||||
|
p_code_challenge_method text, p_auth_time timestamptz, p_amr text[],
|
||||||
|
p_seconds integer)
|
||||||
|
LANGUAGE sql AS $$
|
||||||
|
DELETE FROM oidc_codes WHERE expires < now();
|
||||||
|
INSERT INTO oidc_codes VALUES (p_hash, p_client_id, p_name, p_redirect_uri, p_scopes, p_nonce, p_code_challenge,
|
||||||
|
p_code_challenge_method, p_auth_time, p_amr, now() + make_interval(secs => p_seconds));
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- The unexpired code.
|
||||||
|
CREATE OR REPLACE FUNCTION oidc_code_get(p_hash text)
|
||||||
|
RETURNS TABLE (client_id text, name text, redirect_uri text, scopes text[], nonce text, code_challenge text,
|
||||||
|
code_challenge_method text, auth_time timestamptz, amr text[])
|
||||||
|
LANGUAGE sql STABLE AS $$
|
||||||
|
SELECT client_id, name, redirect_uri, scopes, nonce, code_challenge, code_challenge_method, auth_time, amr
|
||||||
|
FROM oidc_codes WHERE hash = p_hash AND expires > now();
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE PROCEDURE oidc_code_delete(p_hash text)
|
||||||
|
LANGUAGE sql AS $$
|
||||||
|
DELETE FROM oidc_codes WHERE hash = p_hash;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE PROCEDURE oidc_token_save(p_hash text, p_kind text, p_client_id text, p_name text, p_scopes text[],
|
||||||
|
p_auth_time timestamptz, p_amr text[], p_seconds integer)
|
||||||
|
LANGUAGE sql AS $$
|
||||||
|
DELETE FROM oidc_tokens WHERE expires < now();
|
||||||
|
INSERT INTO oidc_tokens VALUES (p_hash, p_kind, p_client_id, p_name, p_scopes, p_auth_time, p_amr,
|
||||||
|
now() + make_interval(secs => p_seconds));
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- The unexpired token of the kind.
|
||||||
|
CREATE OR REPLACE FUNCTION oidc_token_get(p_hash text, p_kind text)
|
||||||
|
RETURNS TABLE (client_id text, name text, scopes text[], auth_time timestamptz, amr text[])
|
||||||
|
LANGUAGE sql STABLE AS $$
|
||||||
|
SELECT client_id, name, scopes, auth_time, amr FROM oidc_tokens
|
||||||
|
WHERE hash = p_hash AND kind = p_kind AND expires > now();
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE PROCEDURE oidc_token_delete(p_hash text)
|
||||||
|
LANGUAGE sql AS $$
|
||||||
|
DELETE FROM oidc_tokens WHERE hash = p_hash;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE PROCEDURE oidc_session_save(p_hash text, p_name text, p_amr text[], p_seconds integer)
|
||||||
|
LANGUAGE sql AS $$
|
||||||
|
DELETE FROM oidc_sessions WHERE expires < now();
|
||||||
|
INSERT INTO oidc_sessions VALUES (p_hash, p_name, now(), p_amr, now() + make_interval(secs => p_seconds));
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- The unexpired session.
|
||||||
|
CREATE OR REPLACE FUNCTION oidc_session_get(p_hash text)
|
||||||
|
RETURNS TABLE (name text, auth_time timestamptz, amr text[])
|
||||||
|
LANGUAGE sql STABLE AS $$
|
||||||
|
SELECT name, auth_time, amr FROM oidc_sessions WHERE hash = p_hash AND expires > now();
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE PROCEDURE oidc_session_delete(p_hash text)
|
||||||
|
LANGUAGE sql AS $$
|
||||||
|
DELETE FROM oidc_sessions WHERE hash = p_hash;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Remove every code, token and session of the user.
|
||||||
|
CREATE OR REPLACE PROCEDURE oidc_revoke(p_name text)
|
||||||
|
LANGUAGE sql AS $$
|
||||||
|
DELETE FROM oidc_codes WHERE name = p_name;
|
||||||
|
DELETE FROM oidc_tokens WHERE name = p_name;
|
||||||
|
DELETE FROM oidc_sessions WHERE name = p_name;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
|
<title>{{ title }}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; background: #f4f5f7; color: #1f2937; margin: 0; }
|
||||||
|
main { max-width: 22rem; margin: 4rem auto; background: #fff; padding: 2rem; border-radius: 0.5rem; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); }
|
||||||
|
h1 { font-size: 1.25rem; margin: 0 0 0.25rem; }
|
||||||
|
p { margin: 0 0 1rem; color: #4b5563; }
|
||||||
|
label { display: block; font-size: 0.875rem; margin: 0.75rem 0 0.25rem; }
|
||||||
|
input { box-sizing: border-box; width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.25rem; font-size: 1rem; }
|
||||||
|
button { width: 100%; margin-top: 1.25rem; padding: 0.6rem; border: 0; border-radius: 0.25rem; font-size: 1rem; cursor: pointer; background: #1d4ed8; color: #fff; }
|
||||||
|
button.secondary { background: #fff; color: #1d4ed8; border: 1px solid #1d4ed8; margin-top: 0.75rem; }
|
||||||
|
.error { background: #fee2e2; color: #991b1b; padding: 0.5rem 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem; }
|
||||||
|
.issuer { font-size: 0.75rem; color: #6b7280; margin: 1.5rem 0 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
{%- if message %}
|
||||||
|
<p>{{ message }}</p>
|
||||||
|
{%- endif %}
|
||||||
|
{%- if error %}
|
||||||
|
<div class="error" role="alert">{{ error }}</div>
|
||||||
|
{%- endif %}
|
||||||
|
{%- if form %}
|
||||||
|
<form method="post" action="/oidc/login">
|
||||||
|
<input type="hidden" name="request" value="{{ request }}">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input id="name" name="name" value="{{ name }}" autocomplete="username webauthn" autocapitalize="none" spellcheck="false" required autofocus>
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||||
|
<label for="code">Code from the authenticator, when you have one</label>
|
||||||
|
<input id="code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code">
|
||||||
|
<button type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
<form id="passkey-form" method="post" action="/oidc/login" hidden>
|
||||||
|
<input type="hidden" name="request" value="{{ request }}">
|
||||||
|
<input type="hidden" name="passkey_id">
|
||||||
|
<input type="hidden" name="credential">
|
||||||
|
</form>
|
||||||
|
<button id="passkey" class="secondary" type="button" hidden>Sign in with a passkey</button>
|
||||||
|
<script>
|
||||||
|
// A passkey sign-in begins at /oidc/passkey and posts the browser's answer to /oidc/login with the authorization request.
|
||||||
|
(function () {
|
||||||
|
var button = document.getElementById('passkey');
|
||||||
|
if (!window.PublicKeyCredential || !PublicKeyCredential.parseRequestOptionsFromJSON) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
button.hidden = false;
|
||||||
|
button.addEventListener('click', async function () {
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
var begun = await (await fetch('/oidc/passkey', { method: 'POST' })).json();
|
||||||
|
var credential = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(begun.options.publicKey) });
|
||||||
|
var form = document.getElementById('passkey-form');
|
||||||
|
form.elements.passkey_id.value = begun.id;
|
||||||
|
form.elements.credential.value = JSON.stringify(credential.toJSON());
|
||||||
|
form.submit();
|
||||||
|
} catch (error) {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{%- endif %}
|
||||||
|
<p class="issuer">{{ issuer }}</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in new issue
Block a user