diff --git a/ADR.md b/ADR.md index d7efb9b..a6e3423 100644 --- a/ADR.md +++ b/ADR.md @@ -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 accepts it only while `ssh.ldap_login` is set, in a `Match Group` drop-in of sshd. Switching console login off keeps the `posixAccount`, hidden by nslcd's filter, so the uid and the home stay the -user's; leaving `admins` switches it off. +user's; leaving `admins` switches it off. A console reaches root through sudo, so a password alone +must not open one: console login needs a TOTP token. Switching it on is refused without one, and +the token is checked at use rather than only when it is switched on, because an administrator can +remove it later: the shell's ticket and every minute of an open shell check it, nslcd's +`pam_authz_search` requires `oathTOTPUser`, and removing the token ends the sessions of the user's +own POSIX account. Consequences: Each administrator's shell, SSH session and sudo run as the administrator, logged under the administrator's name. The directory becomes part of the system's name service through -nslcd, and the system knows only the accounts with console login on. A user enrolled for TOTP types -the code after the password at the SSH and sudo prompts. Homes are created at the first login and +nslcd, and the system knows only the accounts with console login on. The user types the code +after the password at the SSH and sudo prompts. An administrator whose console login was switched +on before the token was required is refused until enrolling. Homes are created at the first login and are not removed with the user. A password is changed through the directory, not with `passwd`. ## ADR-35: WireGuard peers are the devices of the VPN users, kept in the directory @@ -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 directory's backup holds them. A peer cannot exist without a VPN user. A change restarts `wg-quick@wg0` through setup, which briefly interrupts the other peers. + +## ADR-36: Passkeys sign in to the Router Manager only, verified by the router with python3-fido2 + +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. diff --git a/README.md b/README.md index 524f926..272b0f4 100644 --- a/README.md +++ b/README.md @@ -700,7 +700,7 @@ vpn-router-user otp-remove alice | Command | Effect | | --- | --- | -| `list` | The users with their kind, names and e-mail addresses, whether each is enabled or locked, has a certificate and is enrolled for TOTP | +| `list` | The users with their kind, names and e-mail addresses, whether each is enabled or locked, has a certificate and is enrolled for TOTP, the number of passkeys and the console login | | `add --first-name F --last-name L --email E` | Adds a VPN user and issues the certificate | | `add [--email E]` | Adds a Manager-only account, with the e-mail address when given; one name without the other two options is refused | | `add --service [--display-name N] [--description D] [--email E]` | Adds a service account, with its display name, description and e-mail address when given; the names are refused | @@ -713,8 +713,9 @@ vpn-router-user otp-remove alice | `profile ` | The OpenVPN client profile, with the CA, the certificate and the key inline | | `otp-new ` | A new TOTP secret and its `otpauth://` URI, issued by `wan.local_fqdn`, for an authenticator app; nothing is stored | | `otp-enrol ` | Stores the secret read from standard input when the code is a current code of it, replacing a secret the user has | -| `otp-remove ` | Removes the user's TOTP secret | -| `console (--posix \| --account LOCAL \| --off)` | Switches the console login of a member of `admins` on, as the user's own POSIX account or a local account, or off (see Console login and SSH) | +| `otp-remove ` | Removes the user's TOTP secret and ends the sessions of the user's own POSIX account | +| `console (--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 ` | 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 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 -A member of `admins` may have console login: the Router Manager's Shell page (see The shell), -and, for the user's own POSIX account, SSH and sudo. Only root on the router switches it, with -`vpn-router-user console` or a token of `vpn-router-token`, as for the group `admins`: +A member of `admins` enrolled for TOTP may have console login: the Router Manager's Shell page +(see The shell), and, for the user's own POSIX account, SSH and sudo. Only root on the router +switches it, with `vpn-router-user console` or a token of `vpn-router-token`, as for the group +`admins`. Switching it on is refused without the enrolment, and a user whose enrolment is removed +is refused the shell, SSH and sudo until enrolling again; the user's open shells end within a +minute, and removing the enrolment ends the sessions of the user's own POSIX account: ```sh vpn-router-user console alice --posix @@ -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 with the user's name, a uid from 20000 up, the local group `vpn-router-console` as its group, `/home/` and `/bin/bash`. `nslcd` makes it known to the system over slapd's local socket - and checks its password by binding as the user, so a user enrolled for TOTP types the password - followed by a current code, failed logins count towards the lockout, and a disabled user cannot - sign in. The home is created at the first login and kept when the user is removed. Members of + and checks its password by binding as the user, so the user types the password followed by a + current code, failed logins count towards the lockout, and a disabled user cannot sign in. The home is created at the first login and kept when the user is removed. Members of `vpn-router-console` may run any command with sudo, with the same password. A local account of the same name is refused. - **Local account** (`--account`): the console runs as an existing local account with a uid from @@ -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 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 `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 | | --- | --- | --- | -| `POST /auth` | `{"name", "password", "code"}`; `code` only for a user enrolled for TOTP | the tokens | +| `POST /auth` | `{"name", "password", "code"}`; `code` only for a user enrolled for TOTP | the tokens and `"name"` | +| `POST /auth/passkey` | `{}` | `{"id", "options"}`: a pending sign-in, valid 2 minutes, and the options of `navigator.credentials.get` in their JSON form | +| `POST /auth/passkey/complete` | `{"id", "credential"}`, `credential` the browser's answer as its `toJSON()` gives it | the tokens and `"name"`, the user whose passkey signed | | `POST /auth/refresh` | `{"refresh_token"}` | new tokens; the refresh token given is used up | | `POST /auth/logout` | `{"refresh_token"}` | `{"revoked": true}`, or `false` for a token not known | @@ -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 removed. Changing a user's password, disabling, unlocking or removing the user revokes the user's refresh tokens. Five failed logins lock the account for 15 minutes (see the user -directory). `/auth` and `/auth/refresh` take at most 10 calls a minute from one client address, +directory). A passkey that does not sign, is not the user's, or of a disabled, locked or service +account gets 401 too (see Passkeys). `/auth`, `/auth/passkey/complete` and `/auth/refresh` take +at most 10 calls a minute from one client address, and answer 429 with `Retry-After` beyond that; for a call from one of `web.trusted_proxies`, by default the address the call came in on, the client address is the last entry of `X-Forwarded-For`. @@ -970,8 +997,8 @@ every service and tunnel while the public status page shows less. ### The shell -While `web.shell` is `true`, `/shell` gives a member of `admins` whose console login is on (see -Console login and SSH) a login shell on the router, as the user's console account, for the +While `web.shell` is `true`, `/shell` gives a member of `admins` whose console login is on and +who is enrolled for TOTP (see Console login and SSH) a login shell on the router, as the user's console account, for the Router Manager's Shell page. It is off by default and switched on and off on the router only: ```sh @@ -981,19 +1008,20 @@ vpn-config-edit set web.shell true | 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=` | a WebSocket handshake | the terminal | `POST /shell` needs an access token of `/auth`, not one of `vpn-router-token`, and signs its user in again: a wrong password or code gets 403 and counts as a failed login; it gets 403 too while -`web.shell` is `false` or the user's console login is off. 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 `web.shell_auth_timeout` minutes ago, and `{"ticket": null}` otherwise. The ticket opens one shell for the same user within 30 seconds. Over the WebSocket, binary messages of the client are keystrokes, a text message `{"cols", "rows"}` sets the window size, and binary messages of the router are the terminal's output. The shell is a PAM login session of the console account, run by `systemd-run` in a transient unit `vpn-router-shell-`; sudo works as for SSH. It is closed after `web.shell_idle_minutes` without a keystroke, when the shell -exits, or when the connection closes; the router then sends a close frame with the reason. The +exits, when the connection closes, or within a minute of the user's console login being switched +off or the TOTP enrolment removed; the router then sends a close frame with the reason. The journal of `vpn-router-web` records who opened each shell, from which address, in which unit, when it ended and why, and each refused ticket or shell with the reason. @@ -1028,7 +1056,7 @@ and answer bodies are JSON; a request body is at most 65536 bytes. | Request | Body | Answer | | --- | --- | --- | -| `GET /users` | | `{"users": [{"name", "manager_only", "service", "internal", "enabled", "locked", "certificate", "otp", "console", "wireguard_peers", "first_name", "last_name", "email"}, ...], "wireguard": {"enabled", "address"}}`; `console` is `null` or `{"enabled", "account", "local", "uid"}`, `wireguard_peers` a list of `{"public_key", "ips"}`, and `wireguard` the endpoint's settings, whose network the peers' addresses come from | +| `GET /users` | | `{"users": [{"name", "manager_only", "service", "internal", "enabled", "locked", "certificate", "otp", "console", "wireguard_peers", "passkeys", "first_name", "last_name", "email"}, ...], "wireguard": {"enabled", "address"}}`; `console` is `null` or `{"enabled", "account", "local", "uid"}`, `wireguard_peers` a list of `{"public_key", "ips"}`, `passkeys` a list of `{"id", "label", "created"}`, and `wireguard` the endpoint's settings, whose network the peers' addresses come from | | `POST /users` | `{"name", "password", "first_name", "last_name", "email"}` for a VPN user; `{"name", "password", "email"?}` for a Manager-only account | 201 and the user | | `PUT /users/` | 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//password` | `{"password": ...}` | the user | @@ -1040,6 +1068,7 @@ and answer bodies are JSON; a request body is at most 65536 bytes. | `POST /users//otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored | | `PUT /users//otp` | `{"secret", "code"}` | the user, once the code matches the secret | | `DELETE /users//otp` | | the user | +| `DELETE /users//passkeys` | | the user, without passkeys | | `PUT /users//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 @@ -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 | | `POST /account/otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored | | `PUT /account/otp` | `{"current_password", "current_code", "secret", "code"}` | the user, once the code matches the secret | +| `POST /account/passkeys` | `{"current_password", "current_code"}` | `{"id", "options"}`: a pending registration, valid 2 minutes, and the options of `navigator.credentials.create` in their JSON form | +| `PUT /account/passkeys` | `{"id", "credential", "label"?}`, `credential` the browser's answer as its `toJSON()` gives it; `label` at most 64 characters, `Passkey` without one | the user | +| `DELETE /account/passkeys/` | | the user | -`current_code` is needed only for a user enrolled for TOTP. A wrong current password or code gets +`current_code` is needed only for a user enrolled for TOTP; removing a passkey needs neither. A wrong current password or code gets 403 and counts as a failed login; slapd accepts each code once, so a code used to sign in is not accepted again, and a change right after signing in waits for the next code. A user enrols or replaces the enrolment; removing it is left to `/users` and `vpn-router-user`. Changing the diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index dc99656..b51fb1b 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -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/ add, with + the current password and code, and remove the user's passkeys, at most + 10, kept in the new attribute vpnRouterPasskey; DELETE + /users//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 Tue, 03 Nov 2026 08:00:00 +0100 + vpn-router (0.25.4-1) unstable; urgency=medium fix: a shell opens without the password and code again while the user's last use of a shell is within web.shell_auth_timeout minutes. diff --git a/debian-package/debian/control b/debian-package/debian/control index aa3fc78..39c6c99 100644 --- a/debian-package/debian/control +++ b/debian-package/debian/control @@ -22,6 +22,7 @@ Depends: ${misc:Depends}, python3-jinja2, python3-ldap, python3-pyotp, + python3-fido2, python3-psycopg, python3-psutil, python3-prometheus-client, diff --git a/debian-package/src/usr/lib/vpn-router/setup b/debian-package/src/usr/lib/vpn-router/setup index 2eb1a01..2d3efba 100755 --- a/debian-package/src/usr/lib/vpn-router/setup +++ b/debian-package/src/usr/lib/vpn-router/setup @@ -587,7 +587,7 @@ def manage_console(cfg): f'base {vpnrouter_ldap.BASE}', 'filter passwd (&(objectClass=posixAccount)(vpnRouterConsoleLogin=TRUE))', f'nss_min_uid {vpnrouter_ldap.CONSOLE_UID_MIN}', - 'pam_authz_search (&(objectClass=posixAccount)(uid=$username)(vpnRouterConsoleLogin=TRUE))', + 'pam_authz_search (&(objectClass=posixAccount)(objectClass=oathTOTPUser)(uid=$username)(vpnRouterConsoleLogin=TRUE))', 'pam_password_prohibit_message The password is changed in the Router Manager or with vpn-router-user.', '', ]) diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py index 3d3dd2f..73fc5c3 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py @@ -67,7 +67,8 @@ OVERLAYS = ('ppolicy', 'otp') #: description, the labels of a service account, and vpnRouterConsoleLogin and #: vpnRouterConsoleAccount, whether the user may use a console and the local account it runs as, #: without which it runs as the user's own posixAccount, and vpnRouterWireGuardPeer, each a -#: WireGuard peer of the user as " [,...]". vpnRouterAuthority is a CA of the PKI: its +#: WireGuard peer of the user as " [,...]", and vpnRouterPasskey, each a +#: passkey of the user as JSON (vpnrouter_passkeys). vpnRouterAuthority is a CA of the PKI: its #: certificate and CRL (cACertificate and certificateRevocationList of RFC 4523), its key, the #: CSR it waits with for the offline root, the number of its last CRL, where the CRL came from, #: and when it was retired. vpnRouterCertificate is a certificate a CA issued, named by its @@ -100,11 +101,13 @@ SCHEMA_ATTRIBUTE_TYPES = ( f"{_STRING} )", f"( {SCHEMA_OID}.1.15 NAME 'vpnRouterWireGuardPeer' DESC 'VPN router: a WireGuard peer of the user, its public key and IPs' " "EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )", + f"( {SCHEMA_OID}.1.16 NAME 'vpnRouterPasskey' DESC 'VPN router: a passkey of the user, its credential as JSON' " + "EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{4096} )", ) SCHEMA_OBJECT_CLASSES = ( f"( {SCHEMA_OID}.2.1 NAME 'vpnRouterAccount' DESC 'VPN router: properties of a user or a Manager-only account' " "SUP top AUXILIARY MAY ( mail $ vpnRouterScope $ displayName $ description $ vpnRouterConsoleLogin $ " - "vpnRouterConsoleAccount $ vpnRouterWireGuardPeer ) )", + "vpnRouterConsoleAccount $ vpnRouterWireGuardPeer $ vpnRouterPasskey ) )", f"( {SCHEMA_OID}.2.2 NAME 'vpnRouterAuthority' DESC 'VPN router: a CA of the PKI' SUP top STRUCTURAL MUST cn " "MAY ( cACertificate $ certificateRevocationList $ vpnRouterPrivateKey $ vpnRouterCertificateRequest $ " "vpnRouterCRLNumber $ vpnRouterCRLSource $ vpnRouterRetired ) )", diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_passkeys.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_passkeys.py new file mode 100644 index 0000000..a029246 --- /dev/null +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_passkeys.py @@ -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 diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py index 50cca0c..d3d4106 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py @@ -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 code of it. slapd's otp overlay then takes the password followed by a code at every bind. +A user who is not a service account may have passkeys for signing in to the Router Manager, in +vpnRouterPasskey (vpnrouter_passkeys registers and checks them). + A user is disabled when pwdAccountLockedTime holds LOCKED, and locked when the password policy has set it after failed binds, for vpnrouter_ldap.LOCKOUT_SECONDS; unlock ends that. login() checks a user's password and code with a bind. Members of ADMIN_GROUP manage the router through the web service. -A member of ADMIN_GROUP may have console login (console): the Shell page of the Router Manager, -and SSH and sudo with the directory's password, followed by the code for an enrolled user. The +A member of ADMIN_GROUP with a TOTP token may have console login (console): the Shell page of the +Router Manager, and SSH and sudo with the directory's password followed by the code; without the +token the console is refused, also for one whose console login was switched on before. The console runs as a local account, or as the user's own posixAccount, which the user entry becomes and nslcd serves to the system. Leaving ADMIN_GROUP switches it off. The module knows only the directory; the callers revoke a user's refresh tokens after the operations of vpnrouter_auth.REVOKING. @@ -53,6 +57,7 @@ import contextlib import fcntl import grp import ipaddress +import json import pwd import re import subprocess @@ -83,7 +88,7 @@ LABELS = (('display_name', 'displayName'), ('description', 'description')) #: The attributes read for a user. ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope', 'displayName', 'description', 'userCertificate;binary', 'vpnRouterConsoleLogin', 'vpnRouterConsoleAccount', - 'uidNumber', 'vpnRouterWireGuardPeer'] + 'uidNumber', 'vpnRouterWireGuardPeer', 'vpnRouterPasskey'] #: The most WireGuard peers of one user. WIREGUARD_PEERS_MAX = 16 #: The vpnRouterScope of a service account. @@ -252,6 +257,7 @@ def summary(attributes): 'otp': 'oathTOTPToken' in attributes, 'console': console_of(attributes), 'wireguard_peers': peers_of(attributes), + 'passkeys': [{key: passkey[key] for key in ('id', 'label', 'created')} for _, passkey in passkeys_of(attributes)], } for key, attribute in PERSON + LABELS: values = attributes.get(attribute) @@ -284,6 +290,20 @@ def peers_of(attributes): return peers +def passkeys_of(attributes): + """The passkeys of an entry (vpnrouter_passkeys) with their stored values: [(value, {id, credential, label, + created})]; values that cannot be read are left out.""" + passkeys = [] + for value in attributes.get('vpnRouterPasskey', []): + try: + passkey = json.loads(value) + except ValueError: + continue + if isinstance(passkey, dict) and all(isinstance(passkey.get(key), str) for key in ('id', 'credential', 'label', 'created')): + passkeys.append((value, passkey)) + return passkeys + + def checked_peers(connection, name, peers): """The values of vpnRouterWireGuardPeer for the user's WireGuard peers, [{public_key, ips}], when they are valid. @@ -349,8 +369,8 @@ def free_uid(connection): def console(connection, name, enabled, account=None): - """Switch the console login of a member of ADMIN_GROUP on, as the local account given or, without one, as the - user's own posixAccount, or off. + """Switch the console login of a member of ADMIN_GROUP with a TOTP token on, as the local account given or, without + one, as the user's own posixAccount, or off. The posixAccount is added the first time and kept when the console login is switched off, so its uidNumber and the home it owns stay the user's. A local account must exist in the system's passwd with a uid from 1000 to below @@ -373,6 +393,8 @@ def console(connection, name, enabled, account=None): raise Failure(f'{name} is a service account, which has no console', 2) if ADMIN_GROUP not in groups_of(connection, name): raise Failure(f'user {name} is not a member of {ADMIN_GROUP}', 2) + if 'oathTOTPToken' not in current: + raise Failure(f'user {name} has no TOTP token; console login needs one', 2) if account is not None: try: local = pwd.getpwnam(account) @@ -639,13 +661,15 @@ def otp_enrol(connection, name, secret, code): def otp_remove(connection, name): - """Remove the user's TOTP token, when the user has one.""" - if 'oathTOTPToken' in entry(connection, name): + """Remove the user's TOTP token, when the user has one, and end the console sessions it needed (end_sessions).""" + current = entry(connection, name) + if 'oathTOTPToken' in current: token = connection.search_s(dn_of(name), ldap.SCOPE_BASE, attrlist=TOKEN_ATTRIBUTES)[0][1] changes = [(ldap.MOD_DELETE, 'objectClass', [b'oathTOTPUser', b'oathTOTPToken']), (ldap.MOD_DELETE, 'oathSecret', None)] changes += [(ldap.MOD_DELETE, attribute, None) for attribute in token] connection.modify_s(dn_of(name), changes) + end_sessions(current) return summary(entry(connection, name)) diff --git a/debian-package/src/usr/lib/vpn-router/web b/debian-package/src/usr/lib/vpn-router/web index dcf73d6..b8a82c5 100644 --- a/debian-package/src/usr/lib/vpn-router/web +++ b/debian-package/src/usr/lib/vpn-router/web @@ -21,8 +21,8 @@ Pages: service_accounts()) /groups the groups of the router's directory: list, add, rename or describe, remove, and add and take out members (see groups()) - /account the account of the signed-in user: its details, its password and its TOTP - enrolment (see account()) + /account the account of the signed-in user: its details, its password, its TOTP + enrolment and its passkeys (see account()) /site-to-site the site-to-site connections of the configuration: list, add, change and remove, applied by vpn-router-setup (see site_to_site()) @@ -38,11 +38,12 @@ Pages: the DER certificate and CRL of the root (root) and of each issuing CA, which the certificates name in their AIA and CRL distribution points; no authentication /shell while web.shell is true, a login shell of the user's console account on a terminal, for - a member of admins whose console login is on: POST signs the user in again, 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 (see shell_ticket()), GET with it is a WebSocket that carries the terminal (see Handler.shell()) - /auth sign-in with a user's password and TOTP code, refresh and sign-out (see auth()) + /auth sign-in with a user's password and TOTP code or with a passkey, refresh and sign-out + (see auth()) /metrics/history the metrics the metrics service keeps, for a view: hour, day, month or year (see metrics_history()) @@ -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, add or take out its members, and remove one of its members, and switch a user's console login. These paths and /auth are not -served (404) while the key does not exist. /auth is limited to AUTH_LIMIT calls per client +served (404) while the key does not exist. /auth but its sign-out and the beginning of a passkey +sign-in is limited to AUTH_LIMIT calls per client address in AUTH_WINDOW seconds; the address of a call from one of web.trusted_proxies, by default the address the call came in on, is the last entry of its X-Forwarded-For header. @@ -118,6 +120,7 @@ import vpnrouter_auth import vpnrouter_backup import vpnrouter_metrics import vpnrouter_net +import vpnrouter_passkeys import vpnrouter_pki import vpnrouter_serve 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. SHELL_TICKET_SECONDS = 30 +#: Seconds between the checks of an open shell's user, which end the shell once its console is refused. +SHELL_CHECK_SECONDS = 60 + #: The largest WebSocket message accepted from a client, in bytes. MESSAGE_LIMIT = 65536 @@ -527,6 +533,7 @@ def users(state, method, path, body): POST /users//otp a new TOTP secret and its otpauth URI; nothing is stored PUT /users//otp store a TOTP secret: {"secret", "code"}, code a code of the secret DELETE /users//otp remove the TOTP token + DELETE /users//passkeys remove all the user's passkeys PUT /users//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 @@ -553,6 +560,7 @@ def users(state, method, path, body): ('POST', 3, 'otp'): lambda connection: vpnrouter_users.otp_new(connection, name), ('PUT', 3, 'otp'): lambda connection: vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code')), ('DELETE', 3, 'otp'): lambda connection: vpnrouter_users.otp_remove(connection, name), + ('DELETE', 3, 'passkeys'): lambda connection: vpnrouter_passkeys.remove(connection, name), ('PUT', 3, 'console'): lambda connection: vpnrouter_users.console(connection, name, body.get('enabled'), body.get('account')), } # The vpnrouter_users operation of each route that may revoke refresh tokens. @@ -976,20 +984,27 @@ def account(name, method, path, body): POST /account/otp a new TOTP secret and its otpauth URI; nothing is stored PUT /account/otp store a TOTP secret: {"current_password", "current_code"?, "secret", "code"}, code a code of the secret + POST /account/passkeys begin the registration of a passkey: {"current_password", "current_code"?}; + {"id", "options"} for navigator.credentials.create + PUT /account/passkeys store the passkey: {"id", "credential", "label"?}, credential the browser's answer + DELETE /account/passkeys/ + remove a passkey - A change needs the current password and, for an enrolled user, a current code; a wrong one is 403 and counts as - a failed login. A password change revokes the user's refresh tokens. + A change but the removal of a passkey needs the current password and, for an enrolled user, a current code; a wrong + one is 403 and counts as a failed login. A password change revokes the user's refresh tokens. """ if body == 'invalid' or (body is not None and not isinstance(body, dict)): return 400, {'error': 'the body must be a JSON object'} body = body or {} route = (method, path.rstrip('/')) - if route not in (('GET', '/account'), ('PUT', '/account/password'), ('POST', '/account/otp'), ('PUT', '/account/otp')): + removing = method == 'DELETE' and path.startswith('/account/passkeys/') and len(path.strip('/').split('/')) == 3 + if not removing and route not in (('GET', '/account'), ('PUT', '/account/password'), ('POST', '/account/otp'), + ('PUT', '/account/otp'), ('POST', '/account/passkeys'), ('PUT', '/account/passkeys')): return 404, {'error': f'no route {method} {path}'} try: if route == ('PUT', '/account/password'): vpnrouter_users.check_password(body.get('password')) - if method == 'PUT': + if route in (('PUT', '/account/password'), ('PUT', '/account/otp'), ('POST', '/account/passkeys')): vpnrouter_users.login(name, body.get('current_password'), body.get('current_code')) with vpnrouter_users.session() as connection: if route == ('GET', '/account'): @@ -999,6 +1014,13 @@ def account(name, method, path, body): document = vpnrouter_users.passwd(connection, name, body['password']) elif route == ('POST', '/account/otp'): document = vpnrouter_users.otp_new(connection, name) + elif route == ('POST', '/account/passkeys'): + document = vpnrouter_passkeys.register_begin(connection, name) + elif route == ('PUT', '/account/passkeys'): + document = vpnrouter_passkeys.register_complete(connection, name, body.get('id'), body.get('credential'), + body.get('label')) + elif removing: + document = vpnrouter_passkeys.remove(connection, name, urllib.parse.unquote(path.strip('/').split('/')[2])) else: document = vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code')) if route == ('PUT', '/account/password'): @@ -1071,7 +1093,10 @@ def refusal(claims, method, path, body): def auth(path, body): """Answer a POST to /auth with vpnrouter_users and vpnrouter_auth: the HTTP status and the JSON document. - POST /auth sign in: {"name", "password", "code"?}; the tokens + POST /auth sign in: {"name", "password", "code"?}; the tokens and the user's name + POST /auth/passkey begin a sign-in with a passkey: {"id", "options"} for navigator.credentials.get + POST /auth/passkey/complete + {"id", "credential"}, credential the browser's answer; the tokens and the user's name POST /auth/refresh {"refresh_token"}; new tokens, the refresh token used up POST /auth/logout {"refresh_token"}; the refresh token revoked """ @@ -1084,7 +1109,13 @@ def auth(path, body): with vpnrouter_users.session() as connection: if vpnrouter_users.service(vpnrouter_users.entry(connection, body['name'])): raise vpnrouter_users.Failure('the name, the password or the code is wrong', 5) - return 200, vpnrouter_auth.issue(cfg, body['name']) + return 200, {**vpnrouter_auth.issue(cfg, body['name']), 'name': body['name']} + if path == '/auth/passkey': + return 200, vpnrouter_passkeys.login_begin() + if path == '/auth/passkey/complete': + with vpnrouter_users.session() as connection: + name = vpnrouter_passkeys.login_complete(connection, body.get('id'), body.get('credential')) + return 200, {**vpnrouter_auth.issue(cfg, name), 'name': name} name = vpnrouter_auth.take(cfg, body.get('refresh_token')) if path == '/auth/logout': return 200, {'revoked': name is not None} @@ -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 _shell_used) is less than web.shell_auth_timeout minutes ago. The answer is {"ticket", "expires_in"}: a ticket that opens one shell (GET /shell) for the same user within SHELL_TICKET_SECONDS, or a null ticket when the user must - sign in. 403 while web.shell is false 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): 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'] if not options['shell']: return 403, {'error': 'the shell is disabled; vpn-config-edit set web.shell true enables it'} - if console_account(name) is None: - return 403, {'error': f'console login is not enabled for {name}; vpn-router-user console sets it'} + refusal = console_account(name)[1] + if refusal: + return 403, {'error': refusal} if 'password' not in body: with _shell_lock: used = _shell_used.get(name) @@ -1150,10 +1182,15 @@ def shell_ticket(name, body): def console_account(name): - """The system account the console of the user runs as, or None while its console login is not on.""" + """The system account the console of the user runs as and None, or None and the reason the user may not use a + console: its console login is off, or it has no TOTP token.""" with vpnrouter_users.session() as connection: - console = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))['console'] - return console['account'] if console and console['enabled'] else None + user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name)) + if not user['console'] or not user['console']['enabled']: + return None, f'console login is not enabled for {name}; vpn-router-user console sets it' + if not user['otp']: + return None, f'{name} has no TOTP token; the shell needs one, enrolled on the Account page' + return user['console']['account'], None def take_ticket(name, ticket): @@ -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 keystrokes, a text message {"cols", "rows"} sets the window size, and binary messages of the server are the - terminal's output. The shell ends after web.shell_idle_minutes without a keystroke, when it exits, or when the - client closes the connection; the server then closes it with the reason. Each opening, end and refusal is logged + terminal's output. The shell ends after web.shell_idle_minutes without a keystroke, when it exits, when the + client closes the connection, or when a check of the user (SHELL_CHECK_SECONDS) finds its console login off or + its TOTP token gone; the server then closes it with the reason. Each opening, end and refusal is logged with its reason. """ def refuse(code, message): @@ -1305,11 +1343,11 @@ class Handler(BaseHTTPRequestHandler): if not options['shell']: return refuse(403, 'the shell is disabled; vpn-config-edit set web.shell true enables it') try: - login = console_account(name) + login, refusal = console_account(name) except (vpnrouter_users.Failure, ldap.LDAPError, OSError) as exc: return refuse(500, f'the user directory failed: {exc}') - if login is None: - return refuse(403, f'console login is not enabled for {name}') + if refusal: + return refuse(403, refusal) try: account = pwd.getpwnam(login) except KeyError: @@ -1363,12 +1401,23 @@ class Handler(BaseHTTPRequestHandler): reader = threading.Thread(target=receive, name=f'{unit} input', daemon=True) reader.start() reason = 'the shell ended' + checked = started try: while not ended.is_set(): left = typed[0] + idle - time.monotonic() if left <= 0: reason = f'no keystroke for {options["shell_idle_minutes"]} minutes' break + # The user's console login and TOTP token are checked again every SHELL_CHECK_SECONDS. + if time.monotonic() - checked >= SHELL_CHECK_SECONDS: + checked = time.monotonic() + try: + refusal = console_account(name)[1] + except (vpnrouter_users.Failure, ldap.LDAPError, OSError) as exc: + refusal = f'the user directory failed: {exc}' + if refusal: + reason = refusal + break if select.select([master], [], [], min(left, 1))[0]: try: output = os.read(master, 65536) @@ -1410,7 +1459,7 @@ class Handler(BaseHTTPRequestHandler): 'ca', 'shell', 'remote-access') if path == f'/{name}' or path.startswith(f'/{name}/')), None) protected = path in ('/status/full', '/config', '/metrics/history', '/postgresql') or directory is not None - signing = path in ('/auth', '/auth/refresh', '/auth/logout') + signing = path in ('/auth', '/auth/passkey', '/auth/passkey/complete', '/auth/refresh', '/auth/logout') state = current() if protected or signing else {} claims = self.claims(state['key']) if protected and state['key'] else None payload = self.request_body() if (protected or signing) and self.command not in ('GET', 'HEAD') else None @@ -1421,7 +1470,7 @@ class Handler(BaseHTTPRequestHandler): elif signing and self.command != 'POST': body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405 elif signing: - wait = auth_wait(self.client()) if path != '/auth/logout' else 0 + wait = auth_wait(self.client()) if path not in ('/auth/logout', '/auth/passkey') else 0 if wait: code, document = 429, {'error': f'too many sign-in calls; retry in {wait} seconds'} extra.append(('Retry-After', str(wait))) diff --git a/debian-package/src/usr/sbin/vpn-router-user b/debian-package/src/usr/sbin/vpn-router-user index a738f66..99338d8 100755 --- a/debian-package/src/usr/sbin/vpn-router-user +++ b/debian-package/src/usr/sbin/vpn-router-user @@ -25,6 +25,7 @@ user exists, 1 anything else. -h or --help shows the commands and their options. vpn-router-user [--json] otp-enrol (secret on stdin) vpn-router-user [--json] otp-remove vpn-router-user [--json] console (--posix | --account LOCAL | --off) + vpn-router-user [--json] passkeys-remove """ import argparse @@ -37,6 +38,7 @@ import ldap import vpnrouter import vpnrouter_auth +import vpnrouter_passkeys import vpnrouter_users from vpnrouter_users import Failure @@ -57,8 +59,9 @@ HELP = { 'otp-new': 'make a TOTP secret and its otpauth URI; nothing is stored', 'otp-enrol': 'store the TOTP secret read from standard input, when the code is a code of it', 'otp-remove': 'remove the TOTP token', - 'console': "switch the console login of a member of admins on, as the user's own POSIX account in the directory " - 'or as a local account, or off', + 'console': "switch the console login of a member of admins with a TOTP token on, as the user's own POSIX account " + 'in the directory or as a local account, or off', + 'passkeys-remove': "remove all the user's passkeys", } #: The help of the person options. @@ -118,14 +121,19 @@ def run(connection, options, secret): return vpnrouter_users.otp_enrol(connection, options.name, secret, options.code) if command == 'console': return vpnrouter_users.console(connection, options.name, not options.off, options.account) + if command == 'passkeys-remove': + return vpnrouter_passkeys.remove(connection, options.name) return getattr(vpnrouter_users, command.replace('-', '_'))(connection, options.name) -def console_text(console): - """The console login of a user as a cell: -, off, posix (uid N) or local and the account.""" +def console_text(user): + """The console login of a user as a cell: -, off, posix (uid N) or local and the account, refused without a TOTP + token.""" + console = user['console'] if console is None or not console['enabled']: return '-' if console is None else 'off' - return f'local {console["account"]}' if console['local'] else f'posix (uid {console["uid"]})' + text = f'local {console["account"]}' if console['local'] else f'posix (uid {console["uid"]})' + return text if user['otp'] else f'{text}, refused: no OTP' def text_of(answer): @@ -139,11 +147,11 @@ def text_of(answer): users = answer['users'] if 'users' in answer else [answer] if not users: return 'no users' - head = ('USER', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'CONSOLE', 'FIRST NAME', 'LAST NAME', 'E-MAIL') + head = ('USER', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'PASSKEYS', 'CONSOLE', 'FIRST NAME', 'LAST NAME', 'E-MAIL') rows = [(user['name'], 'internal' if user['internal'] else 'service' if user['service'] else 'manager' if user['manager_only'] else 'vpn', 'disabled' if not user['enabled'] else 'locked' if user['locked'] else 'enabled', 'issued' if user['certificate'] else 'missing', - 'enrolled' if user['otp'] else '-', console_text(user['console']), + '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 '-') for user in users] widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]