feature: users can be enrolled for TOTP one-time passwords.
Release / release (push) Failing after 15m23s

This commit is contained in:
slawek committed 2026-10-24 03:00:35 +02:00
1 parent f135d776f8
commit 791fc7fddd
8 files changed
+176 -19

No files matched your search

+21 -7
View File
@@ -1,7 +1,8 @@
#!/usr/bin/python3
"""Manage the users of the router's directory from the command line, with vpnrouter_users.
Passwords are read from the first line of standard input, never from the command line. The
Passwords and TOTP secrets are read from the first line of standard input, never from the
command line. The
answer is text for people on standard output, and a failure is a message on standard error.
With --json the answer is JSON on standard output, and a failure is {"error": ...} there too.
The exit status says what failed: 2 the request is invalid, 3 the user does not exist, 4 the
@@ -15,6 +16,9 @@ user exists, 1 anything else. -h or --help shows the commands and their options.
vpn-router-user [--json] enable <name>
vpn-router-user [--json] remove <name>
vpn-router-user [--json] profile <name>
vpn-router-user [--json] otp-new <name>
vpn-router-user [--json] otp-enrol <name> <code> (secret on stdin)
vpn-router-user [--json] otp-remove <name>
"""
import argparse
@@ -38,6 +42,9 @@ HELP = {
'enable': 'let the user in again',
'remove': 'revoke the certificate and remove the user',
'profile': 'print the OpenVPN client profile',
'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',
}
#: The help of the person options.
@@ -63,16 +70,18 @@ def parse(argv):
for key, _ in vpnrouter_users.PERSON:
sub.add_argument(f'--{key.replace("_", "-")}', dest=key, metavar='TEXT', required=command == 'add',
help=PERSON_HELP[key])
if command == 'otp-enrol':
sub.add_argument('code', help='a code of the secret from the authenticator')
return parser.parse_args(argv)
def password():
"""The password from the first line of standard input."""
"""The password or the TOTP secret from the first line of standard input."""
return sys.stdin.readline().rstrip('\n')
def run(connection, options, secret):
"""The answer of the command the options name; secret is the password of add and passwd."""
"""The answer of the command the options name; secret is the password of add and passwd, the TOTP secret of otp-enrol."""
command = options.command
if command == 'list':
return vpnrouter_users.list_users(connection)
@@ -83,21 +92,26 @@ def run(connection, options, secret):
return vpnrouter_users.update(connection, options.name, values)
if command == 'passwd':
return vpnrouter_users.passwd(connection, options.name, secret)
return getattr(vpnrouter_users, command)(connection, options.name)
if command == 'otp-enrol':
return vpnrouter_users.otp_enrol(connection, options.name, secret, options.code)
return getattr(vpnrouter_users, command.replace('-', '_'))(connection, options.name)
def text_of(answer):
"""The answer as text for people."""
if 'profile' in answer:
return answer['profile'].rstrip('\n')
if 'secret' in answer:
return f'secret {answer["secret"]}\nuri {answer["uri"]}'
if answer.get('removed'):
return f'user {answer["name"]} removed'
users = answer['users'] if 'users' in answer else [answer]
if not users:
return 'no users'
head = ('USER', 'STATE', 'CERTIFICATE', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
head = ('USER', 'STATE', 'CERTIFICATE', 'OTP', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
rows = [(user['name'], 'enabled' if user['enabled'] else 'disabled', 'issued' if user['certificate'] else 'missing',
user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-') for user in users]
'enrolled' if user['otp'] else '-', 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))]
return '\n'.join(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows))
@@ -110,7 +124,7 @@ def main(argv):
as_json = options.json
if options.command != 'list':
vpnrouter_users.check_name(options.name)
secret = password() if options.command in ('add', 'passwd') else None
secret = password() if options.command in ('add', 'passwd', 'otp-enrol') else None
with vpnrouter_users.session() as connection:
answer = run(connection, options, secret)
except Failure as failure: