feature: users sign in to the Router Manager with passkeys, and console login needs a TOTP token, so a user without one is refused the shell, SSH and sudo.
Release / release (push) Successful in 14s
Release / release (push) Successful in 14s
This commit is contained in:
1 parent
91cc64d6a6
commit
90db6d46a2
10 files changed
+408
-62
No files matched your search
@@ -945,12 +945,18 @@ so slapd's otp overlay and password policy apply as for the Router Manager: the
|
|||||||
by the code, and the lockout. Members of `vpn-router-console` get sudo with that password. SSH
|
by the code, and the lockout. Members of `vpn-router-console` get sudo with that password. SSH
|
||||||
accepts it only while `ssh.ldap_login` is set, in a `Match Group` drop-in of sshd. Switching console
|
accepts it only while `ssh.ldap_login` is set, in a `Match Group` drop-in of sshd. Switching console
|
||||||
login off keeps the `posixAccount`, hidden by nslcd's filter, so the uid and the home stay the
|
login off keeps the `posixAccount`, hidden by nslcd's filter, so the uid and the home stay the
|
||||||
user's; leaving `admins` switches it off.
|
user's; leaving `admins` switches it off. A console reaches root through sudo, so a password alone
|
||||||
|
must not open one: console login needs a TOTP token. Switching it on is refused without one, and
|
||||||
|
the token is checked at use rather than only when it is switched on, because an administrator can
|
||||||
|
remove it later: the shell's ticket and every minute of an open shell check it, nslcd's
|
||||||
|
`pam_authz_search` requires `oathTOTPUser`, and removing the token ends the sessions of the user's
|
||||||
|
own POSIX account.
|
||||||
|
|
||||||
Consequences: Each administrator's shell, SSH session and sudo run as the administrator, logged
|
Consequences: Each administrator's shell, SSH session and sudo run as the administrator, logged
|
||||||
under the administrator's name. The directory becomes part of the system's name service through
|
under the administrator's name. The directory becomes part of the system's name service through
|
||||||
nslcd, and the system knows only the accounts with console login on. A user enrolled for TOTP types
|
nslcd, and the system knows only the accounts with console login on. The user types the code
|
||||||
the code after the password at the SSH and sudo prompts. Homes are created at the first login and
|
after the password at the SSH and sudo prompts. An administrator whose console login was switched
|
||||||
|
on before the token was required is refused until enrolling. Homes are created at the first login and
|
||||||
are not removed with the user. A password is changed through the directory, not with `passwd`.
|
are not removed with the user. A password is changed through the directory, not with `passwd`.
|
||||||
|
|
||||||
## ADR-35: WireGuard peers are the devices of the VPN users, kept in the directory
|
## ADR-35: WireGuard peers are the devices of the VPN users, kept in the directory
|
||||||
@@ -972,3 +978,29 @@ endpoint is, choosing the user for each.
|
|||||||
Consequences: Peers follow their users: disabling a user drops the user's devices, and the
|
Consequences: Peers follow their users: disabling a user drops the user's devices, and the
|
||||||
directory's backup holds them. A peer cannot exist without a VPN user. A change restarts
|
directory's backup holds them. A peer cannot exist without a VPN user. A change restarts
|
||||||
`wg-quick@wg0` through setup, which briefly interrupts the other peers.
|
`wg-quick@wg0` through setup, which briefly interrupts the other peers.
|
||||||
|
|
||||||
|
## ADR-36: Passkeys sign in to the Router Manager only, verified by the router with python3-fido2
|
||||||
|
|
||||||
|
Context: Typing the password and a TOTP code at every sign-in to the Router Manager is tedious.
|
||||||
|
WebAuthn needs a browser, HTTPS and a domain name; OpenVPN, IKEv2, WireGuard, SSH and sudo have no
|
||||||
|
browser, and the sudo PAM module for security keys needs the key at the router. The router signs
|
||||||
|
users in (`/auth`) and keeps them in its directory. The standard library cannot read CBOR or COSE
|
||||||
|
keys; Ubuntu packages Yubico's python-fido2 in noble (1.1) and resolute (2.0), whose server API is
|
||||||
|
the same once the JSON mapping of WebAuthn Level 3 is switched on, which 2.0 always uses.
|
||||||
|
|
||||||
|
Decision: A passkey is a discoverable credential with user verification of the relying party
|
||||||
|
`wan.local_fqdn`, with the user name as its user handle, so the sign-in needs no name. The router
|
||||||
|
registers and verifies passkeys with python3-fido2 in `vpnrouter_passkeys`; the origin must be
|
||||||
|
HTTPS on that name, which python-fido2 checks. Each passkey is a JSON value of the multi-valued
|
||||||
|
`vpnRouterPasskey` on the user's entry, with the public key only. Registration needs the current
|
||||||
|
password and code, so a session alone cannot add a lasting way in; removal needs neither. A
|
||||||
|
passkey sign-in gives the same tokens as `/auth`; as it binds no password, the router checks that
|
||||||
|
the user is enabled, not locked and not a service account. The begun steps are kept in the web
|
||||||
|
service's memory for 2 minutes. The browser converts the options and answers with
|
||||||
|
`PublicKeyCredential.parseCreationOptionsFromJSON`, `parseRequestOptionsFromJSON` and `toJSON`.
|
||||||
|
|
||||||
|
Consequences: Passkeys are a convenience for the Router Manager; the password and code stay for
|
||||||
|
everything else, the shell included. A changed `wan.local_fqdn` orphans every passkey. The
|
||||||
|
Router Manager on another name, or in development on `localhost`, cannot use them. Failed passkey
|
||||||
|
sign-ins do not count towards the directory's lockout, but the sign-in calls are rate limited.
|
||||||
|
Browsers without the JSON methods of WebAuthn Level 3 cannot add or use passkeys.
|
||||||
@@ -700,7 +700,7 @@ vpn-router-user otp-remove alice
|
|||||||
|
|
||||||
| Command | Effect |
|
| Command | Effect |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `list` | The users with their kind, names and e-mail addresses, whether each is enabled or locked, has a certificate and is enrolled for TOTP |
|
| `list` | The users with their kind, names and e-mail addresses, whether each is enabled or locked, has a certificate and is enrolled for TOTP, the number of passkeys and the console login |
|
||||||
| `add <name> --first-name F --last-name L --email E` | Adds a VPN user and issues the certificate |
|
| `add <name> --first-name F --last-name L --email E` | Adds a VPN user and issues the certificate |
|
||||||
| `add <name> [--email E]` | Adds a Manager-only account, with the e-mail address when given; one name without the other two options is refused |
|
| `add <name> [--email E]` | Adds a Manager-only account, with the e-mail address when given; one name without the other two options is refused |
|
||||||
| `add <name> --service [--display-name N] [--description D] [--email E]` | Adds a service account, with its display name, description and e-mail address when given; the names are refused |
|
| `add <name> --service [--display-name N] [--description D] [--email E]` | Adds a service account, with its display name, description and e-mail address when given; the names are refused |
|
||||||
@@ -713,8 +713,9 @@ vpn-router-user otp-remove alice
|
|||||||
| `profile <name>` | The OpenVPN client profile, with the CA, the certificate and the key inline |
|
| `profile <name>` | The OpenVPN client profile, with the CA, the certificate and the key inline |
|
||||||
| `otp-new <name>` | A new TOTP secret and its `otpauth://` URI, issued by `wan.local_fqdn`, for an authenticator app; nothing is stored |
|
| `otp-new <name>` | A new TOTP secret and its `otpauth://` URI, issued by `wan.local_fqdn`, for an authenticator app; nothing is stored |
|
||||||
| `otp-enrol <name> <code>` | Stores the secret read from standard input when the code is a current code of it, replacing a secret the user has |
|
| `otp-enrol <name> <code>` | Stores the secret read from standard input when the code is a current code of it, replacing a secret the user has |
|
||||||
| `otp-remove <name>` | Removes the user's TOTP secret |
|
| `otp-remove <name>` | Removes the user's TOTP secret and ends the sessions of the user's own POSIX account |
|
||||||
| `console <name> (--posix \| --account LOCAL \| --off)` | Switches the console login of a member of `admins` on, as the user's own POSIX account or a local account, or off (see Console login and SSH) |
|
| `console <name> (--posix \| --account LOCAL \| --off)` | Switches the console login of a member of `admins` enrolled for TOTP on, as the user's own POSIX account or a local account, or off (see Console login and SSH) |
|
||||||
|
| `passkeys-remove <name>` | Removes all the user's passkeys (see Passkeys) |
|
||||||
|
|
||||||
A user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter; a
|
A user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter; a
|
||||||
password has 8 to 128 characters; a first or last name has 1 to 64 printable characters; an
|
password has 8 to 128 characters; a first or last name has 1 to 64 printable characters; an
|
||||||
@@ -737,9 +738,12 @@ certificates only, so `disable` does not affect it; `remove` revokes the certifi
|
|||||||
|
|
||||||
### Console login and SSH
|
### Console login and SSH
|
||||||
|
|
||||||
A member of `admins` may have console login: the Router Manager's Shell page (see The shell),
|
A member of `admins` enrolled for TOTP may have console login: the Router Manager's Shell page
|
||||||
and, for the user's own POSIX account, SSH and sudo. Only root on the router switches it, with
|
(see The shell), and, for the user's own POSIX account, SSH and sudo. Only root on the router
|
||||||
`vpn-router-user console` or a token of `vpn-router-token`, as for the group `admins`:
|
switches it, with `vpn-router-user console` or a token of `vpn-router-token`, as for the group
|
||||||
|
`admins`. Switching it on is refused without the enrolment, and a user whose enrolment is removed
|
||||||
|
is refused the shell, SSH and sudo until enrolling again; the user's open shells end within a
|
||||||
|
minute, and removing the enrolment ends the sessions of the user's own POSIX account:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
vpn-router-user console alice --posix
|
vpn-router-user console alice --posix
|
||||||
@@ -751,9 +755,8 @@ vpn-config-edit set ssh.ldap_login true
|
|||||||
- **Own POSIX account** (`--posix`): the user's entry becomes a `posixAccount` of the directory
|
- **Own POSIX account** (`--posix`): the user's entry becomes a `posixAccount` of the directory
|
||||||
with the user's name, a uid from 20000 up, the local group `vpn-router-console` as its group,
|
with the user's name, a uid from 20000 up, the local group `vpn-router-console` as its group,
|
||||||
`/home/<name>` and `/bin/bash`. `nslcd` makes it known to the system over slapd's local socket
|
`/home/<name>` and `/bin/bash`. `nslcd` makes it known to the system over slapd's local socket
|
||||||
and checks its password by binding as the user, so a user enrolled for TOTP types the password
|
and checks its password by binding as the user, so the user types the password followed by a
|
||||||
followed by a current code, failed logins count towards the lockout, and a disabled user cannot
|
current code, failed logins count towards the lockout, and a disabled user cannot sign in. The home is created at the first login and kept when the user is removed. Members of
|
||||||
sign in. The home is created at the first login and kept when the user is removed. Members of
|
|
||||||
`vpn-router-console` may run any command with sudo, with the same password. A local account of
|
`vpn-router-console` may run any command with sudo, with the same password. A local account of
|
||||||
the same name is refused.
|
the same name is refused.
|
||||||
- **Local account** (`--account`): the console runs as an existing local account with a uid from
|
- **Local account** (`--account`): the console runs as an existing local account with a uid from
|
||||||
@@ -767,6 +770,26 @@ While `ssh.ldap_login` is `true`, setup lets sshd accept the password of the mem
|
|||||||
settings. A password change is made in the Router Manager or with `vpn-router-user passwd`, not
|
settings. A password change is made in the Router Manager or with `vpn-router-user passwd`, not
|
||||||
with `passwd`.
|
with `passwd`.
|
||||||
|
|
||||||
|
### Passkeys
|
||||||
|
|
||||||
|
A user who is not a service account may sign in to the Router Manager with a passkey instead of
|
||||||
|
the password and code. Passkeys are for that sign-in only: OpenVPN, the shell, SSH and sudo keep
|
||||||
|
the password and code. A passkey belongs to `wan.local_fqdn`, so the browser offers it on the
|
||||||
|
Router Manager's HTTPS address on that name, and passkeys need the name set; a passkey made
|
||||||
|
before the name changes is no longer offered. The authenticator checks the user (a PIN or a
|
||||||
|
fingerprint), and the passkey is kept by it or by the user's password manager.
|
||||||
|
|
||||||
|
A user adds a passkey on the Router Manager's Account page with the current password and code,
|
||||||
|
and removes it there. Each passkey is a value of `vpnRouterPasskey` on the user's entry, with its
|
||||||
|
public key, label and the time it was added; a user has at most 10. An administrator removes all
|
||||||
|
passkeys of a user on the Users page, through `/users`, or with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vpn-router-user passkeys-remove alice
|
||||||
|
```
|
||||||
|
|
||||||
|
A disabled or locked user is refused a passkey sign-in too.
|
||||||
|
|
||||||
### Managing groups
|
### Managing groups
|
||||||
|
|
||||||
`vpn-router-group` manages the groups. A group has a name, with the rules of a user name, an
|
`vpn-router-group` manages the groups. A group has a name, with the rules of a user name, an
|
||||||
@@ -937,7 +960,9 @@ vpn-router-token --json --days 30
|
|||||||
|
|
||||||
| Request | Body | Answer |
|
| Request | Body | Answer |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `POST /auth` | `{"name", "password", "code"}`; `code` only for a user enrolled for TOTP | the tokens |
|
| `POST /auth` | `{"name", "password", "code"}`; `code` only for a user enrolled for TOTP | the tokens and `"name"` |
|
||||||
|
| `POST /auth/passkey` | `{}` | `{"id", "options"}`: a pending sign-in, valid 2 minutes, and the options of `navigator.credentials.get` in their JSON form |
|
||||||
|
| `POST /auth/passkey/complete` | `{"id", "credential"}`, `credential` the browser's answer as its `toJSON()` gives it | the tokens and `"name"`, the user whose passkey signed |
|
||||||
| `POST /auth/refresh` | `{"refresh_token"}` | new tokens; the refresh token given is used up |
|
| `POST /auth/refresh` | `{"refresh_token"}` | new tokens; the refresh token given is used up |
|
||||||
| `POST /auth/logout` | `{"refresh_token"}` | `{"revoked": true}`, or `false` for a token not known |
|
| `POST /auth/logout` | `{"refresh_token"}` | `{"revoked": true}`, or `false` for a token not known |
|
||||||
|
|
||||||
@@ -946,7 +971,9 @@ The tokens are `{"access_token", "token_type": "Bearer", "expires_in": 300, "ref
|
|||||||
get 401 with the same message; a refresh token is refused once its user is disabled, locked or
|
get 401 with the same message; a refresh token is refused once its user is disabled, locked or
|
||||||
removed. Changing a user's password, disabling, unlocking or removing the user revokes the
|
removed. Changing a user's password, disabling, unlocking or removing the user revokes the
|
||||||
user's refresh tokens. Five failed logins lock the account for 15 minutes (see the user
|
user's refresh tokens. Five failed logins lock the account for 15 minutes (see the user
|
||||||
directory). `/auth` and `/auth/refresh` take at most 10 calls a minute from one client address,
|
directory). A passkey that does not sign, is not the user's, or of a disabled, locked or service
|
||||||
|
account gets 401 too (see Passkeys). `/auth`, `/auth/passkey/complete` and `/auth/refresh` take
|
||||||
|
at most 10 calls a minute from one client address,
|
||||||
and answer 429 with `Retry-After` beyond that; for a call from one of `web.trusted_proxies`, by
|
and answer 429 with `Retry-After` beyond that; for a call from one of `web.trusted_proxies`, by
|
||||||
default the address the call came in on, the client address is the last entry of
|
default the address the call came in on, the client address is the last entry of
|
||||||
`X-Forwarded-For`.
|
`X-Forwarded-For`.
|
||||||
@@ -970,8 +997,8 @@ every service and tunnel while the public status page shows less.
|
|||||||
|
|
||||||
### The shell
|
### The shell
|
||||||
|
|
||||||
While `web.shell` is `true`, `/shell` gives a member of `admins` whose console login is on (see
|
While `web.shell` is `true`, `/shell` gives a member of `admins` whose console login is on and
|
||||||
Console login and SSH) a login shell on the router, as the user's console account, for the
|
who is enrolled for TOTP (see Console login and SSH) a login shell on the router, as the user's console account, for the
|
||||||
Router Manager's Shell page. It is off by default and switched on and off on the router only:
|
Router Manager's Shell page. It is off by default and switched on and off on the router only:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -981,19 +1008,20 @@ vpn-config-edit set web.shell true
|
|||||||
|
|
||||||
| Request | Body | Answer |
|
| Request | Body | Answer |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `POST /shell` | `{"password", "code"}`; `code` only for a user enrolled for TOTP; `{}` within `web.shell_auth_timeout` | `{"ticket", "expires_in": 30}` |
|
| `POST /shell` | `{"password", "code"}`; `{}` within `web.shell_auth_timeout` | `{"ticket", "expires_in": 30}` |
|
||||||
| `GET /shell?ticket=<ticket>` | a WebSocket handshake | the terminal |
|
| `GET /shell?ticket=<ticket>` | a WebSocket handshake | the terminal |
|
||||||
|
|
||||||
`POST /shell` needs an access token of `/auth`, not one of `vpn-router-token`, and signs its user
|
`POST /shell` needs an access token of `/auth`, not one of `vpn-router-token`, and signs its user
|
||||||
in again: a wrong password or code gets 403 and counts as a failed login; it gets 403 too while
|
in again: a wrong password or code gets 403 and counts as a failed login; it gets 403 too while
|
||||||
`web.shell` is `false` or the user's console login is off. Without a password it gives a ticket
|
`web.shell` is `false`, the user's console login is off or the user is not enrolled for TOTP. Without a password it gives a ticket
|
||||||
while the user's last sign-in of `POST /shell` or keystroke in a shell is less than
|
while the user's last sign-in of `POST /shell` or keystroke in a shell is less than
|
||||||
`web.shell_auth_timeout` minutes ago, and `{"ticket": null}` otherwise. The ticket opens one
|
`web.shell_auth_timeout` minutes ago, and `{"ticket": null}` otherwise. The ticket opens one
|
||||||
shell for the same user within 30 seconds. Over the WebSocket, binary messages of the client
|
shell for the same user within 30 seconds. Over the WebSocket, binary messages of the client
|
||||||
are keystrokes, a text message `{"cols", "rows"}` sets the window size, and binary messages of
|
are keystrokes, a text message `{"cols", "rows"}` sets the window size, and binary messages of
|
||||||
the router are the terminal's output. The shell is a PAM login session of the console account,
|
the router are the terminal's output. The shell is a PAM login session of the console account,
|
||||||
run by `systemd-run` in a transient unit `vpn-router-shell-<id>`; sudo works as for SSH. It is closed after `web.shell_idle_minutes` without a keystroke, when the shell
|
run by `systemd-run` in a transient unit `vpn-router-shell-<id>`; sudo works as for SSH. It is closed after `web.shell_idle_minutes` without a keystroke, when the shell
|
||||||
exits, or when the connection closes; the router then sends a close frame with the reason. The
|
exits, when the connection closes, or within a minute of the user's console login being switched
|
||||||
|
off or the TOTP enrolment removed; the router then sends a close frame with the reason. The
|
||||||
journal of `vpn-router-web` records who opened each shell, from which address, in which unit,
|
journal of `vpn-router-web` records who opened each shell, from which address, in which unit,
|
||||||
when it ended and why, and each refused ticket or shell with the reason.
|
when it ended and why, and each refused ticket or shell with the reason.
|
||||||
|
|
||||||
@@ -1028,7 +1056,7 @@ and answer bodies are JSON; a request body is at most 65536 bytes.
|
|||||||
|
|
||||||
| Request | Body | Answer |
|
| Request | Body | Answer |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `GET /users` | | `{"users": [{"name", "manager_only", "service", "internal", "enabled", "locked", "certificate", "otp", "console", "wireguard_peers", "first_name", "last_name", "email"}, ...], "wireguard": {"enabled", "address"}}`; `console` is `null` or `{"enabled", "account", "local", "uid"}`, `wireguard_peers` a list of `{"public_key", "ips"}`, and `wireguard` the endpoint's settings, whose network the peers' addresses come from |
|
| `GET /users` | | `{"users": [{"name", "manager_only", "service", "internal", "enabled", "locked", "certificate", "otp", "console", "wireguard_peers", "passkeys", "first_name", "last_name", "email"}, ...], "wireguard": {"enabled", "address"}}`; `console` is `null` or `{"enabled", "account", "local", "uid"}`, `wireguard_peers` a list of `{"public_key", "ips"}`, `passkeys` a list of `{"id", "label", "created"}`, and `wireguard` the endpoint's settings, whose network the peers' addresses come from |
|
||||||
| `POST /users` | `{"name", "password", "first_name", "last_name", "email"}` for a VPN user; `{"name", "password", "email"?}` for a Manager-only account | 201 and the user |
|
| `POST /users` | `{"name", "password", "first_name", "last_name", "email"}` for a VPN user; `{"name", "password", "email"?}` for a Manager-only account | 201 and the user |
|
||||||
| `PUT /users/<name>` | any of `{"first_name", "last_name", "email", "wireguard_peers"}`; `wireguard_peers`, `[{"public_key", "ips"}]`, replaces a VPN user's WireGuard peers (see Configuration); for a Manager-only account only `{"email"}`, `""` removing it | the user |
|
| `PUT /users/<name>` | any of `{"first_name", "last_name", "email", "wireguard_peers"}`; `wireguard_peers`, `[{"public_key", "ips"}]`, replaces a VPN user's WireGuard peers (see Configuration); for a Manager-only account only `{"email"}`, `""` removing it | the user |
|
||||||
| `PUT /users/<name>/password` | `{"password": ...}` | the user |
|
| `PUT /users/<name>/password` | `{"password": ...}` | the user |
|
||||||
@@ -1040,6 +1068,7 @@ and answer bodies are JSON; a request body is at most 65536 bytes.
|
|||||||
| `POST /users/<name>/otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored |
|
| `POST /users/<name>/otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored |
|
||||||
| `PUT /users/<name>/otp` | `{"secret", "code"}` | the user, once the code matches the secret |
|
| `PUT /users/<name>/otp` | `{"secret", "code"}` | the user, once the code matches the secret |
|
||||||
| `DELETE /users/<name>/otp` | | the user |
|
| `DELETE /users/<name>/otp` | | the user |
|
||||||
|
| `DELETE /users/<name>/passkeys` | | the user, without passkeys |
|
||||||
| `PUT /users/<name>/console` | `{"enabled", "account"?}`; only with a token of `vpn-router-token` | the user |
|
| `PUT /users/<name>/console` | `{"enabled", "account"?}`; only with a token of `vpn-router-token` | the user |
|
||||||
|
|
||||||
A failure is `{"error": ...}` with 400 for an invalid request, 404 for a user that does not
|
A failure is `{"error": ...}` with 400 for an invalid request, 404 for a user that does not
|
||||||
@@ -1086,8 +1115,11 @@ router manages them, their display name and description included. A failure is o
|
|||||||
| `PUT /account/password` | `{"current_password", "current_code", "password"}` | the user |
|
| `PUT /account/password` | `{"current_password", "current_code", "password"}` | the user |
|
||||||
| `POST /account/otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored |
|
| `POST /account/otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored |
|
||||||
| `PUT /account/otp` | `{"current_password", "current_code", "secret", "code"}` | the user, once the code matches the secret |
|
| `PUT /account/otp` | `{"current_password", "current_code", "secret", "code"}` | the user, once the code matches the secret |
|
||||||
|
| `POST /account/passkeys` | `{"current_password", "current_code"}` | `{"id", "options"}`: a pending registration, valid 2 minutes, and the options of `navigator.credentials.create` in their JSON form |
|
||||||
|
| `PUT /account/passkeys` | `{"id", "credential", "label"?}`, `credential` the browser's answer as its `toJSON()` gives it; `label` at most 64 characters, `Passkey` without one | the user |
|
||||||
|
| `DELETE /account/passkeys/<id>` | | the user |
|
||||||
|
|
||||||
`current_code` is needed only for a user enrolled for TOTP. A wrong current password or code gets
|
`current_code` is needed only for a user enrolled for TOTP; removing a passkey needs neither. A wrong current password or code gets
|
||||||
403 and counts as a failed login; slapd accepts each code once, so a code used to sign in is not
|
403 and counts as a failed login; slapd accepts each code once, so a code used to sign in is not
|
||||||
accepted again, and a change right after signing in waits for the next code. A user enrols or
|
accepted again, and a change right after signing in waits for the next code. A user enrols or
|
||||||
replaces the enrolment; removing it is left to `/users` and `vpn-router-user`. Changing the
|
replaces the enrolment; removing it is left to `/users` and `vpn-router-user`. Changing the
|
||||||
|
|||||||
@@ -1,3 +1,27 @@
|
|||||||
|
vpn-router (0.26.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
feature: users sign in to the Router Manager with passkeys, and console login needs a TOTP token, so a user without one is refused the shell, SSH and sudo.
|
||||||
|
|
||||||
|
* Console login is switched on only for a user enrolled for TOTP. A user
|
||||||
|
whose console login is on without a token is refused POST /shell, the
|
||||||
|
shell's WebSocket, SSH and sudo; an open shell ends within a minute of
|
||||||
|
the token's removal or of console login being switched off, and
|
||||||
|
removing the token ends the sessions of the user's own POSIX account.
|
||||||
|
* POST /auth/passkey and /auth/passkey/complete sign a user in with a
|
||||||
|
passkey of wan.local_fqdn, giving the tokens of /auth; POST /auth also
|
||||||
|
answers the user's name.
|
||||||
|
* POST, PUT /account/passkeys and DELETE /account/passkeys/<id> add, with
|
||||||
|
the current password and code, and remove the user's passkeys, at most
|
||||||
|
10, kept in the new attribute vpnRouterPasskey; DELETE
|
||||||
|
/users/<name>/passkeys and vpn-router-user passkeys-remove remove all
|
||||||
|
passkeys of a user.
|
||||||
|
* Users have passkeys, [{"id", "label", "created"}], in /users and
|
||||||
|
/account; vpn-router-user list shows their number and a console login
|
||||||
|
refused for want of a TOTP token.
|
||||||
|
* The package depends on python3-fido2.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 08:00:00 +0100
|
||||||
|
|
||||||
vpn-router (0.25.4-1) unstable; urgency=medium
|
vpn-router (0.25.4-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: a shell opens without the password and code again while the user's last use of a shell is within web.shell_auth_timeout minutes.
|
fix: a shell opens without the password and code again while the user's last use of a shell is within web.shell_auth_timeout minutes.
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ Depends: ${misc:Depends},
|
|||||||
python3-jinja2,
|
python3-jinja2,
|
||||||
python3-ldap,
|
python3-ldap,
|
||||||
python3-pyotp,
|
python3-pyotp,
|
||||||
|
python3-fido2,
|
||||||
python3-psycopg,
|
python3-psycopg,
|
||||||
python3-psutil,
|
python3-psutil,
|
||||||
python3-prometheus-client,
|
python3-prometheus-client,
|
||||||
|
|||||||
@@ -587,7 +587,7 @@ def manage_console(cfg):
|
|||||||
f'base {vpnrouter_ldap.BASE}',
|
f'base {vpnrouter_ldap.BASE}',
|
||||||
'filter passwd (&(objectClass=posixAccount)(vpnRouterConsoleLogin=TRUE))',
|
'filter passwd (&(objectClass=posixAccount)(vpnRouterConsoleLogin=TRUE))',
|
||||||
f'nss_min_uid {vpnrouter_ldap.CONSOLE_UID_MIN}',
|
f'nss_min_uid {vpnrouter_ldap.CONSOLE_UID_MIN}',
|
||||||
'pam_authz_search (&(objectClass=posixAccount)(uid=$username)(vpnRouterConsoleLogin=TRUE))',
|
'pam_authz_search (&(objectClass=posixAccount)(objectClass=oathTOTPUser)(uid=$username)(vpnRouterConsoleLogin=TRUE))',
|
||||||
'pam_password_prohibit_message The password is changed in the Router Manager or with vpn-router-user.',
|
'pam_password_prohibit_message The password is changed in the Router Manager or with vpn-router-user.',
|
||||||
'',
|
'',
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ OVERLAYS = ('ppolicy', 'otp')
|
|||||||
#: description, the labels of a service account, and vpnRouterConsoleLogin and
|
#: description, the labels of a service account, and vpnRouterConsoleLogin and
|
||||||
#: vpnRouterConsoleAccount, whether the user may use a console and the local account it runs as,
|
#: vpnRouterConsoleAccount, whether the user may use a console and the local account it runs as,
|
||||||
#: without which it runs as the user's own posixAccount, and vpnRouterWireGuardPeer, each a
|
#: without which it runs as the user's own posixAccount, and vpnRouterWireGuardPeer, each a
|
||||||
#: WireGuard peer of the user as "<public key> <IP>[,<IP>...]". vpnRouterAuthority is a CA of the PKI: its
|
#: WireGuard peer of the user as "<public key> <IP>[,<IP>...]", and vpnRouterPasskey, each a
|
||||||
|
#: passkey of the user as JSON (vpnrouter_passkeys). vpnRouterAuthority is a CA of the PKI: its
|
||||||
#: certificate and CRL (cACertificate and certificateRevocationList of RFC 4523), its key, the
|
#: certificate and CRL (cACertificate and certificateRevocationList of RFC 4523), its key, the
|
||||||
#: CSR it waits with for the offline root, the number of its last CRL, where the CRL came from,
|
#: CSR it waits with for the offline root, the number of its last CRL, where the CRL came from,
|
||||||
#: and when it was retired. vpnRouterCertificate is a certificate a CA issued, named by its
|
#: and when it was retired. vpnRouterCertificate is a certificate a CA issued, named by its
|
||||||
@@ -100,11 +101,13 @@ SCHEMA_ATTRIBUTE_TYPES = (
|
|||||||
f"{_STRING} )",
|
f"{_STRING} )",
|
||||||
f"( {SCHEMA_OID}.1.15 NAME 'vpnRouterWireGuardPeer' DESC 'VPN router: a WireGuard peer of the user, its public key and IPs' "
|
f"( {SCHEMA_OID}.1.15 NAME 'vpnRouterWireGuardPeer' DESC 'VPN router: a WireGuard peer of the user, its public key and IPs' "
|
||||||
"EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )",
|
"EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )",
|
||||||
|
f"( {SCHEMA_OID}.1.16 NAME 'vpnRouterPasskey' DESC 'VPN router: a passkey of the user, its credential as JSON' "
|
||||||
|
"EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{4096} )",
|
||||||
)
|
)
|
||||||
SCHEMA_OBJECT_CLASSES = (
|
SCHEMA_OBJECT_CLASSES = (
|
||||||
f"( {SCHEMA_OID}.2.1 NAME 'vpnRouterAccount' DESC 'VPN router: properties of a user or a Manager-only account' "
|
f"( {SCHEMA_OID}.2.1 NAME 'vpnRouterAccount' DESC 'VPN router: properties of a user or a Manager-only account' "
|
||||||
"SUP top AUXILIARY MAY ( mail $ vpnRouterScope $ displayName $ description $ vpnRouterConsoleLogin $ "
|
"SUP top AUXILIARY MAY ( mail $ vpnRouterScope $ displayName $ description $ vpnRouterConsoleLogin $ "
|
||||||
"vpnRouterConsoleAccount $ vpnRouterWireGuardPeer ) )",
|
"vpnRouterConsoleAccount $ vpnRouterWireGuardPeer $ vpnRouterPasskey ) )",
|
||||||
f"( {SCHEMA_OID}.2.2 NAME 'vpnRouterAuthority' DESC 'VPN router: a CA of the PKI' SUP top STRUCTURAL MUST cn "
|
f"( {SCHEMA_OID}.2.2 NAME 'vpnRouterAuthority' DESC 'VPN router: a CA of the PKI' SUP top STRUCTURAL MUST cn "
|
||||||
"MAY ( cACertificate $ certificateRevocationList $ vpnRouterPrivateKey $ vpnRouterCertificateRequest $ "
|
"MAY ( cACertificate $ certificateRevocationList $ vpnRouterPrivateKey $ vpnRouterCertificateRequest $ "
|
||||||
"vpnRouterCRLNumber $ vpnRouterCRLSource $ vpnRouterRetired ) )",
|
"vpnRouterCRLNumber $ vpnRouterCRLSource $ vpnRouterRetired ) )",
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""The passkeys of the router's users, for signing in to the Router Manager, with python3-fido2.
|
||||||
|
|
||||||
|
A passkey is a discoverable WebAuthn credential with user verification, of the relying party
|
||||||
|
wan.local_fqdn, so the browser offers it at the Router Manager's address on that name over HTTPS.
|
||||||
|
Its user handle is the user name. Each passkey of a user is a value of vpnRouterPasskey on the
|
||||||
|
user entry: a JSON object {"id", "credential", "label", "created"}, id the credential ID and
|
||||||
|
credential the attested credential data (with the public key), both base64url. A user has at
|
||||||
|
most PASSKEYS_MAX passkeys; service accounts have none.
|
||||||
|
|
||||||
|
Registration and sign-in are two steps each: begin answers the options for the browser and a
|
||||||
|
pending id, whose state the web service keeps in memory for PENDING_SECONDS; complete takes the
|
||||||
|
id and the browser's answer. A request that cannot be done raises vpnrouter_users.Failure, with
|
||||||
|
the statuses of that module; a sign-in that fails gives status 5 whatever the reason.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import fido2.features
|
||||||
|
import ldap
|
||||||
|
from fido2.server import Fido2Server
|
||||||
|
from fido2.utils import websafe_decode, websafe_encode
|
||||||
|
from fido2.webauthn import (AttestedCredentialData, PublicKeyCredentialRpEntity, PublicKeyCredentialUserEntity,
|
||||||
|
ResidentKeyRequirement, UserVerificationRequirement)
|
||||||
|
|
||||||
|
import vpnrouter
|
||||||
|
import vpnrouter_users
|
||||||
|
|
||||||
|
# The options and the browser's answers are in the JSON mapping of WebAuthn Level 3; python3-fido2 2.0 always uses it.
|
||||||
|
_json_mapping = getattr(fido2.features, 'webauthn_json_mapping', None)
|
||||||
|
if _json_mapping is not None:
|
||||||
|
_json_mapping.enabled = True
|
||||||
|
|
||||||
|
#: The most passkeys of one user.
|
||||||
|
PASSKEYS_MAX = 10
|
||||||
|
#: The longest label of a passkey, in characters.
|
||||||
|
LABEL_MAX = 64
|
||||||
|
#: Seconds a begun registration or sign-in may be completed.
|
||||||
|
PENDING_SECONDS = 120
|
||||||
|
#: The most begun registrations and sign-ins kept at once.
|
||||||
|
PENDING_MAX = 1000
|
||||||
|
|
||||||
|
#: The begun registrations and sign-ins: pending id -> (kind, user name or None, state, monotonic time of expiry).
|
||||||
|
_pending = {}
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def server():
|
||||||
|
"""The Fido2Server of the relying party wan.local_fqdn; passkeys need the name."""
|
||||||
|
fqdn = vpnrouter.load_config()['wan']['local_fqdn'].strip()
|
||||||
|
if not fqdn:
|
||||||
|
raise vpnrouter_users.Failure('passkeys need wan.local_fqdn, the name the Router Manager is reached at', 2)
|
||||||
|
return Fido2Server(rp=PublicKeyCredentialRpEntity(name=fqdn, id=fqdn))
|
||||||
|
|
||||||
|
|
||||||
|
def keep(kind, name, state):
|
||||||
|
"""Keep the state of a begun step and answer its pending id."""
|
||||||
|
pending = secrets.token_urlsafe(24)
|
||||||
|
now = time.monotonic()
|
||||||
|
with _lock:
|
||||||
|
for old in [key for key, (*_, expires) in _pending.items() if expires <= now]:
|
||||||
|
del _pending[old]
|
||||||
|
if len(_pending) >= PENDING_MAX:
|
||||||
|
raise vpnrouter_users.Failure('too many passkey requests at once; retry in a moment')
|
||||||
|
_pending[pending] = (kind, name, state, now + PENDING_SECONDS)
|
||||||
|
return pending
|
||||||
|
|
||||||
|
|
||||||
|
def take(kind, name, pending):
|
||||||
|
"""The state of a begun step of the kind and user, used up; None when it is unknown or expired."""
|
||||||
|
with _lock:
|
||||||
|
found = _pending.pop(pending, None) if isinstance(pending, str) else None
|
||||||
|
if found is None or found[0] != kind or found[1] != name or found[3] <= time.monotonic():
|
||||||
|
return None
|
||||||
|
return found[2]
|
||||||
|
|
||||||
|
|
||||||
|
def credentials(attributes):
|
||||||
|
"""The attested credential data of the passkeys of an entry."""
|
||||||
|
return [AttestedCredentialData(websafe_decode(passkey['credential']))
|
||||||
|
for _, passkey in vpnrouter_users.passkeys_of(attributes)]
|
||||||
|
|
||||||
|
|
||||||
|
def register_begin(connection, name):
|
||||||
|
"""Begin the registration of a passkey of the user: {"id", "options"}, options those of navigator.credentials.create."""
|
||||||
|
current = vpnrouter_users.entry(connection, name)
|
||||||
|
if vpnrouter_users.service(current):
|
||||||
|
raise vpnrouter_users.Failure(f'{name} is a service account, which has no passkeys', 2)
|
||||||
|
if len(vpnrouter_users.passkeys_of(current)) >= PASSKEYS_MAX:
|
||||||
|
raise vpnrouter_users.Failure(f'user {name} has {PASSKEYS_MAX} passkeys, the most; remove one first', 2)
|
||||||
|
options, state = server().register_begin(
|
||||||
|
PublicKeyCredentialUserEntity(id=name.encode(), name=name, display_name=name),
|
||||||
|
credentials(current),
|
||||||
|
resident_key_requirement=ResidentKeyRequirement.REQUIRED,
|
||||||
|
user_verification=UserVerificationRequirement.REQUIRED,
|
||||||
|
)
|
||||||
|
return {'id': keep('register', name, state), 'options': dict(options)}
|
||||||
|
|
||||||
|
|
||||||
|
def register_complete(connection, name, pending, response, label):
|
||||||
|
"""Store the passkey the browser created for a begun registration, with its label; the user (vpnrouter_users.summary)."""
|
||||||
|
state = take('register', name, pending)
|
||||||
|
if state is None:
|
||||||
|
raise vpnrouter_users.Failure('the registration is unknown or expired; begin it again', 2)
|
||||||
|
if label is None or (isinstance(label, str) and not label.strip()):
|
||||||
|
label = 'Passkey'
|
||||||
|
if not isinstance(label, str) or len(label.strip()) > LABEL_MAX:
|
||||||
|
raise vpnrouter_users.Failure(f'the label must be text of at most {LABEL_MAX} characters', 2)
|
||||||
|
if not isinstance(response, dict):
|
||||||
|
raise vpnrouter_users.Failure('credential must be the answer of navigator.credentials.create as JSON', 2)
|
||||||
|
try:
|
||||||
|
data = server().register_complete(state, response).credential_data
|
||||||
|
except (ValueError, KeyError, TypeError) as exc:
|
||||||
|
raise vpnrouter_users.Failure(f'the passkey is refused: {exc}', 2)
|
||||||
|
current = vpnrouter_users.entry(connection, name)
|
||||||
|
value = json.dumps({
|
||||||
|
'id': websafe_encode(data.credential_id),
|
||||||
|
'credential': websafe_encode(bytes(data)),
|
||||||
|
'label': label.strip(),
|
||||||
|
'created': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
||||||
|
}, separators=(',', ':'))
|
||||||
|
changes = [] if 'vpnrouteraccount' in vpnrouter_users.classes(current) else [(ldap.MOD_ADD, 'objectClass', [b'vpnRouterAccount'])]
|
||||||
|
changes.append((ldap.MOD_ADD, 'vpnRouterPasskey', [value.encode()]))
|
||||||
|
connection.modify_s(vpnrouter_users.dn_of(name), changes)
|
||||||
|
return vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
|
||||||
|
|
||||||
|
|
||||||
|
def remove(connection, name, passkey_id=None):
|
||||||
|
"""Remove the user's passkey of the id, or all its passkeys without one; the user (vpnrouter_users.summary)."""
|
||||||
|
current = vpnrouter_users.entry(connection, name)
|
||||||
|
values = current.get('vpnRouterPasskey', [])
|
||||||
|
kept = [value for value, passkey in vpnrouter_users.passkeys_of(current)
|
||||||
|
if passkey_id is not None and passkey['id'] != passkey_id]
|
||||||
|
if passkey_id is not None and len(kept) == len(vpnrouter_users.passkeys_of(current)):
|
||||||
|
raise vpnrouter_users.Failure(f'user {name} has no passkey {passkey_id}', 3)
|
||||||
|
if len(kept) != len(values):
|
||||||
|
connection.modify_s(vpnrouter_users.dn_of(name), [(ldap.MOD_REPLACE, 'vpnRouterPasskey', kept)])
|
||||||
|
return vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
|
||||||
|
|
||||||
|
|
||||||
|
def login_begin():
|
||||||
|
"""Begin a sign-in with a passkey: {"id", "options"}, options those of navigator.credentials.get, for any user."""
|
||||||
|
options, state = server().authenticate_begin(user_verification=UserVerificationRequirement.REQUIRED)
|
||||||
|
return {'id': keep('login', None, state), 'options': dict(options)}
|
||||||
|
|
||||||
|
|
||||||
|
def login_complete(connection, pending, response):
|
||||||
|
"""The name of the user whose passkey signed a begun sign-in, when the user is enabled, not locked and not a
|
||||||
|
service account."""
|
||||||
|
refused = vpnrouter_users.Failure('the passkey is not accepted', 5)
|
||||||
|
state = take('login', None, pending)
|
||||||
|
if state is None or not isinstance(response, dict):
|
||||||
|
raise refused
|
||||||
|
try:
|
||||||
|
name = websafe_decode(response['response']['userHandle']).decode()
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
raise refused
|
||||||
|
if not vpnrouter_users.NAME.match(name):
|
||||||
|
raise refused
|
||||||
|
try:
|
||||||
|
current = vpnrouter_users.entry(connection, name)
|
||||||
|
except vpnrouter_users.Failure:
|
||||||
|
raise refused
|
||||||
|
user = vpnrouter_users.summary(current)
|
||||||
|
if user['service'] or not user['enabled'] or user['locked']:
|
||||||
|
raise refused
|
||||||
|
try:
|
||||||
|
server().authenticate_complete(state, credentials(current), response)
|
||||||
|
except (ValueError, KeyError, TypeError):
|
||||||
|
raise refused
|
||||||
|
return name
|
||||||
@@ -30,13 +30,17 @@ vpnrouter_ldap.TOTP_PARAMS. Enrolment stores nothing until a code of the new sec
|
|||||||
verified: otp_new makes a secret and its otpauth URI, and otp_enrol stores the secret given a
|
verified: otp_new makes a secret and its otpauth URI, and otp_enrol stores the secret given a
|
||||||
code of it. slapd's otp overlay then takes the password followed by a code at every bind.
|
code of it. slapd's otp overlay then takes the password followed by a code at every bind.
|
||||||
|
|
||||||
|
A user who is not a service account may have passkeys for signing in to the Router Manager, in
|
||||||
|
vpnRouterPasskey (vpnrouter_passkeys registers and checks them).
|
||||||
|
|
||||||
A user is disabled when pwdAccountLockedTime holds LOCKED, and locked when the password
|
A user is disabled when pwdAccountLockedTime holds LOCKED, and locked when the password
|
||||||
policy has set it after failed binds, for vpnrouter_ldap.LOCKOUT_SECONDS; unlock ends that.
|
policy has set it after failed binds, for vpnrouter_ldap.LOCKOUT_SECONDS; unlock ends that.
|
||||||
login() checks a user's password and code with a bind. Members of ADMIN_GROUP manage the
|
login() checks a user's password and code with a bind. Members of ADMIN_GROUP manage the
|
||||||
router through the web service.
|
router through the web service.
|
||||||
|
|
||||||
A member of ADMIN_GROUP may have console login (console): the Shell page of the Router Manager,
|
A member of ADMIN_GROUP with a TOTP token may have console login (console): the Shell page of the
|
||||||
and SSH and sudo with the directory's password, followed by the code for an enrolled user. The
|
Router Manager, and SSH and sudo with the directory's password followed by the code; without the
|
||||||
|
token the console is refused, also for one whose console login was switched on before. The
|
||||||
console runs as a local account, or as the user's own posixAccount, which the user entry becomes
|
console runs as a local account, or as the user's own posixAccount, which the user entry becomes
|
||||||
and nslcd serves to the system. Leaving ADMIN_GROUP switches it off. The module knows only the directory; the callers revoke a
|
and nslcd serves to the system. Leaving ADMIN_GROUP switches it off. The module knows only the directory; the callers revoke a
|
||||||
user's refresh tokens after the operations of vpnrouter_auth.REVOKING.
|
user's refresh tokens after the operations of vpnrouter_auth.REVOKING.
|
||||||
@@ -53,6 +57,7 @@ import contextlib
|
|||||||
import fcntl
|
import fcntl
|
||||||
import grp
|
import grp
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import json
|
||||||
import pwd
|
import pwd
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -83,7 +88,7 @@ LABELS = (('display_name', 'displayName'), ('description', 'description'))
|
|||||||
#: The attributes read for a user.
|
#: The attributes read for a user.
|
||||||
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope',
|
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope',
|
||||||
'displayName', 'description', 'userCertificate;binary', 'vpnRouterConsoleLogin', 'vpnRouterConsoleAccount',
|
'displayName', 'description', 'userCertificate;binary', 'vpnRouterConsoleLogin', 'vpnRouterConsoleAccount',
|
||||||
'uidNumber', 'vpnRouterWireGuardPeer']
|
'uidNumber', 'vpnRouterWireGuardPeer', 'vpnRouterPasskey']
|
||||||
#: The most WireGuard peers of one user.
|
#: The most WireGuard peers of one user.
|
||||||
WIREGUARD_PEERS_MAX = 16
|
WIREGUARD_PEERS_MAX = 16
|
||||||
#: The vpnRouterScope of a service account.
|
#: The vpnRouterScope of a service account.
|
||||||
@@ -252,6 +257,7 @@ def summary(attributes):
|
|||||||
'otp': 'oathTOTPToken' in attributes,
|
'otp': 'oathTOTPToken' in attributes,
|
||||||
'console': console_of(attributes),
|
'console': console_of(attributes),
|
||||||
'wireguard_peers': peers_of(attributes),
|
'wireguard_peers': peers_of(attributes),
|
||||||
|
'passkeys': [{key: passkey[key] for key in ('id', 'label', 'created')} for _, passkey in passkeys_of(attributes)],
|
||||||
}
|
}
|
||||||
for key, attribute in PERSON + LABELS:
|
for key, attribute in PERSON + LABELS:
|
||||||
values = attributes.get(attribute)
|
values = attributes.get(attribute)
|
||||||
@@ -284,6 +290,20 @@ def peers_of(attributes):
|
|||||||
return peers
|
return peers
|
||||||
|
|
||||||
|
|
||||||
|
def passkeys_of(attributes):
|
||||||
|
"""The passkeys of an entry (vpnrouter_passkeys) with their stored values: [(value, {id, credential, label,
|
||||||
|
created})]; values that cannot be read are left out."""
|
||||||
|
passkeys = []
|
||||||
|
for value in attributes.get('vpnRouterPasskey', []):
|
||||||
|
try:
|
||||||
|
passkey = json.loads(value)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if isinstance(passkey, dict) and all(isinstance(passkey.get(key), str) for key in ('id', 'credential', 'label', 'created')):
|
||||||
|
passkeys.append((value, passkey))
|
||||||
|
return passkeys
|
||||||
|
|
||||||
|
|
||||||
def checked_peers(connection, name, peers):
|
def checked_peers(connection, name, peers):
|
||||||
"""The values of vpnRouterWireGuardPeer for the user's WireGuard peers, [{public_key, ips}], when they are valid.
|
"""The values of vpnRouterWireGuardPeer for the user's WireGuard peers, [{public_key, ips}], when they are valid.
|
||||||
|
|
||||||
@@ -349,8 +369,8 @@ def free_uid(connection):
|
|||||||
|
|
||||||
|
|
||||||
def console(connection, name, enabled, account=None):
|
def console(connection, name, enabled, account=None):
|
||||||
"""Switch the console login of a member of ADMIN_GROUP on, as the local account given or, without one, as the
|
"""Switch the console login of a member of ADMIN_GROUP with a TOTP token on, as the local account given or, without
|
||||||
user's own posixAccount, or off.
|
one, as the user's own posixAccount, or off.
|
||||||
|
|
||||||
The posixAccount is added the first time and kept when the console login is switched off, so its uidNumber and the
|
The posixAccount is added the first time and kept when the console login is switched off, so its uidNumber and the
|
||||||
home it owns stay the user's. A local account must exist in the system's passwd with a uid from 1000 to below
|
home it owns stay the user's. A local account must exist in the system's passwd with a uid from 1000 to below
|
||||||
@@ -373,6 +393,8 @@ def console(connection, name, enabled, account=None):
|
|||||||
raise Failure(f'{name} is a service account, which has no console', 2)
|
raise Failure(f'{name} is a service account, which has no console', 2)
|
||||||
if ADMIN_GROUP not in groups_of(connection, name):
|
if ADMIN_GROUP not in groups_of(connection, name):
|
||||||
raise Failure(f'user {name} is not a member of {ADMIN_GROUP}', 2)
|
raise Failure(f'user {name} is not a member of {ADMIN_GROUP}', 2)
|
||||||
|
if 'oathTOTPToken' not in current:
|
||||||
|
raise Failure(f'user {name} has no TOTP token; console login needs one', 2)
|
||||||
if account is not None:
|
if account is not None:
|
||||||
try:
|
try:
|
||||||
local = pwd.getpwnam(account)
|
local = pwd.getpwnam(account)
|
||||||
@@ -639,13 +661,15 @@ def otp_enrol(connection, name, secret, code):
|
|||||||
|
|
||||||
|
|
||||||
def otp_remove(connection, name):
|
def otp_remove(connection, name):
|
||||||
"""Remove the user's TOTP token, when the user has one."""
|
"""Remove the user's TOTP token, when the user has one, and end the console sessions it needed (end_sessions)."""
|
||||||
if 'oathTOTPToken' in entry(connection, name):
|
current = entry(connection, name)
|
||||||
|
if 'oathTOTPToken' in current:
|
||||||
token = connection.search_s(dn_of(name), ldap.SCOPE_BASE, attrlist=TOKEN_ATTRIBUTES)[0][1]
|
token = connection.search_s(dn_of(name), ldap.SCOPE_BASE, attrlist=TOKEN_ATTRIBUTES)[0][1]
|
||||||
changes = [(ldap.MOD_DELETE, 'objectClass', [b'oathTOTPUser', b'oathTOTPToken']),
|
changes = [(ldap.MOD_DELETE, 'objectClass', [b'oathTOTPUser', b'oathTOTPToken']),
|
||||||
(ldap.MOD_DELETE, 'oathSecret', None)]
|
(ldap.MOD_DELETE, 'oathSecret', None)]
|
||||||
changes += [(ldap.MOD_DELETE, attribute, None) for attribute in token]
|
changes += [(ldap.MOD_DELETE, attribute, None) for attribute in token]
|
||||||
connection.modify_s(dn_of(name), changes)
|
connection.modify_s(dn_of(name), changes)
|
||||||
|
end_sessions(current)
|
||||||
return summary(entry(connection, name))
|
return summary(entry(connection, name))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ Pages:
|
|||||||
service_accounts())
|
service_accounts())
|
||||||
/groups the groups of the router's directory: list, add, rename or describe, remove, and
|
/groups the groups of the router's directory: list, add, rename or describe, remove, and
|
||||||
add and take out members (see groups())
|
add and take out members (see groups())
|
||||||
/account the account of the signed-in user: its details, its password and its TOTP
|
/account the account of the signed-in user: its details, its password, its TOTP
|
||||||
enrolment (see account())
|
enrolment and its passkeys (see account())
|
||||||
/site-to-site
|
/site-to-site
|
||||||
the site-to-site connections of the configuration: list, add, change and remove,
|
the site-to-site connections of the configuration: list, add, change and remove,
|
||||||
applied by vpn-router-setup (see site_to_site())
|
applied by vpn-router-setup (see site_to_site())
|
||||||
@@ -38,11 +38,12 @@ Pages:
|
|||||||
the DER certificate and CRL of the root (root) and of each issuing CA, which the
|
the DER certificate and CRL of the root (root) and of each issuing CA, which the
|
||||||
certificates name in their AIA and CRL distribution points; no authentication
|
certificates name in their AIA and CRL distribution points; no authentication
|
||||||
/shell while web.shell is true, a login shell of the user's console account on a terminal, for
|
/shell while web.shell is true, a login shell of the user's console account on a terminal, for
|
||||||
a member of admins whose console login is on: POST signs the user in again, unless
|
a member of admins whose console login is on and who has a TOTP token: POST signs the user in again, unless
|
||||||
the user used a shell within web.shell_auth_timeout minutes, for a one-time ticket
|
the user used a shell within web.shell_auth_timeout minutes, for a one-time ticket
|
||||||
(see shell_ticket()), GET with it is a WebSocket
|
(see shell_ticket()), GET with it is a WebSocket
|
||||||
that carries the terminal (see Handler.shell())
|
that carries the terminal (see Handler.shell())
|
||||||
/auth sign-in with a user's password and TOTP code, refresh and sign-out (see auth())
|
/auth sign-in with a user's password and TOTP code or with a passkey, refresh and sign-out
|
||||||
|
(see auth())
|
||||||
/metrics/history
|
/metrics/history
|
||||||
the metrics the metrics service keeps, for a view: hour, day, month or year (see
|
the metrics the metrics service keeps, for a view: hour, day, month or year (see
|
||||||
metrics_history())
|
metrics_history())
|
||||||
@@ -57,7 +58,8 @@ token of vpn-router-token has the admin claim and names no user; it may call eve
|
|||||||
/account, and only such a token may change ADMIN_GROUP: create, rename to it, change, remove it,
|
/account, and only such a token may change ADMIN_GROUP: create, rename to it, change, remove it,
|
||||||
add or take out its members, and remove one of its members, and switch a user's console login.
|
add or take out its members, and remove one of its members, and switch a user's console login.
|
||||||
These paths and /auth are not
|
These paths and /auth are not
|
||||||
served (404) while the key does not exist. /auth is limited to AUTH_LIMIT calls per client
|
served (404) while the key does not exist. /auth but its sign-out and the beginning of a passkey
|
||||||
|
sign-in is limited to AUTH_LIMIT calls per client
|
||||||
address in AUTH_WINDOW seconds; the address of a call from one of web.trusted_proxies, by
|
address in AUTH_WINDOW seconds; the address of a call from one of web.trusted_proxies, by
|
||||||
default the address the call came in on, is the last entry of its X-Forwarded-For header.
|
default the address the call came in on, is the last entry of its X-Forwarded-For header.
|
||||||
|
|
||||||
@@ -118,6 +120,7 @@ import vpnrouter_auth
|
|||||||
import vpnrouter_backup
|
import vpnrouter_backup
|
||||||
import vpnrouter_metrics
|
import vpnrouter_metrics
|
||||||
import vpnrouter_net
|
import vpnrouter_net
|
||||||
|
import vpnrouter_passkeys
|
||||||
import vpnrouter_pki
|
import vpnrouter_pki
|
||||||
import vpnrouter_serve
|
import vpnrouter_serve
|
||||||
import vpnrouter_users
|
import vpnrouter_users
|
||||||
@@ -170,6 +173,9 @@ PUBLISHED = {'.crt': 'application/pkix-cert', '.crl': 'application/pkix-crl'}
|
|||||||
#: Seconds a ticket of POST /shell may open a shell.
|
#: Seconds a ticket of POST /shell may open a shell.
|
||||||
SHELL_TICKET_SECONDS = 30
|
SHELL_TICKET_SECONDS = 30
|
||||||
|
|
||||||
|
#: Seconds between the checks of an open shell's user, which end the shell once its console is refused.
|
||||||
|
SHELL_CHECK_SECONDS = 60
|
||||||
|
|
||||||
#: The largest WebSocket message accepted from a client, in bytes.
|
#: The largest WebSocket message accepted from a client, in bytes.
|
||||||
MESSAGE_LIMIT = 65536
|
MESSAGE_LIMIT = 65536
|
||||||
|
|
||||||
@@ -527,6 +533,7 @@ def users(state, method, path, body):
|
|||||||
POST /users/<name>/otp a new TOTP secret and its otpauth URI; nothing is stored
|
POST /users/<name>/otp a new TOTP secret and its otpauth URI; nothing is stored
|
||||||
PUT /users/<name>/otp store a TOTP secret: {"secret", "code"}, code a code of the secret
|
PUT /users/<name>/otp store a TOTP secret: {"secret", "code"}, code a code of the secret
|
||||||
DELETE /users/<name>/otp remove the TOTP token
|
DELETE /users/<name>/otp remove the TOTP token
|
||||||
|
DELETE /users/<name>/passkeys remove all the user's passkeys
|
||||||
PUT /users/<name>/console switch console login on or off: {"enabled", "account"?}, account a local account,
|
PUT /users/<name>/console switch console login on or off: {"enabled", "account"?}, account a local account,
|
||||||
without which the user's own posixAccount; only a token of vpn-router-token
|
without which the user's own posixAccount; only a token of vpn-router-token
|
||||||
|
|
||||||
@@ -553,6 +560,7 @@ def users(state, method, path, body):
|
|||||||
('POST', 3, 'otp'): lambda connection: vpnrouter_users.otp_new(connection, name),
|
('POST', 3, 'otp'): lambda connection: vpnrouter_users.otp_new(connection, name),
|
||||||
('PUT', 3, 'otp'): lambda connection: vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code')),
|
('PUT', 3, 'otp'): lambda connection: vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code')),
|
||||||
('DELETE', 3, 'otp'): lambda connection: vpnrouter_users.otp_remove(connection, name),
|
('DELETE', 3, 'otp'): lambda connection: vpnrouter_users.otp_remove(connection, name),
|
||||||
|
('DELETE', 3, 'passkeys'): lambda connection: vpnrouter_passkeys.remove(connection, name),
|
||||||
('PUT', 3, 'console'): lambda connection: vpnrouter_users.console(connection, name, body.get('enabled'), body.get('account')),
|
('PUT', 3, 'console'): lambda connection: vpnrouter_users.console(connection, name, body.get('enabled'), body.get('account')),
|
||||||
}
|
}
|
||||||
# The vpnrouter_users operation of each route that may revoke refresh tokens.
|
# The vpnrouter_users operation of each route that may revoke refresh tokens.
|
||||||
@@ -976,20 +984,27 @@ def account(name, method, path, body):
|
|||||||
POST /account/otp a new TOTP secret and its otpauth URI; nothing is stored
|
POST /account/otp a new TOTP secret and its otpauth URI; nothing is stored
|
||||||
PUT /account/otp store a TOTP secret: {"current_password", "current_code"?, "secret", "code"},
|
PUT /account/otp store a TOTP secret: {"current_password", "current_code"?, "secret", "code"},
|
||||||
code a code of the secret
|
code a code of the secret
|
||||||
|
POST /account/passkeys begin the registration of a passkey: {"current_password", "current_code"?};
|
||||||
|
{"id", "options"} for navigator.credentials.create
|
||||||
|
PUT /account/passkeys store the passkey: {"id", "credential", "label"?}, credential the browser's answer
|
||||||
|
DELETE /account/passkeys/<id>
|
||||||
|
remove a passkey
|
||||||
|
|
||||||
A change needs the current password and, for an enrolled user, a current code; a wrong one is 403 and counts as
|
A change but the removal of a passkey needs the current password and, for an enrolled user, a current code; a wrong
|
||||||
a failed login. A password change revokes the user's refresh tokens.
|
one is 403 and counts as a failed login. A password change revokes the user's refresh tokens.
|
||||||
"""
|
"""
|
||||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||||
return 400, {'error': 'the body must be a JSON object'}
|
return 400, {'error': 'the body must be a JSON object'}
|
||||||
body = body or {}
|
body = body or {}
|
||||||
route = (method, path.rstrip('/'))
|
route = (method, path.rstrip('/'))
|
||||||
if route not in (('GET', '/account'), ('PUT', '/account/password'), ('POST', '/account/otp'), ('PUT', '/account/otp')):
|
removing = method == 'DELETE' and path.startswith('/account/passkeys/') and len(path.strip('/').split('/')) == 3
|
||||||
|
if not removing and route not in (('GET', '/account'), ('PUT', '/account/password'), ('POST', '/account/otp'),
|
||||||
|
('PUT', '/account/otp'), ('POST', '/account/passkeys'), ('PUT', '/account/passkeys')):
|
||||||
return 404, {'error': f'no route {method} {path}'}
|
return 404, {'error': f'no route {method} {path}'}
|
||||||
try:
|
try:
|
||||||
if route == ('PUT', '/account/password'):
|
if route == ('PUT', '/account/password'):
|
||||||
vpnrouter_users.check_password(body.get('password'))
|
vpnrouter_users.check_password(body.get('password'))
|
||||||
if method == 'PUT':
|
if route in (('PUT', '/account/password'), ('PUT', '/account/otp'), ('POST', '/account/passkeys')):
|
||||||
vpnrouter_users.login(name, body.get('current_password'), body.get('current_code'))
|
vpnrouter_users.login(name, body.get('current_password'), body.get('current_code'))
|
||||||
with vpnrouter_users.session() as connection:
|
with vpnrouter_users.session() as connection:
|
||||||
if route == ('GET', '/account'):
|
if route == ('GET', '/account'):
|
||||||
@@ -999,6 +1014,13 @@ def account(name, method, path, body):
|
|||||||
document = vpnrouter_users.passwd(connection, name, body['password'])
|
document = vpnrouter_users.passwd(connection, name, body['password'])
|
||||||
elif route == ('POST', '/account/otp'):
|
elif route == ('POST', '/account/otp'):
|
||||||
document = vpnrouter_users.otp_new(connection, name)
|
document = vpnrouter_users.otp_new(connection, name)
|
||||||
|
elif route == ('POST', '/account/passkeys'):
|
||||||
|
document = vpnrouter_passkeys.register_begin(connection, name)
|
||||||
|
elif route == ('PUT', '/account/passkeys'):
|
||||||
|
document = vpnrouter_passkeys.register_complete(connection, name, body.get('id'), body.get('credential'),
|
||||||
|
body.get('label'))
|
||||||
|
elif removing:
|
||||||
|
document = vpnrouter_passkeys.remove(connection, name, urllib.parse.unquote(path.strip('/').split('/')[2]))
|
||||||
else:
|
else:
|
||||||
document = vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code'))
|
document = vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code'))
|
||||||
if route == ('PUT', '/account/password'):
|
if route == ('PUT', '/account/password'):
|
||||||
@@ -1071,7 +1093,10 @@ def refusal(claims, method, path, body):
|
|||||||
def auth(path, body):
|
def auth(path, body):
|
||||||
"""Answer a POST to /auth with vpnrouter_users and vpnrouter_auth: the HTTP status and the JSON document.
|
"""Answer a POST to /auth with vpnrouter_users and vpnrouter_auth: the HTTP status and the JSON document.
|
||||||
|
|
||||||
POST /auth sign in: {"name", "password", "code"?}; the tokens
|
POST /auth sign in: {"name", "password", "code"?}; the tokens and the user's name
|
||||||
|
POST /auth/passkey begin a sign-in with a passkey: {"id", "options"} for navigator.credentials.get
|
||||||
|
POST /auth/passkey/complete
|
||||||
|
{"id", "credential"}, credential the browser's answer; the tokens and the user's name
|
||||||
POST /auth/refresh {"refresh_token"}; new tokens, the refresh token used up
|
POST /auth/refresh {"refresh_token"}; new tokens, the refresh token used up
|
||||||
POST /auth/logout {"refresh_token"}; the refresh token revoked
|
POST /auth/logout {"refresh_token"}; the refresh token revoked
|
||||||
"""
|
"""
|
||||||
@@ -1084,7 +1109,13 @@ def auth(path, body):
|
|||||||
with vpnrouter_users.session() as connection:
|
with vpnrouter_users.session() as connection:
|
||||||
if vpnrouter_users.service(vpnrouter_users.entry(connection, body['name'])):
|
if vpnrouter_users.service(vpnrouter_users.entry(connection, body['name'])):
|
||||||
raise vpnrouter_users.Failure('the name, the password or the code is wrong', 5)
|
raise vpnrouter_users.Failure('the name, the password or the code is wrong', 5)
|
||||||
return 200, vpnrouter_auth.issue(cfg, body['name'])
|
return 200, {**vpnrouter_auth.issue(cfg, body['name']), 'name': body['name']}
|
||||||
|
if path == '/auth/passkey':
|
||||||
|
return 200, vpnrouter_passkeys.login_begin()
|
||||||
|
if path == '/auth/passkey/complete':
|
||||||
|
with vpnrouter_users.session() as connection:
|
||||||
|
name = vpnrouter_passkeys.login_complete(connection, body.get('id'), body.get('credential'))
|
||||||
|
return 200, {**vpnrouter_auth.issue(cfg, name), 'name': name}
|
||||||
name = vpnrouter_auth.take(cfg, body.get('refresh_token'))
|
name = vpnrouter_auth.take(cfg, body.get('refresh_token'))
|
||||||
if path == '/auth/logout':
|
if path == '/auth/logout':
|
||||||
return 200, {'revoked': name is not None}
|
return 200, {'revoked': name is not None}
|
||||||
@@ -1112,7 +1143,7 @@ def shell_ticket(name, body):
|
|||||||
as a failed login. Without a password, the user is not asked to sign in while its last use of a shell (see
|
as a failed login. Without a password, the user is not asked to sign in while its last use of a shell (see
|
||||||
_shell_used) is less than web.shell_auth_timeout minutes ago. The answer is {"ticket", "expires_in"}: a ticket that
|
_shell_used) is less than web.shell_auth_timeout minutes ago. The answer is {"ticket", "expires_in"}: a ticket that
|
||||||
opens one shell (GET /shell) for the same user within SHELL_TICKET_SECONDS, or a null ticket when the user must
|
opens one shell (GET /shell) for the same user within SHELL_TICKET_SECONDS, or a null ticket when the user must
|
||||||
sign in. 403 while web.shell is false or the user's console login is not on.
|
sign in. 403 while web.shell is false, the user's console login is not on or the user has no TOTP token.
|
||||||
"""
|
"""
|
||||||
if not isinstance(name, str):
|
if not isinstance(name, str):
|
||||||
return 403, {'error': 'the shell needs a token of a signed-in user'}
|
return 403, {'error': 'the shell needs a token of a signed-in user'}
|
||||||
@@ -1122,8 +1153,9 @@ def shell_ticket(name, body):
|
|||||||
options = vpnrouter.load_config()['web']
|
options = vpnrouter.load_config()['web']
|
||||||
if not options['shell']:
|
if not options['shell']:
|
||||||
return 403, {'error': 'the shell is disabled; vpn-config-edit set web.shell true enables it'}
|
return 403, {'error': 'the shell is disabled; vpn-config-edit set web.shell true enables it'}
|
||||||
if console_account(name) is None:
|
refusal = console_account(name)[1]
|
||||||
return 403, {'error': f'console login is not enabled for {name}; vpn-router-user console sets it'}
|
if refusal:
|
||||||
|
return 403, {'error': refusal}
|
||||||
if 'password' not in body:
|
if 'password' not in body:
|
||||||
with _shell_lock:
|
with _shell_lock:
|
||||||
used = _shell_used.get(name)
|
used = _shell_used.get(name)
|
||||||
@@ -1150,10 +1182,15 @@ def shell_ticket(name, body):
|
|||||||
|
|
||||||
|
|
||||||
def console_account(name):
|
def console_account(name):
|
||||||
"""The system account the console of the user runs as, or None while its console login is not on."""
|
"""The system account the console of the user runs as and None, or None and the reason the user may not use a
|
||||||
|
console: its console login is off, or it has no TOTP token."""
|
||||||
with vpnrouter_users.session() as connection:
|
with vpnrouter_users.session() as connection:
|
||||||
console = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))['console']
|
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
|
||||||
return console['account'] if console and console['enabled'] else None
|
if not user['console'] or not user['console']['enabled']:
|
||||||
|
return None, f'console login is not enabled for {name}; vpn-router-user console sets it'
|
||||||
|
if not user['otp']:
|
||||||
|
return None, f'{name} has no TOTP token; the shell needs one, enrolled on the Account page'
|
||||||
|
return user['console']['account'], None
|
||||||
|
|
||||||
|
|
||||||
def take_ticket(name, ticket):
|
def take_ticket(name, ticket):
|
||||||
@@ -1278,8 +1315,9 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
The ticket must come from POST /shell for the user named by the token. Binary messages of the client are its
|
The ticket must come from POST /shell for the user named by the token. Binary messages of the client are its
|
||||||
keystrokes, a text message {"cols", "rows"} sets the window size, and binary messages of the server are the
|
keystrokes, a text message {"cols", "rows"} sets the window size, and binary messages of the server are the
|
||||||
terminal's output. The shell ends after web.shell_idle_minutes without a keystroke, when it exits, or when the
|
terminal's output. The shell ends after web.shell_idle_minutes without a keystroke, when it exits, when the
|
||||||
client closes the connection; the server then closes it with the reason. Each opening, end and refusal is logged
|
client closes the connection, or when a check of the user (SHELL_CHECK_SECONDS) finds its console login off or
|
||||||
|
its TOTP token gone; the server then closes it with the reason. Each opening, end and refusal is logged
|
||||||
with its reason.
|
with its reason.
|
||||||
"""
|
"""
|
||||||
def refuse(code, message):
|
def refuse(code, message):
|
||||||
@@ -1305,11 +1343,11 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if not options['shell']:
|
if not options['shell']:
|
||||||
return refuse(403, 'the shell is disabled; vpn-config-edit set web.shell true enables it')
|
return refuse(403, 'the shell is disabled; vpn-config-edit set web.shell true enables it')
|
||||||
try:
|
try:
|
||||||
login = console_account(name)
|
login, refusal = console_account(name)
|
||||||
except (vpnrouter_users.Failure, ldap.LDAPError, OSError) as exc:
|
except (vpnrouter_users.Failure, ldap.LDAPError, OSError) as exc:
|
||||||
return refuse(500, f'the user directory failed: {exc}')
|
return refuse(500, f'the user directory failed: {exc}')
|
||||||
if login is None:
|
if refusal:
|
||||||
return refuse(403, f'console login is not enabled for {name}')
|
return refuse(403, refusal)
|
||||||
try:
|
try:
|
||||||
account = pwd.getpwnam(login)
|
account = pwd.getpwnam(login)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
@@ -1363,12 +1401,23 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
reader = threading.Thread(target=receive, name=f'{unit} input', daemon=True)
|
reader = threading.Thread(target=receive, name=f'{unit} input', daemon=True)
|
||||||
reader.start()
|
reader.start()
|
||||||
reason = 'the shell ended'
|
reason = 'the shell ended'
|
||||||
|
checked = started
|
||||||
try:
|
try:
|
||||||
while not ended.is_set():
|
while not ended.is_set():
|
||||||
left = typed[0] + idle - time.monotonic()
|
left = typed[0] + idle - time.monotonic()
|
||||||
if left <= 0:
|
if left <= 0:
|
||||||
reason = f'no keystroke for {options["shell_idle_minutes"]} minutes'
|
reason = f'no keystroke for {options["shell_idle_minutes"]} minutes'
|
||||||
break
|
break
|
||||||
|
# The user's console login and TOTP token are checked again every SHELL_CHECK_SECONDS.
|
||||||
|
if time.monotonic() - checked >= SHELL_CHECK_SECONDS:
|
||||||
|
checked = time.monotonic()
|
||||||
|
try:
|
||||||
|
refusal = console_account(name)[1]
|
||||||
|
except (vpnrouter_users.Failure, ldap.LDAPError, OSError) as exc:
|
||||||
|
refusal = f'the user directory failed: {exc}'
|
||||||
|
if refusal:
|
||||||
|
reason = refusal
|
||||||
|
break
|
||||||
if select.select([master], [], [], min(left, 1))[0]:
|
if select.select([master], [], [], min(left, 1))[0]:
|
||||||
try:
|
try:
|
||||||
output = os.read(master, 65536)
|
output = os.read(master, 65536)
|
||||||
@@ -1410,7 +1459,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
'ca', 'shell', 'remote-access')
|
'ca', 'shell', 'remote-access')
|
||||||
if path == f'/{name}' or path.startswith(f'/{name}/')), None)
|
if path == f'/{name}' or path.startswith(f'/{name}/')), None)
|
||||||
protected = path in ('/status/full', '/config', '/metrics/history', '/postgresql') or directory is not None
|
protected = path in ('/status/full', '/config', '/metrics/history', '/postgresql') or directory is not None
|
||||||
signing = path in ('/auth', '/auth/refresh', '/auth/logout')
|
signing = path in ('/auth', '/auth/passkey', '/auth/passkey/complete', '/auth/refresh', '/auth/logout')
|
||||||
state = current() if protected or signing else {}
|
state = current() if protected or signing else {}
|
||||||
claims = self.claims(state['key']) if protected and state['key'] else None
|
claims = self.claims(state['key']) if protected and state['key'] else None
|
||||||
payload = self.request_body() if (protected or signing) and self.command not in ('GET', 'HEAD') else None
|
payload = self.request_body() if (protected or signing) and self.command not in ('GET', 'HEAD') else None
|
||||||
@@ -1421,7 +1470,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
elif signing and self.command != 'POST':
|
elif signing and self.command != 'POST':
|
||||||
body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
|
body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
|
||||||
elif signing:
|
elif signing:
|
||||||
wait = auth_wait(self.client()) if path != '/auth/logout' else 0
|
wait = auth_wait(self.client()) if path not in ('/auth/logout', '/auth/passkey') else 0
|
||||||
if wait:
|
if wait:
|
||||||
code, document = 429, {'error': f'too many sign-in calls; retry in {wait} seconds'}
|
code, document = 429, {'error': f'too many sign-in calls; retry in {wait} seconds'}
|
||||||
extra.append(('Retry-After', str(wait)))
|
extra.append(('Retry-After', str(wait)))
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ user exists, 1 anything else. -h or --help shows the commands and their options.
|
|||||||
vpn-router-user [--json] otp-enrol <name> <code> (secret on stdin)
|
vpn-router-user [--json] otp-enrol <name> <code> (secret on stdin)
|
||||||
vpn-router-user [--json] otp-remove <name>
|
vpn-router-user [--json] otp-remove <name>
|
||||||
vpn-router-user [--json] console <name> (--posix | --account LOCAL | --off)
|
vpn-router-user [--json] console <name> (--posix | --account LOCAL | --off)
|
||||||
|
vpn-router-user [--json] passkeys-remove <name>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -37,6 +38,7 @@ import ldap
|
|||||||
|
|
||||||
import vpnrouter
|
import vpnrouter
|
||||||
import vpnrouter_auth
|
import vpnrouter_auth
|
||||||
|
import vpnrouter_passkeys
|
||||||
import vpnrouter_users
|
import vpnrouter_users
|
||||||
from vpnrouter_users import Failure
|
from vpnrouter_users import Failure
|
||||||
|
|
||||||
@@ -57,8 +59,9 @@ HELP = {
|
|||||||
'otp-new': 'make a TOTP secret and its otpauth URI; nothing is stored',
|
'otp-new': 'make a TOTP secret and its otpauth URI; nothing is stored',
|
||||||
'otp-enrol': 'store the TOTP secret read from standard input, when the code is a code of it',
|
'otp-enrol': 'store the TOTP secret read from standard input, when the code is a code of it',
|
||||||
'otp-remove': 'remove the TOTP token',
|
'otp-remove': 'remove the TOTP token',
|
||||||
'console': "switch the console login of a member of admins on, as the user's own POSIX account in the directory "
|
'console': "switch the console login of a member of admins with a TOTP token on, as the user's own POSIX account "
|
||||||
'or as a local account, or off',
|
'in the directory or as a local account, or off',
|
||||||
|
'passkeys-remove': "remove all the user's passkeys",
|
||||||
}
|
}
|
||||||
|
|
||||||
#: The help of the person options.
|
#: The help of the person options.
|
||||||
@@ -118,14 +121,19 @@ def run(connection, options, secret):
|
|||||||
return vpnrouter_users.otp_enrol(connection, options.name, secret, options.code)
|
return vpnrouter_users.otp_enrol(connection, options.name, secret, options.code)
|
||||||
if command == 'console':
|
if command == 'console':
|
||||||
return vpnrouter_users.console(connection, options.name, not options.off, options.account)
|
return vpnrouter_users.console(connection, options.name, not options.off, options.account)
|
||||||
|
if command == 'passkeys-remove':
|
||||||
|
return vpnrouter_passkeys.remove(connection, options.name)
|
||||||
return getattr(vpnrouter_users, command.replace('-', '_'))(connection, options.name)
|
return getattr(vpnrouter_users, command.replace('-', '_'))(connection, options.name)
|
||||||
|
|
||||||
|
|
||||||
def console_text(console):
|
def console_text(user):
|
||||||
"""The console login of a user as a cell: -, off, posix (uid N) or local and the account."""
|
"""The console login of a user as a cell: -, off, posix (uid N) or local and the account, refused without a TOTP
|
||||||
|
token."""
|
||||||
|
console = user['console']
|
||||||
if console is None or not console['enabled']:
|
if console is None or not console['enabled']:
|
||||||
return '-' if console is None else 'off'
|
return '-' if console is None else 'off'
|
||||||
return f'local {console["account"]}' if console['local'] else f'posix (uid {console["uid"]})'
|
text = f'local {console["account"]}' if console['local'] else f'posix (uid {console["uid"]})'
|
||||||
|
return text if user['otp'] else f'{text}, refused: no OTP'
|
||||||
|
|
||||||
|
|
||||||
def text_of(answer):
|
def text_of(answer):
|
||||||
@@ -139,11 +147,11 @@ def text_of(answer):
|
|||||||
users = answer['users'] if 'users' in answer else [answer]
|
users = answer['users'] if 'users' in answer else [answer]
|
||||||
if not users:
|
if not users:
|
||||||
return 'no users'
|
return 'no users'
|
||||||
head = ('USER', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'CONSOLE', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
|
head = ('USER', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'PASSKEYS', 'CONSOLE', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
|
||||||
rows = [(user['name'], 'internal' if user['internal'] else 'service' if user['service'] else 'manager' if user['manager_only'] else 'vpn',
|
rows = [(user['name'], 'internal' if user['internal'] else 'service' if user['service'] else 'manager' if user['manager_only'] else 'vpn',
|
||||||
'disabled' if not user['enabled'] else 'locked' if user['locked'] else 'enabled',
|
'disabled' if not user['enabled'] else 'locked' if user['locked'] else 'enabled',
|
||||||
'issued' if user['certificate'] else 'missing',
|
'issued' if user['certificate'] else 'missing',
|
||||||
'enrolled' if user['otp'] else '-', console_text(user['console']),
|
'enrolled' if user['otp'] else '-', str(len(user['passkeys'])) if user['passkeys'] else '-', console_text(user),
|
||||||
user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-')
|
user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-')
|
||||||
for user in users]
|
for user in users]
|
||||||
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
|
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
|
||||||
|
|||||||
Reference in new issue
Block a user