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