feature: every user manages their own account through /account.
Release / release (push) Successful in 11s
Release / release (push) Successful in 11s
This commit is contained in:
1 parent
17331eea37
commit
fe17e83b49
4 files changed
+124
-10
No files matched your search
@@ -454,3 +454,27 @@ used.
|
|||||||
Consequences: The schema check stays strict. New attributes extend `vpnRouterAccount`, and their
|
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
|
OIDs continue the arcs. Nothing reads `vpnRouterScope` yet. The OID must never be reused for
|
||||||
anything else.
|
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.
|
||||||
@@ -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 |
|
| `/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 |
|
| `/users` | The users of OpenVPN and road-warrior access; needs a token |
|
||||||
| `/groups` | The groups of users; 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
|
The status holds the state of `vpn-router-setup`, `slapd` and each PostgreSQL cluster unit
|
||||||
(`postgresql@<version>-<cluster>`, or `postgresql` as `no cluster` without one), of `strongswan` when site-to-site or
|
(`postgresql@<version>-<cluster>`, 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
|
### 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).
|
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
|
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
|
(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:
|
`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
|
- an access token of `/auth` names a user in `sub` and is valid for 5 minutes; every user may
|
||||||
be a member of the group `admins`, otherwise the request gets 403;
|
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
|
- 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
|
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.
|
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
|
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
|
### The /groups API
|
||||||
|
|
||||||
`/groups` manages groups with the same token and the same module as `/users`.
|
`/groups` manages groups with the same token and the same module as `/users`.
|
||||||
|
|||||||
@@ -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 <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 10:00:00 +0200
|
||||||
|
|
||||||
vpn-router (0.11.0-1) unstable; urgency=medium
|
vpn-router (0.11.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: the status page shows the Router Manager's version.
|
feature: the status page shows the Router Manager's version.
|
||||||
|
|||||||
@@ -15,12 +15,15 @@ Pages:
|
|||||||
profile, and TOTP enrolment (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())
|
||||||
|
/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())
|
/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
|
/config, /users, /groups and /account need an HS256 JWT in an Authorization: Bearer header signed
|
||||||
vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names a user in sub, who
|
with vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names a user in sub;
|
||||||
must be a member of vpnrouter_users.ADMIN_GROUP; a token of vpn-router-token has the admin
|
/account serves that user, and the other paths need a member of vpnrouter_users.ADMIN_GROUP. A
|
||||||
claim, and only such a token may change ADMIN_GROUP: create, rename to it, change, remove it,
|
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
|
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
|
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
|
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
|
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):
|
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."""
|
"""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
|
admins = vpnrouter_users.ADMIN_GROUP
|
||||||
@@ -523,9 +574,11 @@ def changes_admins(connection, method, parts, body):
|
|||||||
|
|
||||||
def refusal(claims, method, path, 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."""
|
"""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:
|
if claims.get('admin') is True:
|
||||||
return ''
|
return ''
|
||||||
name = claims.get('sub')
|
|
||||||
if not isinstance(name, str):
|
if not isinstance(name, str):
|
||||||
return 'the token names no user'
|
return 'the token names no user'
|
||||||
admins = vpnrouter_users.ADMIN_GROUP
|
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.
|
# A body that is not read must not be taken for the next request.
|
||||||
if self.command not in ('GET', 'HEAD'):
|
if self.command not in ('GET', 'HEAD'):
|
||||||
self.close_connection = True
|
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
|
protected = path == '/config' or directory is not None
|
||||||
signing = path in ('/auth', '/auth/refresh', '/auth/logout')
|
signing = path in ('/auth', '/auth/refresh', '/auth/logout')
|
||||||
state = current() if protected or signing else {}
|
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'
|
body = json.dumps({'error': refused[1]}, indent=2).encode() + b'\n'
|
||||||
elif path == '/config' and self.command in ('GET', 'HEAD'):
|
elif path == '/config' and self.command in ('GET', 'HEAD'):
|
||||||
body, kind, code = json.dumps(details(state), indent=2).encode() + b'\n', 'application/json', 200
|
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':
|
elif protected and path != '/config':
|
||||||
code, document = (users if directory == 'users' else groups)(state, self.command, path, payload)
|
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'
|
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||||
|
|||||||
Reference in new issue
Block a user