feature: the OpenID Provider has the client credentials and device authorization grants, public clients, and service accounts as resources with roles and scopes in JWT access tokens.
Release / release (push) Successful in 17s
Release / release (push) Successful in 17s
This commit is contained in:
1 parent
3ed79c7563
commit
3c4c5a8c32
15 files changed
+1572
-240
No files matched your search
@@ -481,11 +481,14 @@ standard attributes, are the display name and the description of a service accou
|
||||
`account` cannot hold. Manager-only accounts have the class from their creation, so an e-mail
|
||||
address can be added later without changing their classes; VPN users get it once scopes are used.
|
||||
`vpnrouter_ldap.provision` replaces the definitions in `cn=config` that differ, so an extended class
|
||||
reaches an existing directory at the next setup run.
|
||||
reaches an existing directory at the next setup run. The structural class `vpnRouterRole`,
|
||||
`MUST cn MAY ( description $ member )`, is a role of a resource of the OpenID Provider (ADR-37),
|
||||
an entry `cn=<resource>:<role>` of `ou=roles`, which provision makes; the manager account manages
|
||||
that subtree, and every account reads it.
|
||||
|
||||
Consequences: The schema check stays strict. New attributes extend `vpnRouterAccount`, and their
|
||||
OIDs continue the arcs. The scope `service` marks a service account (ADR-23). The OID must never
|
||||
be reused for anything else.
|
||||
Consequences: The schema check stays strict. New attributes of accounts extend
|
||||
`vpnRouterAccount`, and their OIDs continue the arcs. The scope `service` marks a service account
|
||||
(ADR-23). The OID must never be reused for anything else.
|
||||
|
||||
## ADR-20: Users manage their own account through /account, proving who they are again
|
||||
|
||||
@@ -1013,43 +1016,97 @@ Router Manager on another name, or in development on `localhost`, cannot use the
|
||||
sign-ins do not count towards the directory's lockout, but the sign-in calls are rate limited.
|
||||
Browsers without the JSON methods of WebAuthn Level 3 cannot add or use passkeys.
|
||||
|
||||
## ADR-37: The web service is an OpenID Provider built on python3-oauthlib; its clients are service accounts
|
||||
## ADR-37: The web service is an OpenID Provider built on python3-oauthlib; its clients and resources 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.
|
||||
Context:
|
||||
|
||||
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.
|
||||
- **What is needed:** 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. Programs on hosts
|
||||
without a browser, and jobs without a user, need tokens as well. Services such as an S3 gateway
|
||||
should learn from a token what its holder may do there, as resources of Entra ID do with app
|
||||
roles and scopes.
|
||||
- **What the archive has:** no packaged identity provider. `python3-oauthlib` and `python3-jwt`
|
||||
are in main for noble and resolute; `python3-authlib` and `python3-jwcrypto` are in universe
|
||||
only.
|
||||
- **What oauthlib does:** it implements the OAuth 2.0 and OpenID Connect flows, including the
|
||||
client credentials grant, and leaves the checks, the storage and the signatures to the
|
||||
application. Noble's 3.2.2 has no device authorization grant (RFC 8628). Resolute's 3.3.1 has
|
||||
one, as has the current 4.0.0, but it checks no device code and never answers
|
||||
`authorization_pending` or `slow_down`.
|
||||
- **What the web service has:** it 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 or a resource verifies a token itself.
|
||||
|
||||
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.
|
||||
Decision:
|
||||
|
||||
- **Where it runs:** 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.
|
||||
- **Clients:**
|
||||
- A client is a service account with redirect URIs or grants, kept with the account in
|
||||
attributes of `vpnRouterAccount`: `vpnRouterRedirectUri`, `vpnRouterPostLogoutRedirectUri`,
|
||||
`vpnRouterAllowedGroup`, `vpnRouterGrantType`, `vpnRouterPublicClient` and
|
||||
`vpnRouterClientScope`.
|
||||
- Its name is the client ID and its password the secret, checked with a bind; a public client has
|
||||
none.
|
||||
- Allowed groups limit who may sign in; without them every user may.
|
||||
- **Grants:**
|
||||
- the authorization code with PKCE S256, which redirect URIs give;
|
||||
- `client_credentials`, for confidential clients only;
|
||||
- the device code;
|
||||
- the refresh token for the first and the last.
|
||||
|
||||
The device grant is the router's own grant class on oauthlib's token endpoint, with its
|
||||
authorization endpoint and pages, so it works the same with every oauthlib release. A loopback
|
||||
redirect URI matches with any port (RFC 8252).
|
||||
- **Consent:** a registered client is trusted, so there is no consent page, except for a device:
|
||||
the user who enters its code is asked to allow it, as RFC 8628 advises against remote phishing.
|
||||
- **The sign-in page:** the web service's own, a Jinja2 template with the password, the code and a
|
||||
passkey, so the provider works without the Router Manager.
|
||||
- **Resources, as Entra ID:**
|
||||
- Every service account is a resource. Its scopes are in `vpnRouterResourceScope`, and its roles
|
||||
are `vpnRouterRole` entries (ADR-19) whose members are groups and accounts.
|
||||
- A token request names its resource with `resource` (RFC 8707) or by the resource's scopes,
|
||||
`<resource>:<scope>`, which the client's scopes must allow.
|
||||
- Without a resource, the tokens are for the client.
|
||||
- The grant keeps the resource, so a code, a device code or a refresh token gives tokens for that
|
||||
resource only.
|
||||
- **Tokens:**
|
||||
- Both kinds are RS256 JWTs, 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`.
|
||||
- An ID token is for the client and carries the user's roles of the client.
|
||||
- An access token follows RFC 9068: it is for the resource and carries the holder's roles there
|
||||
and the scopes granted, so a resource needs nothing from the router but the JWKS.
|
||||
- Roles are read at every issue.
|
||||
- **Storage:**
|
||||
- Codes, device codes, access and refresh tokens and sessions are kept as their SHA-256 in the
|
||||
database of the refresh tokens.
|
||||
- They are defined in `/usr/share/vpn-router/sql/oidc.sql`, with the procedures and functions
|
||||
the module calls; a process loads the file at its first connection.
|
||||
- The operations that revoke the refresh tokens of `/auth`, of users and of service accounts,
|
||||
also revoke them, and every use of a code, a device code or a token checks the user and the
|
||||
client's allowed groups again.
|
||||
|
||||
Consequences:
|
||||
|
||||
- **Requirements:** the package depends on `python3-oauthlib` and `python3-jwt`. The provider needs
|
||||
HTTPS, a public name and the web service.
|
||||
- **Management:** clients, resources and their roles are managed like service accounts, by the
|
||||
CLI, `vpn-router-role`, `/service-accounts` and the Router Manager.
|
||||
- **The directory keeps itself consistent:**
|
||||
- a group a client allows cannot be removed, and renaming it renames it in the clients and the
|
||||
roles;
|
||||
- a resource scope a client may ask for cannot be removed.
|
||||
- **Revocation:** an access token, a JWT, is valid at a resource until it expires, 5 minutes after
|
||||
issue, whatever the router revokes. The signing key does not rotate; replacing it invalidates
|
||||
every token issued.
|
||||
- **Lockout:** wrong client secrets lock the client like any account.
|
||||
- **Not offered:** the implicit and hybrid flows, the password grant and dynamic client
|
||||
registration.
|
||||
|
||||
## ADR-38: Bash completions are files per command in bash-completion's completions directory
|
||||
|
||||
|
||||
@@ -375,7 +375,7 @@ 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`, `vpn-router-caddy`
|
||||
`vpn-router-role`, `vpn-router-token`, `vpn-config-edit`, `vpn-router-backup`, `vpn-router-ca`, `vpn-router-caddy`
|
||||
and `vpn-router-site` 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
|
||||
@@ -702,8 +702,9 @@ it is added:
|
||||
- a service account, a Manager-only account whose `vpnRouterScope` is `service`, which another
|
||||
system signs in with to use a router service, such as an OpenTelemetry Collector reading
|
||||
`/metrics`; it cannot sign in to `/auth`. Besides its name, it has a display name and a
|
||||
description, 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`,
|
||||
description, a service account with redirect URIs or grants is a client of the OpenID Provider,
|
||||
and every one is a resource of it, with its scopes and roles (see OpenID Provider). `add
|
||||
--service` adds one, and the web service manages them at `/service-accounts`,
|
||||
not at `/users`. Internal service accounts are those the router creates and manages for its own
|
||||
services, display name and description included; at present that is the metrics service's, the
|
||||
account `metrics.service_account` names while metrics are enabled (see Metrics), which
|
||||
@@ -741,9 +742,9 @@ vpn-router-user otp-remove alice
|
||||
| `list` | The users with their kind, names and e-mail addresses, whether each is enabled or locked, has a certificate and is enrolled for TOTP, the number of passkeys and the console login |
|
||||
| `add <name> --first-name F --last-name L --email E` | Adds a VPN user and issues the certificate |
|
||||
| `add <name> [--email E]` | Adds a Manager-only account, with the e-mail address when given; one name without the other two options is refused |
|
||||
| `add <name> --service [--display-name N] [--description D] [--email E] [--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 |
|
||||
| `add <name> --service [--display-name N] [--description D] [--email E] [--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...] [--grant G ...] [--public-client yes\|no] [--client-scope R:S ...] [--resource-scope S ...]` | Adds a service account, with its display name, description, e-mail address and OpenID Provider settings when given; the names are refused |
|
||||
| `update <name> [--first-name F] [--last-name L] [--email E]` | Changes the names or the e-mail address |
|
||||
| `update <name> [--display-name N] [--description D] [--email E] [--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 |
|
||||
| `update <name> [--display-name N] [--description D] [--email E] [--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...] [--grant G ...] [--public-client yes\|no] [--client-scope R:S ...] [--resource-scope S ...]` | Changes the display name, the description, the e-mail address or the OpenID Provider settings of a service account; `''` removes one; a repeated option gives all values of its kind |
|
||||
| `passwd <name>` | Sets the password |
|
||||
| `disable <name>`, `enable <name>` | Locks the user out of OpenVPN and `/auth`, and lets the user in again |
|
||||
| `unlock <name>` | Ends a lockout after failed logins and clears the failure count; a disabled user stays disabled |
|
||||
@@ -855,9 +856,10 @@ A disabled or locked user is refused a passkey sign-in too.
|
||||
`vpn-router-group` manages the groups. A group has a name, with the rules of a user name, an
|
||||
optional description of at most 256 printable characters, and at least one member, each a user
|
||||
of the directory. The group `admins` grants the management of the router, and the allowed groups
|
||||
of a client of the OpenID Provider admit their members to it; other groups record membership
|
||||
only. Renaming a group renames it in the clients that allow it, and a group a client allows
|
||||
cannot be removed. The output, `--json`, `-h` and `--help` work as for `vpn-router-user`.
|
||||
of a client of the OpenID Provider admit their members to it, and a role of a resource assigned to
|
||||
a group is a role of its members; other groups record membership only. Renaming a group renames it
|
||||
in the clients that allow it and in its roles, a group a client allows cannot be removed, and
|
||||
removing a group takes it out of its roles. The output, `--json`, `-h` and `--help` work as for `vpn-router-user`.
|
||||
|
||||
```sh
|
||||
vpn-router-group add ops --member alice --member bob --description 'Operations team'
|
||||
@@ -1278,22 +1280,33 @@ curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
|
||||
| Request | Body | Answer |
|
||||
| --- | --- | --- |
|
||||
| `GET /service-accounts` | | `{"service_accounts": [{"name", "display_name", "description", "internal", "enabled", "locked", "email", "redirect_uris", "post_logout_redirect_uris", "allowed_groups", ...}, ...], "oidc": {"enabled", "issuer"}}`, each as `/users` shows a user, and the issuer of the OpenID Provider, `null` while it is disabled |
|
||||
| `POST /service-accounts` | `{"name", "password", "display_name"?, "description"?, "email"?, "redirect_uris"?, "post_logout_redirect_uris"?, "allowed_groups"?}` | 201 and the account |
|
||||
| `PUT /service-accounts/<name>` | any of `{"display_name", "description", "email", "redirect_uris", "post_logout_redirect_uris", "allowed_groups"}`; `""` removes one of the first three, `[]` the values of the others | the account |
|
||||
| `GET /service-accounts` | | `{"service_accounts": [{"name", "display_name", "description", "internal", "enabled", "locked", "email", "redirect_uris", "post_logout_redirect_uris", "allowed_groups", "grants", "public_client", "client_scopes", "resource_scopes", "roles", ...}, ...], "oidc": {"enabled", "issuer"}}`, each as `/users` shows a user, and the issuer of the OpenID Provider, `null` while it is disabled |
|
||||
| `POST /service-accounts` | `{"name", "password", "display_name"?, "description"?, "email"?, "redirect_uris"?, "post_logout_redirect_uris"?, "allowed_groups"?, "grants"?, "public_client"?, "client_scopes"?, "resource_scopes"?, "roles"?}` | 201 and the account |
|
||||
| `PUT /service-accounts/<name>` | any of `{"display_name", "description", "email", "redirect_uris", "post_logout_redirect_uris", "allowed_groups", "grants", "public_client", "client_scopes", "resource_scopes", "roles"}`; `""` removes one of the first three, `[]` the values of the lists, `false` the public client | the account |
|
||||
| `PUT /service-accounts/<name>/password` | `{"password": ...}` | the account |
|
||||
| `POST /service-accounts/<name>/disable` | | the account |
|
||||
| `POST /service-accounts/<name>/enable` | | the account |
|
||||
| `POST /service-accounts/<name>/unlock` | | the account |
|
||||
| `DELETE /service-accounts/<name>` | | `{"name", "removed": true}` |
|
||||
|
||||
A display name has at most 64 printable characters and a description at most 256. The client
|
||||
settings are lists of at most 16 values: URIs of `https`, or of `http` for `localhost`,
|
||||
`127.0.0.1` or `::1`, without a fragment and of at most 1024 characters, and names of existing
|
||||
groups (see OpenID Provider). The internal
|
||||
service accounts, `internal` true, are only listed: every other request on one gets 403, as the
|
||||
router manages them, their display name and description included. A failure is otherwise as for
|
||||
`/users`, with 404 also for a name that is not a service account.
|
||||
A display name has at most 64 printable characters and a description at most 256. The settings of
|
||||
the OpenID Provider (see OpenID Provider) are:
|
||||
|
||||
- **URIs and allowed groups:** 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;
|
||||
- **`grants`:** `client_credentials` and `device_code`; `public_client`, `true` or `false`;
|
||||
- **`client_scopes`:** at most 16 scopes of resources, `<resource>:<scope>`;
|
||||
- **`resource_scopes`:** at most 16 scope names of lowercase letters, digits, dots, underscores and
|
||||
hyphens;
|
||||
- **`roles`:** `[{"name", "description", "groups", "accounts"}]`, which replaces the account's roles:
|
||||
role names of letters, digits, dots, underscores and hyphens, and existing groups and accounts.
|
||||
|
||||
The internal service accounts, `internal` true, are only listed: every other request on one, and
|
||||
a role assigned to one, gets 403, as the router manages them, their display name and description
|
||||
included. A password change, disabling, unlocking and removing revoke the tokens the account was
|
||||
issued. A failure is otherwise as for `/users`, with 404 also for a name that is not a service
|
||||
account.
|
||||
|
||||
### The /account API
|
||||
|
||||
@@ -1521,22 +1534,33 @@ 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 /oidc/jwks` | The public key ID tokens and access 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 |
|
||||
| `POST /oidc/token` | Exchanges a code, a refresh token, a device code or a client's credentials for tokens |
|
||||
| `POST /oidc/device_authorization` | Begins the device authorization grant: a device code and a user code |
|
||||
| `GET`, `POST /oidc/device` | The page where a user enters a device's user code, signs in and allows the device |
|
||||
| `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
|
||||
**Clients**
|
||||
|
||||
A client is a service account with a redirect URI or a grant. 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:
|
||||
wrong secrets lock the client for 15 minutes. A public client sends only its `client_id` (`none`).
|
||||
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.
|
||||
- **redirect URIs:** where the provider sends the browser back with a code; they give the client
|
||||
the authorization code grant. A URI uses `https`, or `http` for `localhost`, `127.0.0.1` or
|
||||
`::1`, and is matched exactly, but the port of an `http` one, which a native program chooses
|
||||
when it starts (RFC 8252);
|
||||
- **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;
|
||||
- **grants:** `client_credentials` and `device_code`, besides the authorization code;
|
||||
- **public client:** the client has no secret, as a native program or a CLI, which cannot keep
|
||||
one; it cannot have `client_credentials`;
|
||||
- **client scopes:** the scopes of resources the client may ask for, `<resource>:<scope>`.
|
||||
|
||||
```sh
|
||||
echo 'cl1ent-s3cret' | vpn-router-user add 3f2b8c1e-9a4d-4e6f-8b2a-1c5d7e9f0a3b --service \
|
||||
@@ -1545,34 +1569,131 @@ echo 'cl1ent-s3cret' | vpn-router-user add 3f2b8c1e-9a4d-4e6f-8b2a-1c5d7e9f0a3b
|
||||
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.
|
||||
**Grants**
|
||||
|
||||
| Grant | Who signs in | Tokens |
|
||||
| --- | --- | --- |
|
||||
| `authorization_code` | A user, in the browser, with PKCE (`S256`) | Access, refresh and ID token |
|
||||
| `urn:ietf:params:oauth:grant-type:device_code` | A user, on another device | Access, refresh and ID token |
|
||||
| `client_credentials` | The service account itself, with its secret | Access token |
|
||||
| `refresh_token` | | A new access and refresh token |
|
||||
|
||||
The implicit and hybrid flows, the password grant and dynamic registration are refused. A
|
||||
registered client is trusted, so a user is not asked for consent, except for a device.
|
||||
|
||||
**Signing in**
|
||||
|
||||
- **The page:** 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).
|
||||
- **Lockout and rate limit:** failed sign-ins count towards the lockout. `POST /oidc/login`,
|
||||
`/oidc/device` and `/oidc/device_authorization` take at most 10 calls a minute from one client
|
||||
address, as `/auth` does.
|
||||
- **Who may not sign in:** a disabled, locked or removed user, a service account, and a user
|
||||
outside the client's allowed groups. The codes and tokens already issued to such a user stop
|
||||
working.
|
||||
- **The session:** the sign-in starts 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.
|
||||
|
||||
**Device authorization (RFC 8628)**
|
||||
|
||||
A client with `device_code`, such as a CLI on a host without a browser, signs a user in like this:
|
||||
|
||||
1. It posts `client_id` (and its secret, unless it is public), `scope` and optionally `resource` to
|
||||
`/oidc/device_authorization`. The answer holds `device_code`, `user_code` (8 letters, shown as
|
||||
`XXXX-XXXX`), `verification_uri`, `verification_uri_complete`, `expires_in` (600 seconds) and
|
||||
`interval` (5 seconds).
|
||||
2. It shows the user the code and the URI, then polls `/oidc/token` with
|
||||
`grant_type=urn:ietf:params:oauth:grant-type:device_code` and `device_code`. Until the user
|
||||
decides, the answer is `authorization_pending`; a poll sooner than the interval answers
|
||||
`slow_down` and adds 5 seconds to it.
|
||||
3. The user opens the URI on any device, enters the code, signs in, and allows or denies the
|
||||
client, the resource and the scopes shown. The next poll answers the tokens, or
|
||||
`access_denied`; an expired code answers `expired_token`.
|
||||
|
||||
```sh
|
||||
curl -d client_id=backup-cli -d 'scope=openid profile' \
|
||||
https://router.example.com:7444/oidc/device_authorization
|
||||
```
|
||||
|
||||
**Client credentials**
|
||||
|
||||
A confidential client with `client_credentials` gets an access token as its own service account,
|
||||
without a user. The scopes are those of a resource, and the profile scopes give the service
|
||||
account's claims; `openid` is refused, and no refresh token is issued.
|
||||
|
||||
```sh
|
||||
curl -u backup-job:'j0b-s3cret' -d grant_type=client_credentials -d resource=s3 -d scope=s3:read \
|
||||
https://router.example.com:7444/oidc/token
|
||||
```
|
||||
|
||||
**Resources, roles and scopes**
|
||||
|
||||
Every service account is also a resource, an application that checks the router's tokens, as an
|
||||
API registration of Entra ID:
|
||||
|
||||
- **Scopes:** the resource scopes the account defines, such as `read`, which clients ask for as
|
||||
`<resource>:<scope>` (`s3:read`) when their client scopes allow them.
|
||||
- **Roles:** named roles, such as `Reader`, each assigned to groups, users and service accounts;
|
||||
`vpn-router-role` manages them.
|
||||
|
||||
```sh
|
||||
echo 's3-s3cret' | vpn-router-user add s3 --service --resource-scope read --resource-scope write
|
||||
vpn-router-role add s3 Reader --description 'Reads every bucket'
|
||||
vpn-router-role member-add s3 Reader --group backup
|
||||
vpn-router-user update backup-job --grant client_credentials --client-scope s3:read
|
||||
```
|
||||
|
||||
A request is for a resource when it names it with `resource` (RFC 8707), its name or
|
||||
`<issuer>/<name>`, or asks for its scopes; more than one resource, or one that is not a
|
||||
resource, is `invalid_target`. A scope the client may not ask for is `invalid_scope`. Without a
|
||||
resource, the tokens are for the client itself. The resource is kept with the code, the device code
|
||||
and the refresh token, and a token request naming another one is `invalid_target`.
|
||||
|
||||
| Command | What it does |
|
||||
| --- | --- |
|
||||
| `vpn-router-role list [<resource>]` | The roles with the groups and accounts they are assigned to |
|
||||
| `vpn-router-role add <resource> <role> [--description D]` | Add a role |
|
||||
| `vpn-router-role remove <resource> <role>` | Remove a role |
|
||||
| `vpn-router-role member-add <resource> <role> --group G\|--account A` | Assign the role |
|
||||
| `vpn-router-role member-remove <resource> <role> --group G\|--account A` | Take the role away |
|
||||
|
||||
A scope a client may ask for cannot be removed from its resource, nor the resource while a client
|
||||
may ask for its scopes. Removing a service account removes its roles and takes it out of the
|
||||
others; removing or renaming a group does the same in the roles.
|
||||
|
||||
**Tokens**
|
||||
|
||||
| Scope | Claims |
|
||||
| --- | --- |
|
||||
| `openid` (required) | `sub`, the `entryUUID` of the user's entry |
|
||||
| `openid` (required but by `client_credentials`, which refuses it) | `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 |
|
||||
| `<resource>:<scope>` | None; the scope is in the access token's `scope` |
|
||||
|
||||
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.
|
||||
- **ID tokens:** RS256 JWTs for the client (`aud`), valid for 5 minutes, with the claims of the
|
||||
scopes, `auth_time`, `amr` (`pwd`, `pwd` and `otp`, or `hwk` and `user` for a passkey), and
|
||||
`roles`, the user's roles of the client itself. `/oidc/userinfo` answers the same claims.
|
||||
- **Access tokens:** RS256 JWTs of RFC 9068 (`typ` `at+jwt`), valid for 5 minutes, which a
|
||||
resource verifies with the JWKS. Their claims:
|
||||
- `iss`, `sub`, `iat`, `exp` and `jti`;
|
||||
- `aud`, the resource, or the client without one, and `client_id`, the client;
|
||||
- `scope`, the scopes granted;
|
||||
- `roles`, the roles of the resource the user or the service account holds, directly or by a
|
||||
group;
|
||||
- the claims of the scopes, and of a user `auth_time` and `amr`.
|
||||
- **Roles are read at every issue,** refresh included, so a role taken away is gone from the next
|
||||
token.
|
||||
- **The key:** tokens are 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.
|
||||
- **Lifetimes:** a code is valid for 1 minute and used once, a device code for 10 minutes, and a
|
||||
refresh token for 8 hours and used once: each refresh answers a new one.
|
||||
- **Storage and revocation:** the codes, device codes, tokens and sessions are kept, as their
|
||||
SHA-256, in the database of the refresh tokens of `/auth`. Changing the password of a user or a
|
||||
service account, disabling, unlocking or removing it ends its sessions and revokes its codes and
|
||||
tokens.
|
||||
|
||||
## Web sites
|
||||
|
||||
|
||||
@@ -1,3 +1,32 @@
|
||||
vpn-router (0.37.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the OpenID Provider has the client credentials and device authorization grants, public clients, and service accounts as resources with roles and scopes in JWT access tokens.
|
||||
|
||||
* A client may have the grants client_credentials, by which a
|
||||
confidential client gets an access token as its own service account,
|
||||
and the device code (RFC 8628): POST /oidc/device_authorization gives
|
||||
a device code and a user code, the user enters the code on
|
||||
/oidc/device, signs in and allows or denies the device, and the device
|
||||
polls /oidc/token. A public client has no secret.
|
||||
* An http loopback redirect URI matches with any port.
|
||||
* Every service account is a resource: it defines scopes, which clients
|
||||
ask for as <resource>:<scope> when their client scopes allow them, and
|
||||
roles, assigned to groups, users and service accounts. A token request
|
||||
names its resource with resource (RFC 8707) or by its scopes.
|
||||
* Access tokens are RS256 JWTs of RFC 9068 for the resource, or the
|
||||
client, with client_id, scope, roles and the claims of the scopes; ID
|
||||
tokens and /oidc/userinfo carry the user's roles of the client.
|
||||
* vpn-router-user takes --grant, --public-client, --client-scope and
|
||||
--resource-scope; the new vpn-router-role manages the roles; and
|
||||
/service-accounts takes and answers grants, public_client,
|
||||
client_scopes, resource_scopes and roles.
|
||||
* Changing the password of a service account, disabling, unlocking or
|
||||
removing it through /service-accounts revokes its tokens.
|
||||
* POST /oidc/device and /oidc/device_authorization are rate limited as
|
||||
/oidc/login is.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 00:57:11 +0100
|
||||
|
||||
vpn-router (0.36.0-1) unstable; urgency=medium
|
||||
|
||||
feature: a shell opens with one of the user's passkeys in place of the password and the TOTP code.
|
||||
|
||||
@@ -21,6 +21,7 @@ src/usr/lib/vpn-router/editor-file usr/lib/vpn-router/
|
||||
src/usr/bin/vpn-router-editor usr/bin/
|
||||
src/usr/sbin/vpn-router-user usr/sbin/
|
||||
src/usr/sbin/vpn-router-group usr/sbin/
|
||||
src/usr/sbin/vpn-router-role usr/sbin/
|
||||
src/usr/sbin/vpn-router-token usr/sbin/
|
||||
src/usr/sbin/vpn-config-edit usr/sbin/
|
||||
src/usr/sbin/vpn-router-backup usr/sbin/
|
||||
|
||||
@@ -18,6 +18,9 @@ vpnRouterConsoleLogin TRUE to the system over the local socket.
|
||||
|
||||
ou=pki holds the router's PKI (vpnrouter_pki): an entry per CA, and under it an entry per
|
||||
certificate the CA issued. Private keys are readable by root alone, the database's root DN.
|
||||
|
||||
ou=roles holds the roles of the resources of the OpenID Provider: an entry per role,
|
||||
cn=<resource>:<role>, whose members are the groups, users and service accounts it is assigned to.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -39,6 +42,7 @@ GROUPS = f'ou=groups,{BASE}'
|
||||
SERVICES = f'ou=services,{BASE}'
|
||||
POLICIES = f'ou=policies,{BASE}'
|
||||
PKI = f'ou=pki,{BASE}'
|
||||
ROLES = f'ou=roles,{BASE}'
|
||||
DEFAULT_POLICY = f'cn=default,{POLICIES}'
|
||||
TOTP_PARAMS = f'cn=totp,{POLICIES}'
|
||||
MANAGER = f'cn=user-manager,{SERVICES}'
|
||||
@@ -71,7 +75,12 @@ OVERLAYS = ('ppolicy', 'otp')
|
||||
#: 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
|
||||
#: groups whose members may sign in to it; vpnRouterGrantType, the further grants it may use,
|
||||
#: vpnRouterPublicClient, whether it has no secret, and vpnRouterClientScope, the scopes of
|
||||
#: resources it may ask for, as <resource>:<scope>. A service account with vpnRouterResourceScope
|
||||
#: is a resource of the OpenID Provider with those scopes. vpnRouterRole is a role of a resource,
|
||||
#: named cn=<resource>:<role>, with the DNs of the groups and accounts it is assigned to in
|
||||
#: member. vpnRouterAuthority is a CA of the PKI: its
|
||||
#: certificate and CRL (cACertificate and certificateRevocationList of RFC 4523), its key, the
|
||||
#: CSR it waits with for the offline root, the number of its last CRL, where the CRL came from,
|
||||
#: and when it was retired. vpnRouterCertificate is a certificate a CA issued, named by its
|
||||
@@ -112,18 +121,29 @@ SCHEMA_ATTRIBUTE_TYPES = (
|
||||
"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 )",
|
||||
f"( {SCHEMA_OID}.1.20 NAME 'vpnRouterGrantType' DESC 'VPN router: a grant an OpenID Connect client may use besides "
|
||||
"the authorization code' EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{64} )",
|
||||
f"( {SCHEMA_OID}.1.21 NAME 'vpnRouterPublicClient' DESC 'VPN router: whether an OpenID Connect client has no secret' "
|
||||
"EQUALITY booleanMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 SINGLE-VALUE )",
|
||||
f"( {SCHEMA_OID}.1.22 NAME 'vpnRouterClientScope' DESC 'VPN router: a scope of a resource an OpenID Connect client "
|
||||
"may ask for' EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{128} )",
|
||||
f"( {SCHEMA_OID}.1.23 NAME 'vpnRouterResourceScope' DESC 'VPN router: a scope of a resource of the OpenID Provider' "
|
||||
"EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{64} )",
|
||||
)
|
||||
SCHEMA_OBJECT_CLASSES = (
|
||||
f"( {SCHEMA_OID}.2.1 NAME 'vpnRouterAccount' DESC 'VPN router: properties of a user or a Manager-only account' "
|
||||
"SUP top AUXILIARY MAY ( mail $ vpnRouterScope $ displayName $ description $ vpnRouterConsoleLogin $ "
|
||||
"vpnRouterConsoleAccount $ vpnRouterWireGuardPeer $ vpnRouterPasskey $ vpnRouterRedirectUri $ "
|
||||
"vpnRouterPostLogoutRedirectUri $ vpnRouterAllowedGroup ) )",
|
||||
"vpnRouterPostLogoutRedirectUri $ vpnRouterAllowedGroup $ vpnRouterGrantType $ vpnRouterPublicClient $ "
|
||||
"vpnRouterClientScope $ vpnRouterResourceScope ) )",
|
||||
f"( {SCHEMA_OID}.2.2 NAME 'vpnRouterAuthority' DESC 'VPN router: a CA of the PKI' SUP top STRUCTURAL MUST cn "
|
||||
"MAY ( cACertificate $ certificateRevocationList $ vpnRouterPrivateKey $ vpnRouterCertificateRequest $ "
|
||||
"vpnRouterCRLNumber $ vpnRouterCRLSource $ vpnRouterRetired ) )",
|
||||
f"( {SCHEMA_OID}.2.3 NAME 'vpnRouterCertificate' DESC 'VPN router: a certificate a CA of the PKI issued' SUP top "
|
||||
"STRUCTURAL MUST serialNumber MAY ( cn $ userCertificate $ vpnRouterPrivateKey $ vpnRouterKind $ vpnRouterTemplate $ "
|
||||
"vpnRouterSubjectAltName $ vpnRouterNotAfter $ vpnRouterRevoked $ vpnRouterRevocationReason ) )",
|
||||
f"( {SCHEMA_OID}.2.4 NAME 'vpnRouterRole' DESC 'VPN router: a role of a resource of the OpenID Provider' SUP top "
|
||||
"STRUCTURAL MUST cn MAY ( description $ member ) )",
|
||||
)
|
||||
|
||||
#: The TOTP parameters: digits, the OID of HMAC-SHA1, seconds per time step, and the time steps accepted before and after now.
|
||||
@@ -152,6 +172,7 @@ ACCESS = (
|
||||
f'to attrs=oathSecret by dn.exact="{MANAGER}" =w by * none',
|
||||
f'to dn.subtree="{USERS}" by dn.exact="{MANAGER}" manage by * read',
|
||||
f'to dn.subtree="{GROUPS}" by dn.exact="{MANAGER}" manage by * read',
|
||||
f'to dn.subtree="{ROLES}" by dn.exact="{MANAGER}" manage by * read',
|
||||
'to * by * read',
|
||||
)
|
||||
|
||||
@@ -333,7 +354,7 @@ def provision():
|
||||
database = _ensure_database(connection)
|
||||
_ensure_overlays(connection, database)
|
||||
_ensure_entry(connection, BASE, {'objectClass': [b'dcObject', b'organization'], 'dc': [b'vpn-router'], 'o': [b'vpn-router']})
|
||||
for unit in (USERS, GROUPS, SERVICES, POLICIES, PKI):
|
||||
for unit in (USERS, GROUPS, SERVICES, POLICIES, PKI, ROLES):
|
||||
_ensure_entry(connection, unit, {'objectClass': [b'organizationalUnit'], 'ou': [unit.split(',')[0][3:].encode()]})
|
||||
_ensure_entry(connection, DEFAULT_POLICY, {
|
||||
'objectClass': [b'organizationalRole', b'pwdPolicy'],
|
||||
|
||||
@@ -3,28 +3,45 @@
|
||||
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.
|
||||
clients, the service accounts with redirect URIs or grants (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 public client
|
||||
sends only its ID. 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 /oidc/jwks the public key the ID tokens and the access 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
|
||||
POST /oidc/token exchange a code, a refresh token or a device code for tokens, or a client's
|
||||
credentials for an access token
|
||||
POST /oidc/device_authorization begin the device authorization grant (RFC 8628): a device code and a user code
|
||||
GET, POST /oidc/device the page where the user enters the user code, signs in and is asked to allow
|
||||
the device
|
||||
POST /oidc/device/decide allow or deny the device
|
||||
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.
|
||||
The grants: authorization_code for a client with redirect URIs, client_credentials, by which a confidential client
|
||||
gets an access token as itself, and the device code, as the client's grants give them, each with refresh_token but
|
||||
client_credentials. A client asks for tokens for a resource, a service account, with resource (RFC 8707), its name or
|
||||
<issuer>/<name>, or by the resource's scopes it asks for, <resource>:<scope>, of its client scopes; without one the
|
||||
tokens are for the client itself.
|
||||
|
||||
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, for the client. Access tokens are RS256 JWTs of RFC 9068 with the same key, for
|
||||
the resource or the client: iss, sub, aud, client_id, iat, exp, jti, scope, roles, the claims of the scopes, and of a
|
||||
user auth_time and amr. 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 but with client_credentials, which refuses it; profile, preferred_username and
|
||||
the names; email; groups, the names of the user's groups; their claims are in the ID token, the access token and the
|
||||
userinfo answer; and the scopes of the resources. roles are the roles of the resource assigned to the user or service
|
||||
account or a group of it (vpnrouter_users.roles_held), read at every issue; the ID token and the userinfo answer have
|
||||
those of the client. A code is valid CODE_SECONDS, an access token and an ID token ACCESS_SECONDS, a refresh token
|
||||
REFRESH_SECONDS, used once, and a device code DEVICE_SECONDS, polled at most every DEVICE_INTERVAL seconds, 5 more after
|
||||
each poll that came too soon. A session, the cookie COOKIE of the issuer's /oidc/ path, lasts SESSION_SECONDS. Codes,
|
||||
tokens, device codes 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 or a service account's. A client is registered by an
|
||||
administrator, so a user is not asked for consent, but for a device, which the user allows on the router's page.
|
||||
"""
|
||||
|
||||
import base64
|
||||
@@ -45,8 +62,13 @@ 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.common import Request, add_params_to_uri
|
||||
from oauthlib.oauth2 import (AccessDeniedError, FatalClientError, InvalidClientError, InvalidGrantError,
|
||||
InvalidRequestError, InvalidScopeError, OAuth2Error, UnauthorizedClientError,
|
||||
UnsupportedGrantTypeError)
|
||||
from oauthlib.oauth2.rfc6749 import utils
|
||||
from oauthlib.oauth2.rfc6749.grant_types.base import GrantTypeBase
|
||||
from oauthlib.oauth2.rfc6749.tokens import random_token_generator
|
||||
from oauthlib.openid import RequestValidator, Server
|
||||
from oauthlib.openid.connect.core.exceptions import LoginRequired
|
||||
|
||||
@@ -63,6 +85,14 @@ CODE_SECONDS = 60
|
||||
ACCESS_SECONDS = 300
|
||||
REFRESH_SECONDS = 8 * 3600
|
||||
SESSION_SECONDS = 8 * 3600
|
||||
DEVICE_SECONDS = 600
|
||||
DEVICE_INTERVAL = 5
|
||||
|
||||
#: The grant type of the device authorization grant.
|
||||
DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'
|
||||
#: A user code: USER_CODE_LENGTH of these letters, shown in two halves.
|
||||
USER_CODE_LETTERS = 'BCDFGHJKLMNPQRSTVWXZ'
|
||||
USER_CODE_LENGTH = 8
|
||||
|
||||
COOKIE = 'vpn_router_oidc'
|
||||
|
||||
@@ -75,10 +105,11 @@ SCOPE_CLAIMS = {
|
||||
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.
|
||||
#: passkey, which verifies the user; a service account's client credentials are its password.
|
||||
PASSWORD_AMR = ['pwd']
|
||||
OTP_AMR = ['pwd', 'otp']
|
||||
PASSKEY_AMR = ['hwk', 'user']
|
||||
SERVICE_AMR = ['pwd']
|
||||
|
||||
TEMPLATE = 'oidc-page.html.j2'
|
||||
|
||||
@@ -96,6 +127,26 @@ _pages = jinja2.Environment(loader=jinja2.FileSystemLoader(str(vpnrouter.TEMPLAT
|
||||
undefined=jinja2.StrictUndefined)
|
||||
|
||||
|
||||
class InvalidTarget(OAuth2Error):
|
||||
"""A resource that is unknown, or more than one (RFC 8707)."""
|
||||
error = 'invalid_target'
|
||||
|
||||
|
||||
class AuthorizationPending(OAuth2Error):
|
||||
"""A device code the user has not yet allowed or denied (RFC 8628)."""
|
||||
error = 'authorization_pending'
|
||||
|
||||
|
||||
class SlowDown(OAuth2Error):
|
||||
"""A device code polled sooner than its interval (RFC 8628)."""
|
||||
error = 'slow_down'
|
||||
|
||||
|
||||
class ExpiredToken(OAuth2Error):
|
||||
"""A device code that has expired (RFC 8628)."""
|
||||
error = 'expired_token'
|
||||
|
||||
|
||||
def ensure_key():
|
||||
"""Make the signing key when it is missing."""
|
||||
if KEY.is_file() and KEY.stat().st_size:
|
||||
@@ -122,30 +173,33 @@ def public_jwk(key):
|
||||
return {**jwk, 'kid': vpnrouter_auth.b64encode(thumbprint), 'use': 'sig', 'alg': 'RS256'}
|
||||
|
||||
|
||||
def sign(claims):
|
||||
def sign(claims, headers=None):
|
||||
"""The claims as an RS256 JWT with the key's kid and the headers given."""
|
||||
key = private_key()
|
||||
return jwt.encode(claims, key, algorithm='RS256', headers={'kid': public_jwk(key)['kid']})
|
||||
return jwt.encode(claims, key, algorithm='RS256', headers={'kid': public_jwk(key)['kid'], **(headers or {})})
|
||||
|
||||
|
||||
def discovery(issuer):
|
||||
"""The discovery document of OpenID Connect Discovery 1.0."""
|
||||
def discovery(issuer, resource_scopes):
|
||||
"""The discovery document of OpenID Connect Discovery 1.0, with the device authorization endpoint of RFC 8628;
|
||||
resource_scopes are the scopes of the resources."""
|
||||
return {
|
||||
'issuer': issuer,
|
||||
'authorization_endpoint': f'{issuer}/oidc/authorize',
|
||||
'token_endpoint': f'{issuer}/oidc/token',
|
||||
'device_authorization_endpoint': f'{issuer}/oidc/device_authorization',
|
||||
'userinfo_endpoint': f'{issuer}/oidc/userinfo',
|
||||
'jwks_uri': f'{issuer}/oidc/jwks',
|
||||
'end_session_endpoint': f'{issuer}/oidc/logout',
|
||||
'scopes_supported': list(SCOPES),
|
||||
'scopes_supported': [*SCOPES, *resource_scopes],
|
||||
'response_types_supported': ['code'],
|
||||
'response_modes_supported': ['query'],
|
||||
'grant_types_supported': ['authorization_code', 'refresh_token'],
|
||||
'grant_types_supported': ['authorization_code', 'refresh_token', 'client_credentials', DEVICE_GRANT],
|
||||
'subject_types_supported': ['public'],
|
||||
'id_token_signing_alg_values_supported': ['RS256'],
|
||||
'token_endpoint_auth_methods_supported': ['client_secret_basic', 'client_secret_post'],
|
||||
'token_endpoint_auth_methods_supported': ['client_secret_basic', 'client_secret_post', 'none'],
|
||||
'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)],
|
||||
'claims_supported': ['iss', 'sub', 'aud', 'exp', 'iat', 'auth_time', 'nonce', 'amr', 'at_hash', 'client_id',
|
||||
'scope', 'roles', *(claim for claims in SCOPE_CLAIMS.values() for claim in claims)],
|
||||
}
|
||||
|
||||
|
||||
@@ -159,19 +213,52 @@ def directory():
|
||||
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):
|
||||
def account_of(name):
|
||||
"""A namespace of the vpnrouter_users.summary of the service account of the name, else None."""
|
||||
if not isinstance(name, str):
|
||||
return None
|
||||
with directory() as connection:
|
||||
try:
|
||||
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, client_id))
|
||||
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
|
||||
except vpnrouter_users.Failure:
|
||||
return None
|
||||
if not user['service'] or not user['enabled'] or not user['redirect_uris']:
|
||||
return types.SimpleNamespace(**user) if user['service'] else None
|
||||
|
||||
|
||||
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 or grants; else None."""
|
||||
account = account_of(client_id)
|
||||
if account is None or not account.enabled or not (account.redirect_uris or account.grants):
|
||||
return None
|
||||
return types.SimpleNamespace(client_id=client_id, **user)
|
||||
account.client_id = client_id
|
||||
return account
|
||||
|
||||
|
||||
def resource_of(name):
|
||||
"""The resource of the name, a namespace of its vpnrouter_users.summary, when it is an enabled service account that
|
||||
is not internal; else None."""
|
||||
account = account_of(name)
|
||||
return account if account is not None and account.enabled and not account.internal else None
|
||||
|
||||
|
||||
def resource_scopes():
|
||||
"""The scopes of every resource, <resource>:<scope>, sorted."""
|
||||
with directory() as connection:
|
||||
users = vpnrouter_users.list_users(connection)['users']
|
||||
return sorted(f'{user["name"]}:{scope}' for user in users
|
||||
if user['service'] and user['enabled'] and not user['internal'] for scope in user['resource_scopes'])
|
||||
|
||||
|
||||
def account_claims(connection, name):
|
||||
"""The summary of the entry of the name and its claims of every scope, with sub; Failure when there is none."""
|
||||
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
|
||||
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']
|
||||
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 user, {claim: value for claim, value in claims.items() if value is not None}
|
||||
|
||||
|
||||
def subject(name, client):
|
||||
@@ -179,20 +266,26 @@ def subject(name, client):
|
||||
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)
|
||||
user, claims = account_claims(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):
|
||||
if client.allowed_groups and not set(client.allowed_groups) & set(claims['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}
|
||||
return claims
|
||||
|
||||
|
||||
def service_subject(name):
|
||||
"""The claims of every scope of the service account, with sub."""
|
||||
with directory() as connection:
|
||||
return account_claims(connection, name)[1]
|
||||
|
||||
|
||||
def roles_at(resource, name):
|
||||
"""The roles of the resource the user or service account holds."""
|
||||
with directory() as connection:
|
||||
return vpnrouter_users.roles_held(connection, resource, name)
|
||||
|
||||
|
||||
def claims_of(person, scopes):
|
||||
@@ -201,6 +294,46 @@ def claims_of(person, scopes):
|
||||
if claim in person}}
|
||||
|
||||
|
||||
def target_of(issuer, resource, scopes):
|
||||
"""The name of the resource a request with the resource parameter, None when absent, and the scopes names, None
|
||||
for none; InvalidTarget when they name more than one or one that is not a resource."""
|
||||
names = {scope.partition(':')[0] for scope in scopes if scope not in SCOPES}
|
||||
if resource is not None:
|
||||
names.add(resource[len(issuer) + 1:] if isinstance(resource, str) and resource.startswith(f'{issuer}/') else resource)
|
||||
if not names:
|
||||
return None
|
||||
if len(names) > 1:
|
||||
raise InvalidTarget(description='the resource and the scopes name more than one resource')
|
||||
name = names.pop()
|
||||
if resource_of(name) is None:
|
||||
raise InvalidTarget(description=f'{name} is not a resource')
|
||||
return name
|
||||
|
||||
|
||||
def scopes_allowed(client, scopes, target, grant_type):
|
||||
"""Whether the client may ask for the scopes: openid, required but by client_credentials, which refuses it, the
|
||||
other scopes of SCOPES, and those of its client scopes that belong to the target."""
|
||||
for scope in scopes:
|
||||
if scope not in SCOPES and (scope not in client.client_scopes or scope.partition(':')[0] != target):
|
||||
return False
|
||||
return ('openid' not in scopes) if grant_type == 'client_credentials' else ('openid' in scopes)
|
||||
|
||||
|
||||
def same_redirect(registered, given):
|
||||
"""Whether the redirect URI given is the registered one; an http URI of a loopback host may have any port (RFC 8252
|
||||
7.3)."""
|
||||
if registered == given:
|
||||
return True
|
||||
try:
|
||||
wanted, found = urllib.parse.urlsplit(registered), urllib.parse.urlsplit(given)
|
||||
# A port that is not a number makes .port raise ValueError.
|
||||
wanted.port, found.port
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
return False
|
||||
return wanted.scheme == 'http' and wanted.hostname in vpnrouter_users.LOOPBACK_HOSTS and not found.fragment and \
|
||||
(wanted.scheme, wanted.hostname, wanted.path, wanted.query) == (found.scheme, found.hostname, found.path, found.query)
|
||||
|
||||
|
||||
def cookie_value(cookies):
|
||||
"""The value of the session cookie in a Cookie header, or ''."""
|
||||
try:
|
||||
@@ -220,11 +353,31 @@ def session_of(cfg, cookies):
|
||||
return Session(*row) if row else None
|
||||
|
||||
|
||||
def access_token(cfg, request):
|
||||
"""The access token of a token request: a JWT of RFC 9068 for the request's target, or the client."""
|
||||
now = int(time.time())
|
||||
client_id = request.client.client_id
|
||||
service = request.grant_type == 'client_credentials'
|
||||
name = client_id if service else request.user
|
||||
person = service_subject(client_id) if service else request.oidc_person
|
||||
target = getattr(request, 'oidc_target', None)
|
||||
claims = {**claims_of(person, request.scopes), 'iss': vpnrouter.oidc_issuer(cfg), 'aud': target or client_id,
|
||||
'client_id': client_id, 'iat': now, 'exp': now + ACCESS_SECONDS, 'jti': secrets.token_urlsafe(16),
|
||||
'scope': ' '.join(request.scopes)}
|
||||
roles = roles_at(target or client_id, name)
|
||||
if roles:
|
||||
claims['roles'] = roles
|
||||
if not service:
|
||||
claims.update({'auth_time': int(request.auth_time.timestamp()), 'amr': request.amr})
|
||||
return sign(claims, {'typ': 'at+jwt'})
|
||||
|
||||
|
||||
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.
|
||||
The grants are those of validate_grant_type, with PKCE S256 for the authorization code. The values of a code, a
|
||||
token, a device code or a session found are kept on the request as oidc_code, oidc_scopes and oidc_person, the
|
||||
resource the tokens are for as oidc_target.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg):
|
||||
@@ -243,6 +396,14 @@ class Validator(RequestValidator):
|
||||
def session(self, request):
|
||||
return session_of(self.cfg, request.headers.get('Cookie'))
|
||||
|
||||
def check_target(self, request, stored):
|
||||
"""Refuse a resource parameter that names another resource than the one the grant was made for."""
|
||||
resource = getattr(request, 'resource', None)
|
||||
if 'resource' in request.duplicate_params or \
|
||||
resource is not None and target_of(vpnrouter.oidc_issuer(self.cfg), resource, []) != stored:
|
||||
raise InvalidTarget(description='the grant was made for another resource', request=request)
|
||||
request.oidc_target = stored
|
||||
|
||||
# Clients.
|
||||
|
||||
def validate_client_id(self, client_id, request, *args, **kwargs):
|
||||
@@ -250,7 +411,7 @@ class Validator(RequestValidator):
|
||||
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
|
||||
return any(same_redirect(uri, redirect_uri) for uri in request.client.redirect_uris)
|
||||
|
||||
def get_default_redirect_uri(self, client_id, request, *args, **kwargs):
|
||||
return None
|
||||
@@ -259,17 +420,29 @@ class Validator(RequestValidator):
|
||||
return response_type == 'code'
|
||||
|
||||
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
|
||||
return 'openid' in scopes and set(scopes) <= set(SCOPES)
|
||||
"""Whether the client may ask for the scopes; the resource they are for is kept as oidc_target."""
|
||||
if 'resource' in request.duplicate_params:
|
||||
raise InvalidTarget(description='give one resource', request=request)
|
||||
try:
|
||||
request.oidc_target = target_of(vpnrouter.oidc_issuer(self.cfg), getattr(request, 'resource', None), scopes)
|
||||
except InvalidTarget as error:
|
||||
raise InvalidTarget(description=error.description, request=request)
|
||||
return scopes_allowed(client, scopes, request.oidc_target, request.grant_type)
|
||||
|
||||
def get_default_scopes(self, client_id, request, *args, **kwargs):
|
||||
return ['openid']
|
||||
return [] if request.grant_type == 'client_credentials' else ['openid']
|
||||
|
||||
def is_pkce_required(self, client_id, request):
|
||||
return True
|
||||
|
||||
def client_authentication_required(self, request, *args, **kwargs):
|
||||
"""Whether the client must sign the request with its secret: unless it is a public client named by client_id."""
|
||||
client = client_of(request.client_id)
|
||||
return client is None or not client.public_client
|
||||
|
||||
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."""
|
||||
ID and secret form-encoded (RFC 6749 2.3.1), or in the body; a public client has none."""
|
||||
name, secret = request.client_id, request.client_secret
|
||||
header = request.headers.get('Authorization', '')
|
||||
if header[:6].lower() == 'basic ':
|
||||
@@ -279,7 +452,7 @@ class Validator(RequestValidator):
|
||||
return False
|
||||
name, secret = urllib.parse.unquote_plus(name), urllib.parse.unquote_plus(secret)
|
||||
client = client_of(name)
|
||||
if client is None:
|
||||
if client is None or client.public_client:
|
||||
return False
|
||||
try:
|
||||
vpnrouter_users.login(name, secret, None)
|
||||
@@ -289,10 +462,24 @@ class Validator(RequestValidator):
|
||||
return True
|
||||
|
||||
def authenticate_client_id(self, client_id, request, *args, **kwargs):
|
||||
return False
|
||||
"""Accept a public client by its ID."""
|
||||
client = client_of(client_id)
|
||||
if client is None or not client.public_client:
|
||||
return False
|
||||
request.client = client
|
||||
return True
|
||||
|
||||
def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs):
|
||||
return grant_type in ('authorization_code', 'refresh_token')
|
||||
"""The grants of the client: authorization_code with redirect URIs, client_credentials but for a public client,
|
||||
the device code, and refresh_token with either of the first and the last."""
|
||||
grants = set(client.grants) - ({'client_credentials'} if client.public_client else set())
|
||||
if client.redirect_uris:
|
||||
grants.add('authorization_code')
|
||||
if 'device_code' in grants:
|
||||
grants.add(DEVICE_GRANT)
|
||||
if grants & {'authorization_code', DEVICE_GRANT}:
|
||||
grants.add('refresh_token')
|
||||
return grant_type in grants
|
||||
|
||||
def validate_user(self, username, password, client, request, *args, **kwargs):
|
||||
return False
|
||||
@@ -300,15 +487,15 @@ class Validator(RequestValidator):
|
||||
# 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)',
|
||||
self.call(('CALL oidc_code_save(%s, %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)))
|
||||
getattr(request, 'oidc_target', None), 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
|
||||
'auth_time', 'amr', 'resource'), row)) if row else None
|
||||
|
||||
def get_authorization_code_scopes(self, client_id, code, redirect_uri, request):
|
||||
found = self.code(code)
|
||||
@@ -321,6 +508,7 @@ class Validator(RequestValidator):
|
||||
person = subject(found['name'], client)
|
||||
if person is None:
|
||||
return False
|
||||
self.check_target(request, found['resource'])
|
||||
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
|
||||
@@ -340,20 +528,53 @@ class Validator(RequestValidator):
|
||||
def invalidate_authorization_code(self, client_id, code, request, *args, **kwargs):
|
||||
self.call(('CALL oidc_code_delete(%s)', (vpnrouter_auth.digest(code),)))
|
||||
|
||||
# Device codes.
|
||||
|
||||
def poll_device(self, code, request):
|
||||
"""Poll the device code for the request's client: with the user's sign-in on the request when the user allowed
|
||||
it, else the error RFC 8628 gives."""
|
||||
row = self.fetch('SELECT * FROM oidc_device_poll(%s)', (vpnrouter_auth.digest(code),)) \
|
||||
if isinstance(code, str) and code else None
|
||||
found = dict(zip(('client_id', 'scopes', 'resource', 'state', 'name', 'auth_time', 'amr', 'expired', 'too_soon'),
|
||||
row)) if row else None
|
||||
if found is None or found['client_id'] != request.client.client_id:
|
||||
raise InvalidGrantError(description='the device code is unknown', request=request)
|
||||
if found['expired']:
|
||||
raise ExpiredToken(description='the device code has expired', request=request)
|
||||
if found['state'] == 'pending':
|
||||
raise (SlowDown if found['too_soon'] else AuthorizationPending)(request=request)
|
||||
if found['state'] == 'denied':
|
||||
self.call(('CALL oidc_device_delete(%s)', (vpnrouter_auth.digest(code),)))
|
||||
raise AccessDeniedError(description='the user denied the device', request=request)
|
||||
person = subject(found['name'], request.client)
|
||||
if person is None:
|
||||
raise AccessDeniedError(description=f'{found["name"]} may not sign in to this client', request=request)
|
||||
self.check_target(request, found['resource'])
|
||||
request.user, request.scopes, request.auth_time, request.amr = found['name'], found['scopes'], found['auth_time'], found['amr']
|
||||
request.oidc_person = person
|
||||
|
||||
def delete_device(self, code):
|
||||
self.call(('CALL oidc_device_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
|
||||
return dict(zip(('client_id', 'name', 'scopes', 'auth_time', 'amr', 'resource'), 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)',
|
||||
"""Keep the access token and the refresh token; a refresh token that was exchanged is used up. A token of
|
||||
client_credentials is kept for its service account."""
|
||||
if request.grant_type == 'client_credentials':
|
||||
name, auth_time, amr = request.client.client_id, datetime.datetime.now(datetime.timezone.utc), SERVICE_AMR
|
||||
else:
|
||||
name, auth_time, amr = request.user, request.auth_time, request.amr
|
||||
values = (request.client.client_id, name, request.scopes, auth_time, amr, getattr(request, 'oidc_target', None))
|
||||
statements = [('CALL oidc_token_save(%s, %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)',
|
||||
statements.append(('CALL oidc_token_save(%s, %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),)))
|
||||
@@ -366,6 +587,7 @@ class Validator(RequestValidator):
|
||||
person = subject(found['name'], client)
|
||||
if person is None:
|
||||
return False
|
||||
self.check_target(request, found['resource'])
|
||||
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
|
||||
@@ -385,7 +607,9 @@ class Validator(RequestValidator):
|
||||
return True
|
||||
|
||||
def get_userinfo_claims(self, request):
|
||||
return claims_of(request.oidc_person, request.oidc_scopes)
|
||||
claims = claims_of(request.oidc_person, request.oidc_scopes)
|
||||
roles = roles_at(request.client.client_id, request.user)
|
||||
return {**claims, 'roles': roles} if roles else claims
|
||||
|
||||
# OpenID Connect.
|
||||
|
||||
@@ -394,6 +618,9 @@ class Validator(RequestValidator):
|
||||
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})
|
||||
roles = roles_at(request.client.client_id, request.user)
|
||||
if roles:
|
||||
id_token['roles'] = roles
|
||||
return sign(id_token)
|
||||
|
||||
def validate_silent_login(self, request):
|
||||
@@ -417,10 +644,100 @@ class Validator(RequestValidator):
|
||||
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."""
|
||||
class DeviceCodeGrant(GrantTypeBase):
|
||||
"""The token request of the device authorization grant (RFC 8628): a device code the user allowed gives an access
|
||||
token, a refresh token and, with openid, an ID token; until then the request is answered authorization_pending or
|
||||
slow_down."""
|
||||
|
||||
def create_token_response(self, request, token_handler):
|
||||
headers = self._get_default_headers()
|
||||
try:
|
||||
self.validate_token_request(request)
|
||||
except OAuth2Error as error:
|
||||
headers.update(error.headers)
|
||||
return headers, error.json, error.status_code
|
||||
token = token_handler.create_token(request, refresh_token=True)
|
||||
if 'openid' in request.scopes:
|
||||
token['id_token'] = self.request_validator.finalize_id_token(
|
||||
{'aud': request.client.client_id, 'iat': int(time.time())}, token, token_handler, request)
|
||||
self.request_validator.save_token(token, request)
|
||||
self.request_validator.delete_device(request.device_code)
|
||||
return headers, json.dumps(token), 200
|
||||
|
||||
def validate_token_request(self, request):
|
||||
if request.grant_type != DEVICE_GRANT:
|
||||
raise UnsupportedGrantTypeError(request=request)
|
||||
for param in ('grant_type', 'device_code'):
|
||||
if param in request.duplicate_params:
|
||||
raise InvalidRequestError(description=f'Duplicate {param} parameter.', request=request)
|
||||
request.device_code = getattr(request, 'device_code', None)
|
||||
if not request.device_code:
|
||||
raise InvalidRequestError(description='Missing device_code parameter.', request=request)
|
||||
if self.request_validator.client_authentication_required(request):
|
||||
if not self.request_validator.authenticate_client(request):
|
||||
raise InvalidClientError(request=request)
|
||||
elif not self.request_validator.authenticate_client_id(request.client_id, request):
|
||||
raise InvalidClientError(request=request)
|
||||
self.validate_grant_type(request)
|
||||
self.request_validator.poll_device(request.device_code, request)
|
||||
|
||||
|
||||
def user_code_of(value):
|
||||
"""The user code a user typed, without the dash and spaces, in capitals; '' when it is not one."""
|
||||
code = ''.join(character for character in (value or '').upper() if character.isalnum())
|
||||
return code if len(code) == USER_CODE_LENGTH and all(character in USER_CODE_LETTERS for character in code) else ''
|
||||
|
||||
|
||||
def device_authorization(validator, cfg, text, headers):
|
||||
"""Answer a device authorization request (RFC 8628 3.1): the client, public by client_id or confidential with its
|
||||
secret, with the device_code grant, the scopes and the resource; the device code, the user code and the
|
||||
verification URIs."""
|
||||
issuer = vpnrouter.oidc_issuer(cfg)
|
||||
request = Request(f'{issuer}/oidc/device_authorization', 'POST', text, headers)
|
||||
for param in ('scope', 'resource'):
|
||||
if param in request.duplicate_params:
|
||||
raise InvalidRequestError(description=f'Duplicate {param} parameter.', request=request)
|
||||
if validator.client_authentication_required(request):
|
||||
if not validator.authenticate_client(request):
|
||||
raise InvalidClientError(request=request)
|
||||
elif not validator.authenticate_client_id(request.client_id, request):
|
||||
raise InvalidClientError(request=request)
|
||||
if not validator.validate_grant_type(request.client.client_id, DEVICE_GRANT, request.client, request):
|
||||
raise UnauthorizedClientError(description='the client may not use the device authorization grant', request=request)
|
||||
scopes = utils.scope_to_list(request.scope) or ['openid']
|
||||
target = target_of(issuer, getattr(request, 'resource', None), scopes)
|
||||
if not scopes_allowed(request.client, scopes, target, DEVICE_GRANT):
|
||||
raise InvalidScopeError(description='the client may not ask for these scopes; openid is required', request=request)
|
||||
device_code = secrets.token_urlsafe(32)
|
||||
user_code = ''.join(secrets.choice(USER_CODE_LETTERS) for _ in range(USER_CODE_LENGTH))
|
||||
with vpnrouter_auth.database(cfg) as connection:
|
||||
connection.execute('CALL oidc_device_save(%s, %s, %s, %s, %s, %s, %s)',
|
||||
(vpnrouter_auth.digest(device_code), vpnrouter_auth.digest(user_code), request.client.client_id,
|
||||
scopes, target, DEVICE_INTERVAL, DEVICE_SECONDS))
|
||||
shown = f'{user_code[:USER_CODE_LENGTH // 2]}-{user_code[USER_CODE_LENGTH // 2:]}'
|
||||
return document(200, {'device_code': device_code, 'user_code': shown, 'verification_uri': f'{issuer}/oidc/device',
|
||||
'verification_uri_complete': f'{issuer}/oidc/device?' + urllib.parse.urlencode({'user_code': shown}),
|
||||
'expires_in': DEVICE_SECONDS, 'interval': DEVICE_INTERVAL})
|
||||
|
||||
|
||||
def device_of(cfg, user_code):
|
||||
"""The waiting device code of a user code, {client_id, scopes, resource}, or None."""
|
||||
code = user_code_of(user_code)
|
||||
if not code:
|
||||
return None
|
||||
with vpnrouter_auth.database(cfg) as connection:
|
||||
row = connection.execute('SELECT * FROM oidc_device_find(%s)', (vpnrouter_auth.digest(code),)).fetchone()
|
||||
return dict(zip(('client_id', 'scopes', 'resource'), row)) if row else None
|
||||
|
||||
|
||||
def page(status, cfg, title, message='', error='', form=False, request='', name='', headers=(), action='/oidc/login',
|
||||
device=False, user_code='', decide=False, items=()):
|
||||
"""An HTML page of TEMPLATE: a message, an error and items; with form the sign-in form, posted to action with the
|
||||
authorization request, or with device the user code field, with or without the sign-in fields; with decide the
|
||||
Allow and Deny buttons of the device of the user code."""
|
||||
body = _pages.get_template(TEMPLATE).render(title=title, message=message, error=error, form=form, request=request,
|
||||
name=name, issuer=vpnrouter.oidc_issuer(cfg))
|
||||
name=name, issuer=vpnrouter.oidc_issuer(cfg), action=action, device=device,
|
||||
user_code=user_code, decide=decide, items=list(items))
|
||||
return status, 'text/html; charset=utf-8', body.encode(), [*PAGE_HEADERS, *headers]
|
||||
|
||||
|
||||
@@ -443,6 +760,30 @@ def cookie(value, seconds):
|
||||
return 'Set-Cookie', f'{COOKIE}={value}; Path=/oidc/; Max-Age={seconds}; Secure; HttpOnly; SameSite=Lax'
|
||||
|
||||
|
||||
def sign_in(form):
|
||||
"""The name and the amr of a sign-in with the form's name, password and code, or its passkey; Failure when it is
|
||||
refused, ValueError when the passkey's answer is not JSON."""
|
||||
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', '')))
|
||||
return name, PASSKEY_AMR
|
||||
name, code = form.get('name', '').strip(), 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)
|
||||
return name, OTP_AMR if code else PASSWORD_AMR
|
||||
|
||||
|
||||
def start_session(cfg, name, amr):
|
||||
"""Start a session of the user: a namespace of the Session and the header that sets its cookie."""
|
||||
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))
|
||||
return types.SimpleNamespace(session=Session(name, datetime.datetime.now(datetime.timezone.utc), amr),
|
||||
cookie=cookie(value, SESSION_SECONDS))
|
||||
|
||||
|
||||
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.
|
||||
@@ -491,27 +832,72 @@ def login(server, cfg, form, headers):
|
||||
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
|
||||
name, amr = sign_in(form)
|
||||
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)
|
||||
return authorize(server, cfg, params, headers, fresh=start_session(cfg, name, amr))
|
||||
|
||||
|
||||
def device_page(cfg, status=200, signed_in=False, user_code='', name='', error='', headers=()):
|
||||
"""The page where the user enters the user code of a device, with the sign-in fields unless signed_in."""
|
||||
return page(status, cfg, 'Sign in on a device', message='Enter the code the device shows.', error=error,
|
||||
form=not signed_in, name=name, headers=headers, action='/oidc/device', device=True, user_code=user_code)
|
||||
|
||||
|
||||
def device(cfg, form, headers):
|
||||
"""The device page's form: sign in when the browser has no session, then ask the user to allow the device of the
|
||||
user code."""
|
||||
user_code, fresh = form.get('user_code', ''), None
|
||||
session = session_of(cfg, headers.get('Cookie'))
|
||||
if session is None:
|
||||
try:
|
||||
name, amr = sign_in(form)
|
||||
except vpnrouter_users.Failure as failure:
|
||||
return device_page(cfg, 401, user_code=user_code, name=form.get('name', '').strip(), error=str(failure))
|
||||
except ValueError:
|
||||
return device_page(cfg, 401, user_code=user_code, name=form.get('name', '').strip(),
|
||||
error='the passkey is not accepted')
|
||||
fresh = start_session(cfg, name, amr)
|
||||
session = fresh.session
|
||||
extra = [fresh.cookie] if fresh else []
|
||||
found = device_of(cfg, user_code)
|
||||
client = client_of(found['client_id']) if found else None
|
||||
if client is None:
|
||||
return device_page(cfg, 400, signed_in=True, user_code=user_code, error='the code is wrong or has expired',
|
||||
headers=extra)
|
||||
shown = client.display_name or client.client_id
|
||||
if subject(session.name, client) is None:
|
||||
return device_page(cfg, 403, signed_in=True, user_code=user_code, headers=extra,
|
||||
error=f'{session.name} may not sign in to {shown}')
|
||||
items = ([f'resource: {found["resource"]}'] if found['resource'] else []) + [f'scope: {scope}' for scope in found['scopes']]
|
||||
return page(200, cfg, f'Allow {shown}?', message=f'{shown} asks to sign in as {session.name} on a device.',
|
||||
items=items, decide=True, user_code=user_code_of(user_code), headers=extra)
|
||||
|
||||
|
||||
def decide(cfg, form, headers):
|
||||
"""Allow or deny the device of the user code as the user of the session."""
|
||||
session = session_of(cfg, headers.get('Cookie'))
|
||||
if session is None:
|
||||
return device_page(cfg, 401, user_code=form.get('user_code', ''), error='sign in first')
|
||||
found = device_of(cfg, form.get('user_code', ''))
|
||||
client = client_of(found['client_id']) if found else None
|
||||
if client is None or subject(session.name, client) is None:
|
||||
return device_page(cfg, 400, signed_in=True, user_code=form.get('user_code', ''),
|
||||
error='the code is wrong or has expired')
|
||||
allowed = form.get('decision') == 'allow'
|
||||
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)
|
||||
decided = connection.execute('SELECT oidc_device_decide(%s, %s, %s, %s, %s)',
|
||||
(vpnrouter_auth.digest(user_code_of(form['user_code'])), session.name,
|
||||
session.auth_time, session.amr, allowed)).fetchone()[0]
|
||||
if not decided:
|
||||
return device_page(cfg, 400, signed_in=True, error='the code is wrong or has expired')
|
||||
shown = client.display_name or client.client_id
|
||||
if allowed:
|
||||
return page(200, cfg, 'The device is signed in', message=f'{shown} is signed in as {session.name}. You can return '
|
||||
'to the device.')
|
||||
return page(200, cfg, 'The device is denied', message=f'{shown} is not signed in.')
|
||||
|
||||
|
||||
def logout(cfg, query, headers):
|
||||
@@ -552,9 +938,12 @@ def answer(method, target, headers, body):
|
||||
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)
|
||||
validator = Validator(cfg)
|
||||
server = Server(validator, token_expires_in=ACCESS_SECONDS, token_generator=lambda request: access_token(cfg, request),
|
||||
refresh_token_generator=random_token_generator)
|
||||
server.grant_types[DEVICE_GRANT] = DeviceCodeGrant(validator)
|
||||
if (method, path) == ('GET', '/.well-known/openid-configuration'):
|
||||
return document(200, discovery(issuer))
|
||||
return document(200, discovery(issuer, resource_scopes()))
|
||||
if (method, path) == ('GET', '/oidc/jwks'):
|
||||
return document(200, {'keys': [public_jwk(private_key())]})
|
||||
if path == '/oidc/authorize' and method in ('GET', 'POST'):
|
||||
@@ -565,6 +954,15 @@ def answer(method, target, headers, body):
|
||||
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 (method, path) == ('POST', '/oidc/device_authorization'):
|
||||
return device_authorization(validator, cfg, text, headers)
|
||||
if (method, path) == ('GET', '/oidc/device'):
|
||||
signed_in = session_of(cfg, headers.get('Cookie')) is not None
|
||||
return device_page(cfg, signed_in=signed_in, user_code=dict(urllib.parse.parse_qsl(query)).get('user_code', ''))
|
||||
if (method, path) == ('POST', '/oidc/device'):
|
||||
return device(cfg, dict(urllib.parse.parse_qsl(text)), headers)
|
||||
if (method, path) == ('POST', '/oidc/device/decide'):
|
||||
return decide(cfg, dict(urllib.parse.parse_qsl(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'):
|
||||
|
||||
@@ -15,10 +15,18 @@ internal service accounts are those the router creates and manages for its own s
|
||||
included (internal_accounts); vpn-router-setup keeps the metrics service's.
|
||||
Users exist whether or not a P2S system is enabled.
|
||||
|
||||
A service account with redirect URIs is a client of the OpenID Provider (vpnrouter_oidc), its name the client ID and
|
||||
its password the client secret (client_settings): redirect_uris, where the provider sends a user back after a sign-in,
|
||||
post_logout_redirect_uris, after a sign-out, and allowed_groups, the groups whose members may sign in to it, every
|
||||
user who is not a service account when there are none.
|
||||
A service account with redirect URIs or grants 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, allowed_groups, the groups whose members may sign in to it,
|
||||
every user who is not a service account when there are none, grants, those of GRANTS it may use besides the
|
||||
authorization code, public_client, whether it signs no request with its secret, which a client with client_credentials
|
||||
must, and client_scopes, the scopes of resources it may ask for, as <resource>:<scope>.
|
||||
|
||||
A service account is also a resource of the OpenID Provider, the audience of the tokens issued for it: resource_scopes
|
||||
are the scopes it defines, and its roles (roles_of_resource) are entries cn=<resource>:<role> of vpnrouter_ldap.ROLES
|
||||
whose members are the groups and accounts the role is assigned to. A scope a client may ask for cannot be removed, nor
|
||||
the resource while a client may ask for its scopes; removing an account removes its roles and takes it out of the
|
||||
others, and removing or renaming a group does the same in the roles.
|
||||
|
||||
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
|
||||
@@ -105,9 +113,20 @@ LABELS = (('display_name', 'displayName'), ('description', 'description'))
|
||||
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope',
|
||||
'displayName', 'description', 'userCertificate;binary', 'vpnRouterConsoleLogin', 'vpnRouterConsoleAccount',
|
||||
'uidNumber', 'vpnRouterWireGuardPeer', 'vpnRouterPasskey', 'vpnRouterRedirectUri',
|
||||
'vpnRouterPostLogoutRedirectUri', 'vpnRouterAllowedGroup']
|
||||
'vpnRouterPostLogoutRedirectUri', 'vpnRouterAllowedGroup', 'vpnRouterGrantType', 'vpnRouterPublicClient',
|
||||
'vpnRouterClientScope', 'vpnRouterResourceScope']
|
||||
#: The grants an OpenID Provider client may have besides the authorization code, which its redirect URIs give it.
|
||||
GRANTS = ('client_credentials', 'device_code')
|
||||
#: The name of a role of a resource, and of a scope a resource defines.
|
||||
ROLE_NAME = re.compile(r'^[A-Za-z][A-Za-z0-9._-]{0,63}$')
|
||||
SCOPE_NAME = re.compile(r'^[a-z][a-z0-9._-]{0,63}$')
|
||||
#: The attributes read for a role.
|
||||
ROLE_ATTRIBUTES = ['cn', 'description', 'member']
|
||||
#: 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 keys of the OpenID Provider settings of a service account (client_settings, and roles of set_roles).
|
||||
CLIENT_KEYS = ('redirect_uris', 'post_logout_redirect_uris', 'allowed_groups', 'grants', 'public_client', 'client_scopes',
|
||||
'resource_scopes', 'roles')
|
||||
#: The most URIs of each kind and allowed groups of one client, and the longest URI.
|
||||
CLIENT_VALUES_MAX = 16
|
||||
URI_MAX = 1024
|
||||
@@ -218,11 +237,13 @@ def labels(values):
|
||||
|
||||
|
||||
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.
|
||||
"""The OpenID Provider settings of a service account in values ({redirect_uris, post_logout_redirect_uris,
|
||||
allowed_groups, grants, public_client, client_scopes, resource_scopes}), checked, as {LDAP attribute: [value, ...]};
|
||||
a key whose value is None is not given, [] or false 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.
|
||||
A URI is absolute, has no fragment and uses https, or http for a host of LOOPBACK_HOSTS; an allowed group exists; a
|
||||
grant is one of GRANTS; a client scope is a scope of a resource, <resource>:<scope>; a resource scope is a
|
||||
SCOPE_NAME. Each list has at most CLIENT_VALUES_MAX values.
|
||||
"""
|
||||
found = {}
|
||||
for key, attribute in CLIENT_URIS:
|
||||
@@ -249,13 +270,68 @@ def client_settings(connection, values):
|
||||
for group in allowed:
|
||||
group_entry(connection, group)
|
||||
found['vpnRouterAllowedGroup'] = [group_dn(group).encode() for group in sorted(set(allowed))]
|
||||
grants = values.get('grants')
|
||||
if grants is not None:
|
||||
if not isinstance(grants, list) or not all(isinstance(grant, str) and grant in GRANTS for grant in grants):
|
||||
raise Failure(f'grants must be a list of {", ".join(GRANTS)}', 2)
|
||||
found['vpnRouterGrantType'] = [grant.encode() for grant in GRANTS if grant in grants]
|
||||
public = values.get('public_client')
|
||||
if public is not None:
|
||||
if not isinstance(public, bool):
|
||||
raise Failure('public_client must be true or false', 2)
|
||||
found['vpnRouterPublicClient'] = [b'TRUE'] if public else []
|
||||
scopes = values.get('client_scopes')
|
||||
if scopes is not None:
|
||||
if not isinstance(scopes, list) or len(scopes) > CLIENT_VALUES_MAX or not all(isinstance(scope, str) for scope in scopes):
|
||||
raise Failure(f'client_scopes must be a list of at most {CLIENT_VALUES_MAX} scopes', 2)
|
||||
for scope in scopes:
|
||||
resource, _, name = scope.partition(':')
|
||||
if name not in resource_scopes(connection, resource):
|
||||
raise Failure(f'{scope!r} is not a scope of a resource, <resource>:<scope>', 2)
|
||||
found['vpnRouterClientScope'] = [scope.encode() for scope in sorted(set(scopes))]
|
||||
defined = values.get('resource_scopes')
|
||||
if defined is not None:
|
||||
if not isinstance(defined, list) or len(defined) > CLIENT_VALUES_MAX or \
|
||||
not all(isinstance(scope, str) and SCOPE_NAME.match(scope) for scope in defined):
|
||||
raise Failure(f'resource_scopes must be a list of at most {CLIENT_VALUES_MAX} scope names: lowercase letters, '
|
||||
'digits, dots, underscores and hyphens, starting with a letter, at most 64', 2)
|
||||
found['vpnRouterResourceScope'] = [scope.encode() for scope in sorted(set(defined))]
|
||||
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)
|
||||
"""Refuse OpenID Provider settings for a user who is not a service account."""
|
||||
if any(values.get(key) is not None for key in CLIENT_KEYS):
|
||||
raise Failure(f'{name} is not a service account; only a service account is a client or a resource of the OpenID '
|
||||
'Provider', 2)
|
||||
|
||||
|
||||
def check_client(name, current, changes):
|
||||
"""Refuse the client settings an entry's attributes would have with changes ({LDAP attribute: [value, ...]}) when the
|
||||
client is public and has client_credentials, which needs its secret."""
|
||||
merged = {**current, **changes}
|
||||
if merged.get('vpnRouterPublicClient') == [b'TRUE'] and b'client_credentials' in merged.get('vpnRouterGrantType', []):
|
||||
raise Failure(f'{name}: a public client has no secret, so it cannot use client_credentials', 2)
|
||||
|
||||
|
||||
def resource_scopes(connection, name):
|
||||
"""The scopes the service account defines as a resource, [] when the name is not that of a service account."""
|
||||
if not isinstance(name, str) or not (NAME.match(name) or GUID.match(name)):
|
||||
return []
|
||||
try:
|
||||
attributes = entry(connection, name)
|
||||
except Failure:
|
||||
return []
|
||||
return [value.decode() for value in attributes.get('vpnRouterResourceScope', [])] if service(attributes) else []
|
||||
|
||||
|
||||
def asking(connection, scopes):
|
||||
"""The names of the clients whose client scopes have any of the scopes, <resource>:<scope>, sorted."""
|
||||
if not scopes:
|
||||
return []
|
||||
condition = ''.join(f'(vpnRouterClientScope={ldap.filter.escape_filter_chars(scope)})' for scope in scopes)
|
||||
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, f'(|{condition})', ['uid'])
|
||||
return sorted(attributes['uid'][0].decode() for _, attributes in found)
|
||||
|
||||
|
||||
def dn_of(name):
|
||||
@@ -310,7 +386,7 @@ def summary(attributes):
|
||||
"""A user as the answers show it; a person attribute or a label the entry does not have is None.
|
||||
|
||||
internal is whether it is an internal service account (internal_accounts). A service account also has its OpenID
|
||||
Provider client settings (client_settings), allowed_groups as group names.
|
||||
Provider settings (client_settings), allowed_groups as group names; with_roles adds its roles.
|
||||
"""
|
||||
name = attributes['uid'][0].decode()
|
||||
lock = attributes.get('pwdAccountLockedTime', [None])[0]
|
||||
@@ -336,9 +412,18 @@ def summary(attributes):
|
||||
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))
|
||||
user['grants'] = [value.decode() for value in attributes.get('vpnRouterGrantType', [])]
|
||||
user['public_client'] = attributes.get('vpnRouterPublicClient') == [b'TRUE']
|
||||
user['client_scopes'] = [value.decode() for value in attributes.get('vpnRouterClientScope', [])]
|
||||
user['resource_scopes'] = [value.decode() for value in attributes.get('vpnRouterResourceScope', [])]
|
||||
return user
|
||||
|
||||
|
||||
def with_roles(connection, user):
|
||||
"""The summary of a service account with roles, its roles as a resource (roles_of_resource)."""
|
||||
return {**user, 'roles': roles_of_resource(connection, user['name'])}
|
||||
|
||||
|
||||
def console_of(attributes):
|
||||
"""The console login of an entry as the answers show it: None when it was never set, else {enabled, account, local,
|
||||
uid}: account the system account the console runs as, the local one of vpnRouterConsoleAccount (local true) or the
|
||||
@@ -536,8 +621,8 @@ def list_users(connection):
|
||||
def add(connection, name, password, values, service=False):
|
||||
"""Add a user: a VPN user with its certificate when values gives all three person attributes, a Manager-only account
|
||||
with at most the e-mail address when it gives neither name, and, with service, a service account, with at most the
|
||||
e-mail address, its labels (labels) and its OpenID Provider client settings (client_settings); only a service
|
||||
account may be named by a GUID."""
|
||||
e-mail address, its labels (labels), its OpenID Provider settings (client_settings) and its roles (set_roles); only
|
||||
a service account may be named by a GUID."""
|
||||
check_name(name, service)
|
||||
details = person(values, required=False)
|
||||
vpn_user = 'givenName' in details or 'sn' in details
|
||||
@@ -547,6 +632,9 @@ def add(connection, name, password, values, service=False):
|
||||
if service:
|
||||
details.update({attribute: value for attribute, value in labels(values).items() if value})
|
||||
client = {attribute: found for attribute, found in client_settings(connection, values).items() if found}
|
||||
check_client(name, {}, client)
|
||||
if values.get('roles') is not None:
|
||||
checked_roles(connection, values['roles'])
|
||||
else:
|
||||
refuse_client_settings(name, values)
|
||||
if vpn_user and len(details) != len(PERSON):
|
||||
@@ -574,6 +662,10 @@ def add(connection, name, password, values, service=False):
|
||||
vpnrouter_pki.issue_user(vpnrouter.load_config(), name, details['mail'])
|
||||
except vpnrouter_pki.Failure as failure:
|
||||
raise Failure(f'the certificate cannot be issued: {failure}', failure.status)
|
||||
if service:
|
||||
if values.get('roles') is not None:
|
||||
set_roles(connection, name, values['roles'])
|
||||
return with_roles(connection, summary(entry(connection, name)))
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
@@ -628,20 +720,37 @@ def apply_wireguard():
|
||||
|
||||
|
||||
def update_service(connection, name, values):
|
||||
"""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)):
|
||||
"""Change any of the e-mail address, the labels (labels), the OpenID Provider settings (client_settings) and the
|
||||
roles (set_roles) of a service account that values gives; '' removes the e-mail address or a label, [] the values
|
||||
of a setting. A resource scope a client may ask for is not removed."""
|
||||
current = entry(connection, name)
|
||||
if not service(current):
|
||||
raise Failure(f'{name} is not a service account', 3)
|
||||
settings = client_settings(connection, values)
|
||||
check_client(name, current, settings)
|
||||
if 'vpnRouterResourceScope' in settings:
|
||||
removed = {value.decode() for value in current.get('vpnRouterResourceScope', [])} - \
|
||||
{value.decode() for value in settings['vpnRouterResourceScope']}
|
||||
clients = asking(connection, [f'{name}:{scope}' for scope in sorted(removed)])
|
||||
if clients:
|
||||
raise Failure(f'the clients {", ".join(clients)} may ask for the scopes of {name} being removed; take them out '
|
||||
'of their client scopes first', 2)
|
||||
roles = values.get('roles')
|
||||
if roles is not None:
|
||||
checked_roles(connection, roles)
|
||||
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()]
|
||||
changes += [(ldap.MOD_REPLACE, attribute, found) for attribute, found in settings.items()]
|
||||
email = values.get('email')
|
||||
if email is not None:
|
||||
address = '' if isinstance(email, str) and email.strip() == '' else person({'email': email}, required=False)['mail']
|
||||
changes.append((ldap.MOD_REPLACE, 'mail', [address.encode()] if address else []))
|
||||
if not changes:
|
||||
raise Failure('give email, display_name, description, redirect_uris, post_logout_redirect_uris or allowed_groups', 2)
|
||||
connection.modify_s(dn_of(name), changes)
|
||||
return summary(entry(connection, name))
|
||||
if not changes and roles is None:
|
||||
raise Failure(f'give email, display_name, description or {", ".join(CLIENT_KEYS)}', 2)
|
||||
if changes:
|
||||
connection.modify_s(dn_of(name), changes)
|
||||
if roles is not None:
|
||||
set_roles(connection, name, roles)
|
||||
return with_roles(connection, summary(entry(connection, name)))
|
||||
|
||||
|
||||
def passwd(connection, name, password):
|
||||
@@ -761,15 +870,24 @@ def remove(connection, name):
|
||||
"""Remove the user from its groups and the directory, revoke the certificate and end the sessions of its own
|
||||
posixAccount.
|
||||
|
||||
A user who is the last member of a group is not removed.
|
||||
A user who is the last member of a group is not removed, nor a resource whose scopes a client may ask for. The roles
|
||||
of a removed service account are removed, and the account is taken out of the other roles.
|
||||
"""
|
||||
current = entry(connection, name)
|
||||
groups = groups_of(connection, name)
|
||||
alone = sorted(group for group, members in groups.items() if members == [name])
|
||||
if alone:
|
||||
raise Failure(f'user {name} is the last member of {", ".join(alone)}; remove the group first', 2)
|
||||
clients = sorted(set(asking(connection, [f'{name}:{scope.decode()}' for scope in current.get('vpnRouterResourceScope', [])]))
|
||||
- {name})
|
||||
if clients:
|
||||
raise Failure(f'the clients {", ".join(clients)} may ask for the scopes of {name}; take them out of their client '
|
||||
'scopes first', 2)
|
||||
for group in groups:
|
||||
connection.modify_s(group_dn(group), [(ldap.MOD_DELETE, 'member', [dn_of(name).encode()])])
|
||||
for role in roles_of_resource(connection, name):
|
||||
connection.delete_s(role_dn(name, role['name']))
|
||||
leave_roles(connection, dn_of(name))
|
||||
try:
|
||||
vpnrouter_pki.revoke_user(vpnrouter.load_config(), name)
|
||||
except vpnrouter_pki.Failure as failure:
|
||||
@@ -977,7 +1095,7 @@ def group_add(connection, name, description, members):
|
||||
def group_update(connection, name, new_name=None, description=None):
|
||||
"""Rename the group or change its description ('' removes it); at least one must be given. Renaming ADMIN_GROUP
|
||||
switches the console login of its members off; renaming a group a client of the OpenID Provider allows renames it
|
||||
there, and renaming the editors group of web sites renames it in the sites."""
|
||||
there, as in the roles it is assigned, and renaming the editors group of web sites renames it in the sites."""
|
||||
import vpnrouter_sites
|
||||
if new_name is None and description is None:
|
||||
raise Failure('give a new name or a description', 2)
|
||||
@@ -996,6 +1114,9 @@ def group_update(connection, name, new_name=None, description=None):
|
||||
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()])])
|
||||
for dn in assigned(connection, group_dn(name)):
|
||||
connection.modify_s(dn, [(ldap.MOD_DELETE, 'member', [group_dn(name).encode()]),
|
||||
(ldap.MOD_ADD, 'member', [group_dn(new_name).encode()])])
|
||||
try:
|
||||
vpnrouter_sites.rename_group(name, new_name)
|
||||
except vpnrouter_sites.DatabaseError as exc:
|
||||
@@ -1007,8 +1128,8 @@ def group_update(connection, name, new_name=None, description=None):
|
||||
|
||||
|
||||
def group_remove(connection, name):
|
||||
"""Remove the group, unless a client of the OpenID Provider allows it or it is the editors group of web sites;
|
||||
removing ADMIN_GROUP switches the console login of its members off."""
|
||||
"""Remove the group, unless a client of the OpenID Provider allows it or it is the editors group of web sites, and
|
||||
take it out of the roles it is assigned; removing ADMIN_GROUP switches the console login of its members off."""
|
||||
import vpnrouter_sites
|
||||
members = member_names(group_entry(connection, name))
|
||||
clients = allowing(connection, name)
|
||||
@@ -1021,6 +1142,7 @@ def group_remove(connection, name):
|
||||
if sites:
|
||||
raise Failure(f'group {name} is the editors group of the web sites {", ".join(sites)}; change them first', 2)
|
||||
connection.delete_s(group_dn(name))
|
||||
leave_roles(connection, group_dn(name))
|
||||
if name == ADMIN_GROUP:
|
||||
console_off(connection, members)
|
||||
return {'name': name, 'removed': True}
|
||||
@@ -1049,6 +1171,186 @@ def group_member_remove(connection, name, user):
|
||||
return group_summary(group_entry(connection, name))
|
||||
|
||||
|
||||
def role_dn(resource, role):
|
||||
return f'cn={resource}:{role},{vpnrouter_ldap.ROLES}'
|
||||
|
||||
|
||||
def check_resource(connection, resource):
|
||||
"""Refuse a name that is not that of a service account."""
|
||||
if not service(entry(connection, resource)):
|
||||
raise Failure(f'{resource} is not a service account; only a service account is a resource of the OpenID Provider', 2)
|
||||
|
||||
|
||||
def check_role_name(role):
|
||||
if not isinstance(role, str) or not ROLE_NAME.match(role):
|
||||
raise Failure('a role name is 1 to 64 letters, digits, dots, underscores and hyphens, starting with a letter', 2)
|
||||
|
||||
|
||||
def role_summary(attributes):
|
||||
"""A role as the answers show it: {name, description, groups, accounts}, the groups and accounts it is assigned to."""
|
||||
groups, accounts = [], []
|
||||
for value in attributes.get('member', []):
|
||||
dn = value.decode()
|
||||
if dn.startswith('cn=') and dn.endswith(f',{vpnrouter_ldap.GROUPS}'):
|
||||
groups.append(dn[3:-len(f',{vpnrouter_ldap.GROUPS}')])
|
||||
elif dn.startswith('uid=') and dn.endswith(f',{vpnrouter_ldap.USERS}'):
|
||||
accounts.append(dn[4:-len(f',{vpnrouter_ldap.USERS}')])
|
||||
values = attributes.get('description')
|
||||
return {'name': attributes['cn'][0].decode().partition(':')[2], 'description': values[0].decode() if values else '',
|
||||
'groups': sorted(groups), 'accounts': sorted(accounts)}
|
||||
|
||||
|
||||
def role_search(connection, resource, condition=''):
|
||||
"""The entries of the resource's roles that also match the LDAP filter condition, as [(DN, attributes)]."""
|
||||
prefix = ldap.filter.escape_filter_chars(f'{resource}:')
|
||||
found = connection.search_s(vpnrouter_ldap.ROLES, ldap.SCOPE_ONELEVEL,
|
||||
f'(&(objectClass=vpnRouterRole)(cn={prefix}*){condition})', ROLE_ATTRIBUTES)
|
||||
return [(dn, attributes) for dn, attributes in found if attributes['cn'][0].decode().startswith(f'{resource}:')]
|
||||
|
||||
|
||||
def roles_of_resource(connection, resource):
|
||||
"""The roles of the resource, sorted by name."""
|
||||
return sorted((role_summary(attributes) for _, attributes in role_search(connection, resource)),
|
||||
key=lambda role: role['name'])
|
||||
|
||||
|
||||
def role_entry(connection, resource, role):
|
||||
"""The attributes of the role's entry; Failure with status 3 when there is none."""
|
||||
check_role_name(role)
|
||||
try:
|
||||
return connection.search_s(role_dn(resource, role), ldap.SCOPE_BASE, attrlist=ROLE_ATTRIBUTES)[0][1]
|
||||
except ldap.NO_SUCH_OBJECT:
|
||||
raise Failure(f'{resource} has no role {role}', 3)
|
||||
|
||||
|
||||
def role_add(connection, resource, role, description=''):
|
||||
"""Add a role to the resource, with a description ('' for none) and no members."""
|
||||
check_resource(connection, resource)
|
||||
check_role_name(role)
|
||||
description = check_description(description if description is not None else '')
|
||||
record = {'objectClass': [b'vpnRouterRole'], 'cn': [f'{resource}:{role}'.encode()]}
|
||||
if description:
|
||||
record['description'] = [description.encode()]
|
||||
try:
|
||||
connection.add_s(role_dn(resource, role), ldap.modlist.addModlist(record))
|
||||
except ldap.ALREADY_EXISTS:
|
||||
raise Failure(f'{resource} has a role {role}', 4)
|
||||
return role_summary(role_entry(connection, resource, role))
|
||||
|
||||
|
||||
def role_remove(connection, resource, role):
|
||||
role_entry(connection, resource, role)
|
||||
connection.delete_s(role_dn(resource, role))
|
||||
return {'resource': resource, 'name': role, 'removed': True}
|
||||
|
||||
|
||||
def member_dn(connection, group=None, account=None):
|
||||
"""The DN of the existing group or account a role is assigned to; exactly one is given."""
|
||||
if (group is None) == (account is None):
|
||||
raise Failure('give a group or an account', 2)
|
||||
if group is not None:
|
||||
group_entry(connection, group)
|
||||
return group_dn(group)
|
||||
entry(connection, account)
|
||||
return dn_of(account)
|
||||
|
||||
|
||||
def role_member_add(connection, resource, role, group=None, account=None):
|
||||
"""Assign the role to the group or the account."""
|
||||
attributes = role_entry(connection, resource, role)
|
||||
dn = member_dn(connection, group, account)
|
||||
if dn.encode() not in attributes.get('member', []):
|
||||
connection.modify_s(role_dn(resource, role), [(ldap.MOD_ADD, 'member', [dn.encode()])])
|
||||
return role_summary(role_entry(connection, resource, role))
|
||||
|
||||
|
||||
def role_member_remove(connection, resource, role, group=None, account=None):
|
||||
"""Take the role from the group or the account."""
|
||||
attributes = role_entry(connection, resource, role)
|
||||
if (group is None) == (account is None):
|
||||
raise Failure('give a group or an account', 2)
|
||||
dn = group_dn(group) if group is not None else dn_of(account)
|
||||
if dn.encode() not in attributes.get('member', []):
|
||||
raise Failure(f'role {role} of {resource} is not assigned to {group or account}', 3)
|
||||
connection.modify_s(role_dn(resource, role), [(ldap.MOD_DELETE, 'member', [dn.encode()])])
|
||||
return role_summary(role_entry(connection, resource, role))
|
||||
|
||||
|
||||
def checked_roles(connection, roles):
|
||||
"""The roles of set_roles, checked: [{name, description, groups, accounts}], names unique, every group and account
|
||||
existing; description, groups and accounts may be left out."""
|
||||
if not isinstance(roles, list) or not all(isinstance(role, dict) for role in roles):
|
||||
raise Failure('roles must be a list of {"name", "description", "groups", "accounts"}', 2)
|
||||
checked = []
|
||||
for role in roles:
|
||||
check_role_name(role.get('name'))
|
||||
groups, accounts = role.get('groups') or [], role.get('accounts') or []
|
||||
if not isinstance(groups, list) or not isinstance(accounts, list):
|
||||
raise Failure(f'the groups and the accounts of role {role["name"]} must be lists of names', 2)
|
||||
for group in groups:
|
||||
group_entry(connection, group)
|
||||
for account in accounts:
|
||||
entry(connection, account)
|
||||
checked.append({'name': role['name'], 'description': check_description(role.get('description') or ''),
|
||||
'members': sorted({*(group_dn(group) for group in groups), *(dn_of(account) for account in accounts)})})
|
||||
if len({role['name'] for role in checked}) != len(checked):
|
||||
raise Failure('each role name may appear once', 2)
|
||||
return checked
|
||||
|
||||
|
||||
def set_roles(connection, resource, roles):
|
||||
"""Make the resource's roles those of roles (checked_roles): roles left out are removed, new ones added, and the
|
||||
others get the description and the members given."""
|
||||
check_resource(connection, resource)
|
||||
wanted = {role['name']: role for role in checked_roles(connection, roles)}
|
||||
for role in roles_of_resource(connection, resource):
|
||||
if role['name'] not in wanted:
|
||||
connection.delete_s(role_dn(resource, role['name']))
|
||||
present = {role['name'] for role in roles_of_resource(connection, resource)}
|
||||
for name, role in wanted.items():
|
||||
members = [dn.encode() for dn in role['members']]
|
||||
description = [role['description'].encode()] if role['description'] else []
|
||||
if name in present:
|
||||
connection.modify_s(role_dn(resource, name), [(ldap.MOD_REPLACE, 'description', description),
|
||||
(ldap.MOD_REPLACE, 'member', members)])
|
||||
continue
|
||||
record = {'objectClass': [b'vpnRouterRole'], 'cn': [f'{resource}:{name}'.encode()]}
|
||||
if description:
|
||||
record['description'] = description
|
||||
if members:
|
||||
record['member'] = members
|
||||
connection.add_s(role_dn(resource, name), ldap.modlist.addModlist(record))
|
||||
return roles_of_resource(connection, resource)
|
||||
|
||||
|
||||
def assigned(connection, dn):
|
||||
"""The DNs of the roles assigned to the group or account of the DN."""
|
||||
found = connection.search_s(vpnrouter_ldap.ROLES, ldap.SCOPE_ONELEVEL,
|
||||
f'(&(objectClass=vpnRouterRole)(member={ldap.filter.escape_filter_chars(dn)}))', ['1.1'])
|
||||
return [role for role, _ in found]
|
||||
|
||||
|
||||
def leave_roles(connection, dn):
|
||||
"""Take the group or account of the DN out of every role."""
|
||||
for role in assigned(connection, dn):
|
||||
connection.modify_s(role, [(ldap.MOD_DELETE, 'member', [dn.encode()])])
|
||||
|
||||
|
||||
def list_roles(connection, resource=None):
|
||||
"""The roles of every resource, or of one, as {"roles": [{resource, name, description, groups, accounts}]}."""
|
||||
found = connection.search_s(vpnrouter_ldap.ROLES, ldap.SCOPE_ONELEVEL, '(objectClass=vpnRouterRole)', ROLE_ATTRIBUTES)
|
||||
roles = [{'resource': attributes['cn'][0].decode().partition(':')[0], **role_summary(attributes)} for _, attributes in found]
|
||||
return {'roles': sorted((role for role in roles if resource is None or role['resource'] == resource),
|
||||
key=lambda role: (role['resource'], role['name']))}
|
||||
|
||||
|
||||
def roles_held(connection, resource, name):
|
||||
"""The names of the resource's roles assigned to the account or to a group it is a member of, sorted."""
|
||||
members = [dn_of(name), *(group_dn(group) for group in groups_of(connection, name))]
|
||||
condition = '(|' + ''.join(f'(member={ldap.filter.escape_filter_chars(dn)})' for dn in members) + ')'
|
||||
return sorted(role_summary(attributes)['name'] for _, attributes in role_search(connection, resource, condition))
|
||||
|
||||
|
||||
def extract_pem(text):
|
||||
"""The PEM blocks of the certificates in a file that also holds their text form."""
|
||||
begin, end = '-----BEGIN CERTIFICATE-----', '-----END CERTIFICATE-----'
|
||||
|
||||
@@ -162,7 +162,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
code, document = (users if directory == 'users' else groups)(state, self.command, path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif oidc:
|
||||
wait = auth_wait(self.client()) if (self.command, path) == ('POST', '/oidc/login') else 0
|
||||
wait = auth_wait(self.client()) if self.command == 'POST' and path in (
|
||||
'/oidc/login', '/oidc/device', '/oidc/device_authorization') 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'
|
||||
|
||||
@@ -99,22 +99,29 @@ def oidc_settings():
|
||||
def service_accounts(method, path, body):
|
||||
"""Answer a /service-accounts request with vpnrouter_users: the HTTP status and the JSON document.
|
||||
|
||||
GET /service-accounts the service accounts: {"service_accounts": [...], "oidc": {"enabled",
|
||||
"issuer"}}, issuer that of the OpenID Provider, null while it is disabled
|
||||
GET /service-accounts the service accounts, each with its roles: {"service_accounts": [...],
|
||||
"oidc": {"enabled", "issuer"}}, issuer that of the OpenID Provider, null
|
||||
while it is disabled
|
||||
POST /service-accounts add one: {"name", "password", "display_name"?, "description"?, "email"?,
|
||||
"redirect_uris"?, "post_logout_redirect_uris"?, "allowed_groups"?}
|
||||
"redirect_uris"?, "post_logout_redirect_uris"?, "allowed_groups"?,
|
||||
"grants"?, "public_client"?, "client_scopes"?, "resource_scopes"?,
|
||||
"roles"?}
|
||||
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
|
||||
"post_logout_redirect_uris", "allowed_groups", "grants",
|
||||
"public_client", "client_scopes", "resource_scopes", "roles"}; ""
|
||||
removes one of the first three, [] the values of the lists
|
||||
PUT /service-accounts/<name>/password set the password: {"password": ...}
|
||||
POST /service-accounts/<name>/disable lock it out
|
||||
POST /service-accounts/<name>/enable let it in again
|
||||
POST /service-accounts/<name>/unlock end a lockout after failed logins
|
||||
DELETE /service-accounts/<name> remove it
|
||||
|
||||
A service account with redirect URIs is a client of the OpenID Provider (vpnrouter_users.client_settings). A name
|
||||
that is not a service account is 404. The internal service accounts (vpnrouter_users.internal_accounts), which
|
||||
the router manages, are only listed: every other request on one is refused with 403.
|
||||
A service account with redirect URIs or grants is a client of the OpenID Provider, and every service account a
|
||||
resource (vpnrouter_users.client_settings); roles, [{"name", "description", "groups", "accounts"}], replaces its
|
||||
roles (vpnrouter_users.set_roles). A name that is not a service account is 404. The internal service accounts
|
||||
(vpnrouter_users.internal_accounts), which the router manages, are only listed: every other request on one, and a
|
||||
role assigned to one, is refused with 403. A password change, disabling, unlocking and removing revoke the tokens
|
||||
the account was issued.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
@@ -122,11 +129,11 @@ def service_accounts(method, path, body):
|
||||
parts = path.strip('/').split('/')
|
||||
route = (method, len(parts), parts[2] if len(parts) == 3 else None)
|
||||
name = parts[1] if len(parts) > 1 else None
|
||||
settings = {key: body.get(key) for key in ('email', 'display_name', 'description', 'redirect_uris',
|
||||
'post_logout_redirect_uris', 'allowed_groups')}
|
||||
settings = {key: body.get(key) for key in ('email', 'display_name', 'description', *vpnrouter_users.CLIENT_KEYS)}
|
||||
actions = {
|
||||
('GET', 1, None): lambda connection: {
|
||||
'service_accounts': [user for user in vpnrouter_users.list_users(connection)['users'] if user['service']],
|
||||
'service_accounts': [vpnrouter_users.with_roles(connection, user)
|
||||
for user in vpnrouter_users.list_users(connection)['users'] if user['service']],
|
||||
'oidc': oidc_settings()},
|
||||
('POST', 1, None): lambda connection: vpnrouter_users.add(connection, body.get('name'), body.get('password'), settings,
|
||||
service=True),
|
||||
@@ -137,19 +144,34 @@ def service_accounts(method, path, body):
|
||||
('POST', 3, 'unlock'): lambda connection: vpnrouter_users.unlock(connection, name),
|
||||
('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name),
|
||||
}
|
||||
# The vpnrouter_users operation of each route that may revoke tokens.
|
||||
operations = {('PUT', 3, 'password'): 'passwd', ('POST', 3, 'disable'): 'disable', ('POST', 3, 'unlock'): 'unlock',
|
||||
('DELETE', 2, None): 'remove'}
|
||||
if route not in actions:
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
if name is not None and name in vpnrouter_users.internal_accounts():
|
||||
internal = vpnrouter_users.internal_accounts()
|
||||
if name is not None and name in internal:
|
||||
return 403, {'error': f'{name} is an internal service account, which the router manages'}
|
||||
roles = settings['roles'] if isinstance(settings['roles'], list) else []
|
||||
assigned = sorted(set(internal).intersection(account for role in roles if isinstance(role, dict)
|
||||
for account in (role.get('accounts') or [])
|
||||
if isinstance(account, str)))
|
||||
if assigned:
|
||||
return 403, {'error': f'{", ".join(assigned)}: internal service accounts, which the router manages, are not '
|
||||
'assigned roles'}
|
||||
try:
|
||||
with vpnrouter_users.session() as connection:
|
||||
if name is not None and not vpnrouter_users.service(vpnrouter_users.entry(connection, name)):
|
||||
raise vpnrouter_users.Failure(f'{name} is not a service account', 3)
|
||||
document = actions[route](connection)
|
||||
if operations.get(route) in vpnrouter_auth.REVOKING:
|
||||
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
|
||||
except vpnrouter_users.Failure as failure:
|
||||
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
return 500, {'error': f'the user directory failed: {exc}'}
|
||||
except vpnrouter_auth.DatabaseError as exc:
|
||||
return 500, {'error': str(exc)}
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
return (201 if route == ('POST', 1, None) else 200), document
|
||||
|
||||
Executable
+121
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/python3
|
||||
"""Manage the roles of the resources of the router's OpenID Provider from the command line, with vpnrouter_users.
|
||||
|
||||
A resource is a service account; a role of it has a name, an optional description, and the groups and accounts it is
|
||||
assigned to, whose tokens for the resource carry it in their roles claim. The answer is text for people on standard
|
||||
output, and a failure is a message on standard error. With --json the answer is JSON on standard output, and a failure
|
||||
is {"error": ...} there too. The exit status says what failed: 2 the request is invalid, 3 the resource, the role, the
|
||||
group or the account does not exist, 4 the role exists, 1 anything else. -h or --help shows the commands and their
|
||||
options.
|
||||
|
||||
vpn-router-role [--json] list [<resource>]
|
||||
vpn-router-role [--json] add <resource> <role> [--description D]
|
||||
vpn-router-role [--json] remove <resource> <role>
|
||||
vpn-router-role [--json] member-add <resource> <role> (--group G | --account A)
|
||||
vpn-router-role [--json] member-remove <resource> <role> (--group G | --account A)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/usr/lib/vpn-router')
|
||||
|
||||
import ldap
|
||||
|
||||
import vpnrouter_users
|
||||
from vpnrouter_users import Failure
|
||||
|
||||
#: The help line of each command.
|
||||
HELP = {
|
||||
'list': 'list the roles of every resource, or of one, with the groups and accounts they are assigned to',
|
||||
'add': 'add a role to a resource',
|
||||
'remove': 'remove a role of a resource',
|
||||
'member-add': 'assign the role to a group or an account',
|
||||
'member-remove': 'take the role from a group or an account',
|
||||
}
|
||||
|
||||
|
||||
class Parser(argparse.ArgumentParser):
|
||||
"""An argument parser whose errors are Failures with status 2, so that --json answers them as JSON."""
|
||||
|
||||
def error(self, message):
|
||||
raise Failure(f'{message}; see {self.prog} --help', 2)
|
||||
|
||||
|
||||
def parse(argv):
|
||||
parser = Parser(prog='vpn-router-role', description='Manage the roles of the resources of the OpenID Provider.')
|
||||
parser.add_argument('--json', action='store_true', help='answer in JSON')
|
||||
commands = parser.add_subparsers(dest='command', required=True, metavar='command', parser_class=Parser)
|
||||
for command, text in HELP.items():
|
||||
sub = commands.add_parser(command, help=text, description=text[0].upper() + text[1:] + '.')
|
||||
if command == 'list':
|
||||
sub.add_argument('resource', nargs='?', help='the service account whose roles are listed')
|
||||
continue
|
||||
sub.add_argument('resource', help='the service account the role belongs to')
|
||||
sub.add_argument('role', help='the role name')
|
||||
if command == 'add':
|
||||
sub.add_argument('--description', metavar='TEXT', help='the description')
|
||||
if command in ('member-add', 'member-remove'):
|
||||
member = sub.add_mutually_exclusive_group(required=True)
|
||||
member.add_argument('--group', metavar='GROUP', help='the group')
|
||||
member.add_argument('--account', metavar='USER', help='the user or service account')
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def run(connection, options):
|
||||
"""The answer of the command the options name."""
|
||||
command = options.command
|
||||
if command == 'list':
|
||||
if options.resource is not None:
|
||||
vpnrouter_users.check_resource(connection, options.resource)
|
||||
return vpnrouter_users.list_roles(connection, options.resource)
|
||||
if command == 'add':
|
||||
role = vpnrouter_users.role_add(connection, options.resource, options.role, options.description)
|
||||
elif command == 'remove':
|
||||
return vpnrouter_users.role_remove(connection, options.resource, options.role)
|
||||
elif command == 'member-add':
|
||||
role = vpnrouter_users.role_member_add(connection, options.resource, options.role, options.group, options.account)
|
||||
else:
|
||||
role = vpnrouter_users.role_member_remove(connection, options.resource, options.role, options.group, options.account)
|
||||
return {'resource': options.resource, **role}
|
||||
|
||||
|
||||
def text_of(answer):
|
||||
"""The answer as text for people."""
|
||||
if answer.get('removed'):
|
||||
return f'role {answer["name"]} of {answer["resource"]} removed'
|
||||
roles = answer['roles'] if 'roles' in answer else [answer]
|
||||
if not roles:
|
||||
return 'no roles'
|
||||
head = ('RESOURCE', 'ROLE', 'GROUPS', 'ACCOUNTS', 'DESCRIPTION')
|
||||
rows = [(role['resource'], role['name'], ', '.join(role['groups']) or '-', ', '.join(role['accounts']) or '-',
|
||||
role['description'] or '-') for role in roles]
|
||||
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))
|
||||
|
||||
|
||||
def main(argv):
|
||||
# A request that cannot be parsed is answered in JSON when --json is among the arguments.
|
||||
as_json = '--json' in argv
|
||||
try:
|
||||
options = parse(argv)
|
||||
as_json = options.json
|
||||
with vpnrouter_users.session() as connection:
|
||||
answer = run(connection, options)
|
||||
except Failure as failure:
|
||||
message, status = str(failure), failure.status
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
message, status = f'the user directory failed: {exc}', 1
|
||||
else:
|
||||
print(json.dumps(answer) if as_json else text_of(answer))
|
||||
return 0
|
||||
if as_json:
|
||||
print(json.dumps({'error': message}))
|
||||
else:
|
||||
print(f'vpn-router-role: {message}', file=sys.stderr)
|
||||
return status
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -13,14 +13,17 @@ user exists, 1 anything else. -h or --help shows the commands and their options.
|
||||
vpn-router-user [--json] add <name> [--email E] (a Manager-only account; password on stdin)
|
||||
vpn-router-user [--json] add <name> --service [--display-name N] [--description D] [--email E]
|
||||
[--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]
|
||||
[--grant G ...] [--public-client yes|no] [--client-scope R:S ...] [--resource-scope S ...]
|
||||
(a service account; password on stdin)
|
||||
vpn-router-user [--json] update <name> [--first-name F] [--last-name L] [--email E]
|
||||
vpn-router-user [--json] update <name> [--display-name N] [--description D] [--email E]
|
||||
[--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]
|
||||
[--grant G ...] [--public-client yes|no] [--client-scope R:S ...] [--resource-scope S ...]
|
||||
(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.
|
||||
A service account with a redirect URI or a grant (client_credentials, device_code) is a client of the OpenID Provider,
|
||||
and one with a resource scope a resource; each of the repeatable options replaces all values of its kind, and one ''
|
||||
removes them. vpn-router-role manages the roles of a resource.
|
||||
vpn-router-user [--json] passwd <name> (password on stdin)
|
||||
vpn-router-user [--json] disable <name>
|
||||
vpn-router-user [--json] enable <name>
|
||||
@@ -80,6 +83,9 @@ 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'),
|
||||
('grants', 'grant', 'a grant the client may use besides the authorization code: client_credentials or device_code'),
|
||||
('client_scopes', 'client-scope', 'a scope of a resource the client may ask for, <resource>:<scope>'),
|
||||
('resource_scopes', 'resource-scope', 'a scope the service account defines as a resource'),
|
||||
)
|
||||
|
||||
|
||||
@@ -104,6 +110,8 @@ def parse(argv):
|
||||
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")
|
||||
sub.add_argument('--public-client', choices=('yes', 'no'),
|
||||
help='whether the client of a service account is public, without a secret')
|
||||
if command == 'add':
|
||||
sub.add_argument('--service', action='store_true', help='add a service account, which cannot sign in to the web service')
|
||||
if command == 'otp-enrol':
|
||||
@@ -131,6 +139,8 @@ def run(connection, options, secret):
|
||||
given = getattr(options, key, None)
|
||||
if given is not None:
|
||||
values[key] = [value for value in given if value]
|
||||
if getattr(options, 'public_client', None) is not None:
|
||||
values['public_client'] = options.public_client == 'yes'
|
||||
if command == 'add':
|
||||
return vpnrouter_users.add(connection, options.name, secret, values, service=options.service)
|
||||
if command == 'update':
|
||||
@@ -157,12 +167,20 @@ def console_text(user):
|
||||
|
||||
|
||||
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}'
|
||||
"""The OpenID Provider settings of a service account as a cell: -, or the numbers of its URIs, its allowed groups,
|
||||
grants, public, its client scopes and its resource scopes."""
|
||||
parts = []
|
||||
if user.get('redirect_uris'):
|
||||
groups = ','.join(user['allowed_groups']) or 'all users'
|
||||
parts.append(f'{len(user["redirect_uris"])} redirect, {len(user["post_logout_redirect_uris"])} logout, {groups}')
|
||||
parts += user.get('grants') or []
|
||||
if user.get('public_client'):
|
||||
parts.append('public')
|
||||
if user.get('client_scopes'):
|
||||
parts.append('asks ' + ','.join(user['client_scopes']))
|
||||
if user.get('resource_scopes'):
|
||||
parts.append('defines ' + ','.join(user['resource_scopes']))
|
||||
return '; '.join(parts) or '-'
|
||||
|
||||
|
||||
def text_of(answer):
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# bash completion for vpn-router-role -*- shell-script -*-
|
||||
|
||||
# The service accounts, the users, the groups and the roles of a resource of the router's directory, read as root;
|
||||
# nothing for other users.
|
||||
_vpn_router_role_resources()
|
||||
{
|
||||
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"] if user["service"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_role_accounts()
|
||||
{
|
||||
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_role_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_role_names()
|
||||
{
|
||||
vpn-router-role --json list "$1" 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(role["name"] for role in json.load(sys.stdin)["roles"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_role()
|
||||
{
|
||||
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 resource=''
|
||||
for ((i = 1; i < cword; i++)); do
|
||||
if [[ -z $command ]]; then
|
||||
[[ ${words[i]} != -* ]] && command=${words[i]}
|
||||
continue
|
||||
fi
|
||||
case ${words[i]} in
|
||||
--description | --group | --account) ((i++)) ;;
|
||||
-*) ;;
|
||||
*)
|
||||
((args == 0)) && resource=${words[i]}
|
||||
((args++))
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z $command ]]; then
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W 'list add remove member-add member-remove' -- "$cur"))
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
case $prev in
|
||||
--group)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_role_groups)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--account)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_role_accounts)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--description)
|
||||
return
|
||||
;;
|
||||
esac
|
||||
$split && return
|
||||
|
||||
if [[ $cur == -* ]]; then
|
||||
local options='--help'
|
||||
case $command in
|
||||
add) options+=' --description' ;;
|
||||
member-add | member-remove) options+=' --group --account' ;;
|
||||
esac
|
||||
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
# The resource, then the role, which add takes new.
|
||||
if ((args == 0)); then
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_role_resources)" -- "$cur"))
|
||||
elif ((args == 1)) && [[ $command != @(list|add) ]]; then
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_role_names "$resource")" -- "$cur"))
|
||||
fi
|
||||
} &&
|
||||
complete -F _vpn_router_role vpn-router-role
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -13,6 +13,14 @@ _vpn_router_user_groups()
|
||||
python3 -c 'import json, sys; print("\n".join(group["name"] for group in json.load(sys.stdin)["groups"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
# The scopes of the resources, <resource>:<scope>, read as root.
|
||||
_vpn_router_user_scopes()
|
||||
{
|
||||
vpn-router-user --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(user["name"] + ":" + scope for user in json.load(sys.stdin)["users"]
|
||||
for scope in user.get("resource_scopes", [])))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_user()
|
||||
{
|
||||
local cur prev words cword split
|
||||
@@ -30,7 +38,8 @@ _vpn_router_user()
|
||||
fi
|
||||
case ${words[i]} in
|
||||
--first-name | --last-name | --email | --display-name | --description | --redirect-uri | \
|
||||
--post-logout-redirect-uri | --allowed-group | --account) ((i++)) ;;
|
||||
--post-logout-redirect-uri | --allowed-group | --account | --grant | --public-client | --client-scope | \
|
||||
--resource-scope) ((i++)) ;;
|
||||
-*) ;;
|
||||
*) ((args++)) ;;
|
||||
esac
|
||||
@@ -50,6 +59,21 @@ _vpn_router_user()
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_user_groups)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--grant)
|
||||
COMPREPLY=($(compgen -W 'client_credentials device_code' -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--public-client)
|
||||
COMPREPLY=($(compgen -W 'yes no' -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--client-scope)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_user_scopes)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--resource-scope)
|
||||
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"))
|
||||
@@ -65,9 +89,9 @@ _vpn_router_user()
|
||||
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' ;;
|
||||
--post-logout-redirect-uri --allowed-group --grant --public-client --client-scope --resource-scope' ;;
|
||||
update) options+=' --first-name --last-name --email --display-name --description --redirect-uri
|
||||
--post-logout-redirect-uri --allowed-group' ;;
|
||||
--post-logout-redirect-uri --allowed-group --grant --public-client --client-scope --resource-scope' ;;
|
||||
console) options+=' --posix --account --off' ;;
|
||||
esac
|
||||
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
-- 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.
|
||||
-- Codes, tokens, device codes 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.
|
||||
-- An authorization code, valid until it is exchanged for tokens or expires; resource is the resource the tokens are
|
||||
-- for, null for the client itself.
|
||||
CREATE TABLE IF NOT EXISTS oidc_codes (
|
||||
hash text PRIMARY KEY,
|
||||
client_id text NOT NULL,
|
||||
@@ -16,10 +18,13 @@ CREATE TABLE IF NOT EXISTS oidc_codes (
|
||||
code_challenge_method text NOT NULL,
|
||||
auth_time timestamptz NOT NULL,
|
||||
amr text[] NOT NULL,
|
||||
expires timestamptz NOT NULL
|
||||
expires timestamptz NOT NULL,
|
||||
resource text
|
||||
);
|
||||
ALTER TABLE oidc_codes ADD COLUMN IF NOT EXISTS resource text;
|
||||
|
||||
-- An access token or a refresh token (kind) a client was issued for a user.
|
||||
-- An access token or a refresh token (kind) a client was issued for a user, or for itself as a service account with
|
||||
-- client credentials; resource as in oidc_codes.
|
||||
CREATE TABLE IF NOT EXISTS oidc_tokens (
|
||||
hash text PRIMARY KEY,
|
||||
kind text NOT NULL CHECK (kind IN ('access', 'refresh')),
|
||||
@@ -28,9 +33,30 @@ CREATE TABLE IF NOT EXISTS oidc_tokens (
|
||||
scopes text[] NOT NULL,
|
||||
auth_time timestamptz NOT NULL,
|
||||
amr text[] NOT NULL,
|
||||
expires timestamptz NOT NULL,
|
||||
resource text
|
||||
);
|
||||
ALTER TABLE oidc_tokens ADD COLUMN IF NOT EXISTS resource text;
|
||||
CREATE INDEX IF NOT EXISTS oidc_tokens_name ON oidc_tokens (name);
|
||||
|
||||
-- A device code of the device authorization grant and its user code (user_hash): pending until the user allows or
|
||||
-- denies it, then the user, when and how they signed in; poll_seconds is the least time between two polls, polled
|
||||
-- the last poll.
|
||||
CREATE TABLE IF NOT EXISTS oidc_devices (
|
||||
hash text PRIMARY KEY,
|
||||
user_hash text NOT NULL UNIQUE,
|
||||
client_id text NOT NULL,
|
||||
scopes text[] NOT NULL,
|
||||
resource text,
|
||||
state text NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'allowed', 'denied')),
|
||||
name text,
|
||||
auth_time timestamptz,
|
||||
amr text[],
|
||||
poll_seconds integer NOT NULL,
|
||||
polled timestamptz,
|
||||
expires timestamptz NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS oidc_tokens_name ON oidc_tokens (name);
|
||||
CREATE INDEX IF NOT EXISTS oidc_devices_name ON oidc_devices (name);
|
||||
|
||||
-- A user's sign-in at the OpenID Provider, which the session cookie names.
|
||||
CREATE TABLE IF NOT EXISTS oidc_sessions (
|
||||
@@ -41,22 +67,30 @@ CREATE TABLE IF NOT EXISTS oidc_sessions (
|
||||
expires timestamptz NOT NULL
|
||||
);
|
||||
|
||||
-- The functions and procedures whose parameters or results differ from those of the same name.
|
||||
DROP PROCEDURE IF EXISTS oidc_code_save(text, text, text, text, text[], text, text, text, timestamptz, text[], integer);
|
||||
DROP FUNCTION IF EXISTS oidc_code_get(text);
|
||||
DROP PROCEDURE IF EXISTS oidc_token_save(text, text, text, text, text[], timestamptz, text[], integer);
|
||||
DROP FUNCTION IF EXISTS oidc_token_get(text, text);
|
||||
|
||||
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)
|
||||
p_resource 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));
|
||||
INSERT INTO oidc_codes (hash, client_id, name, redirect_uri, scopes, nonce, code_challenge, code_challenge_method,
|
||||
auth_time, amr, resource, expires)
|
||||
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, p_resource, 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[])
|
||||
code_challenge_method text, auth_time timestamptz, amr text[], resource text)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT client_id, name, redirect_uri, scopes, nonce, code_challenge, code_challenge_method, auth_time, amr
|
||||
SELECT client_id, name, redirect_uri, scopes, nonce, code_challenge, code_challenge_method, auth_time, amr, resource
|
||||
FROM oidc_codes WHERE hash = p_hash AND expires > now();
|
||||
$$;
|
||||
|
||||
@@ -66,18 +100,19 @@ LANGUAGE sql AS $$
|
||||
$$;
|
||||
|
||||
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)
|
||||
p_auth_time timestamptz, p_amr text[], p_resource 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));
|
||||
INSERT INTO oidc_tokens (hash, kind, client_id, name, scopes, auth_time, amr, resource, expires)
|
||||
VALUES (p_hash, p_kind, p_client_id, p_name, p_scopes, p_auth_time, p_amr, p_resource,
|
||||
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[])
|
||||
RETURNS TABLE (client_id text, name text, scopes text[], auth_time timestamptz, amr text[], resource text)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT client_id, name, scopes, auth_time, amr FROM oidc_tokens
|
||||
SELECT client_id, name, scopes, auth_time, amr, resource FROM oidc_tokens
|
||||
WHERE hash = p_hash AND kind = p_kind AND expires > now();
|
||||
$$;
|
||||
|
||||
@@ -86,6 +121,59 @@ LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_tokens WHERE hash = p_hash;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_device_save(p_hash text, p_user_hash text, p_client_id text, p_scopes text[],
|
||||
p_resource text, p_poll_seconds integer, p_seconds integer)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_devices WHERE expires < now();
|
||||
INSERT INTO oidc_devices (hash, user_hash, client_id, scopes, resource, poll_seconds, expires)
|
||||
VALUES (p_hash, p_user_hash, p_client_id, p_scopes, p_resource, p_poll_seconds,
|
||||
now() + make_interval(secs => p_seconds));
|
||||
$$;
|
||||
|
||||
-- The unexpired device code of the user code while it waits for the user.
|
||||
CREATE OR REPLACE FUNCTION oidc_device_find(p_user_hash text)
|
||||
RETURNS TABLE (client_id text, scopes text[], resource text)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT client_id, scopes, resource FROM oidc_devices
|
||||
WHERE user_hash = p_user_hash AND state = 'pending' AND expires > now();
|
||||
$$;
|
||||
|
||||
-- Allow or deny the waiting device code of the user code as the user; whether there was one.
|
||||
CREATE OR REPLACE FUNCTION oidc_device_decide(p_user_hash text, p_name text, p_auth_time timestamptz, p_amr text[],
|
||||
p_allowed boolean)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql AS $$
|
||||
WITH decided AS (
|
||||
UPDATE oidc_devices SET state = CASE WHEN p_allowed THEN 'allowed' ELSE 'denied' END, name = p_name,
|
||||
auth_time = p_auth_time, amr = p_amr
|
||||
WHERE user_hash = p_user_hash AND state = 'pending' AND expires > now()
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT count(*) > 0 FROM decided;
|
||||
$$;
|
||||
|
||||
-- A poll of the device code: the row, whether it has expired, and whether it came sooner than poll_seconds after the
|
||||
-- last one, which adds 5 seconds to poll_seconds.
|
||||
CREATE OR REPLACE FUNCTION oidc_device_poll(p_hash text)
|
||||
RETURNS TABLE (client_id text, scopes text[], resource text, state text, name text, auth_time timestamptz, amr text[],
|
||||
expired boolean, too_soon boolean)
|
||||
LANGUAGE sql AS $$
|
||||
UPDATE oidc_devices AS device
|
||||
SET polled = now(),
|
||||
poll_seconds = device.poll_seconds +
|
||||
CASE WHEN device.polled + make_interval(secs => device.poll_seconds) > now() THEN 5 ELSE 0 END
|
||||
FROM (SELECT hash, polled, poll_seconds FROM oidc_devices WHERE hash = p_hash) AS previous
|
||||
WHERE device.hash = previous.hash
|
||||
RETURNING device.client_id, device.scopes, device.resource, device.state, device.name, device.auth_time, device.amr,
|
||||
device.expires <= now(),
|
||||
previous.polled IS NOT NULL AND previous.polled + make_interval(secs => previous.poll_seconds) > now();
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_device_delete(p_hash text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_devices 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();
|
||||
@@ -104,10 +192,11 @@ LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_sessions WHERE hash = p_hash;
|
||||
$$;
|
||||
|
||||
-- Remove every code, token and session of the user.
|
||||
-- Remove every code, token, device code and session of the user or service account.
|
||||
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_devices WHERE name = p_name;
|
||||
DELETE FROM oidc_sessions WHERE name = p_name;
|
||||
$$;
|
||||
@@ -15,6 +15,8 @@ input { box-sizing: border-box; width: 100%; padding: 0.5rem; border: 1px solid
|
||||
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; }
|
||||
ul { margin: 0 0 1rem; padding-left: 1.25rem; color: #4b5563; }
|
||||
#user_code { text-transform: uppercase; letter-spacing: 0.15em; font-family: ui-monospace, monospace; }
|
||||
.issuer { font-size: 0.75rem; color: #6b7280; margin: 1.5rem 0 0; }
|
||||
</style>
|
||||
</head>
|
||||
@@ -27,25 +29,52 @@ button.secondary { background: #fff; color: #1d4ed8; border: 1px solid #1d4ed8;
|
||||
{%- if error %}
|
||||
<div class="error" role="alert">{{ error }}</div>
|
||||
{%- endif %}
|
||||
{%- if form %}
|
||||
<form method="post" action="/oidc/login">
|
||||
{%- if items %}
|
||||
<ul>
|
||||
{%- for item in items %}
|
||||
<li>{{ item }}</li>
|
||||
{%- endfor %}
|
||||
</ul>
|
||||
{%- endif %}
|
||||
{%- if decide %}
|
||||
<form method="post" action="/oidc/device/decide">
|
||||
<input type="hidden" name="user_code" value="{{ user_code }}">
|
||||
<button type="submit" name="decision" value="allow">Allow</button>
|
||||
<button class="secondary" type="submit" name="decision" value="deny">Deny</button>
|
||||
</form>
|
||||
{%- endif %}
|
||||
{%- if form or device %}
|
||||
<form id="sign-in" method="post" action="{{ action }}">
|
||||
{%- if device %}
|
||||
<label for="user_code">Code shown on the device</label>
|
||||
<input id="user_code" name="user_code" value="{{ user_code }}" autocapitalize="characters" autocomplete="off" spellcheck="false" required{% if not form %} autofocus{% endif %}>
|
||||
{%- else %}
|
||||
<input type="hidden" name="request" value="{{ request }}">
|
||||
{%- endif %}
|
||||
{%- if form %}
|
||||
<label for="name">Name</label>
|
||||
<input id="name" name="name" value="{{ name }}" autocomplete="username webauthn" autocapitalize="none" spellcheck="false" required autofocus>
|
||||
<input id="name" name="name" value="{{ name }}" autocomplete="username webauthn" autocapitalize="none" spellcheck="false" required{% if not device %} autofocus{% endif %}>
|
||||
<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>
|
||||
{%- else %}
|
||||
<button type="submit">Continue</button>
|
||||
{%- endif %}
|
||||
</form>
|
||||
<form id="passkey-form" method="post" action="/oidc/login" hidden>
|
||||
{%- endif %}
|
||||
{%- if form %}
|
||||
<form id="passkey-form" method="post" action="{{ action }}" hidden>
|
||||
<input type="hidden" name="request" value="{{ request }}">
|
||||
<input type="hidden" name="user_code">
|
||||
<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.
|
||||
// A passkey sign-in begins at /oidc/passkey and posts the browser's answer to the form's action with the authorization
|
||||
// request, or with the user code the sign-in form holds.
|
||||
(function () {
|
||||
var button = document.getElementById('passkey');
|
||||
if (!window.PublicKeyCredential || !PublicKeyCredential.parseRequestOptionsFromJSON) {
|
||||
@@ -53,11 +82,16 @@ button.secondary { background: #fff; color: #1d4ed8; border: 1px solid #1d4ed8;
|
||||
}
|
||||
button.hidden = false;
|
||||
button.addEventListener('click', async function () {
|
||||
var code = document.getElementById('user_code');
|
||||
if (code && !code.reportValidity()) {
|
||||
return;
|
||||
}
|
||||
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.user_code.value = code ? code.value : '';
|
||||
form.elements.passkey_id.value = begun.id;
|
||||
form.elements.credential.value = JSON.stringify(credential.toJSON());
|
||||
form.submit();
|
||||
|
||||
Reference in new issue
Block a user