"""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 the web service makes on its first run 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. psycopg is imported by driver() when the database is used, not when the module is loaded, so the module loads while libpq cannot be found yet. A driver that cannot be imported and every failure of the database raise DatabaseError, the only database exception callers see. 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 on the web service's first run, with the password in DATABASE_SECRET. A refresh token is used once: refreshing replaces it. """ import base64 import contextlib import hashlib import hmac import json import pathlib import secrets import subprocess import time 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)' class DatabaseError(Exception): """The PostgreSQL driver cannot be loaded, or the database of the refresh tokens failed.""" def key(): """The signing key, or '' while the web service 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-web makes it when it starts') now = int(time.time()) return {'token': sign({'admin': True, 'iat': now, 'exp': now + seconds}, secret), 'expires': now + seconds} def driver(): """The psycopg module; DatabaseError while libpq cannot be found.""" try: import psycopg except ImportError as exc: raise DatabaseError(f'the PostgreSQL driver cannot be loaded: {exc}') from exc return psycopg def connection_string(cfg): """web.database, or the database provision() creates; its password, of URL-safe characters, is written unquoted.""" configured = cfg['web']['database'].strip() if configured: return configured password = DATABASE_SECRET.read_text(encoding='utf-8').strip() return f'host=localhost dbname={DATABASE} user={DATABASE} password={password}' @contextlib.contextmanager def database(cfg): """A connection to the database of the refresh tokens, with their table, closed at the end; DatabaseError on any failure.""" psycopg = driver() try: with psycopg.connect(connection_string(cfg), autocommit=True) as connection: connection.execute(SCHEMA) yield connection except psycopg.Error as exc: raise DatabaseError(f'the token database failed: {exc}') from exc 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 database(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 database(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 database(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)