feature: users can be enrolled for TOTP one-time passwords.
Release / release (push) Failing after 15m23s
Release / release (push) Failing after 15m23s
This commit is contained in:
1 parent
f135d776f8
commit
791fc7fddd
8 files changed
+176
-19
No files matched your search
@@ -247,3 +247,27 @@ serves them under `/groups`. Group names follow the rules of user names.
|
||||
Consequences: A group always satisfies the schema without a placeholder member, and an empty
|
||||
group cannot exist. Removing a user or a member can need another step first. A later use of
|
||||
groups, such as allowing OpenVPN only to some, can read them as they are.
|
||||
|
||||
## ADR-12: TOTP secrets are kept with the OATH-LDAP schema of slapd's otp module
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: Users are to be enrolled for TOTP one-time passwords, with nothing checking the codes
|
||||
yet. Ubuntu's slapd ships the `otp` module, which holds the OATH-LDAP schema and an overlay that
|
||||
checks a code appended to the password at bind time. Ubuntu packages `python3-pyotp`. An
|
||||
authenticator app is enrolled from a QR code of an `otpauth://` URI.
|
||||
|
||||
Decision: The `otp` module is loaded for its schema; its overlay is not configured. The TOTP
|
||||
token is the user entry itself: enrolment adds `oathTOTPUser` and `oathTOTPToken` to it, with
|
||||
`oathTOTPToken` naming the entry, the secret in `oathSecret` and `oathTOTPParams` naming one
|
||||
shared parameter entry, `cn=totp,ou=policies`, with the parameters authenticator apps assume
|
||||
(6 digits, HMAC-SHA1, 30 seconds). An ACL lets the user manager account set `oathSecret` and
|
||||
no one read it. Enrolment is confirmed: `otp_new` makes a secret with `pyotp` and returns it
|
||||
with its URI, storing nothing, and `otp_enrol` stores the secret it is given back only when a
|
||||
code of it verifies, so a secret is never stored that the user's app does not produce. The
|
||||
router answers the URI; the client renders the QR code.
|
||||
|
||||
Consequences: Removing a user removes its token. Configuring the overlay later makes slapd
|
||||
check the stored tokens without moving data. The secret passes through the client between
|
||||
`otp_new` and `otp_enrol`, so the router keeps no pending state; enrolling again replaces the
|
||||
secret only once a code of the new one verifies.
|
||||
@@ -331,6 +331,11 @@ is switched on:
|
||||
`/etc/vpn-router/ldap/user-manager.secret` (root only);
|
||||
- passwords are stored as ARGON2 hashes, and a disabled user is locked with the password policy
|
||||
(`pwdAccountLockedTime`);
|
||||
- slapd's `otp` module is loaded for the OATH-LDAP schema; a user enrolled for TOTP is also an
|
||||
`oathTOTPUser` and `oathTOTPToken` whose `oathTOTPToken` names the entry itself, with the
|
||||
secret in `oathSecret`, which the user manager account may set and no one can read, and
|
||||
`oathTOTPParams` naming `cn=totp,ou=policies` (6 digits, HMAC-SHA1, 30 second steps, one step
|
||||
of tolerance). The module's overlay is not configured, so binds take the password alone;
|
||||
- slapd listens on its local socket only (`SLAPD_SERVICES` in `/etc/default/slapd`).
|
||||
|
||||
The directory is kept on purge.
|
||||
@@ -340,8 +345,9 @@ The directory is kept on purge.
|
||||
`vpn-router-user` manages the users. A user is the directory entry, with a first name, a last
|
||||
name and an e-mail address (`givenName`, `sn` and `mail`; `cn` is the first and the last name),
|
||||
and a client certificate with the user name as its common name, in `/etc/vpn-router/pki/users`.
|
||||
Passwords are read from the first line of standard input. The answer is text: a table of the
|
||||
users, the client profile or a short message, and a failure is a message on standard error.
|
||||
Passwords and TOTP secrets are read from the first line of standard input. The answer is text: a
|
||||
table of the users, the client profile, a TOTP secret or a short message, and a failure is a
|
||||
message on standard error.
|
||||
`--json`, before the command, gives the answer as JSON on standard output, and a failure as
|
||||
`{"error": ...}` there too; the web service uses it. `-h` or `--help` shows the commands, and
|
||||
after a command its options.
|
||||
@@ -356,21 +362,29 @@ echo 'n3w-pass' | vpn-router-user passwd alice
|
||||
vpn-router-user disable alice
|
||||
vpn-router-user enable alice
|
||||
vpn-router-user remove alice
|
||||
vpn-router-user otp-new alice
|
||||
echo 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP' | vpn-router-user otp-enrol alice 123456
|
||||
vpn-router-user otp-remove alice
|
||||
```
|
||||
|
||||
| Command | Effect |
|
||||
| --- | --- |
|
||||
| `list` | The users with their names and e-mail addresses, whether each is enabled and has a certificate |
|
||||
| `list` | The users with their names and e-mail addresses, whether each is enabled, has a certificate and is enrolled for TOTP |
|
||||
| `add <name> --first-name F --last-name L --email E` | Adds the user and issues the certificate; all three are required |
|
||||
| `update <name> [--first-name F] [--last-name L] [--email E]` | Changes the names or the e-mail address |
|
||||
| `passwd <name>` | Sets the password |
|
||||
| `disable <name>`, `enable <name>` | Locks the user out of OpenVPN, and lets the user in again |
|
||||
| `remove <name>` | Takes the user out of its groups, revokes the certificate, renews the CRL and removes the user; refused while the user is the last member of a group |
|
||||
| `profile <name>` | The OpenVPN client profile, with the CA, the certificate and the key inline |
|
||||
| `otp-new <name>` | A new TOTP secret and its `otpauth://` URI, issued by `wan.local_fqdn`, for an authenticator app; nothing is stored |
|
||||
| `otp-enrol <name> <code>` | Stores the secret read from standard input when the code is a current code of it, replacing a secret the user has |
|
||||
| `otp-remove <name>` | Removes the user's TOTP secret |
|
||||
|
||||
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
|
||||
e-mail address has one `@`, no spaces and a dot in its domain. In the answers a user added
|
||||
e-mail address has one `@`, no spaces and a dot in its domain; a TOTP secret is 32 base32
|
||||
characters, as `otp-new` makes it, and a code 6 digits. Enrolment does not change how users log
|
||||
in. In the answers a user added
|
||||
without the names or the e-mail address has them as `null`. The exit status is 0 on success, 2 for an invalid request,
|
||||
3 when the user does not exist, 4 when the user exists and 1 for any other failure.
|
||||
|
||||
@@ -521,7 +535,7 @@ and answer bodies are JSON; a request body is at most 4096 bytes.
|
||||
|
||||
| Request | Body | Answer |
|
||||
| --- | --- | --- |
|
||||
| `GET /users` | | `{"users": [{"name", "enabled", "certificate", "first_name", "last_name", "email"}, ...]}` |
|
||||
| `GET /users` | | `{"users": [{"name", "enabled", "certificate", "otp", "first_name", "last_name", "email"}, ...]}` |
|
||||
| `POST /users` | `{"name", "password", "first_name", "last_name", "email"}`, all required | 201 and the user |
|
||||
| `PUT /users/<name>` | any of `{"first_name", "last_name", "email"}` | the user |
|
||||
| `PUT /users/<name>/password` | `{"password": ...}` | the user |
|
||||
@@ -529,11 +543,14 @@ and answer bodies are JSON; a request body is at most 4096 bytes.
|
||||
| `POST /users/<name>/enable` | | the user |
|
||||
| `DELETE /users/<name>` | | `{"name", "removed": true}` |
|
||||
| `GET /users/<name>/profile` | | `{"name", "profile"}`, the OpenVPN client profile |
|
||||
| `POST /users/<name>/otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored |
|
||||
| `PUT /users/<name>/otp` | `{"secret", "code"}` | the user, once the code matches the secret |
|
||||
| `DELETE /users/<name>/otp` | | the user |
|
||||
|
||||
A failure is `{"error": ...}` with 400 for an invalid request, 404 for a user that does not
|
||||
exist, 409 for a user that exists and 500 otherwise. `/users` works whether or not OpenVPN or
|
||||
road-warrior access is enabled; the profile needs OpenVPN. Passwords and private keys cross the internal
|
||||
network in plain HTTP, like the token.
|
||||
road-warrior access is enabled; the profile needs OpenVPN. Passwords, private keys and TOTP
|
||||
secrets cross the internal network in plain HTTP, like the token.
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
vpn-router (0.6.0-1) unstable; urgency=medium
|
||||
|
||||
feature: users can be enrolled for TOTP one-time passwords.
|
||||
|
||||
* vpn-router-user otp-new makes a TOTP secret and its otpauth URI,
|
||||
otp-enrol stores the secret, read from standard input, given a code of
|
||||
it, and otp-remove removes it. list and every answer show whether a user
|
||||
is enrolled, as otp.
|
||||
* The web service enrols with POST, PUT and DELETE /users/<name>/otp.
|
||||
* The secret is kept in the user's entry with the OATH-LDAP schema of
|
||||
slapd's otp module; only the user manager account may set it, and no
|
||||
one can read it. Logins do not ask for the codes.
|
||||
* The package depends on python3-pyotp.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 23:30:00 +0200
|
||||
|
||||
vpn-router (0.5.9-1) unstable; urgency=medium
|
||||
|
||||
fix: package upgrades no longer ask the configuration questions.
|
||||
|
||||
@@ -21,6 +21,7 @@ Depends: ${misc:Depends},
|
||||
python3,
|
||||
python3-jinja2,
|
||||
python3-ldap,
|
||||
python3-pyotp,
|
||||
slapd,
|
||||
openvpn,
|
||||
openvpn-auth-ldap,
|
||||
|
||||
@@ -4,7 +4,12 @@ The package keeps its own database, dc=vpn-router,dc=local in /var/lib/ldap/vpn-
|
||||
next to the one slapd's installation creates. Root manages it through the local socket
|
||||
with SASL EXTERNAL: the database's root DN is root's peer-credential DN, so there is no
|
||||
administrator password. Users and groups are changed by the user-manager service account,
|
||||
which the ACLs allow to write ou=users and ou=groups and to set, never read, passwords.
|
||||
which the ACLs allow to write ou=users and ou=groups and to set, never read, passwords and
|
||||
TOTP secrets.
|
||||
|
||||
slapd's otp module is loaded for the OATH-LDAP schema; its overlay is not configured on the
|
||||
database, so binds do not check one-time passwords. cn=totp,ou=policies holds the TOTP
|
||||
parameters the token of every user points to.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -24,6 +29,7 @@ GROUPS = f'ou=groups,{BASE}'
|
||||
SERVICES = f'ou=services,{BASE}'
|
||||
POLICIES = f'ou=policies,{BASE}'
|
||||
DEFAULT_POLICY = f'cn=default,{POLICIES}'
|
||||
TOTP_PARAMS = f'cn=totp,{POLICIES}'
|
||||
MANAGER = f'cn=user-manager,{SERVICES}'
|
||||
ROOT = 'gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth'
|
||||
|
||||
@@ -33,12 +39,19 @@ MANAGER_SECRET = SECRET_DIR / 'user-manager.secret'
|
||||
SLAPD_DEFAULTS = pathlib.Path('/etc/default/slapd')
|
||||
SLAPD_SERVICES = 'SLAPD_SERVICES="ldapi:///"'
|
||||
|
||||
MODULES = ('argon2', 'ppolicy')
|
||||
MODULES = ('argon2', 'ppolicy', 'otp')
|
||||
PASSWORD_HASH = '{ARGON2}'
|
||||
|
||||
#: The TOTP parameters: digits, the OID of HMAC-SHA1, seconds per time step, and the time steps accepted before and after now.
|
||||
TOTP_DIGITS = 6
|
||||
TOTP_ALGORITHM = '1.2.840.113549.2.7'
|
||||
TOTP_PERIOD = 30
|
||||
TOTP_WINDOW = 1
|
||||
|
||||
#: The ACLs of the database, in order.
|
||||
ACCESS = (
|
||||
f'to attrs=userPassword by dn.exact="{MANAGER}" =wx by self =wx by anonymous auth by * none',
|
||||
f'to attrs=oathSecret by dn.exact="{MANAGER}" =w by * none',
|
||||
f'to dn.subtree="{USERS}" by dn.exact="{MANAGER}" manage by * read',
|
||||
f'to dn.subtree="{GROUPS}" by dn.exact="{MANAGER}" manage by * read',
|
||||
'to * by * read',
|
||||
@@ -179,6 +192,14 @@ def provision():
|
||||
'pwdLockout': [b'TRUE'],
|
||||
'pwdMaxFailure': [b'0'],
|
||||
})
|
||||
_ensure_entry(connection, TOTP_PARAMS, {
|
||||
'objectClass': [b'organizationalRole', b'oathTOTPParams'],
|
||||
'cn': [b'totp'],
|
||||
'oathOTPLength': [str(TOTP_DIGITS).encode()],
|
||||
'oathHMACAlgorithm': [TOTP_ALGORITHM.encode()],
|
||||
'oathTOTPTimeStepPeriod': [str(TOTP_PERIOD).encode()],
|
||||
'oathTOTPTimeStepWindow': [str(TOTP_WINDOW).encode()],
|
||||
})
|
||||
_ensure_manager(connection)
|
||||
finally:
|
||||
connection.unbind_s()
|
||||
@@ -10,22 +10,31 @@ optional description and at least one member; a group cannot be empty, so the la
|
||||
cannot leave it and a user who is the last member of a group cannot be removed. Groups only
|
||||
record membership; nothing on the router reads them.
|
||||
|
||||
A user may have a TOTP token: the user entry itself is an oathTOTPUser and oathTOTPToken whose
|
||||
oathTOTPToken points to itself, with the secret in oathSecret and the parameters of
|
||||
vpnrouter_ldap.TOTP_PARAMS. Enrolment stores nothing until a code of the new secret is
|
||||
verified: otp_new makes a secret and its otpauth URI, and otp_enrol stores the secret given a
|
||||
code of it. Nothing on the router checks the codes at login.
|
||||
|
||||
Every operation runs in a session(), which holds the lock that serializes changes and the
|
||||
connection of the user manager account. A request that cannot be done raises Failure, whose
|
||||
status says why: 2 the request is invalid, 3 the user does not exist, 4 the user exists, 1
|
||||
anything else. Answers are dicts with the keys of the JSON answers.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import fcntl
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import ldap
|
||||
import ldap.filter
|
||||
import ldap.modlist
|
||||
import pyotp
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_ldap
|
||||
@@ -42,7 +51,12 @@ EMAIL_MAX = 254
|
||||
#: The keys of the person attributes and their LDAP attributes.
|
||||
PERSON = (('first_name', 'givenName'), ('last_name', 'sn'), ('email', 'mail'))
|
||||
#: The attributes read for a user.
|
||||
ATTRIBUTES = ['uid', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail']
|
||||
ATTRIBUTES = ['uid', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken']
|
||||
#: The readable attributes of a TOTP token; oathSecret is never readable.
|
||||
TOKEN_ATTRIBUTES = ['oathTOTPToken', 'oathTOTPParams', 'oathSecretTime', 'oathTOTPLastTimeStep', 'oathTOTPTimeStepDrift',
|
||||
'oathLastLogin', 'oathLastFailure', 'oathFailureCount']
|
||||
#: A TOTP secret as otp_new makes it: 32 base32 characters, 160 bits.
|
||||
TOTP_SECRET = re.compile(r'^[A-Z2-7]{32}$')
|
||||
#: The attributes read for a group.
|
||||
GROUP_ATTRIBUTES = ['cn', 'description', 'member']
|
||||
#: The longest group description, in characters.
|
||||
@@ -145,6 +159,7 @@ def summary(attributes):
|
||||
'name': name,
|
||||
'enabled': 'pwdAccountLockedTime' not in attributes,
|
||||
'certificate': certificate(name)[0].is_file(),
|
||||
'otp': 'oathTOTPToken' in attributes,
|
||||
}
|
||||
for key, attribute in PERSON:
|
||||
values = attributes.get(attribute)
|
||||
@@ -218,6 +233,49 @@ def enable(connection, name):
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
def totp(secret):
|
||||
return pyotp.TOTP(secret, digits=vpnrouter_ldap.TOTP_DIGITS, interval=vpnrouter_ldap.TOTP_PERIOD)
|
||||
|
||||
|
||||
def otp_new(connection, name):
|
||||
"""A new TOTP secret for the user and its otpauth URI, issued by the router's wan.local_fqdn; nothing is stored."""
|
||||
entry(connection, name)
|
||||
secret = pyotp.random_base32()
|
||||
issuer = vpnrouter.load_config()['wan']['local_fqdn'].strip()
|
||||
return {'name': name, 'secret': secret, 'uri': totp(secret).provisioning_uri(name=name, issuer_name=issuer or None)}
|
||||
|
||||
|
||||
def otp_enrol(connection, name, secret, code):
|
||||
"""Store the secret of otp_new as the user's TOTP token, replacing one the user has, when code is a code of it."""
|
||||
current = entry(connection, name)
|
||||
if not isinstance(secret, str) or not TOTP_SECRET.match(secret):
|
||||
raise Failure('the secret must be 32 base32 characters, as a new secret has them', 2)
|
||||
if not isinstance(code, str) or not re.fullmatch(rf'\d{{{vpnrouter_ldap.TOTP_DIGITS}}}', code):
|
||||
raise Failure(f'the code must be {vpnrouter_ldap.TOTP_DIGITS} digits', 2)
|
||||
if not totp(secret).verify(code, valid_window=vpnrouter_ldap.TOTP_WINDOW):
|
||||
raise Failure('the code does not match the secret; check the clock of the authenticator', 2)
|
||||
changes = [] if 'oathTOTPToken' in current else [(ldap.MOD_ADD, 'objectClass', [b'oathTOTPUser', b'oathTOTPToken'])]
|
||||
changes += [
|
||||
(ldap.MOD_REPLACE, 'oathTOTPToken', [dn_of(name).encode()]),
|
||||
(ldap.MOD_REPLACE, 'oathTOTPParams', [vpnrouter_ldap.TOTP_PARAMS.encode()]),
|
||||
(ldap.MOD_REPLACE, 'oathSecret', [base64.b32decode(secret)]),
|
||||
(ldap.MOD_REPLACE, 'oathSecretTime', [time.strftime('%Y%m%d%H%M%SZ', time.gmtime()).encode()]),
|
||||
]
|
||||
connection.modify_s(dn_of(name), changes)
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
def otp_remove(connection, name):
|
||||
"""Remove the user's TOTP token, when the user has one."""
|
||||
if 'oathTOTPToken' in entry(connection, name):
|
||||
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)
|
||||
return summary(entry(connection, name))
|
||||
|
||||
|
||||
def remove(connection, name):
|
||||
"""Remove the user from its groups and the directory, and revoke the certificate.
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ Pages:
|
||||
/config the configuration without its secrets, and what the router resolved and
|
||||
runs, as JSON
|
||||
/users the users in the router's directory, whether or not a P2S system is enabled: list,
|
||||
add, change, set the password, disable, enable, remove, and the OpenVPN client
|
||||
profile (see users())
|
||||
add, change, set the password, disable, enable, remove, the OpenVPN client
|
||||
profile, and TOTP enrolment (see users())
|
||||
/groups the groups of the router's directory: list, add, rename or describe, remove, and
|
||||
add and take out members (see groups())
|
||||
|
||||
@@ -422,6 +422,9 @@ def users(state, method, path, body):
|
||||
POST /users/<name>/enable let the user in again
|
||||
DELETE /users/<name> remove the user and revoke the certificate
|
||||
GET /users/<name>/profile the OpenVPN client profile
|
||||
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
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
@@ -438,6 +441,9 @@ def users(state, method, path, body):
|
||||
('POST', 3, 'enable'): lambda connection: vpnrouter_users.enable(connection, name),
|
||||
('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name),
|
||||
('GET', 3, 'profile'): lambda connection: vpnrouter_users.profile(connection, name),
|
||||
('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),
|
||||
}
|
||||
if route not in actions:
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user