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

This commit is contained in:
slawek committed 2026-10-26 08:35:10 +01:00
1 parent 91cc64d6a6
commit 90db6d46a2
10 files changed
+408 -62

No files matched your search

@@ -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))