feature: users sign in to the web service, and only members of admins manage the router.
Release / release (push) Failing after 16m57s

This commit is contained in:
slawek committed 2026-10-24 04:19:01 +02:00
1 parent 791fc7fddd
commit caf0e334f0
17 files changed
+792 -177

No files matched your search

+12 -1
View File
@@ -23,6 +23,7 @@ sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
import ldap
import vpnrouter
import vpnrouter_auth
import vpnrouter_ldap
from vpnrouter import fail
@@ -39,7 +40,7 @@ MANAGER_PORT = 7080
FILTER_ANCHOR = '# End required lines'
#: Configuration options left out wherever the configuration is shown.
SECRET_OPTION = re.compile(r'(^psk$)|secret|password|token|_key$')
SECRET_OPTION = re.compile(r'(^psk$)|secret|password|token|_key$|^database$')
BLOCKS = (
('# IPSEC RULES START', '# IPSEC RULES END'),
@@ -357,6 +358,15 @@ def manage_directory():
fail(f'the user directory cannot be provisioned: {exc}')
def manage_tokens(cfg):
"""Make the web service's signing key, and the database of its refresh tokens unless web.database names one."""
vpnrouter_auth.ensure_key()
try:
vpnrouter_auth.provision(cfg)
except OSError as exc:
fail(f'the token database cannot be provisioned: {exc}')
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'
@@ -464,6 +474,7 @@ def main():
run(['ufw', 'reload'], check=False)
manage_directory()
manage_tokens(cfg)
manage_strongswan(ctx, changed)
manage_wireguard(ctx, changed)
manage_openvpn(ctx, changed)
@@ -54,12 +54,10 @@ SCHEMA = {
'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
'docker': {'mode': 'auto', 'exposed_ports': []},
'web': {'enabled': True, 'port': 7081, 'jwt_secret': ''},
# web.jwt_secret is read and ignored; the tokens are signed with vpnrouter_auth.WEB_KEY.
'web': {'enabled': True, 'port': 7081, 'jwt_secret': '', 'database': '', 'trusted_proxies': []},
}
#: The shortest secret that signs the tokens of the status web service.
WEB_SECRET_MIN = 32
DOCKER_MODES = ('auto', 'on', 'off')
OPENVPN_PROTOCOLS = ('udp', 'tcp')
@@ -262,9 +260,8 @@ def validate(cfg):
if isinstance(web_port, bool) or not isinstance(web_port, int) or not 1 <= web_port <= 65535:
raise ConfigError(f'web.port: {web_port!r} is not a valid port')
web_secret = _get(cfg, 'web', 'jwt_secret')
if web_secret and len(web_secret) < WEB_SECRET_MIN:
raise ConfigError(f'web.jwt_secret: use at least {WEB_SECRET_MIN} characters')
for item in _get_list(cfg, 'web', 'trusted_proxies'):
_check_address(item, 'web.trusted_proxies')
docker_mode = _get(cfg, 'docker', 'mode')
if docker_mode not in DOCKER_MODES:
@@ -0,0 +1,194 @@
"""The tokens of the router's web service, for the web service and vpn-router-token.
Tokens are HS256 JWTs signed with WEB_KEY, a key vpn-router-setup makes on the router and that
never leaves it. /auth issues an access token naming the user in sub, valid ACCESS_SECONDS, and
a refresh token valid REFRESH_SECONDS; vpn-router-token issues tokens with the admin claim and
any lifetime. A token without the admin claim must expire within MAX_LIFETIME seconds.
Refresh tokens are random strings kept in PostgreSQL as their SHA-256, in the database of
web.database, by default the vpn-router database on this host that provision() creates, with
the password in DATABASE_SECRET. A refresh token is used once: refreshing replaces it.
"""
import base64
import hashlib
import hmac
import json
import pathlib
import secrets
import subprocess
import time
import psycopg
from psycopg.conninfo import make_conninfo
WEB_KEY = pathlib.Path('/etc/vpn-router/web.key')
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'
#: Seconds an access token of /auth and a refresh token are valid.
ACCESS_SECONDS = 300
REFRESH_SECONDS = 8 * 3600
#: The longest a token without the admin claim may be valid, from now, in seconds.
MAX_LIFETIME = 600
#: Seconds of clock difference accepted between a token and this router.
LEEWAY = 60
SCHEMA = 'CREATE TABLE IF NOT EXISTS refresh_tokens (hash text PRIMARY KEY, name text NOT NULL, expires timestamptz NOT NULL)'
def key():
"""The signing key, or '' while vpn-router-setup has not made it."""
try:
return WEB_KEY.read_text(encoding='utf-8').strip()
except OSError:
return ''
def ensure_key():
"""Make the signing key when it is missing."""
if not key():
WEB_KEY.parent.mkdir(parents=True, exist_ok=True)
WEB_KEY.touch(mode=0o600)
WEB_KEY.chmod(0o600)
WEB_KEY.write_text(secrets.token_urlsafe(36) + '\n', encoding='utf-8')
def b64encode(data):
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
def b64decode(part):
return base64.urlsafe_b64decode(part + '=' * (-len(part) % 4))
def sign(claims, secret):
head = b64encode(json.dumps({'alg': 'HS256', 'typ': 'JWT'}).encode())
body = b64encode(json.dumps(claims).encode())
signature = hmac.new(secret.encode(), f'{head}.{body}'.encode(), hashlib.sha256).digest()
return f'{head}.{body}.{b64encode(signature)}'
def verify(token, secret):
"""The claims of a token signed with HS256 by the secret, or None when it is not acceptable.
Only HS256 is accepted, so a token cannot choose a weaker algorithm. The token must expire
(exp); without the admin claim not later than MAX_LIFETIME seconds from now, so that a stolen
token is of little use. LEEWAY seconds of clock difference are allowed.
"""
try:
header, payload, signature = token.split('.')
if json.loads(b64decode(header)).get('alg') != 'HS256':
return None
expected = hmac.new(secret.encode(), f'{header}.{payload}'.encode(), hashlib.sha256).digest()
if not hmac.compare_digest(expected, b64decode(signature)):
return None
claims = json.loads(b64decode(payload))
now = time.time()
expires = claims['exp']
if isinstance(expires, bool) or not isinstance(expires, (int, float)) or expires < now - LEEWAY:
return None
if claims.get('admin') is not True and expires > now + MAX_LIFETIME + LEEWAY:
return None
not_before = claims.get('nbf')
if not_before is not None and (isinstance(not_before, bool) or not isinstance(not_before, (int, float)) or not_before > now + LEEWAY):
return None
except (ValueError, KeyError, TypeError, AttributeError):
return None
return claims
def admin_token(seconds):
"""A token with the admin claim, valid for the seconds given."""
secret = key()
if not secret:
raise OSError(f'{WEB_KEY} does not exist; vpn-router-setup makes it once a platform is chosen')
now = int(time.time())
return {'token': sign({'admin': True, 'iat': now, 'exp': now + seconds}, secret), 'expires': now + seconds}
def connection_string(cfg):
"""web.database, or the database provision() creates."""
configured = cfg['web']['database'].strip()
if configured:
return configured
password = DATABASE_SECRET.read_text(encoding='utf-8').strip()
return make_conninfo(host='localhost', dbname=DATABASE, user=DATABASE, password=password)
def connect(cfg):
"""A connection to the database of the refresh tokens, with their table."""
connection = psycopg.connect(connection_string(cfg), autocommit=True)
connection.execute(SCHEMA)
return connection
def digest(token):
return hashlib.sha256(token.encode()).hexdigest()
def issue(cfg, name):
"""An access token and a refresh token for the user, as /auth answers them."""
secret = key()
now = int(time.time())
refresh = secrets.token_urlsafe(32)
with connect(cfg) as connection:
connection.execute('DELETE FROM refresh_tokens WHERE expires < now()')
connection.execute("INSERT INTO refresh_tokens VALUES (%s, %s, now() + make_interval(secs => %s))",
(digest(refresh), name, REFRESH_SECONDS))
return {
'access_token': sign({'sub': name, 'iat': now, 'exp': now + ACCESS_SECONDS}, secret),
'token_type': 'Bearer',
'expires_in': ACCESS_SECONDS,
'refresh_token': refresh,
'refresh_expires_in': REFRESH_SECONDS,
}
def take(cfg, token):
"""Remove an unexpired refresh token and return the user it was issued to, or None."""
if not isinstance(token, str) or not token:
return None
with connect(cfg) as connection:
row = connection.execute('DELETE FROM refresh_tokens WHERE hash = %s RETURNING name, expires > now()',
(digest(token),)).fetchone()
return row[0] if row and row[1] else None
def revoke(cfg, name):
"""Remove all refresh tokens of the user."""
with connect(cfg) as connection:
connection.execute('DELETE FROM refresh_tokens WHERE name = %s', (name,))
def psql(sql, name):
"""The output of an SQL statement, read from standard input, run by psql as postgres with name as the variable v1."""
args = ['runuser', '-u', 'postgres', '--', 'psql', '-X', '-q', '-t', '-A', '-v', 'ON_ERROR_STOP=1', '-v', f'v1={name}']
result = subprocess.run(args, input=sql, capture_output=True, text=True)
if result.returncode != 0:
raise OSError(f'psql failed: {result.stderr.strip()}')
return result.stdout.strip()
def provision(cfg):
"""Create the role, its password file and the database when web.database is not set and they are missing.
The password, of URL-safe characters, reaches psql on standard input, not on its command line.
"""
if cfg['web']['database'].strip():
return
if not psql("SELECT 1 FROM pg_roles WHERE rolname = :'v1'", DATABASE):
DATABASE_SECRET.unlink(missing_ok=True)
psql("CREATE ROLE :\"v1\" LOGIN", DATABASE)
if not DATABASE_SECRET.is_file():
password = secrets.token_urlsafe(32)
psql(f"ALTER ROLE :\"v1\" PASSWORD '{password}'", DATABASE)
DATABASE_SECRET.touch(mode=0o600)
DATABASE_SECRET.chmod(0o600)
DATABASE_SECRET.write_text(password + '\n', encoding='utf-8')
if not psql("SELECT 1 FROM pg_database WHERE datname = :'v1'", DATABASE):
psql("CREATE DATABASE :\"v1\" OWNER :\"v1\"", DATABASE)
@@ -7,9 +7,10 @@ administrator password. Users and groups are changed by the user-manager service
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.
The password policy locks an account for LOCKOUT_SECONDS after LOCKOUT_FAILURES failed binds;
a successful bind clears the count. slapd's otp overlay is configured on the database, so a
bind of a user with a TOTP token takes the password followed by a current code.
cn=totp,ou=policies holds the TOTP parameters the token of every user points to.
"""
import os
@@ -42,6 +43,13 @@ SLAPD_SERVICES = 'SLAPD_SERVICES="ldapi:///"'
MODULES = ('argon2', 'ppolicy', 'otp')
PASSWORD_HASH = '{ARGON2}'
#: Failed binds that lock an account, within LOCKOUT_SECONDS, and the seconds it stays locked.
LOCKOUT_FAILURES = 5
LOCKOUT_SECONDS = 900
#: The overlays of the database, in the order they are configured.
OVERLAYS = ('ppolicy', 'otp')
#: 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'
@@ -131,13 +139,18 @@ def _ensure_database(connection):
return dn
def _ensure_overlay(connection, database):
if not connection.search_s(database, ldap.SCOPE_ONELEVEL, '(objectClass=olcPPolicyConfig)', ['1.1']):
connection.add_s(f'olcOverlay=ppolicy,{database}', ldap.modlist.addModlist({
'objectClass': [b'olcOverlayConfig', b'olcPPolicyConfig'],
'olcOverlay': [b'ppolicy'],
'olcPPolicyDefault': [DEFAULT_POLICY.encode()],
}))
def _ensure_overlays(connection, database):
"""Add the overlays of OVERLAYS the database lacks; the otp overlay has no settings of its own."""
found = connection.search_s(database, ldap.SCOPE_ONELEVEL, '(objectClass=olcOverlayConfig)', ['olcOverlay'])
present = {ORDER.sub(b'', attributes['olcOverlay'][0]).decode() for _, attributes in found}
for name in OVERLAYS:
if name in present:
continue
record = {'objectClass': [b'olcOverlayConfig'], 'olcOverlay': [name.encode()]}
if name == 'ppolicy':
record = {'objectClass': [b'olcOverlayConfig', b'olcPPolicyConfig'], 'olcOverlay': [b'ppolicy'],
'olcPPolicyDefault': [DEFAULT_POLICY.encode()]}
connection.add_s(f'olcOverlay={name},{database}', ldap.modlist.addModlist(record))
def _ensure_entry(connection, dn, attributes):
@@ -181,7 +194,7 @@ def provision():
_ensure_modules(connection)
_ensure_password_hash(connection)
database = _ensure_database(connection)
_ensure_overlay(connection, database)
_ensure_overlays(connection, database)
_ensure_entry(connection, BASE, {'objectClass': [b'dcObject', b'organization'], 'dc': [b'vpn-router'], 'o': [b'vpn-router']})
for unit in (USERS, GROUPS, SERVICES, POLICIES):
_ensure_entry(connection, unit, {'objectClass': [b'organizationalUnit'], 'ou': [unit.split(',')[0][3:].encode()]})
@@ -190,7 +203,9 @@ def provision():
'cn': [b'default'],
'pwdAttribute': [b'userPassword'],
'pwdLockout': [b'TRUE'],
'pwdMaxFailure': [b'0'],
'pwdMaxFailure': [str(LOCKOUT_FAILURES).encode()],
'pwdLockoutDuration': [str(LOCKOUT_SECONDS).encode()],
'pwdFailureCountInterval': [str(LOCKOUT_SECONDS).encode()],
})
_ensure_entry(connection, TOTP_PARAMS, {
'objectClass': [b'organizationalRole', b'oathTOTPParams'],
@@ -14,15 +14,22 @@ A user may have a TOTP token: the user entry itself is an oathTOTPUser and oathT
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.
code of it. slapd's otp overlay then takes the password followed by a code at every bind.
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.
Every operation runs in a session(), which holds the lock that serializes changes and the
connection of the user manager account. A request that cannot be done raises Failure, whose
status says why: 2 the request is invalid, 3 the user does not exist, 4 the user exists, 1
anything else. Answers are dicts with the keys of the JSON answers.
status says why: 2 the request is invalid, 3 the user does not exist, 4 the user exists, 5 a
login failed, 1 anything else. Answers are dicts with the keys of the JSON answers.
"""
import base64
import calendar
import contextlib
import fcntl
import pathlib
@@ -37,6 +44,7 @@ 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.
@@ -61,13 +69,17 @@ TOTP_SECRET = re.compile(r'^[A-Z2-7]{32}$')
GROUP_ATTRIBUTES = ['cn', 'description', 'member']
#: The longest group description, in characters.
DESCRIPTION_MAX = 256
#: The value of pwdAccountLockedTime that locks an account until it is unlocked.
#: The value of pwdAccountLockedTime that disables an account until it is enabled.
LOCKED = b'000001010000Z'
#: The group whose members manage the router through the web service.
ADMIN_GROUP = 'admins'
#: The digits of a TOTP code given to login().
CODE = re.compile(r'^\d{6}$')
LOCK_FILE = pathlib.Path('/run/vpn-router/users.lock')
class Failure(Exception):
"""A request that cannot be done; status is 2 invalid, 3 no such user, 4 the user exists, 1 other."""
"""A request that cannot be done; status is 2 invalid, 3 no such user, 4 the user exists, 5 failed login, 1 other."""
def __init__(self, message, status=1):
super().__init__(message)
@@ -152,12 +164,22 @@ def publish_crl():
subprocess.run(['swanctl', '--load-creds'], capture_output=True, text=True)
def locked_since(value):
"""The time a pwdAccountLockedTime value names, in seconds since the epoch, or 0 when it names none."""
try:
return calendar.timegm(time.strptime(value[:14].decode(), '%Y%m%d%H%M%S'))
except ValueError:
return 0
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,
'enabled': 'pwdAccountLockedTime' not in 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(),
'otp': 'oathTOTPToken' in attributes,
}
@@ -218,21 +240,64 @@ 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 'pwdAccountLockedTime' not in entry(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))
def enable(connection, name):
if 'pwdAccountLockedTime' in entry(connection, name):
if entry(connection, name).get('pwdAccountLockedTime') == [LOCKED]:
connection.modify_s(dn_of(name), [(ldap.MOD_DELETE, 'pwdAccountLockedTime', None)])
return summary(entry(connection, name))
def unlock(connection, name):
"""End a lockout after failed binds and clear the failure count; a disabled user stays disabled."""
entry(connection, name)
current = connection.search_s(dn_of(name), ldap.SCOPE_BASE, attrlist=['pwdAccountLockedTime', 'pwdFailureTime'])[0][1]
changes = [(ldap.MOD_DELETE, 'pwdFailureTime', None)] if 'pwdFailureTime' in current else []
if 'pwdAccountLockedTime' in current and current['pwdAccountLockedTime'] != [LOCKED]:
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))
def login(name, password, code):
"""Check the user's password, and the TOTP code of an enrolled user, with a bind as the user.
A wrong name, password or code, a locked or a disabled user give the same Failure, status 5.
"""
refused = Failure('the name, the password or the code is wrong', 5)
if not isinstance(name, str) or not NAME.match(name) or not isinstance(password, str) or not password:
raise refused
if code is not None and code != '' and (not isinstance(code, str) or not CODE.match(code)):
raise refused
connection = ldap.initialize(vpnrouter_ldap.URI)
try:
connection.simple_bind_s(dn_of(name), password + (code or ''))
except ldap.INVALID_CREDENTIALS:
raise refused
finally:
connection.unbind_s()
def is_admin(connection, name):
"""Whether the user exists and is a member of ADMIN_GROUP."""
try:
entry(connection, name)
except Failure:
return False
return ADMIN_GROUP in groups_of(connection, name)
def totp(secret):
return pyotp.TOTP(secret, digits=vpnrouter_ldap.TOTP_DIGITS, interval=vpnrouter_ldap.TOTP_PERIOD)
@@ -296,6 +361,7 @@ 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}
+155 -59
View File
@@ -15,10 +15,16 @@ 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())
/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 that is signed
with web.jwt_secret and expires within ten minutes; they are not served (404) while
web.jwt_secret is not set.
/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,
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
default the address the service listens on, is the last entry of its X-Forwarded-For header.
/ and /status show only the status: whether the services run, the package version, whether
Docker is installed, and the state of site-to-site, road-warrior (IPsec P2S), OpenVPN and
@@ -28,15 +34,13 @@ The service needs nothing else to start and does not end on its own. A discovery
out the internal address from /etc/vpn-router/vpn-router.json as general.mode directs, with
web.port and web.enabled, every DISCOVERY_SECONDS, and runs the server thread there: it starts
it, moves it when the address or the port changes, and stops it while the service is disabled
or the address is not known. Every request reads what it needs when it is made: the token secret
from the configuration, and the features of the router and the facts shown by /config from
or the address is not known. Every request reads what it needs when it is made: the signing
key, the configuration, and the features of the router and the facts shown by /config from
/run/vpn-router/web.json, which vpn-router-setup writes. Before setup has written that file
the router has no features, and /config shows no configuration or interfaces.
"""
import base64
import hashlib
import hmac
import collections
import json
import os
import signal
@@ -50,7 +54,10 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
import ldap
import psycopg
import vpnrouter
import vpnrouter_auth
import vpnrouter_net
import vpnrouter_users
@@ -65,11 +72,9 @@ DISCOVERY_SECONDS = 30
COMMAND_TIMEOUT = 5
CACHE_SECONDS = 2
#: Seconds of clock difference accepted between a token and this router.
LEEWAY = 60
#: The longest a token may be valid, from now, in seconds.
MAX_LIFETIME = 600
#: The calls of /auth allowed per client address in AUTH_WINDOW seconds.
AUTH_LIMIT = 10
AUTH_WINDOW = 60
TAILWIND = 'https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4'
@@ -82,11 +87,15 @@ OPENVPN_STATUS = '/run/openvpn-server/status-vpn-router.log'
BODY_LIMIT = 4096
#: The HTTP status for each status of a vpnrouter_users.Failure; any other is 500.
USER_STATUS = {2: 400, 3: 404, 4: 409}
USER_STATUS = {2: 400, 3: 404, 4: 409, 5: 401}
_cache = {'at': 0.0, 'data': None}
_lock = threading.Lock()
#: The times of the recent /auth calls of each client address.
_auth_calls = collections.defaultdict(collections.deque)
_auth_lock = threading.Lock()
def read_json(path):
"""The object in a JSON file, or {} when the file is missing or holds no object."""
@@ -105,14 +114,12 @@ def section(configuration, name):
def current():
"""What a request needs, read now: the features and facts setup resolved, and the token secret.
"""What a request needs, read now: the features and facts setup resolved, and the signing key.
Before setup has run the router has no features, and without a secret /config and /users are
not served.
Before setup has run the router has no features, and without a key the protected paths and
/auth are not served.
"""
state = read_json(STATE_FILE)
secret = section(read_json(CONFIG_FILE), 'web').get('jwt_secret')
return dict(state, jwt_secret=secret if isinstance(secret, str) else '')
return dict(read_json(STATE_FILE), key=vpnrouter_auth.key())
def run(*args):
@@ -253,37 +260,6 @@ def collect(state):
return status
def b64decode(part):
return base64.urlsafe_b64decode(part + '=' * (-len(part) % 4))
def verify(token, secret):
"""The claims of a token signed with HS256 by the secret, or None when it is not acceptable.
Only HS256 is accepted, so a token cannot choose a weaker algorithm. The token
must expire (exp), and not later than MAX_LIFETIME seconds from now, so that a
stolen token is of little use; LEEWAY seconds of clock difference are allowed.
"""
try:
header, payload, signature = token.split('.')
if json.loads(b64decode(header)).get('alg') != 'HS256':
return None
expected = hmac.new(secret.encode(), f'{header}.{payload}'.encode(), hashlib.sha256).digest()
if not hmac.compare_digest(expected, b64decode(signature)):
return None
claims = json.loads(b64decode(payload))
now = time.time()
expires = claims['exp']
if isinstance(expires, bool) or not isinstance(expires, (int, float)) or not now - LEEWAY <= expires <= now + MAX_LIFETIME + LEEWAY:
return None
not_before = claims.get('nbf')
if not_before is not None and (isinstance(not_before, bool) or not isinstance(not_before, (int, float)) or not_before > now + LEEWAY):
return None
except (ValueError, KeyError, TypeError, AttributeError):
return None
return claims
def lines_of(*args):
"""The output lines of a command, or None when it fails."""
result = run(*args)
@@ -420,6 +396,7 @@ def users(state, method, path, body):
PUT /users/<name>/password set the password: {"password": ...}
POST /users/<name>/disable lock the user out of OpenVPN
POST /users/<name>/enable let the user in again
POST /users/<name>/unlock end a lockout after failed logins
DELETE /users/<name> remove the user and revoke the certificate
GET /users/<name>/profile the OpenVPN client profile
POST /users/<name>/otp a new TOTP secret and its otpauth URI; nothing is stored
@@ -439,6 +416,7 @@ def users(state, method, path, body):
('PUT', 3, 'password'): lambda connection: vpnrouter_users.passwd(connection, name, body.get('password')),
('POST', 3, 'disable'): lambda connection: vpnrouter_users.disable(connection, name),
('POST', 3, 'enable'): lambda connection: vpnrouter_users.enable(connection, name),
('POST', 3, 'unlock'): lambda connection: vpnrouter_users.unlock(connection, name),
('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name),
('GET', 3, 'profile'): lambda connection: vpnrouter_users.profile(connection, name),
('POST', 3, 'otp'): lambda connection: vpnrouter_users.otp_new(connection, name),
@@ -454,6 +432,8 @@ def users(state, method, path, body):
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 psycopg.Error as exc:
return 500, {'error': f'the token database failed: {exc}'}
except SystemExit:
# vpnrouter ends with SystemExit on a configuration it cannot read; it has printed the reason.
return 500, {'error': 'the router configuration cannot be read'}
@@ -498,6 +478,85 @@ def groups(state, method, path, body):
return (201 if route == ('POST', 1, None) else 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
if parts[0] == 'groups':
if method == 'POST' and len(parts) == 1:
return body.get('name') == admins
if method != 'GET' and len(parts) > 1 and parts[1] == admins:
return True
return method == 'PUT' and len(parts) == 2 and body.get('name') == admins
if parts[0] == 'users' and method == 'DELETE' and len(parts) == 2:
return admins in vpnrouter_users.groups_of(connection, parts[1])
return False
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."""
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
with vpnrouter_users.session() as connection:
if not vpnrouter_users.is_admin(connection, name):
return f'user {name} is not a member of {admins}'
if changes_admins(connection, method, path.strip('/').split('/'), body if isinstance(body, dict) else {}):
return f'only a token of vpn-router-token may change the group {admins} or remove its members'
return ''
def auth(path, body):
"""Answer a POST to /auth with vpnrouter_users and vpnrouter_auth: the HTTP status and the JSON document.
POST /auth sign in: {"name", "password", "code"?}; the tokens
POST /auth/refresh {"refresh_token"}; new tokens, the refresh token used up
POST /auth/logout {"refresh_token"}; the refresh token revoked
"""
if not isinstance(body, dict):
return 400, {'error': 'the body must be a JSON object'}
try:
cfg = vpnrouter.load_config()
if path == '/auth':
vpnrouter_users.login(body.get('name'), body.get('password'), body.get('code'))
return 200, vpnrouter_auth.issue(cfg, body['name'])
name = vpnrouter_auth.take(cfg, body.get('refresh_token'))
if path == '/auth/logout':
return 200, {'revoked': name is not None}
if name is None:
return 401, {'error': 'the refresh token is not valid'}
with vpnrouter_users.session() as connection:
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
if not user['enabled'] or user['locked']:
return 401, {'error': f'user {name} is disabled or locked'}
return 200, vpnrouter_auth.issue(cfg, name)
except vpnrouter_users.Failure as failure:
return (401 if failure.status == 3 else 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 psycopg.Error as exc:
return 500, {'error': f'the token database failed: {exc}'}
except SystemExit:
return 500, {'error': 'the router configuration cannot be read'}
def auth_wait(address):
"""Seconds until the client address may call /auth again, 0 when it may now; a call that may is counted."""
now = time.monotonic()
with _auth_lock:
for idle in [key for key, times in _auth_calls.items() if times[-1] <= now - AUTH_WINDOW]:
del _auth_calls[idle]
calls = _auth_calls[address]
while calls and calls[0] <= now - AUTH_WINDOW:
calls.popleft()
if len(calls) >= AUTH_LIMIT:
return int(calls[0] + AUTH_WINDOW - now) + 1
calls.append(now)
return 0
class Handler(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1'
@@ -508,29 +567,65 @@ class Handler(BaseHTTPRequestHandler):
_cache['at'] = time.monotonic()
return _cache['data']
def authorized(self, secret):
"""Whether the request carries a token signed with the secret; False also while no secret is set."""
def claims(self, secret):
"""The claims of the request's token signed with the secret, or None without an acceptable one."""
header = self.headers.get('Authorization', '')
return bool(secret) and header.startswith('Bearer ') and verify(header[7:].strip(), secret) is not None
return vpnrouter_auth.verify(header[7:].strip(), secret) if header.startswith('Bearer ') else None
def client(self):
"""The client address: the last X-Forwarded-For entry when the peer is a trusted proxy, else the peer."""
peer = self.client_address[0]
trusted = section(read_json(CONFIG_FILE), 'web').get('trusted_proxies') or [self.server.server_address[0]]
forwarded = self.headers.get('X-Forwarded-For', '')
if isinstance(trusted, list) and peer in trusted and forwarded.strip():
return forwarded.split(',')[-1].strip()
return peer
def refusal(self, claims, path, payload):
"""The HTTP status and the message that refuse a request to a protected path, or None when it may be made."""
try:
reason = refusal(claims, self.command, path, payload)
except (ldap.LDAPError, OSError) as exc:
return 500, f'the user directory failed: {exc}'
return (403, reason) if reason else None
def answer(self, send_body):
path = self.path.split('?', 1)[0]
extra = []
self.principal = None
# 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)
protected = path == '/config' or directory is not None
state = current() if protected else {}
if protected and not state['jwt_secret']:
signing = path in ('/auth', '/auth/refresh', '/auth/logout')
state = current() if protected or signing else {}
claims = self.claims(state['key']) if protected and state['key'] else None
payload = self.request_body() if (protected or signing) and self.command not in ('GET', 'HEAD') else None
if claims is not None:
self.principal = 'admin' if claims.get('admin') is True else claims.get('sub')
if (protected or signing) and not state['key']:
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
elif protected and not self.authorized(state['jwt_secret']):
elif signing and self.command != 'POST':
body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
elif signing:
wait = auth_wait(self.client()) if path != '/auth/logout' else 0
if wait:
code, document = 429, {'error': f'too many sign-in calls; retry in {wait} seconds'}
extra.append(('Retry-After', str(wait)))
else:
code, document = auth(path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif protected and claims is None:
body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401
extra.append(('WWW-Authenticate', 'Bearer'))
elif protected and (refused := self.refusal(claims, path, payload)):
code, kind = refused[0], 'application/json'
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 protected and path != '/config':
code, document = (users if directory == 'users' else groups)(state, self.command, path, self.request_body())
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'
elif self.command not in ('GET', 'HEAD'):
body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
@@ -584,7 +679,8 @@ class Handler(BaseHTTPRequestHandler):
self.answer(True)
def log_message(self, format, *args):
sys.stderr.write('%s - - [%s] %s\n' % (self.client_address[0], self.log_date_time_string(), format % args))
principal = getattr(self, 'principal', None) or '-'
sys.stderr.write('%s - %s [%s] %s\n' % (self.client_address[0], principal, self.log_date_time_string(), format % args))
def wanted():
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/python3
"""Make a token with the admin claim for the router's web service, with vpnrouter_auth.
The token is signed with the router's key and is valid for the lifetime given, without a
maximum. It may call every protected path, and it is the only token that may change the
admins group. The answer is the token on standard output, and a failure is a message on
standard error. With --json the answer is {"token", "expires"} on standard output, and a
failure is {"error": ...} there too. The exit status is 2 for an invalid request and 1 for
any other failure. -h or --help shows the options.
vpn-router-token [--json] (--minutes N | --hours N | --days N)
"""
import argparse
import json
import sys
sys.path.insert(0, '/usr/lib/vpn-router')
import vpnrouter_auth
#: The seconds of each unit of the lifetime.
UNITS = {'minutes': 60, 'hours': 3600, 'days': 86400}
class Failure(Exception):
"""A request that cannot be done; status is 2 invalid, 1 other."""
def __init__(self, message, status=1):
super().__init__(message)
self.status = status
class Parser(argparse.ArgumentParser):
"""An argument parser whose errors are Failures with status 2, so that --json answers them as JSON."""
def error(self, message):
raise Failure(f'{message}; see {self.prog} --help', 2)
def positive(value):
try:
number = int(value)
except ValueError:
number = 0
if number < 1:
raise argparse.ArgumentTypeError(f'{value!r} is not a positive whole number')
return number
def main(argv):
# A request that cannot be parsed is answered in JSON when --json is among the arguments.
as_json = '--json' in argv
try:
parser = Parser(prog='vpn-router-token', description='Make a token with the admin claim for the web service.')
parser.add_argument('--json', action='store_true', help='answer in JSON')
lifetime = parser.add_mutually_exclusive_group(required=True)
for unit in UNITS:
lifetime.add_argument(f'--{unit}', type=positive, metavar='N', help=f'valid for N {unit}')
options = parser.parse_args(argv)
as_json = options.json
seconds = next(getattr(options, unit) * factor for unit, factor in UNITS.items() if getattr(options, unit))
answer = vpnrouter_auth.admin_token(seconds)
except Failure as failure:
message, status = str(failure), failure.status
except OSError as exc:
message, status = str(exc), 1
else:
print(json.dumps(answer) if as_json else answer['token'])
return 0
if as_json:
print(json.dumps({'error': message}))
else:
print(f'vpn-router-token: {message}', file=sys.stderr)
return status
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
+7 -1
View File
@@ -14,6 +14,7 @@ user exists, 1 anything else. -h or --help shows the commands and their options.
vpn-router-user [--json] passwd <name> (password on stdin)
vpn-router-user [--json] disable <name>
vpn-router-user [--json] enable <name>
vpn-router-user [--json] unlock <name>
vpn-router-user [--json] remove <name>
vpn-router-user [--json] profile <name>
vpn-router-user [--json] otp-new <name>
@@ -28,6 +29,7 @@ import sys
sys.path.insert(0, '/usr/lib/vpn-router')
import ldap
import psycopg
import vpnrouter_users
from vpnrouter_users import Failure
@@ -40,6 +42,7 @@ HELP = {
'passwd': 'set the password, read from standard input',
'disable': 'lock the user out of OpenVPN',
'enable': 'let the user in again',
'unlock': 'end a lockout after failed logins',
'remove': 'revoke the certificate and remove the user',
'profile': 'print the OpenVPN client profile',
'otp-new': 'make a TOTP secret and its otpauth URI; nothing is stored',
@@ -109,7 +112,8 @@ def text_of(answer):
if not users:
return 'no users'
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'], '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]
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
@@ -131,6 +135,8 @@ def main(argv):
message, status = str(failure), failure.status
except (ldap.LDAPError, OSError) as exc:
message, status = f'the user directory failed: {exc}', 1
except psycopg.Error as exc:
message, status = f'the token database failed: {exc}', 1
else:
print(json.dumps(answer) if as_json else text_of(answer))
return 0
@@ -46,6 +46,7 @@
"web": {
"enabled": true,
"port": 7081,
"jwt_secret": ""
"database": "",
"trusted_proxies": []
}
}