diff --git a/ADR.md b/ADR.md index 06f3956..9a29a39 100644 --- a/ADR.md +++ b/ADR.md @@ -401,3 +401,56 @@ a `SystemExit` carrying its message, so setup records it. Consequences: The diagnostics need nothing the setup provides and cannot collide with the real services. After the first success they never run again, also not after an upgrade or a reboot. The page shows log lines to whoever reaches the internal network during the first setup. + +## ADR-18: Manager-only accounts are a second kind of user; the package can create the first admin + +Status: Accepted + +Context: The Router Manager needs a first administrator before anyone has added users, as other +packages create a default user from their debconf answers. Such an account, and others that only +manage the router, have no person behind them and need no VPN access. A VPN user is an +`inetOrgPerson`, which requires a last name and a common name, and its certificate needs the +names. The Manager is configured before the router's directory exists during an installation. + +Decision: A user is one of two kinds in `ou=users`, fixed when it is added: a VPN user, an +`inetOrgPerson` with the three person attributes and a certificate, or a Manager-only account, +an `account`, `simpleSecurityObject` and `vpnRouterAccount` (ADR-19) with the name, the password, +an optional e-mail address for notifications, and no certificate. `add` with all three person +attributes adds a VPN user and without the first and the last name a Manager-only account; one +name without the rest is refused. The CLI and the API add both kinds; the Manager's dialog +requires the names unless Manager access only is chosen, which offers the e-mail address only. +Answers carry `manager_only`, and `update` changes only the e-mail address of a Manager-only +account. The Manager's package asks `admin_username` and `admin_password`; with both +it writes them to a root-only file, clears the answers, and `vpn-router-setup` creates the account +after it provisions the directory, adds it to `admins` (creating the group when missing) and +removes the file. Without a name nothing is created. `vpnrouter_users` knows only the directory, +so setup can use it before PostgreSQL's driver can be loaded; `web` and `vpn-router-user` revoke +refresh tokens after the operations of `vpnrouter_auth.REVOKING`. + +Consequences: A Manager-only account cannot become a VPN user, as LDAP cannot change an entry's +structural class and its password hash cannot be copied; it is removed and added again instead. +It has no certificate, so OpenVPN and road-warrior access refuse it. The administrator's password +is on disk only until setup has run. + +## ADR-19: The package has an LDAP schema of its own under a UUID OID + +Status: Accepted + +Context: A Manager-only account (ADR-18) is an `account`, which allows no `mail`, yet notifications +need an address for it; no standard auxiliary class allows `mail` except `extensibleObject`, which +allows every attribute. Authorization scopes for users of both kinds may follow. An attribute or +class of its own needs an OID, and the package has no registered arc. + +Decision: The package defines its schema, `cn=vpn-router,cn=schema,cn=config`, which +`vpnrouter_ldap.provision` adds when missing and keeps as `SCHEMA_ATTRIBUTE_TYPES` and +`SCHEMA_OBJECT_CLASSES`. Its OID is `2.25.98477352595735834532404256531620013001`, made from a +random UUID under the arc 2.25 (ITU-T X.667), which needs no registration; attribute types are +under `.1` and object classes under `.2`. The first version has the attribute `vpnRouterScope` +(multi-valued, case-insensitive strings) and the auxiliary class `vpnRouterAccount`, +`MAY ( mail $ vpnRouterScope )`. Manager-only accounts have the class from their creation, so an +e-mail address can be added later without changing their classes; VPN users get it once scopes are +used. + +Consequences: The schema check stays strict. New attributes extend `vpnRouterAccount`, and their +OIDs continue the arcs. Nothing reads `vpnRouterScope` yet. The OID must never be reused for +anything else. diff --git a/README.md b/README.md index 8eb61e2..12f3d81 100644 --- a/README.md +++ b/README.md @@ -324,7 +324,12 @@ is switched on: - its own database, `dc=vpn-router,dc=local` in `/var/lib/ldap/vpn-router`, next to the one the slapd package creates; root manages it through the local socket, with no administrator password; -- `ou=users` holds one `inetOrgPerson` per user, named by `uid`; +- `ou=users` holds one entry per user, named by `uid`: an `inetOrgPerson` for a VPN user, an + `account` for a Manager-only account (see Managing users); +- the package's own schema, `cn=vpn-router,cn=schema,cn=config`, under the OID + `2.25.98477352595735834532404256531620013001` of the UUID arc: the auxiliary class + `vpnRouterAccount` with `MAY ( mail $ vpnRouterScope )`, which Manager-only accounts have, and + the attribute `vpnRouterScope`, authorization scopes that nothing reads yet; - `ou=groups` holds one `groupOfNames` per group, named by `cn`, with its members' DNs in `member` and an optional `description`; - `cn=user-manager,ou=services` is the account that changes users and groups; it may write @@ -346,9 +351,17 @@ The directory is kept on purge. ### Managing users -`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`. +`vpn-router-user` manages the users. A user is a directory entry of one of two kinds, fixed when it +is added: + +- a VPN user, an `inetOrgPerson` 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`; +- a Manager-only account, an `account`, `simpleSecurityObject` and `vpnRouterAccount` with the + name, the password and an optional e-mail address, for notifications: no names and no + certificate, so no VPN access. It can sign in to `/auth` and, as a member of `admins`, manage + the router. `add` without the first and the last name adds one; its names cannot be set later. + 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. @@ -358,6 +371,7 @@ after a command its options. ```sh echo 's3cr3t-pass' | vpn-router-user add alice --first-name Alice --last-name Smith --email alice@example.com +echo 'm4nager-pass' | vpn-router-user add ops vpn-router-user update alice --email a.smith@example.com vpn-router-user list vpn-router-user profile alice > alice.ovpn @@ -374,8 +388,9 @@ vpn-router-user otp-remove alice | Command | Effect | | --- | --- | -| `list` | The users with their names and e-mail addresses, whether each is enabled or locked, has a certificate and is enrolled for TOTP | -| `add --first-name F --last-name L --email E` | Adds the user and issues the certificate; all three are required | +| `list` | The users with their kind, names and e-mail addresses, whether each is enabled or locked, has a certificate and is enrolled for TOTP | +| `add --first-name F --last-name L --email E` | Adds a VPN user and issues the certificate | +| `add [--email E]` | Adds a Manager-only account, with the e-mail address when given; one name without the other two options is refused | | `update [--first-name F] [--last-name L] [--email E]` | Changes the names or the e-mail address | | `passwd ` | Sets the password | | `disable `, `enable ` | Locks the user out of OpenVPN and `/auth`, and lets the user in again | @@ -390,9 +405,17 @@ A user name is 1 to 32 lowercase letters, digits and hyphens, starting with a le 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; a TOTP secret is 32 base32 characters, as `otp-new` makes it, and a code 6 digits. Once enrolled, a user logs in to OpenVPN -and `/auth` with the password followed by a current code. 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. +and `/auth` with the password followed by a current code. In the answers `manager_only` tells the +kind, and a Manager-only account has its names as `null`; `update` changes only its e-mail +address, and `--email ''` removes it. Changing a user's password, disabling, unlocking or removing the user also revokes its +refresh tokens of `/auth`. 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. + +When the Router Manager is installed with an administrator (its debconf questions +`vpn-router-manager/admin_username` and `admin_password`), its package leaves the answers in +`/etc/vpn-router-manager/admin.json` (root only). `vpn-router-setup` then creates that user as a +Manager-only account, adds it to the group `admins`, which it creates when missing, and removes +the file. The certificate is also accepted by road-warrior (IKEv2 EAP-TLS) access, which checks certificates only, so `disable` does not affect it; `remove` revokes the certificate for both. @@ -590,9 +613,9 @@ and answer bodies are JSON; a request body is at most 4096 bytes. | Request | Body | Answer | | --- | --- | --- | -| `GET /users` | | `{"users": [{"name", "enabled", "locked", "certificate", "otp", "first_name", "last_name", "email"}, ...]}` | -| `POST /users` | `{"name", "password", "first_name", "last_name", "email"}`, all required | 201 and the user | -| `PUT /users/` | any of `{"first_name", "last_name", "email"}` | the user | +| `GET /users` | | `{"users": [{"name", "manager_only", "enabled", "locked", "certificate", "otp", "first_name", "last_name", "email"}, ...]}` | +| `POST /users` | `{"name", "password", "first_name", "last_name", "email"}` for a VPN user; `{"name", "password", "email"?}` for a Manager-only account | 201 and the user | +| `PUT /users/` | any of `{"first_name", "last_name", "email"}`; for a Manager-only account only `{"email"}`, `""` removing it | the user | | `PUT /users//password` | `{"password": ...}` | the user | | `POST /users//disable` | | the user | | `POST /users//enable` | | the user | diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index 697e0cf..7f0d0f1 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -1,3 +1,22 @@ +vpn-router (0.10.0-1) unstable; urgency=medium + + feature: Manager-only accounts, and the Router Manager's first administrator. + + * vpn-router-user add without the person options, and POST /users without + them, add a Manager-only account: a name and a password, no names, no + e-mail address and no certificate, so no VPN access. Giving only some of + the three is refused. The answers show the kind as manager_only, list + shows a KIND column, and update refuses a Manager-only account. + * vpn-router-setup creates the administrator the Router Manager's package + leaves in /etc/vpn-router-manager/admin.json as a Manager-only account in + the group admins, which it creates when missing, and removes the file. + * The directory has the package's own schema, with the auxiliary class + vpnRouterAccount and the attribute vpnRouterScope. A Manager-only account + can have an e-mail address: add --email, update --email ('' removes it), + and email in POST and PUT /users. + + -- Slawomir Koszewski Sun, 25 Oct 2026 08:00:00 +0200 + vpn-router (0.9.2-1) unstable; urgency=medium fix: the web service starts once libpq can be found. diff --git a/debian-package/src/usr/lib/vpn-router/setup b/debian-package/src/usr/lib/vpn-router/setup index d47b61f..66ab765 100755 --- a/debian-package/src/usr/lib/vpn-router/setup +++ b/debian-package/src/usr/lib/vpn-router/setup @@ -27,8 +27,11 @@ import time sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) +import ldap + import vpnrouter import vpnrouter_ldap +import vpnrouter_users from vpnrouter import fail BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules') @@ -49,6 +52,10 @@ BOOTSTRAP_PORTS = (17080, 17081) #: The Router Manager, vpn-router-manager, when it is installed, and its settings. MANAGER = pathlib.Path('/usr/lib/vpn-router-manager/manager') MANAGER_CONFIG = pathlib.Path('/etc/vpn-router-manager/manager.json') + +#: The default admin of the Router Manager, {"username", "password"}, which its package writes from +#: its debconf answers; setup creates the account and removes the file. +MANAGER_ADMIN = pathlib.Path('/etc/vpn-router-manager/admin.json') MANAGER_PORT = 7080 FILTER_ANCHOR = '# End required lines' @@ -384,6 +391,31 @@ def manage_directory(): return '' +def manage_admin(): + """Create the Router Manager's default admin from MANAGER_ADMIN, then remove the file; the problem, or ''. + + The admin is a Manager-only account and a member of the admins group, which is created with it + when it does not exist. + """ + if not MANAGER_ADMIN.exists(): + return '' + try: + answers = json.loads(MANAGER_ADMIN.read_text(encoding='utf-8')) + name, password = answers['username'], answers['password'] + with vpnrouter_users.session() as connection: + vpnrouter_users.add(connection, name, password, {}) + try: + vpnrouter_users.group_member_add(connection, vpnrouter_users.ADMIN_GROUP, name) + except vpnrouter_users.Failure as failure: + if failure.status != 3: + raise + vpnrouter_users.group_add(connection, vpnrouter_users.ADMIN_GROUP, 'Router administrators', [name]) + MANAGER_ADMIN.unlink() + except (OSError, ValueError, KeyError, TypeError, ldap.LDAPError, vpnrouter_users.Failure) as exc: + return f'the Router Manager admin cannot be created from {MANAGER_ADMIN}: {exc}' + return '' + + def manage_openvpn(ctx, changed): """Run the OpenVPN server when it is configured, and stop it when it is not.""" unit = 'openvpn-server@vpn-router' @@ -532,9 +564,14 @@ def main(): progress('running', 'WireGuard') manage_wireguard(ctx, changed) - # The tunnels are up before the directory, whose problem ends setup only after every other step. - progress('running', 'user directory') + # The tunnels are up before the directory and the admin, whose problem ends setup only after every other step. + failed_step = 'user directory' + progress('running', failed_step) problem = manage_directory() + if not problem: + failed_step = 'Router Manager admin' + progress('running', failed_step) + problem = manage_admin() progress('running', 'OpenVPN') manage_openvpn(ctx, changed) @@ -549,7 +586,7 @@ def main(): progress('running', 'web service state') refresh_web(cfg, ctx) if problem: - progress('failed', 'user directory', problem) + progress('failed', failed_step, problem) print(f'vpn-router: error: {problem}', file=sys.stderr) return 1 progress('done', 'done') diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_auth.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_auth.py index 392ccab..be3b9c4 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_auth.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_auth.py @@ -30,6 +30,9 @@ DATABASE_SECRET = pathlib.Path('/etc/vpn-router/database.secret') #: The role and the database provision() creates when web.database is not set. DATABASE = 'vpn-router' +#: The user operations of vpnrouter_users after which the user's refresh tokens are revoked. +REVOKING = ('passwd', 'disable', 'unlock', 'remove') + #: Seconds an access token of /auth and a refresh token are valid. ACCESS_SECONDS = 300 REFRESH_SECONDS = 8 * 3600 diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py index f33d77d..3936a5d 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_ldap.py @@ -50,6 +50,21 @@ LOCKOUT_SECONDS = 900 #: The overlays of the database, in the order they are configured. OVERLAYS = ('ppolicy', 'otp') +#: The package's own schema, under an OID of the UUID arc 2.25 (ITU-T X.667): attribute types +#: under .1 and object classes under .2. vpnRouterAccount, auxiliary, adds properties of the +#: package to a user of either kind: mail, for a Manager-only account, and vpnRouterScope, +#: authorization scopes that nothing reads yet. +SCHEMA_OID = '2.25.98477352595735834532404256531620013001' +SCHEMA_NAME = 'vpn-router' +SCHEMA_ATTRIBUTE_TYPES = ( + f"( {SCHEMA_OID}.1.1 NAME 'vpnRouterScope' DESC 'VPN router: an authorization scope of the account' " + "EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} )", +) +SCHEMA_OBJECT_CLASSES = ( + f"( {SCHEMA_OID}.2.1 NAME 'vpnRouterAccount' DESC 'VPN router: properties of a user or a Manager-only account' " + "SUP top AUXILIARY MAY ( mail $ vpnRouterScope ) )", +) + #: 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' @@ -109,6 +124,23 @@ def _ensure_modules(connection): connection.modify_s(dn, [(ldap.MOD_ADD, 'olcModuleLoad', [name.encode() for name in missing])]) +def _ensure_schema(connection): + """Add the package's schema to cn=config when missing, else set its definitions that differ.""" + found = connection.search_s('cn=schema,cn=config', ldap.SCOPE_ONELEVEL, f'(cn=*{SCHEMA_NAME})', + ['olcAttributeTypes', 'olcObjectClasses']) + attributes = {'olcAttributeTypes': [value.encode() for value in SCHEMA_ATTRIBUTE_TYPES], + 'olcObjectClasses': [value.encode() for value in SCHEMA_OBJECT_CLASSES]} + if not found: + connection.add_s(f'cn={SCHEMA_NAME},cn=schema,cn=config', ldap.modlist.addModlist( + dict(attributes, objectClass=[b'olcSchemaConfig'], cn=[SCHEMA_NAME.encode()]))) + return + dn, current = found[0] + changes = [(ldap.MOD_REPLACE, name, values) for name, values in attributes.items() + if unordered(current.get(name, [])) != [value.decode() for value in values]] + if changes: + connection.modify_s(dn, changes) + + def _ensure_password_hash(connection): dn = 'olcDatabase={-1}frontend,cn=config' current = connection.search_s(dn, ldap.SCOPE_BASE, attrlist=['olcPasswordHash'])[0][1].get('olcPasswordHash', []) @@ -188,10 +220,11 @@ def _ensure_manager(connection): def provision(): - """Create or bring up to date the modules, database, ACLs, password policy, entries and service account.""" + """Create or bring up to date the modules, the package's schema, database, ACLs, password policy, entries and service account.""" connection = root() try: _ensure_modules(connection) + _ensure_schema(connection) _ensure_password_hash(connection) database = _ensure_database(connection) _ensure_overlays(connection, database) diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py index 0bfbdde..18cde28 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py @@ -1,9 +1,13 @@ """The users and groups of the router's directory, for vpn-router-user, vpn-router-group and the web service. -A user is an entry in the router's directory, with a first name, a last name and an e-mail -address, whose password OpenVPN checks, and a client certificate of the router CA with the -user name as its common name, which OpenVPN and IKEv2 accept. Users exist whether or not a -P2S system is enabled. +A user is an entry in ou=users of the router's directory, of one of two kinds, fixed when it is +added. A VPN user is an inetOrgPerson with a first name, a last name and an e-mail address, whose +password OpenVPN checks, and a client certificate of the router CA with the user name as its +common name, which OpenVPN and IKEv2 accept. A Manager-only account is an account, +simpleSecurityObject and vpnRouterAccount (the package's schema) with a name, a password and +optionally an e-mail address, no names, no certificate and so no VPN access; it is added when +neither the first nor the last name is given, and can sign in to the web service. Users exist +whether or not a P2S system is enabled. A group is a groupOfNames in ou=groups with a name, which follows the rules of user names, an optional description and at least one member; a group cannot be empty, so the last member @@ -19,8 +23,8 @@ code of it. slapd's otp overlay then takes the password followed by a code at ev 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. Changing the password, disabling, unlocking and removing a -user revoke its refresh tokens. +router through the web service. The module knows only the directory; the callers revoke a +user's refresh tokens after the operations of vpnrouter_auth.REVOKING. 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 @@ -44,7 +48,6 @@ import ldap.modlist import pyotp import vpnrouter -import vpnrouter_auth import vpnrouter_ldap #: A user name is one lowercase DNS label, as it is also the common name of the certificate. @@ -59,7 +62,10 @@ 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', 'oathTOTPToken'] +ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken'] +#: The object classes of a VPN user and of a Manager-only account. +VPN_USER_CLASSES = [b'inetOrgPerson'] +MANAGER_ONLY_CLASSES = [b'account', b'simpleSecurityObject', b'vpnRouterAccount'] #: The readable attributes of a TOTP token; oathSecret is never readable. TOKEN_ATTRIBUTES = ['oathTOTPToken', 'oathTOTPParams', 'oathSecretTime', 'oathTOTPLastTimeStep', 'oathTOTPTimeStepDrift', 'oathLastLogin', 'oathLastFailure', 'oathFailureCount'] @@ -172,12 +178,18 @@ def locked_since(value): return 0 +def manager_only(attributes): + """Whether the entry is a Manager-only account.""" + return b'account' in (value.lower() for value in attributes.get('objectClass', [])) + + def summary(attributes): """A user as the answers show it; a person attribute the entry does not have is None.""" name = attributes['uid'][0].decode() lock = attributes.get('pwdAccountLockedTime', [None])[0] user = { 'name': name, + 'manager_only': manager_only(attributes), 'enabled': lock != LOCKED, 'locked': lock not in (None, LOCKED) and locked_since(lock) > time.time() - vpnrouter_ldap.LOCKOUT_SECONDS, 'certificate': certificate(name)[0].is_file(), @@ -196,38 +208,62 @@ def common_name(attributes, name): def list_users(connection): - found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, '(objectClass=inetOrgPerson)', ATTRIBUTES) + found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, '(|(objectClass=inetOrgPerson)(objectClass=account))', + ATTRIBUTES) return {'users': sorted((summary(attributes) for _, attributes in found), key=lambda user: user['name'])} def add(connection, name, password, values): - """Add a user with the person attributes of values, all three required, and issue the certificate.""" + """Add a user: a VPN user with its certificate when values gives all three person attributes, a Manager-only account + with at most the e-mail address when it gives neither name.""" check_name(name) - details = person(values, required=True) + details = person(values, required=False) + vpn_user = 'givenName' in details or 'sn' in details + if vpn_user and len(details) != len(PERSON): + raise Failure('give first_name, last_name and email for a VPN user, or at most email for a Manager-only account', 2) check_password(password) - if not (vpnrouter.PKI_DIR / 'ca_key.pem').is_file(): + if vpn_user and not (vpnrouter.PKI_DIR / 'ca_key.pem').is_file(): raise Failure('the router CA does not exist; vpn-router-setup creates it once a platform is chosen') - record = {'objectClass': [b'inetOrgPerson'], 'uid': [name.encode()], - 'cn': [common_name(details, name).encode()]} + if vpn_user: + record = {'objectClass': VPN_USER_CLASSES, 'uid': [name.encode()], 'cn': [common_name(details, name).encode()]} + else: + # simpleSecurityObject requires a userPassword; passwd_s sets the real one at once. + record = {'objectClass': MANAGER_ONLY_CLASSES, 'uid': [name.encode()], 'userPassword': [b'{CRYPT}!']} record.update({attribute: [value.encode()] for attribute, value in details.items()}) try: connection.add_s(dn_of(name), ldap.modlist.addModlist(record)) except ldap.ALREADY_EXISTS: raise Failure(f'user {name} exists', 4) connection.passwd_s(dn_of(name), None, password) - vpnrouter.USER_CERT_DIR.mkdir(mode=0o700, parents=True, exist_ok=True) - for path in certificate(name): - path.unlink(missing_ok=True) - simple_ca('make-cert', '--ca-dir', str(vpnrouter.PKI_DIR), '--cert-dir', str(vpnrouter.USER_CERT_DIR), name) + if vpn_user: + vpnrouter.USER_CERT_DIR.mkdir(mode=0o700, parents=True, exist_ok=True) + for path in certificate(name): + path.unlink(missing_ok=True) + simple_ca('make-cert', '--ca-dir', str(vpnrouter.PKI_DIR), '--cert-dir', str(vpnrouter.USER_CERT_DIR), name) return summary(entry(connection, name)) def update(connection, name, values): - """Change the person attributes values gives; the cn follows the first and the last name.""" + """Change the person attributes values gives; the cn follows the first and the last name. + + A Manager-only account has only the e-mail address, which '' removes. + """ + current = entry(connection, name) + if manager_only(current): + if values.get('first_name') is not None or values.get('last_name') is not None: + raise Failure(f'user {name} is a Manager-only account, which has no first or last name', 2) + email = values.get('email') + if email is None: + raise Failure('give email', 2) + if isinstance(email, str) and email.strip() == '': + if 'mail' in current: + connection.modify_s(dn_of(name), [(ldap.MOD_DELETE, 'mail', None)]) + else: + connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'mail', [person({'email': email}, required=False)['mail'].encode()])]) + return summary(entry(connection, name)) details = person(values, required=False) if not details: raise Failure('give at least one of first_name, last_name and email', 2) - current = entry(connection, name) names = {attribute: current[attribute][0].decode() for attribute in ('givenName', 'sn') if current.get(attribute)} names.update({attribute: details[attribute] for attribute in ('givenName', 'sn') if attribute in details}) changes = [(ldap.MOD_REPLACE, attribute, [value.encode()]) for attribute, value in details.items()] @@ -240,14 +276,12 @@ def passwd(connection, name, password): check_password(password) entry(connection, name) connection.passwd_s(dn_of(name), None, password) - vpnrouter_auth.revoke(vpnrouter.load_config(), name) return summary(entry(connection, name)) def disable(connection, name): if entry(connection, name).get('pwdAccountLockedTime') != [LOCKED]: connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'pwdAccountLockedTime', [LOCKED])]) - vpnrouter_auth.revoke(vpnrouter.load_config(), name) return summary(entry(connection, name)) @@ -266,7 +300,6 @@ def unlock(connection, name): changes.append((ldap.MOD_DELETE, 'pwdAccountLockedTime', None)) if changes: connection.modify_s(dn_of(name), changes) - vpnrouter_auth.revoke(vpnrouter.load_config(), name) return summary(entry(connection, name)) @@ -361,7 +394,6 @@ def remove(connection, name): cert.unlink(missing_ok=True) key.unlink(missing_ok=True) connection.delete_s(dn_of(name)) - vpnrouter_auth.revoke(vpnrouter.load_config(), name) return {'name': name, 'removed': True} diff --git a/debian-package/src/usr/lib/vpn-router/web b/debian-package/src/usr/lib/vpn-router/web index d71fe46..6011a82 100644 --- a/debian-package/src/usr/lib/vpn-router/web +++ b/debian-package/src/usr/lib/vpn-router/web @@ -399,7 +399,8 @@ def users(state, method, path, body): """Answer a /users request with vpnrouter_users: the HTTP status and the JSON document. GET /users the users - POST /users add a user: {"name", "password", "first_name", "last_name", "email"} + POST /users add a user: {"name", "password", "first_name", "last_name", "email"}; + without the last three a Manager-only account PUT /users/ change any of {"first_name", "last_name", "email"} PUT /users//password set the password: {"password": ...} POST /users//disable lock the user out of OpenVPN @@ -431,11 +432,16 @@ def users(state, method, path, body): ('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), } + # The vpnrouter_users operation of each route that may revoke refresh tokens. + operations = {('PUT', 3, 'password'): 'passwd', ('POST', 3, 'disable'): 'disable', ('POST', 3, 'unlock'): 'unlock', + ('DELETE', 2, None): 'remove'} if route not in actions: return 404, {'error': f'no route {method} {path}'} try: with vpnrouter_users.session() as connection: document = actions[route](connection) + if operations.get(route) in vpnrouter_auth.REVOKING: + vpnrouter_auth.revoke(vpnrouter.load_config(), name) except vpnrouter_users.Failure as failure: return USER_STATUS.get(failure.status, 500), {'error': str(failure)} except (ldap.LDAPError, OSError) as exc: diff --git a/debian-package/src/usr/sbin/vpn-router-user b/debian-package/src/usr/sbin/vpn-router-user index 43aa7a4..388b5a2 100755 --- a/debian-package/src/usr/sbin/vpn-router-user +++ b/debian-package/src/usr/sbin/vpn-router-user @@ -10,6 +10,7 @@ user exists, 1 anything else. -h or --help shows the commands and their options. vpn-router-user [--json] list vpn-router-user [--json] add --first-name F --last-name L --email E (password on stdin) + vpn-router-user [--json] add [--email E] (a Manager-only account; password on stdin) vpn-router-user [--json] update [--first-name F] [--last-name L] [--email E] vpn-router-user [--json] passwd (password on stdin) vpn-router-user [--json] disable @@ -30,6 +31,7 @@ sys.path.insert(0, '/usr/lib/vpn-router') import ldap +import vpnrouter import vpnrouter_auth import vpnrouter_users from vpnrouter_users import Failure @@ -37,8 +39,10 @@ from vpnrouter_users import Failure #: The help line of each command. HELP = { 'list': 'list the users', - 'add': 'add a user and issue the certificate; the password is read from standard input', - 'update': 'change the first name, the last name or the e-mail address', + 'add': 'add a VPN user with the three person options and issue the certificate, or without the names a ' + 'Manager-only account, with an optional e-mail address; the password is read from standard input', + 'update': "change the first name, the last name or the e-mail address; a Manager-only account has only the " + "e-mail address, which '' removes", 'passwd': 'set the password, read from standard input', 'disable': 'lock the user out of OpenVPN', 'enable': 'let the user in again', @@ -71,8 +75,7 @@ def parse(argv): sub.add_argument('name', help='the user name') if command in ('add', 'update'): for key, _ in vpnrouter_users.PERSON: - sub.add_argument(f'--{key.replace("_", "-")}', dest=key, metavar='TEXT', required=command == 'add', - help=PERSON_HELP[key]) + sub.add_argument(f'--{key.replace("_", "-")}', dest=key, metavar='TEXT', 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) @@ -111,8 +114,9 @@ def text_of(answer): users = answer['users'] if 'users' in answer else [answer] if not users: return 'no users' - head = ('USER', 'STATE', 'CERTIFICATE', 'OTP', 'FIRST NAME', 'LAST NAME', 'E-MAIL') - rows = [(user['name'], 'disabled' if not user['enabled'] else 'locked' if user['locked'] else 'enabled', + head = ('USER', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'FIRST NAME', 'LAST NAME', 'E-MAIL') + rows = [(user['name'], 'manager' if user['manager_only'] else 'vpn', + 'disabled' if not user['enabled'] else 'locked' if user['locked'] else 'enabled', 'issued' if user['certificate'] else 'missing', 'enrolled' if user['otp'] else '-', user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-') for user in users] @@ -131,6 +135,8 @@ def main(argv): secret = password() if options.command in ('add', 'passwd', 'otp-enrol') else None with vpnrouter_users.session() as connection: answer = run(connection, options, secret) + if options.command in vpnrouter_auth.REVOKING: + vpnrouter_auth.revoke(vpnrouter.load_config(), options.name) except Failure as failure: message, status = str(failure), failure.status except (ldap.LDAPError, OSError) as exc: