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
@@ -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)))
|
||||
|
||||
Reference in new issue
Block a user