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
|
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
|
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.
|
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);
|
`/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
|
- passwords are stored as ARGON2 hashes, and a disabled user is locked with the password policy
|
||||||
(`pwdAccountLockedTime`);
|
(`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`).
|
- slapd listens on its local socket only (`SLAPD_SERVICES` in `/etc/default/slapd`).
|
||||||
|
|
||||||
The directory is kept on purge.
|
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
|
`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),
|
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`.
|
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
|
Passwords and TOTP secrets are read from the first line of standard input. The answer is text: a
|
||||||
users, the client profile or a short message, and a failure is a message on standard error.
|
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
|
`--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
|
`{"error": ...}` there too; the web service uses it. `-h` or `--help` shows the commands, and
|
||||||
after a command its options.
|
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 disable alice
|
||||||
vpn-router-user enable alice
|
vpn-router-user enable alice
|
||||||
vpn-router-user remove 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 |
|
| 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 |
|
| `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 |
|
| `update <name> [--first-name F] [--last-name L] [--email E]` | Changes the names or the e-mail address |
|
||||||
| `passwd <name>` | Sets the password |
|
| `passwd <name>` | Sets the password |
|
||||||
| `disable <name>`, `enable <name>` | Locks the user out of OpenVPN, and lets the user in again |
|
| `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 |
|
| `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 |
|
| `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
|
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
|
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,
|
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.
|
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 |
|
| 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 |
|
| `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>` | any of `{"first_name", "last_name", "email"}` | the user |
|
||||||
| `PUT /users/<name>/password` | `{"password": ...}` | 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 |
|
| `POST /users/<name>/enable` | | the user |
|
||||||
| `DELETE /users/<name>` | | `{"name", "removed": true}` |
|
| `DELETE /users/<name>` | | `{"name", "removed": true}` |
|
||||||
| `GET /users/<name>/profile` | | `{"name", "profile"}`, the OpenVPN client profile |
|
| `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
|
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
|
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
|
road-warrior access is enabled; the profile needs OpenVPN. Passwords, private keys and TOTP
|
||||||
network in plain HTTP, like the token.
|
secrets cross the internal network in plain HTTP, like the token.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
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
|
vpn-router (0.5.9-1) unstable; urgency=medium
|
||||||
|
|
||||||
fix: package upgrades no longer ask the configuration questions.
|
fix: package upgrades no longer ask the configuration questions.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ Depends: ${misc:Depends},
|
|||||||
python3,
|
python3,
|
||||||
python3-jinja2,
|
python3-jinja2,
|
||||||
python3-ldap,
|
python3-ldap,
|
||||||
|
python3-pyotp,
|
||||||
slapd,
|
slapd,
|
||||||
openvpn,
|
openvpn,
|
||||||
openvpn-auth-ldap,
|
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
|
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
|
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,
|
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
|
import os
|
||||||
@@ -24,6 +29,7 @@ GROUPS = f'ou=groups,{BASE}'
|
|||||||
SERVICES = f'ou=services,{BASE}'
|
SERVICES = f'ou=services,{BASE}'
|
||||||
POLICIES = f'ou=policies,{BASE}'
|
POLICIES = f'ou=policies,{BASE}'
|
||||||
DEFAULT_POLICY = f'cn=default,{POLICIES}'
|
DEFAULT_POLICY = f'cn=default,{POLICIES}'
|
||||||
|
TOTP_PARAMS = f'cn=totp,{POLICIES}'
|
||||||
MANAGER = f'cn=user-manager,{SERVICES}'
|
MANAGER = f'cn=user-manager,{SERVICES}'
|
||||||
ROOT = 'gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth'
|
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_DEFAULTS = pathlib.Path('/etc/default/slapd')
|
||||||
SLAPD_SERVICES = 'SLAPD_SERVICES="ldapi:///"'
|
SLAPD_SERVICES = 'SLAPD_SERVICES="ldapi:///"'
|
||||||
|
|
||||||
MODULES = ('argon2', 'ppolicy')
|
MODULES = ('argon2', 'ppolicy', 'otp')
|
||||||
PASSWORD_HASH = '{ARGON2}'
|
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.
|
#: The ACLs of the database, in order.
|
||||||
ACCESS = (
|
ACCESS = (
|
||||||
f'to attrs=userPassword by dn.exact="{MANAGER}" =wx by self =wx by anonymous auth by * none',
|
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="{USERS}" by dn.exact="{MANAGER}" manage by * read',
|
||||||
f'to dn.subtree="{GROUPS}" by dn.exact="{MANAGER}" manage by * read',
|
f'to dn.subtree="{GROUPS}" by dn.exact="{MANAGER}" manage by * read',
|
||||||
'to * by * read',
|
'to * by * read',
|
||||||
@@ -179,6 +192,14 @@ def provision():
|
|||||||
'pwdLockout': [b'TRUE'],
|
'pwdLockout': [b'TRUE'],
|
||||||
'pwdMaxFailure': [b'0'],
|
'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)
|
_ensure_manager(connection)
|
||||||
finally:
|
finally:
|
||||||
connection.unbind_s()
|
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
|
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.
|
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
|
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
|
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
|
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.
|
anything else. Answers are dicts with the keys of the JSON answers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
import contextlib
|
import contextlib
|
||||||
import fcntl
|
import fcntl
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
import ldap
|
import ldap
|
||||||
import ldap.filter
|
import ldap.filter
|
||||||
import ldap.modlist
|
import ldap.modlist
|
||||||
|
import pyotp
|
||||||
|
|
||||||
import vpnrouter
|
import vpnrouter
|
||||||
import vpnrouter_ldap
|
import vpnrouter_ldap
|
||||||
@@ -42,7 +51,12 @@ EMAIL_MAX = 254
|
|||||||
#: The keys of the person attributes and their LDAP attributes.
|
#: The keys of the person attributes and their LDAP attributes.
|
||||||
PERSON = (('first_name', 'givenName'), ('last_name', 'sn'), ('email', 'mail'))
|
PERSON = (('first_name', 'givenName'), ('last_name', 'sn'), ('email', 'mail'))
|
||||||
#: The attributes read for a user.
|
#: 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.
|
#: The attributes read for a group.
|
||||||
GROUP_ATTRIBUTES = ['cn', 'description', 'member']
|
GROUP_ATTRIBUTES = ['cn', 'description', 'member']
|
||||||
#: The longest group description, in characters.
|
#: The longest group description, in characters.
|
||||||
@@ -145,6 +159,7 @@ def summary(attributes):
|
|||||||
'name': name,
|
'name': name,
|
||||||
'enabled': 'pwdAccountLockedTime' not in attributes,
|
'enabled': 'pwdAccountLockedTime' not in attributes,
|
||||||
'certificate': certificate(name)[0].is_file(),
|
'certificate': certificate(name)[0].is_file(),
|
||||||
|
'otp': 'oathTOTPToken' in attributes,
|
||||||
}
|
}
|
||||||
for key, attribute in PERSON:
|
for key, attribute in PERSON:
|
||||||
values = attributes.get(attribute)
|
values = attributes.get(attribute)
|
||||||
@@ -218,6 +233,49 @@ def enable(connection, name):
|
|||||||
return summary(entry(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):
|
def remove(connection, name):
|
||||||
"""Remove the user from its groups and the directory, and revoke the certificate.
|
"""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
|
/config the configuration without its secrets, and what the router resolved and
|
||||||
runs, as JSON
|
runs, as JSON
|
||||||
/users the users in the router's directory, whether or not a P2S system is enabled: list,
|
/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
|
add, change, set the password, disable, enable, remove, the OpenVPN client
|
||||||
profile (see users())
|
profile, and TOTP enrolment (see users())
|
||||||
/groups the groups of the router's directory: list, add, rename or describe, remove, and
|
/groups the groups of the router's directory: list, add, rename or describe, remove, and
|
||||||
add and take out members (see groups())
|
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
|
POST /users/<name>/enable let the user in again
|
||||||
DELETE /users/<name> remove the user and revoke the certificate
|
DELETE /users/<name> remove the user and revoke the certificate
|
||||||
GET /users/<name>/profile the OpenVPN client profile
|
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)):
|
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||||
return 400, {'error': 'the body must be a JSON object'}
|
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),
|
('POST', 3, 'enable'): lambda connection: vpnrouter_users.enable(connection, name),
|
||||||
('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name),
|
('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name),
|
||||||
('GET', 3, 'profile'): lambda connection: vpnrouter_users.profile(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:
|
if route not in actions:
|
||||||
return 404, {'error': f'no route {method} {path}'}
|
return 404, {'error': f'no route {method} {path}'}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
"""Manage the users of the router's directory from the command line, with vpnrouter_users.
|
"""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.
|
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.
|
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
|
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] enable <name>
|
||||||
vpn-router-user [--json] remove <name>
|
vpn-router-user [--json] remove <name>
|
||||||
vpn-router-user [--json] profile <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
|
import argparse
|
||||||
@@ -38,6 +42,9 @@ HELP = {
|
|||||||
'enable': 'let the user in again',
|
'enable': 'let the user in again',
|
||||||
'remove': 'revoke the certificate and remove the user',
|
'remove': 'revoke the certificate and remove the user',
|
||||||
'profile': 'print the OpenVPN client profile',
|
'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.
|
#: The help of the person options.
|
||||||
@@ -63,16 +70,18 @@ def parse(argv):
|
|||||||
for key, _ in vpnrouter_users.PERSON:
|
for key, _ in vpnrouter_users.PERSON:
|
||||||
sub.add_argument(f'--{key.replace("_", "-")}', dest=key, metavar='TEXT', required=command == 'add',
|
sub.add_argument(f'--{key.replace("_", "-")}', dest=key, metavar='TEXT', required=command == 'add',
|
||||||
help=PERSON_HELP[key])
|
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)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
def password():
|
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')
|
return sys.stdin.readline().rstrip('\n')
|
||||||
|
|
||||||
|
|
||||||
def run(connection, options, secret):
|
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
|
command = options.command
|
||||||
if command == 'list':
|
if command == 'list':
|
||||||
return vpnrouter_users.list_users(connection)
|
return vpnrouter_users.list_users(connection)
|
||||||
@@ -83,21 +92,26 @@ def run(connection, options, secret):
|
|||||||
return vpnrouter_users.update(connection, options.name, values)
|
return vpnrouter_users.update(connection, options.name, values)
|
||||||
if command == 'passwd':
|
if command == 'passwd':
|
||||||
return vpnrouter_users.passwd(connection, options.name, secret)
|
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):
|
def text_of(answer):
|
||||||
"""The answer as text for people."""
|
"""The answer as text for people."""
|
||||||
if 'profile' in answer:
|
if 'profile' in answer:
|
||||||
return answer['profile'].rstrip('\n')
|
return answer['profile'].rstrip('\n')
|
||||||
|
if 'secret' in answer:
|
||||||
|
return f'secret {answer["secret"]}\nuri {answer["uri"]}'
|
||||||
if answer.get('removed'):
|
if answer.get('removed'):
|
||||||
return f'user {answer["name"]} removed'
|
return f'user {answer["name"]} removed'
|
||||||
users = answer['users'] if 'users' in answer else [answer]
|
users = answer['users'] if 'users' in answer else [answer]
|
||||||
if not users:
|
if not users:
|
||||||
return 'no 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',
|
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))]
|
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))
|
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
|
as_json = options.json
|
||||||
if options.command != 'list':
|
if options.command != 'list':
|
||||||
vpnrouter_users.check_name(options.name)
|
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:
|
with vpnrouter_users.session() as connection:
|
||||||
answer = run(connection, options, secret)
|
answer = run(connection, options, secret)
|
||||||
except Failure as failure:
|
except Failure as failure:
|
||||||
|
|||||||
Reference in new issue
Block a user