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
@@ -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.',
|
||||
'',
|
||||
])
|
||||
|
||||
@@ -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 "<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
|
||||
#: 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 ) )",
|
||||
|
||||
@@ -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
|
||||
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))
|
||||
|
||||
|
||||
|
||||
@@ -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/<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
|
||||
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,
|
||||
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/<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 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)))
|
||||
|
||||
@@ -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-remove <name>
|
||||
vpn-router-user [--json] console <name> (--posix | --account LOCAL | --off)
|
||||
vpn-router-user [--json] passkeys-remove <name>
|
||||
"""
|
||||
|
||||
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))]
|
||||
|
||||
Reference in new issue
Block a user