From fe17e83b4999af3d9adc34422fc4ccbd09825484 Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Sat, 24 Oct 2026 12:44:49 +0200 Subject: [PATCH] feature: every user manages their own account through /account. --- ADR.md | 24 ++++++++ README.md | 29 ++++++++-- debian-package/debian/changelog | 13 +++++ debian-package/src/usr/lib/vpn-router/web | 68 +++++++++++++++++++++-- 4 files changed, 124 insertions(+), 10 deletions(-) diff --git a/ADR.md b/ADR.md index 9a29a39..d2fa8fd 100644 --- a/ADR.md +++ b/ADR.md @@ -454,3 +454,27 @@ 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. + +## ADR-20: Users manage their own account through /account, proving who they are again + +Status: Accepted + +Context: Users who are not members of `admins` can sign in but get 403 on every path, so they +cannot change their password or enrol an authenticator without an administrator. A session +token alone is a weak proof for such changes: whoever holds an open session could lock the user +out. A wrong current password must not look like an ended session to the Manager, which signs a +user out on 401. + +Decision: `/account` serves the user named in the token's `sub`, admin or not: `GET` the user +and whether they are a member of `admins`, `PUT /account/password`, and `POST` and +`PUT /account/otp` to enrol or replace the TOTP enrolment. The routes do not take a user name. +Each change sends the current password and, for an enrolled user, a current code, checked with a +bind as the user like `/auth`, so it counts toward the lockout (ADR-14); a wrong one is 403. A +new password is checked before the bind, so an invalid one does not use up a code. A user cannot +remove their own enrolment; `/users` and `vpn-router-user` do. Tokens of `vpn-router-token` name +no user and get 403 there. + +Consequences: slapd's `otp` overlay accepts each code once, so a change right after signing in +needs the next code. Changing the password revokes the user's refresh tokens, so the user's +Manager session ends with its access token. An enrolled user who has lost the authenticator +needs an administrator. diff --git a/README.md b/README.md index 3f4b4bd..6407003 100644 --- a/README.md +++ b/README.md @@ -525,6 +525,7 @@ curl http://10.0.0.4:17081/ | `/config` | The configuration without its secret options, and what the router resolved and runs, as JSON; needs a token | | `/users` | The users of OpenVPN and road-warrior access; needs a token | | `/groups` | The groups of users; needs a token | +| `/account` | The account of the signed-in user: its details, its password and its TOTP enrolment; needs a user's token | The status holds the state of `vpn-router-setup`, `slapd` and each PostgreSQL cluster unit (`postgresql@-`, or `postgresql` as `no cluster` without one), of `strongswan` when site-to-site or @@ -545,16 +546,18 @@ curl http://10.0.0.4:7081/status ### Tokens and signing in -`/config`, `/users` and `/groups` need a JWT in an `Authorization: Bearer` header, signed with +`/config`, `/users`, `/groups` and `/account` need a JWT in an `Authorization: Bearer` header, signed with HS256 by the key the web service makes on its first run, `/etc/vpn-router/web.key` (root only). The key never leaves the router; while it does not exist these paths and `/auth` are not served (404). A missing, expired or wrongly signed token, or one of another algorithm, gets 401 with `WWW-Authenticate: Bearer`. There are two kinds of tokens: -- an access token of `/auth` names a user in `sub` and is valid for 5 minutes; the user must - be a member of the group `admins`, otherwise the request gets 403; +- an access token of `/auth` names a user in `sub` and is valid for 5 minutes; every user may + call `/account`, and for the other paths the user must be a member of the group `admins`, + otherwise the request gets 403; - a token of `vpn-router-token`, run as root on the router, has the claim `admin` and is valid - for the lifetime given, without a maximum. It may call everything, and only such a token may + for the lifetime given, without a maximum. It names no user, so `/account` refuses it with 403; + it may call everything else, and only such a token may change the group `admins`: create it, rename a group to it, change or remove it, add or take out its members, or remove a user who is a member of it. @@ -639,6 +642,24 @@ curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ http://10.0.0.4:7081/users ``` +### The /account API + +`/account` serves the user the access token names, whether or not the user is a member of +`admins`, with the same module as `/users`: + +| Request | Body | Answer | +| --- | --- | --- | +| `GET /account` | | the user as `/users` shows it, and `"admin"`, whether the user is a member of `admins` | +| `PUT /account/password` | `{"current_password", "current_code", "password"}` | the user | +| `POST /account/otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored | +| `PUT /account/otp` | `{"current_password", "current_code", "secret", "code"}` | the user, once the code matches the secret | + +`current_code` is needed only for a user enrolled for TOTP. A wrong current password or code gets +403 and counts as a failed login; slapd accepts each code once, so a code used to sign in is not +accepted again, and a change right after signing in waits for the next code. A user enrols or +replaces the enrolment; removing it is left to `/users` and `vpn-router-user`. Changing the +password revokes the user's refresh tokens. Other failures are those of `/users`. + ### The /groups API `/groups` manages groups with the same token and the same module as `/users`. diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index a400081..e9c0214 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -1,3 +1,16 @@ +vpn-router (0.12.0-1) unstable; urgency=medium + + feature: every user manages their own account through /account. + + * /account serves the signed-in user, admin or not: GET shows the user + and whether they are a member of admins; PUT /account/password sets + the password and POST and PUT /account/otp enrol or replace the TOTP + enrolment, given the current password and, when enrolled, a current + code. A wrong one is 403 and counts as a failed login. Tokens of + vpn-router-token get 403 there. + + -- Slawomir Koszewski Sun, 25 Oct 2026 10:00:00 +0200 + vpn-router (0.11.0-1) unstable; urgency=medium feature: the status page shows the Router Manager's version. diff --git a/debian-package/src/usr/lib/vpn-router/web b/debian-package/src/usr/lib/vpn-router/web index e3a8258..b93b8c9 100644 --- a/debian-package/src/usr/lib/vpn-router/web +++ b/debian-package/src/usr/lib/vpn-router/web @@ -15,12 +15,15 @@ Pages: 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()) + /account the account of the signed-in user: its details, its password and its TOTP + enrolment (see account()) /auth sign-in with a user's password and TOTP code, refresh and sign-out (see auth()) -/config, /users and /groups need an HS256 JWT in an Authorization: Bearer header signed with -vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names a user in sub, who -must be a member of vpnrouter_users.ADMIN_GROUP; a token of vpn-router-token has the admin -claim, and only such a token may change ADMIN_GROUP: create, rename to it, change, remove it, +/config, /users, /groups and /account need an HS256 JWT in an Authorization: Bearer header signed +with vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names a user in sub; +/account serves that user, and the other paths need a member of vpnrouter_users.ADMIN_GROUP. A +token of vpn-router-token has the admin claim and names no user; it may call every path but +/account, and only such a token may change ADMIN_GROUP: create, rename to it, change, remove it, add or take out its members, and remove one of its members. These paths and /auth are not served (404) while the key does not exist. /auth is limited to AUTH_LIMIT calls per client address in AUTH_WINDOW seconds; the address of a call from one of web.trusted_proxies, by @@ -507,6 +510,54 @@ def groups(state, method, path, body): return (201 if route == ('POST', 1, None) else 200), document +def account(name, method, path, body): + """Answer an /account request of the user named by the token with vpnrouter_users: the HTTP status and the JSON document. + + GET /account the user, with admin, whether the user is a member of ADMIN_GROUP + PUT /account/password set the password: {"current_password", "current_code"?, "password"} + POST /account/otp a new TOTP secret and its otpauth URI; nothing is stored + PUT /account/otp store a TOTP secret: {"current_password", "current_code"?, "secret", "code"}, + code a code of the secret + + A change needs the current password and, for an enrolled user, a current code; a wrong one is 403 and counts as + a failed login. A password change revokes the user's refresh tokens. + """ + if body == 'invalid' or (body is not None and not isinstance(body, dict)): + return 400, {'error': 'the body must be a JSON object'} + body = body or {} + route = (method, path.rstrip('/')) + if route not in (('GET', '/account'), ('PUT', '/account/password'), ('POST', '/account/otp'), ('PUT', '/account/otp')): + return 404, {'error': f'no route {method} {path}'} + try: + if route == ('PUT', '/account/password'): + vpnrouter_users.check_password(body.get('password')) + if method == 'PUT': + vpnrouter_users.login(name, body.get('current_password'), body.get('current_code')) + with vpnrouter_users.session() as connection: + if route == ('GET', '/account'): + document = {**vpnrouter_users.summary(vpnrouter_users.entry(connection, name)), + 'admin': vpnrouter_users.is_admin(connection, name)} + elif route == ('PUT', '/account/password'): + document = vpnrouter_users.passwd(connection, name, body['password']) + elif route == ('POST', '/account/otp'): + document = vpnrouter_users.otp_new(connection, name) + else: + document = vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code')) + if route == ('PUT', '/account/password'): + vpnrouter_auth.revoke(vpnrouter.load_config(), name) + except vpnrouter_users.Failure as failure: + if failure.status == 5: + return 403, {'error': 'the current password or code is wrong'} + return USER_STATUS.get(failure.status, 500), {'error': str(failure)} + except (ldap.LDAPError, OSError) as exc: + return 500, {'error': f'the user directory failed: {exc}'} + except vpnrouter_auth.DatabaseError as exc: + return 500, {'error': str(exc)} + except SystemExit as exc: + return 500, {'error': str(exc.code)} + return 200, document + + def changes_admins(connection, method, parts, body): """Whether a /users or /groups request creates, renames to, changes or removes ADMIN_GROUP, or removes a member of it.""" admins = vpnrouter_users.ADMIN_GROUP @@ -523,9 +574,11 @@ def changes_admins(connection, method, parts, body): def refusal(claims, method, path, body): """Why the holder of the claims may not make the request to a protected path, or '' when it may.""" + name = claims.get('sub') + if path == '/account' or path.startswith('/account/'): + return '' if isinstance(name, str) else 'the token names no user' if claims.get('admin') is True: return '' - name = claims.get('sub') if not isinstance(name, str): return 'the token names no user' admins = vpnrouter_users.ADMIN_GROUP @@ -625,7 +678,7 @@ class Handler(BaseHTTPRequestHandler): # A body that is not read must not be taken for the next request. if self.command not in ('GET', 'HEAD'): self.close_connection = True - directory = next((name for name in ('users', 'groups') if path == f'/{name}' or path.startswith(f'/{name}/')), None) + directory = next((name for name in ('users', 'groups', 'account') if path == f'/{name}' or path.startswith(f'/{name}/')), None) protected = path == '/config' or directory is not None signing = path in ('/auth', '/auth/refresh', '/auth/logout') state = current() if protected or signing else {} @@ -653,6 +706,9 @@ class Handler(BaseHTTPRequestHandler): body = json.dumps({'error': refused[1]}, indent=2).encode() + b'\n' elif path == '/config' and self.command in ('GET', 'HEAD'): body, kind, code = json.dumps(details(state), indent=2).encode() + b'\n', 'application/json', 200 + elif directory == 'account': + code, document = account(claims['sub'], self.command, path, payload) + body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' elif protected and path != '/config': code, document = (users if directory == 'users' else groups)(state, self.command, path, payload) body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'