feature: the web service is an OpenID Provider, which signs the directory's users in to other systems, the clients being service accounts with redirect URIs.
Release / release (push) Successful in 13s
Release / release (push) Successful in 13s
This commit is contained in:
1 parent
d1d565b1d9
commit
8cac21845a
17 files changed
+1189
-58
No files matched your search
@@ -61,6 +61,7 @@ SCHEMA = {
|
||||
'status_content': 'basic', 'shell': False, 'shell_idle_minutes': 15, 'shell_auth_timeout': 15},
|
||||
'ssh': {'ldap_login': False},
|
||||
'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443, 'caddy_source': 'ubuntu'},
|
||||
'oidc': {'enabled': False},
|
||||
'pki': {'root_name': 'VPN Router Root CA', 'root_days': 7300, 'ca_days': 3650, 'key': 'rsa-4096',
|
||||
'crl_days': 7, 'root_crl_days': 180, 'publish': ['http'], 'p2s_name': 'VPN Router P2S CA',
|
||||
's2s_name': 'VPN Router Site-to-Site CA', 'issuing_cas': {}, 'templates': {},
|
||||
@@ -521,6 +522,12 @@ def validate(cfg):
|
||||
if caddy_source not in CADDY_SOURCES:
|
||||
raise ConfigError(f'https.caddy_source: {caddy_source!r} is not one of {", ".join(CADDY_SOURCES)}')
|
||||
|
||||
if _get(cfg, 'oidc', 'enabled') and not (_get(cfg, 'web', 'enabled') and _get(cfg, 'https', 'enabled')
|
||||
and _get(cfg, 'wan', 'local_fqdn')):
|
||||
raise ConfigError('oidc.enabled: the OpenID Provider is the web service served by Caddy at '
|
||||
'https://<wan.local_fqdn>:<https.web_port>, which needs web.enabled, https.enabled and '
|
||||
'wan.local_fqdn')
|
||||
|
||||
for option in ('root_name', 'p2s_name', 's2s_name'):
|
||||
value = _get(cfg, 'pki', option)
|
||||
if not value or len(value) > CA_NAME_MAX or not value.isprintable():
|
||||
@@ -746,6 +753,12 @@ def pki_base_urls(cfg):
|
||||
return [bases[item] for item in _get_list(cfg, 'pki', 'publish') if item in bases] if fqdn else []
|
||||
|
||||
|
||||
def oidc_issuer(cfg):
|
||||
"""The issuer of the OpenID Provider: the web service's HTTPS site, without the port when it is HTTPS_DEFAULT_PORT."""
|
||||
port = _get(cfg, 'https', 'web_port')
|
||||
return f'https://{_get(cfg, "wan", "local_fqdn")}' + ('' if port == HTTPS_DEFAULT_PORT else f':{port}')
|
||||
|
||||
|
||||
def resolve_psk(connection, name):
|
||||
"""Return the pre-shared key of the site_to_site connection: psk_file wins, else psk; '' when none is configured."""
|
||||
psk_file = connection['psk_file'].strip()
|
||||
|
||||
@@ -10,6 +10,9 @@ Every failure of the database raises DatabaseError, the only database exception
|
||||
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.
|
||||
The same database holds the state of the OpenID Provider (vpnrouter_oidc), whose tables and procedures OIDC_SQL
|
||||
defines; the first connection of a process loads it. revoke() removes a user's refresh tokens and OpenID Provider
|
||||
codes, tokens and sessions.
|
||||
"""
|
||||
|
||||
import base64
|
||||
@@ -26,6 +29,7 @@ import psycopg
|
||||
|
||||
WEB_KEY = pathlib.Path('/etc/vpn-router/web.key')
|
||||
DATABASE_SECRET = pathlib.Path('/etc/vpn-router/database.secret')
|
||||
OIDC_SQL = pathlib.Path('/usr/share/vpn-router/sql/oidc.sql')
|
||||
|
||||
#: The role and the database provision() creates when web.database is not set.
|
||||
DATABASE = 'vpn-router'
|
||||
@@ -45,6 +49,9 @@ LEEWAY = 60
|
||||
|
||||
SCHEMA = 'CREATE TABLE IF NOT EXISTS refresh_tokens (hash text PRIMARY KEY, name text NOT NULL, expires timestamptz NOT NULL)'
|
||||
|
||||
#: Whether this process has loaded OIDC_SQL.
|
||||
_oidc_loaded = False
|
||||
|
||||
|
||||
class DatabaseError(Exception):
|
||||
"""The database of the refresh tokens failed."""
|
||||
@@ -131,10 +138,19 @@ def connection_string(cfg):
|
||||
|
||||
@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."""
|
||||
"""A connection to the database of the refresh tokens, with their table and the OpenID Provider's, closed at the end;
|
||||
DatabaseError on any failure."""
|
||||
global _oidc_loaded
|
||||
try:
|
||||
oidc = None if _oidc_loaded else OIDC_SQL.read_text(encoding='utf-8')
|
||||
except OSError as exc:
|
||||
raise DatabaseError(f'{OIDC_SQL} cannot be read: {exc}') from exc
|
||||
try:
|
||||
with psycopg.connect(connection_string(cfg), autocommit=True) as connection:
|
||||
connection.execute(SCHEMA)
|
||||
if oidc is not None:
|
||||
connection.execute(oidc)
|
||||
_oidc_loaded = True
|
||||
yield connection
|
||||
except psycopg.Error as exc:
|
||||
raise DatabaseError(f'the token database failed: {exc}') from exc
|
||||
@@ -173,9 +189,10 @@ def take(cfg, token):
|
||||
|
||||
|
||||
def revoke(cfg, name):
|
||||
"""Remove all refresh tokens of the user."""
|
||||
"""Remove all refresh tokens of the user, and its codes, tokens and sessions of the OpenID Provider."""
|
||||
with database(cfg) as connection:
|
||||
connection.execute('DELETE FROM refresh_tokens WHERE name = %s', (name,))
|
||||
connection.execute('CALL oidc_revoke(%s)', (name,))
|
||||
|
||||
|
||||
def psql(sql, name):
|
||||
|
||||
@@ -68,7 +68,10 @@ OVERLAYS = ('ppolicy', 'otp')
|
||||
#: vpnRouterConsoleAccount, whether the user may use a console and the local account it runs as,
|
||||
#: without which it runs as the user's own posixAccount, and vpnRouterWireGuardPeer, each a
|
||||
#: WireGuard peer of the user as "<public key> <IP>[,<IP>...]", and vpnRouterPasskey, each a
|
||||
#: passkey of the user as JSON (vpnrouter_passkeys). vpnRouterAuthority is a CA of the PKI: its
|
||||
#: passkey of the user as JSON (vpnrouter_passkeys). A service account with vpnRouterRedirectUri
|
||||
#: is a client of the OpenID Provider (vpnrouter_oidc): the URIs it is sent back to after a sign-in
|
||||
#: and, vpnRouterPostLogoutRedirectUri, after a sign-out, and vpnRouterAllowedGroup, the DNs of the
|
||||
#: groups whose members may sign in to it. vpnRouterAuthority is a CA of the PKI: its
|
||||
#: certificate and CRL (cACertificate and certificateRevocationList of RFC 4523), its key, the
|
||||
#: CSR it waits with for the offline root, the number of its last CRL, where the CRL came from,
|
||||
#: and when it was retired. vpnRouterCertificate is a certificate a CA issued, named by its
|
||||
@@ -103,11 +106,18 @@ SCHEMA_ATTRIBUTE_TYPES = (
|
||||
"EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )",
|
||||
f"( {SCHEMA_OID}.1.16 NAME 'vpnRouterPasskey' DESC 'VPN router: a passkey of the user, its credential as JSON' "
|
||||
"EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{4096} )",
|
||||
f"( {SCHEMA_OID}.1.17 NAME 'vpnRouterRedirectUri' DESC 'VPN router: a redirect URI of an OpenID Connect client' "
|
||||
"EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )",
|
||||
f"( {SCHEMA_OID}.1.18 NAME 'vpnRouterPostLogoutRedirectUri' DESC 'VPN router: a post-logout redirect URI of an "
|
||||
"OpenID Connect client' EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )",
|
||||
f"( {SCHEMA_OID}.1.19 NAME 'vpnRouterAllowedGroup' DESC 'VPN router: a group whose members may sign in to an "
|
||||
"OpenID Connect client' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 )",
|
||||
)
|
||||
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 $ displayName $ description $ vpnRouterConsoleLogin $ "
|
||||
"vpnRouterConsoleAccount $ vpnRouterWireGuardPeer $ vpnRouterPasskey ) )",
|
||||
"vpnRouterConsoleAccount $ vpnRouterWireGuardPeer $ vpnRouterPasskey $ vpnRouterRedirectUri $ "
|
||||
"vpnRouterPostLogoutRedirectUri $ vpnRouterAllowedGroup ) )",
|
||||
f"( {SCHEMA_OID}.2.2 NAME 'vpnRouterAuthority' DESC 'VPN router: a CA of the PKI' SUP top STRUCTURAL MUST cn "
|
||||
"MAY ( cACertificate $ certificateRevocationList $ vpnRouterPrivateKey $ vpnRouterCertificateRequest $ "
|
||||
"vpnRouterCRLNumber $ vpnRouterCRLSource $ vpnRouterRetired ) )",
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
"""The router's OpenID Provider, for the web service, with python3-oauthlib and python3-jwt.
|
||||
|
||||
While oidc.enabled is true, the web service answers OpenID Connect at the issuer vpnrouter.oidc_issuer, its HTTPS site
|
||||
that Caddy serves; the web service answers it only on the loopback address, where Caddy passes the requests. The users
|
||||
of the router's directory sign in, with the password and the TOTP code or with a passkey (vpnrouter_passkeys), to its
|
||||
clients, the service accounts with redirect URIs (vpnrouter_users.client_settings): a client's ID is its name and its
|
||||
secret its password, sent with HTTP Basic authentication or in the body of the token request. A client with allowed
|
||||
groups admits only their members; a service account never signs in as a user.
|
||||
|
||||
GET /.well-known/openid-configuration the discovery document
|
||||
GET /oidc/jwks the public key the ID tokens are signed with
|
||||
GET, POST /oidc/authorize the authorization code flow, PKCE with S256 required; the sign-in page while
|
||||
the browser has no session, or with prompt=login or an older one than max_age
|
||||
POST /oidc/login the sign-in page's form: the name, the password and the code, or a passkey
|
||||
POST /oidc/passkey begin a sign-in with a passkey: {"id", "options"}
|
||||
POST /oidc/token exchange a code or a refresh token for tokens
|
||||
GET, POST /oidc/userinfo the claims of the user an access token was issued for
|
||||
GET /oidc/logout end the session, then go to a post-logout redirect URI of the client
|
||||
|
||||
ID tokens are RS256 JWTs signed with KEY, an RSA key the web service makes on its first run on the router, named by
|
||||
the RFC 7638 thumbprint of its public key. sub is the entryUUID of the user's entry, so a user added again under the
|
||||
same name is another subject. The scopes: openid, required; profile, preferred_username and the names; email; groups,
|
||||
the names of the user's groups; their claims are in the ID token and the userinfo answer. A code is valid
|
||||
CODE_SECONDS, an access token and an ID token ACCESS_SECONDS, a refresh token REFRESH_SECONDS, used once. A session,
|
||||
the cookie COOKIE of the issuer's /oidc/ path, lasts SESSION_SECONDS. Codes, tokens and sessions are kept in the
|
||||
database of vpnrouter_auth as their SHA-256 (vpnrouter_auth.OIDC_SQL); vpnrouter_auth.revoke removes a user's. A
|
||||
client is registered by an administrator, so a user is not asked for consent.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import collections
|
||||
import contextlib
|
||||
import datetime
|
||||
import hashlib
|
||||
import http.cookies
|
||||
import json
|
||||
import pathlib
|
||||
import secrets
|
||||
import time
|
||||
import types
|
||||
import urllib.parse
|
||||
|
||||
import jinja2
|
||||
import jwt
|
||||
import ldap
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from oauthlib.common import add_params_to_uri
|
||||
from oauthlib.oauth2 import AccessDeniedError, FatalClientError, InvalidRequestError, OAuth2Error
|
||||
from oauthlib.openid import RequestValidator, Server
|
||||
from oauthlib.openid.connect.core.exceptions import LoginRequired
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_auth
|
||||
import vpnrouter_ldap
|
||||
import vpnrouter_passkeys
|
||||
import vpnrouter_users
|
||||
|
||||
KEY = pathlib.Path('/etc/vpn-router/oidc.key')
|
||||
KEY_BITS = 3072
|
||||
|
||||
CODE_SECONDS = 60
|
||||
ACCESS_SECONDS = 300
|
||||
REFRESH_SECONDS = 8 * 3600
|
||||
SESSION_SECONDS = 8 * 3600
|
||||
|
||||
COOKIE = 'vpn_router_oidc'
|
||||
|
||||
#: The claims of each scope but openid, which gives sub.
|
||||
SCOPE_CLAIMS = {
|
||||
'profile': ('preferred_username', 'name', 'given_name', 'family_name'),
|
||||
'email': ('email',),
|
||||
'groups': ('groups',),
|
||||
}
|
||||
SCOPES = ('openid', *SCOPE_CLAIMS)
|
||||
|
||||
#: The authentication methods (RFC 8176) of a sign-in with a password, with a password and a TOTP code, and with a
|
||||
#: passkey, which verifies the user.
|
||||
PASSWORD_AMR = ['pwd']
|
||||
OTP_AMR = ['pwd', 'otp']
|
||||
PASSKEY_AMR = ['hwk', 'user']
|
||||
|
||||
TEMPLATE = 'oidc-page.html.j2'
|
||||
|
||||
#: The headers of the pages: no framing, and nothing loaded but from the page itself and the router.
|
||||
PAGE_HEADERS = [('Content-Security-Policy', "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; "
|
||||
"connect-src 'self'; frame-ancestors 'none'")]
|
||||
|
||||
#: The headers of oauthlib's answers that the web service sets itself.
|
||||
OWN_HEADERS = ('content-type', 'cache-control', 'content-length')
|
||||
|
||||
#: A user's sign-in at the OpenID Provider: the user name, when, a datetime, and how (amr).
|
||||
Session = collections.namedtuple('Session', 'name auth_time amr')
|
||||
|
||||
_pages = jinja2.Environment(loader=jinja2.FileSystemLoader(str(vpnrouter.TEMPLATE_DIR)), autoescape=True,
|
||||
undefined=jinja2.StrictUndefined)
|
||||
|
||||
|
||||
def ensure_key():
|
||||
"""Make the signing key when it is missing."""
|
||||
if KEY.is_file() and KEY.stat().st_size:
|
||||
return
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=KEY_BITS)
|
||||
KEY.parent.mkdir(parents=True, exist_ok=True)
|
||||
KEY.touch(mode=0o600)
|
||||
KEY.chmod(0o600)
|
||||
KEY.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption()))
|
||||
|
||||
|
||||
def private_key():
|
||||
return serialization.load_pem_private_key(KEY.read_bytes(), None)
|
||||
|
||||
|
||||
def public_jwk(key):
|
||||
"""The JWK of the key's public key, its kid the RFC 7638 thumbprint."""
|
||||
numbers = key.public_key().public_numbers()
|
||||
jwk = {'e': numbers.e, 'kty': 'RSA', 'n': numbers.n}
|
||||
jwk = {name: vpnrouter_auth.b64encode(value.to_bytes((value.bit_length() + 7) // 8, 'big')) if isinstance(value, int)
|
||||
else value for name, value in jwk.items()}
|
||||
thumbprint = hashlib.sha256(json.dumps(jwk, sort_keys=True, separators=(',', ':')).encode()).digest()
|
||||
return {**jwk, 'kid': vpnrouter_auth.b64encode(thumbprint), 'use': 'sig', 'alg': 'RS256'}
|
||||
|
||||
|
||||
def sign(claims):
|
||||
key = private_key()
|
||||
return jwt.encode(claims, key, algorithm='RS256', headers={'kid': public_jwk(key)['kid']})
|
||||
|
||||
|
||||
def discovery(issuer):
|
||||
"""The discovery document of OpenID Connect Discovery 1.0."""
|
||||
return {
|
||||
'issuer': issuer,
|
||||
'authorization_endpoint': f'{issuer}/oidc/authorize',
|
||||
'token_endpoint': f'{issuer}/oidc/token',
|
||||
'userinfo_endpoint': f'{issuer}/oidc/userinfo',
|
||||
'jwks_uri': f'{issuer}/oidc/jwks',
|
||||
'end_session_endpoint': f'{issuer}/oidc/logout',
|
||||
'scopes_supported': list(SCOPES),
|
||||
'response_types_supported': ['code'],
|
||||
'response_modes_supported': ['query'],
|
||||
'grant_types_supported': ['authorization_code', 'refresh_token'],
|
||||
'subject_types_supported': ['public'],
|
||||
'id_token_signing_alg_values_supported': ['RS256'],
|
||||
'token_endpoint_auth_methods_supported': ['client_secret_basic', 'client_secret_post'],
|
||||
'code_challenge_methods_supported': ['S256'],
|
||||
'claims_supported': ['iss', 'sub', 'aud', 'exp', 'iat', 'auth_time', 'nonce', 'amr', 'at_hash',
|
||||
*(claim for claims in SCOPE_CLAIMS.values() for claim in claims)],
|
||||
}
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def directory():
|
||||
"""A connection to the router's directory as the user manager account, for reading."""
|
||||
connection = vpnrouter_ldap.manager()
|
||||
try:
|
||||
yield connection
|
||||
finally:
|
||||
connection.unbind_s()
|
||||
|
||||
|
||||
def client_of(client_id):
|
||||
"""The client of the ID, a namespace of its vpnrouter_users.summary with client_id, when it is an enabled service
|
||||
account with redirect URIs; else None."""
|
||||
if not isinstance(client_id, str):
|
||||
return None
|
||||
with directory() as connection:
|
||||
try:
|
||||
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, client_id))
|
||||
except vpnrouter_users.Failure:
|
||||
return None
|
||||
if not user['service'] or not user['enabled'] or not user['redirect_uris']:
|
||||
return None
|
||||
return types.SimpleNamespace(client_id=client_id, **user)
|
||||
|
||||
|
||||
def subject(name, client):
|
||||
"""The claims of every scope of the user, with sub, when the user may sign in to the client: an enabled, unlocked
|
||||
user who is not a service account, a member of one of the client's allowed groups when it has any; else None."""
|
||||
with directory() as connection:
|
||||
try:
|
||||
attributes = vpnrouter_users.entry(connection, name)
|
||||
except vpnrouter_users.Failure:
|
||||
return None
|
||||
user = vpnrouter_users.summary(attributes)
|
||||
groups = sorted(vpnrouter_users.groups_of(connection, name))
|
||||
uuid = connection.search_s(vpnrouter_users.dn_of(name), ldap.SCOPE_BASE, attrlist=['entryUUID'])[0][1]['entryUUID']
|
||||
if user['service'] or not user['enabled'] or user['locked']:
|
||||
return None
|
||||
if client.allowed_groups and not set(client.allowed_groups) & set(groups):
|
||||
return None
|
||||
full = ' '.join(part for part in (user['first_name'], user['last_name']) if part)
|
||||
claims = {'sub': uuid[0].decode(), 'preferred_username': name, 'name': full or None, 'given_name': user['first_name'],
|
||||
'family_name': user['last_name'], 'email': user['email'], 'groups': groups}
|
||||
return {claim: value for claim, value in claims.items() if value is not None}
|
||||
|
||||
|
||||
def claims_of(person, scopes):
|
||||
"""The claims of the scopes of a subject's claims (subject)."""
|
||||
return {'sub': person['sub'], **{claim: person[claim] for scope in scopes for claim in SCOPE_CLAIMS.get(scope, ())
|
||||
if claim in person}}
|
||||
|
||||
|
||||
def cookie_value(cookies):
|
||||
"""The value of the session cookie in a Cookie header, or ''."""
|
||||
try:
|
||||
morsel = http.cookies.SimpleCookie(cookies or '').get(COOKIE)
|
||||
except http.cookies.CookieError:
|
||||
return ''
|
||||
return morsel.value if morsel is not None else ''
|
||||
|
||||
|
||||
def session_of(cfg, cookies):
|
||||
"""The unexpired Session the session cookie in a Cookie header names, or None."""
|
||||
value = cookie_value(cookies)
|
||||
if not value:
|
||||
return None
|
||||
with vpnrouter_auth.database(cfg) as connection:
|
||||
row = connection.execute('SELECT * FROM oidc_session_get(%s)', (vpnrouter_auth.digest(value),)).fetchone()
|
||||
return Session(*row) if row else None
|
||||
|
||||
|
||||
class Validator(RequestValidator):
|
||||
"""The checks and the storage of oauthlib's server for the router's clients, users and database.
|
||||
|
||||
Only the authorization code grant with PKCE S256 and the refresh token grant are allowed. The values of a code, a
|
||||
token or a session found are kept on the request as oidc_code, oidc_scopes and oidc_person.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg):
|
||||
super().__init__()
|
||||
self.cfg = cfg
|
||||
|
||||
def fetch(self, sql, values):
|
||||
with vpnrouter_auth.database(self.cfg) as connection:
|
||||
return connection.execute(sql, values).fetchone()
|
||||
|
||||
def call(self, *statements):
|
||||
with vpnrouter_auth.database(self.cfg) as connection:
|
||||
for sql, values in statements:
|
||||
connection.execute(sql, values)
|
||||
|
||||
def session(self, request):
|
||||
return session_of(self.cfg, request.headers.get('Cookie'))
|
||||
|
||||
# Clients.
|
||||
|
||||
def validate_client_id(self, client_id, request, *args, **kwargs):
|
||||
request.client = client_of(client_id)
|
||||
return request.client is not None
|
||||
|
||||
def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs):
|
||||
return redirect_uri in request.client.redirect_uris
|
||||
|
||||
def get_default_redirect_uri(self, client_id, request, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def validate_response_type(self, client_id, response_type, client, request, *args, **kwargs):
|
||||
return response_type == 'code'
|
||||
|
||||
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
|
||||
return 'openid' in scopes and set(scopes) <= set(SCOPES)
|
||||
|
||||
def get_default_scopes(self, client_id, request, *args, **kwargs):
|
||||
return ['openid']
|
||||
|
||||
def is_pkce_required(self, client_id, request):
|
||||
return True
|
||||
|
||||
def authenticate_client(self, request, *args, **kwargs):
|
||||
"""Check the client's secret, the password of its service account, given with HTTP Basic authentication, its
|
||||
ID and secret form-encoded (RFC 6749 2.3.1), or in the body."""
|
||||
name, secret = request.client_id, request.client_secret
|
||||
header = request.headers.get('Authorization', '')
|
||||
if header[:6].lower() == 'basic ':
|
||||
try:
|
||||
name, _, secret = base64.b64decode(header[6:].strip(), validate=True).decode().partition(':')
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return False
|
||||
name, secret = urllib.parse.unquote_plus(name), urllib.parse.unquote_plus(secret)
|
||||
client = client_of(name)
|
||||
if client is None:
|
||||
return False
|
||||
try:
|
||||
vpnrouter_users.login(name, secret, None)
|
||||
except vpnrouter_users.Failure:
|
||||
return False
|
||||
request.client = client
|
||||
return True
|
||||
|
||||
def authenticate_client_id(self, client_id, request, *args, **kwargs):
|
||||
return False
|
||||
|
||||
def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs):
|
||||
return grant_type in ('authorization_code', 'refresh_token')
|
||||
|
||||
def validate_user(self, username, password, client, request, *args, **kwargs):
|
||||
return False
|
||||
|
||||
# Codes.
|
||||
|
||||
def save_authorization_code(self, client_id, code, request, *args, **kwargs):
|
||||
self.call(('CALL oidc_code_save(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)',
|
||||
(vpnrouter_auth.digest(code['code']), client_id, request.user, request.redirect_uri, request.scopes,
|
||||
request.nonce, request.code_challenge, request.code_challenge_method, request.auth_time, request.amr,
|
||||
CODE_SECONDS)))
|
||||
|
||||
def code(self, code):
|
||||
row = self.fetch('SELECT * FROM oidc_code_get(%s)', (vpnrouter_auth.digest(code),)) if isinstance(code, str) else None
|
||||
return dict(zip(('client_id', 'name', 'redirect_uri', 'scopes', 'nonce', 'code_challenge', 'code_challenge_method',
|
||||
'auth_time', 'amr'), row)) if row else None
|
||||
|
||||
def get_authorization_code_scopes(self, client_id, code, redirect_uri, request):
|
||||
found = self.code(code)
|
||||
return found['scopes'] if found else []
|
||||
|
||||
def validate_code(self, client_id, code, client, request, *args, **kwargs):
|
||||
found = self.code(code)
|
||||
if found is None or found['client_id'] != client_id:
|
||||
return False
|
||||
person = subject(found['name'], client)
|
||||
if person is None:
|
||||
return False
|
||||
request.user, request.scopes, request.auth_time, request.amr = found['name'], found['scopes'], found['auth_time'], found['amr']
|
||||
request.oidc_code, request.oidc_person = found, person
|
||||
return True
|
||||
|
||||
def get_code_challenge(self, code, request):
|
||||
return request.oidc_code['code_challenge']
|
||||
|
||||
def get_code_challenge_method(self, code, request):
|
||||
return request.oidc_code['code_challenge_method']
|
||||
|
||||
def confirm_redirect_uri(self, client_id, code, redirect_uri, client, request, *args, **kwargs):
|
||||
return redirect_uri == request.oidc_code['redirect_uri']
|
||||
|
||||
def get_authorization_code_nonce(self, client_id, code, redirect_uri, request):
|
||||
return request.oidc_code['nonce']
|
||||
|
||||
def invalidate_authorization_code(self, client_id, code, request, *args, **kwargs):
|
||||
self.call(('CALL oidc_code_delete(%s)', (vpnrouter_auth.digest(code),)))
|
||||
|
||||
# Tokens.
|
||||
|
||||
def token(self, token, kind):
|
||||
row = self.fetch('SELECT * FROM oidc_token_get(%s, %s)', (vpnrouter_auth.digest(token), kind)) \
|
||||
if isinstance(token, str) and token else None
|
||||
return dict(zip(('client_id', 'name', 'scopes', 'auth_time', 'amr'), row)) if row else None
|
||||
|
||||
def save_bearer_token(self, token, request, *args, **kwargs):
|
||||
"""Keep the access token and the refresh token; a refresh token that was exchanged is used up."""
|
||||
values = (request.client.client_id, request.user, request.scopes, request.auth_time, request.amr)
|
||||
statements = [('CALL oidc_token_save(%s, %s, %s, %s, %s, %s, %s, %s)',
|
||||
(vpnrouter_auth.digest(token['access_token']), 'access', *values, ACCESS_SECONDS))]
|
||||
if token.get('refresh_token'):
|
||||
statements.append(('CALL oidc_token_save(%s, %s, %s, %s, %s, %s, %s, %s)',
|
||||
(vpnrouter_auth.digest(token['refresh_token']), 'refresh', *values, REFRESH_SECONDS)))
|
||||
if request.grant_type == 'refresh_token':
|
||||
statements.append(('CALL oidc_token_delete(%s)', (vpnrouter_auth.digest(request.refresh_token),)))
|
||||
self.call(*statements)
|
||||
|
||||
def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs):
|
||||
found = self.token(refresh_token, 'refresh')
|
||||
if found is None or found['client_id'] != client.client_id:
|
||||
return False
|
||||
person = subject(found['name'], client)
|
||||
if person is None:
|
||||
return False
|
||||
request.user, request.auth_time, request.amr = found['name'], found['auth_time'], found['amr']
|
||||
request.oidc_scopes, request.oidc_person = found['scopes'], person
|
||||
return True
|
||||
|
||||
def get_original_scopes(self, refresh_token, request, *args, **kwargs):
|
||||
return request.oidc_scopes
|
||||
|
||||
def validate_bearer_token(self, token, scopes, request):
|
||||
found = self.token(token, 'access')
|
||||
if found is None or not set(scopes) <= set(found['scopes']):
|
||||
return False
|
||||
client = client_of(found['client_id'])
|
||||
person = subject(found['name'], client) if client else None
|
||||
if person is None:
|
||||
return False
|
||||
request.client, request.user, request.oidc_scopes, request.oidc_person = client, found['name'], found['scopes'], person
|
||||
return True
|
||||
|
||||
def get_userinfo_claims(self, request):
|
||||
return claims_of(request.oidc_person, request.oidc_scopes)
|
||||
|
||||
# OpenID Connect.
|
||||
|
||||
def finalize_id_token(self, id_token, token, token_handler, request):
|
||||
now = int(time.time())
|
||||
id_token.update(claims_of(request.oidc_person, request.scopes))
|
||||
id_token.update({'iss': vpnrouter.oidc_issuer(self.cfg), 'exp': now + ACCESS_SECONDS,
|
||||
'auth_time': int(request.auth_time.timestamp()), 'amr': request.amr})
|
||||
return sign(id_token)
|
||||
|
||||
def validate_silent_login(self, request):
|
||||
session = self.session(request)
|
||||
return session is not None and subject(session.name, request.client) is not None
|
||||
|
||||
def validate_silent_authorization(self, request):
|
||||
return True
|
||||
|
||||
def validate_user_match(self, id_token_hint, scopes, claims, request):
|
||||
"""Whether the user of the session, when there is one, is the subject of an ID token the router signed."""
|
||||
session = self.session(request) if id_token_hint else None
|
||||
if session is None:
|
||||
return True
|
||||
try:
|
||||
hint = jwt.decode(id_token_hint, private_key().public_key(), algorithms=['RS256'],
|
||||
options={'verify_exp': False, 'verify_aud': False})
|
||||
except jwt.InvalidTokenError:
|
||||
return False
|
||||
person = subject(session.name, request.client)
|
||||
return person is not None and hint.get('sub') == person['sub']
|
||||
|
||||
|
||||
def page(status, cfg, title, message='', error='', form=False, request='', name='', headers=()):
|
||||
"""An HTML page of TEMPLATE: a message, an error, and with form the sign-in form for the authorization request."""
|
||||
body = _pages.get_template(TEMPLATE).render(title=title, message=message, error=error, form=form, request=request,
|
||||
name=name, issuer=vpnrouter.oidc_issuer(cfg))
|
||||
return status, 'text/html; charset=utf-8', body.encode(), [*PAGE_HEADERS, *headers]
|
||||
|
||||
|
||||
def document(status, value, headers=()):
|
||||
return status, 'application/json', json.dumps(value, indent=2).encode() + b'\n', list(headers)
|
||||
|
||||
|
||||
def relayed(headers, body, status):
|
||||
"""An answer of oauthlib's server, without the headers the web service sets."""
|
||||
kind = next((value for name, value in headers.items() if name.lower() == 'content-type'), 'application/json')
|
||||
return status, kind, (body or '').encode(), [(name, value) for name, value in headers.items()
|
||||
if name.lower() not in OWN_HEADERS]
|
||||
|
||||
|
||||
def redirect(location, headers=()):
|
||||
return 302, 'text/plain; charset=utf-8', b'', [('Location', location), *headers]
|
||||
|
||||
|
||||
def cookie(value, seconds):
|
||||
return 'Set-Cookie', f'{COOKIE}={value}; Path=/oidc/; Max-Age={seconds}; Secure; HttpOnly; SameSite=Lax'
|
||||
|
||||
|
||||
def authorize(server, cfg, params, headers, fresh=None, name='', error=''):
|
||||
"""Answer an authorization request, the query or the form params: the sign-in page while the browser has no
|
||||
session, or with prompt=login or max_age older than the session, else a redirect to the client with a code.
|
||||
|
||||
fresh is the session of a sign-in just made, which counts whatever prompt and max_age say; the redirect then sets
|
||||
its cookie, fresh.cookie. error is why a sign-in failed: the sign-in page shows it, filled with the name.
|
||||
"""
|
||||
uri = f'{vpnrouter.oidc_issuer(cfg)}/oidc/authorize?{params}'
|
||||
try:
|
||||
scopes, info = server.validate_authorization_request(uri, 'GET', None, headers)
|
||||
except FatalClientError as error:
|
||||
return page(error.status_code, cfg, 'The sign-in request is refused', error=error.description or error.error)
|
||||
except OAuth2Error as error:
|
||||
if not error.redirect_uri:
|
||||
return page(error.status_code, cfg, 'The sign-in request is refused', error=error.description or error.error)
|
||||
return redirect(error.in_uri(error.redirect_uri))
|
||||
request = info['request']
|
||||
title = f'Sign in to {request.client.display_name or request.client.client_id}'
|
||||
if info.get('code_challenge_method') != 'S256':
|
||||
return redirect(InvalidRequestError(description='PKCE with code_challenge_method S256 is required',
|
||||
request=request).in_uri(request.redirect_uri))
|
||||
if error:
|
||||
return page(401, cfg, title, error=error, form=True, request=params, name=name)
|
||||
session = fresh.session if fresh else session_of(cfg, headers.get('Cookie'))
|
||||
max_age = getattr(request, 'max_age', None)
|
||||
stale = session is not None and isinstance(max_age, str) and max_age.isdigit() and \
|
||||
time.time() - session.auth_time.timestamp() > int(max_age)
|
||||
prompt = info.get('prompt') or set()
|
||||
if session is None or (not fresh and ('login' in prompt or stale)):
|
||||
if 'none' in prompt:
|
||||
return redirect(LoginRequired(request=request).in_uri(request.redirect_uri))
|
||||
return page(200, cfg, title, form=True, request=params, name=name)
|
||||
if subject(session.name, request.client) is None:
|
||||
if fresh:
|
||||
return redirect(AccessDeniedError(description=f'{session.name} may not sign in to this client',
|
||||
request=request).in_uri(request.redirect_uri), [fresh.cookie])
|
||||
return page(403, cfg, title, error=f'{session.name} may not sign in here; sign in as another user.', form=True,
|
||||
request=params)
|
||||
answer, _, _ = server.create_authorization_response(uri, 'GET', None, headers, scopes, credentials={
|
||||
'user': session.name, 'auth_time': session.auth_time, 'amr': session.amr})
|
||||
return redirect(answer['Location'], [fresh.cookie] if fresh else [])
|
||||
|
||||
|
||||
def login(server, cfg, form, headers):
|
||||
"""Sign a user in with the sign-in page's form, start the session and go on with its authorization request."""
|
||||
params = form.get('request', '')
|
||||
name = form.get('name', '').strip()
|
||||
try:
|
||||
if form.get('passkey_id'):
|
||||
with vpnrouter_users.session() as connection:
|
||||
name = vpnrouter_passkeys.login_complete(connection, form['passkey_id'], json.loads(form.get('credential', '')))
|
||||
amr = PASSKEY_AMR
|
||||
else:
|
||||
code = form.get('code', '').strip()
|
||||
vpnrouter_users.login(name, form.get('password', ''), code or None)
|
||||
with directory() as connection:
|
||||
if vpnrouter_users.service(vpnrouter_users.entry(connection, name)):
|
||||
raise vpnrouter_users.Failure('the name, the password or the code is wrong', 5)
|
||||
amr = OTP_AMR if code else PASSWORD_AMR
|
||||
except vpnrouter_users.Failure as failure:
|
||||
return authorize(server, cfg, params, headers, name=name, error=str(failure))
|
||||
except ValueError:
|
||||
return authorize(server, cfg, params, headers, name=name, error='the passkey is not accepted')
|
||||
value = secrets.token_urlsafe(32)
|
||||
with vpnrouter_auth.database(cfg) as connection:
|
||||
connection.execute('CALL oidc_session_save(%s, %s, %s, %s)', (vpnrouter_auth.digest(value), name, amr, SESSION_SECONDS))
|
||||
fresh = types.SimpleNamespace(session=Session(name, datetime.datetime.now(datetime.timezone.utc), amr),
|
||||
cookie=cookie(value, SESSION_SECONDS))
|
||||
return authorize(server, cfg, params, headers, fresh=fresh)
|
||||
|
||||
|
||||
def logout(cfg, query, headers):
|
||||
"""End the session the cookie names, then go to post_logout_redirect_uri, with state, when it is a post-logout
|
||||
redirect URI of the client that client_id or the audience of id_token_hint names; else show that the user is
|
||||
signed out."""
|
||||
values = dict(urllib.parse.parse_qsl(query))
|
||||
value = cookie_value(headers.get('Cookie'))
|
||||
if value:
|
||||
with vpnrouter_auth.database(cfg) as connection:
|
||||
connection.execute('CALL oidc_session_delete(%s)', (vpnrouter_auth.digest(value),))
|
||||
client_id = values.get('client_id')
|
||||
if not client_id and values.get('id_token_hint'):
|
||||
try:
|
||||
audience = jwt.decode(values['id_token_hint'], private_key().public_key(), algorithms=['RS256'],
|
||||
options={'verify_exp': False, 'verify_aud': False}).get('aud')
|
||||
except jwt.InvalidTokenError:
|
||||
audience = None
|
||||
client_id = audience[0] if isinstance(audience, list) and audience else audience
|
||||
client = client_of(client_id)
|
||||
target = values.get('post_logout_redirect_uri')
|
||||
expired = cookie('', 0)
|
||||
if client is not None and target in client.post_logout_redirect_uris:
|
||||
return redirect(add_params_to_uri(target, [('state', values['state'])]) if values.get('state') else target, [expired])
|
||||
return page(200, cfg, 'Signed out', message='You are signed out of the router.', headers=[expired])
|
||||
|
||||
|
||||
def answer(method, target, headers, body):
|
||||
"""Answer a request of the OpenID Provider: the HTTP status, the content type, the body and [(header, value)]; 404
|
||||
while oidc.enabled is false. target is the path with the query, headers a mapping of the request's headers and
|
||||
body its bytes; HEAD is answered as GET."""
|
||||
path, _, query = target.partition('?')
|
||||
method = 'GET' if method == 'HEAD' else method
|
||||
try:
|
||||
cfg = vpnrouter.load_config()
|
||||
if not cfg['oidc']['enabled']:
|
||||
return 404, 'text/plain; charset=utf-8', b'Not found\n', []
|
||||
issuer = vpnrouter.oidc_issuer(cfg)
|
||||
headers = dict(headers.items())
|
||||
text = body.decode('utf-8', 'replace') if body else ''
|
||||
server = Server(Validator(cfg), token_expires_in=ACCESS_SECONDS)
|
||||
if (method, path) == ('GET', '/.well-known/openid-configuration'):
|
||||
return document(200, discovery(issuer))
|
||||
if (method, path) == ('GET', '/oidc/jwks'):
|
||||
return document(200, {'keys': [public_jwk(private_key())]})
|
||||
if path == '/oidc/authorize' and method in ('GET', 'POST'):
|
||||
return authorize(server, cfg, query if method == 'GET' else text, headers)
|
||||
if (method, path) == ('POST', '/oidc/login'):
|
||||
return login(server, cfg, dict(urllib.parse.parse_qsl(text)), headers)
|
||||
if (method, path) == ('POST', '/oidc/passkey'):
|
||||
return document(200, vpnrouter_passkeys.login_begin())
|
||||
if (method, path) == ('POST', '/oidc/token'):
|
||||
return relayed(*server.create_token_response(f'{issuer}{path}', 'POST', text, headers))
|
||||
if path == '/oidc/userinfo' and method in ('GET', 'POST'):
|
||||
return relayed(*server.create_userinfo_response(f'{issuer}{target}', method, text or None, headers))
|
||||
if (method, path) == ('GET', '/oidc/logout'):
|
||||
return logout(cfg, query, headers)
|
||||
return 404, 'text/plain; charset=utf-8', b'Not found\n', []
|
||||
except OAuth2Error as error:
|
||||
return error.status_code, 'application/json', error.json.encode(), list(error.headers.items())
|
||||
except vpnrouter_users.Failure as failure:
|
||||
return document(400 if failure.status == 2 else 500, {'error': str(failure)})
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
return document(500, {'error': f'the OpenID Provider failed: {exc}'})
|
||||
except vpnrouter_auth.DatabaseError as exc:
|
||||
return document(500, {'error': str(exc)})
|
||||
except SystemExit as exc:
|
||||
return document(500, {'error': str(exc.code)})
|
||||
except ValueError as exc:
|
||||
return document(400, {'error': 'invalid_request', 'error_description': str(exc)})
|
||||
@@ -1,7 +1,8 @@
|
||||
"""The passkeys of the router's users, for signing in to the Router Manager, with python3-fido2.
|
||||
"""The passkeys of the router's users, for signing in to the Router Manager and the OpenID Provider (vpnrouter_oidc),
|
||||
with python3-fido2.
|
||||
|
||||
A passkey is a discoverable WebAuthn credential with user verification, of the relying party
|
||||
wan.local_fqdn, so the browser offers it at the Router Manager's address on that name over HTTPS.
|
||||
wan.local_fqdn, so the browser offers it at the HTTPS addresses on that name.
|
||||
Its user handle is the user name. Each passkey of a user is a value of vpnRouterPasskey on the
|
||||
user entry: a JSON object {"id", "credential", "label", "created"}, id the credential ID and
|
||||
credential the attested credential data (with the public key), both base64url. A user has at
|
||||
|
||||
@@ -15,10 +15,16 @@ internal service accounts are those the router creates and manages for its own s
|
||||
included (internal_accounts); vpn-router-setup keeps the metrics service's.
|
||||
Users exist whether or not a P2S system is enabled.
|
||||
|
||||
A service account with redirect URIs is a client of the OpenID Provider (vpnrouter_oidc), its name the client ID and
|
||||
its password the client secret (client_settings): redirect_uris, where the provider sends a user back after a sign-in,
|
||||
post_logout_redirect_uris, after a sign-out, and allowed_groups, the groups whose members may sign in to it, every
|
||||
user who is not a service account when there are none.
|
||||
|
||||
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
|
||||
cannot leave it and a user who is the last member of a group cannot be removed. Groups only
|
||||
record membership; nothing on the router reads them.
|
||||
cannot leave it and a user who is the last member of a group cannot be removed. ADMIN_GROUP grants the management of
|
||||
the router, and the allowed groups of the OpenID Provider's clients who may sign in to them; renaming a group renames it
|
||||
in those, and a group a client allows cannot be removed.
|
||||
|
||||
A VPN user may have WireGuard peers, each a device with its public key and its IPs, in
|
||||
vpnRouterWireGuardPeer; vpn-router-setup writes a [Peer] of wg0.conf for each peer of a user who
|
||||
@@ -62,6 +68,7 @@ import pwd
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
import ldap
|
||||
import ldap.filter
|
||||
@@ -90,7 +97,15 @@ LABELS = (('display_name', 'displayName'), ('description', 'description'))
|
||||
#: The attributes read for a user.
|
||||
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope',
|
||||
'displayName', 'description', 'userCertificate;binary', 'vpnRouterConsoleLogin', 'vpnRouterConsoleAccount',
|
||||
'uidNumber', 'vpnRouterWireGuardPeer', 'vpnRouterPasskey']
|
||||
'uidNumber', 'vpnRouterWireGuardPeer', 'vpnRouterPasskey', 'vpnRouterRedirectUri',
|
||||
'vpnRouterPostLogoutRedirectUri', 'vpnRouterAllowedGroup']
|
||||
#: The keys of the URIs of an OpenID Provider client and their LDAP attributes.
|
||||
CLIENT_URIS = (('redirect_uris', 'vpnRouterRedirectUri'), ('post_logout_redirect_uris', 'vpnRouterPostLogoutRedirectUri'))
|
||||
#: The most URIs of each kind and allowed groups of one client, and the longest URI.
|
||||
CLIENT_VALUES_MAX = 16
|
||||
URI_MAX = 1024
|
||||
#: The hosts a client's URI may name over plain HTTP: those of the system it runs on.
|
||||
LOOPBACK_HOSTS = ('localhost', '127.0.0.1', '::1')
|
||||
#: The most WireGuard peers of one user.
|
||||
WIREGUARD_PEERS_MAX = 16
|
||||
#: The vpnRouterScope of a service account.
|
||||
@@ -195,6 +210,47 @@ def labels(values):
|
||||
return found
|
||||
|
||||
|
||||
def client_settings(connection, values):
|
||||
"""The OpenID Provider client settings of a service account in values ({redirect_uris, post_logout_redirect_uris,
|
||||
allowed_groups}), checked, as {LDAP attribute: [value, ...]}; a key whose value is None is not given, [] means none.
|
||||
|
||||
A URI is absolute, has no fragment and uses https, or http for a host of LOOPBACK_HOSTS; an allowed group exists. Each
|
||||
list has at most CLIENT_VALUES_MAX values.
|
||||
"""
|
||||
found = {}
|
||||
for key, attribute in CLIENT_URIS:
|
||||
uris = values.get(key)
|
||||
if uris is None:
|
||||
continue
|
||||
if not isinstance(uris, list) or len(uris) > CLIENT_VALUES_MAX or not all(isinstance(uri, str) for uri in uris):
|
||||
raise Failure(f'{key} must be a list of at most {CLIENT_VALUES_MAX} URIs', 2)
|
||||
for uri in uris:
|
||||
try:
|
||||
parts = urllib.parse.urlsplit(uri)
|
||||
host = parts.hostname
|
||||
except ValueError:
|
||||
host = None
|
||||
if len(uri) > URI_MAX or not host or parts.fragment or any(character.isspace() for character in uri) or \
|
||||
not (parts.scheme == 'https' or parts.scheme == 'http' and host in LOOPBACK_HOSTS):
|
||||
raise Failure(f'{uri!r} is not an https URI, or an http URI of localhost, without a fragment and of at '
|
||||
f'most {URI_MAX} characters', 2)
|
||||
found[attribute] = [uri.encode() for uri in dict.fromkeys(uris)]
|
||||
allowed = values.get('allowed_groups')
|
||||
if allowed is not None:
|
||||
if not isinstance(allowed, list) or len(allowed) > CLIENT_VALUES_MAX:
|
||||
raise Failure(f'allowed_groups must be a list of at most {CLIENT_VALUES_MAX} group names', 2)
|
||||
for group in allowed:
|
||||
group_entry(connection, group)
|
||||
found['vpnRouterAllowedGroup'] = [group_dn(group).encode() for group in sorted(set(allowed))]
|
||||
return found
|
||||
|
||||
|
||||
def refuse_client_settings(name, values):
|
||||
"""Refuse OpenID Provider client settings for a user who is not a service account."""
|
||||
if any(values.get(key) is not None for key in ('redirect_uris', 'post_logout_redirect_uris', 'allowed_groups')):
|
||||
raise Failure(f'{name} is not a service account; only a service account is a client of the OpenID Provider', 2)
|
||||
|
||||
|
||||
def dn_of(name):
|
||||
return f'uid={name},{vpnrouter_ldap.USERS}'
|
||||
|
||||
@@ -246,7 +302,8 @@ def internal_accounts():
|
||||
def summary(attributes):
|
||||
"""A user as the answers show it; a person attribute or a label the entry does not have is None.
|
||||
|
||||
internal is whether it is an internal service account (internal_accounts).
|
||||
internal is whether it is an internal service account (internal_accounts). A service account also has its OpenID
|
||||
Provider client settings (client_settings), allowed_groups as group names.
|
||||
"""
|
||||
name = attributes['uid'][0].decode()
|
||||
lock = attributes.get('pwdAccountLockedTime', [None])[0]
|
||||
@@ -266,6 +323,12 @@ def summary(attributes):
|
||||
for key, attribute in PERSON + LABELS:
|
||||
values = attributes.get(attribute)
|
||||
user[key] = values[0].decode() if values else None
|
||||
if user['service']:
|
||||
for key, attribute in CLIENT_URIS:
|
||||
user[key] = [value.decode() for value in attributes.get(attribute, [])]
|
||||
suffix = f',{vpnrouter_ldap.GROUPS}'
|
||||
user['allowed_groups'] = sorted(value.decode()[3:-len(suffix)] for value in attributes.get('vpnRouterAllowedGroup', [])
|
||||
if value.decode().startswith('cn=') and value.decode().endswith(suffix))
|
||||
return user
|
||||
|
||||
|
||||
@@ -466,14 +529,19 @@ def list_users(connection):
|
||||
def add(connection, name, password, values, service=False):
|
||||
"""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, and, with service, a service account, with at most the
|
||||
e-mail address and its labels (labels); only a service account may be named by a GUID."""
|
||||
e-mail address, its labels (labels) and its OpenID Provider client settings (client_settings); only a service
|
||||
account may be named by a GUID."""
|
||||
check_name(name, service)
|
||||
details = person(values, required=False)
|
||||
vpn_user = 'givenName' in details or 'sn' in details
|
||||
if service and vpn_user:
|
||||
raise Failure('a service account has no first or last name', 2)
|
||||
client = {}
|
||||
if service:
|
||||
details.update({attribute: value for attribute, value in labels(values).items() if value})
|
||||
client = {attribute: found for attribute, found in client_settings(connection, values).items() if found}
|
||||
else:
|
||||
refuse_client_settings(name, values)
|
||||
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)
|
||||
@@ -488,6 +556,7 @@ def add(connection, name, password, values, service=False):
|
||||
if service:
|
||||
record['vpnRouterScope'] = [SERVICE_SCOPE.encode()]
|
||||
record.update({attribute: [value.encode()] for attribute, value in details.items()})
|
||||
record.update(client)
|
||||
try:
|
||||
connection.add_s(dn_of(name), ldap.modlist.addModlist(record))
|
||||
except ldap.ALREADY_EXISTS:
|
||||
@@ -511,6 +580,7 @@ def update(connection, name, values):
|
||||
current = entry(connection, name)
|
||||
if service(current):
|
||||
return update_service(connection, name, values)
|
||||
refuse_client_settings(name, values)
|
||||
if manager_only(current):
|
||||
if values.get('wireguard_peers') is not None:
|
||||
raise Failure(f'user {name} is a Manager-only account, which has no VPN access and so no WireGuard peers', 2)
|
||||
@@ -551,16 +621,18 @@ def apply_wireguard():
|
||||
|
||||
|
||||
def update_service(connection, name, values):
|
||||
"""Change any of the e-mail address and the labels (labels) of a service account that values gives; '' removes one."""
|
||||
"""Change any of the e-mail address, the labels (labels) and the OpenID Provider client settings (client_settings) of
|
||||
a service account that values gives; '' removes the e-mail address or a label, [] the values of a client setting."""
|
||||
if not service(entry(connection, name)):
|
||||
raise Failure(f'{name} is not a service account', 3)
|
||||
changes = [(ldap.MOD_REPLACE, attribute, [value.encode()] if value else []) for attribute, value in labels(values).items()]
|
||||
changes += [(ldap.MOD_REPLACE, attribute, found) for attribute, found in client_settings(connection, values).items()]
|
||||
email = values.get('email')
|
||||
if email is not None:
|
||||
address = '' if isinstance(email, str) and email.strip() == '' else person({'email': email}, required=False)['mail']
|
||||
changes.append((ldap.MOD_REPLACE, 'mail', [address.encode()] if address else []))
|
||||
if not changes:
|
||||
raise Failure('give email, display_name or description', 2)
|
||||
raise Failure('give email, display_name, description, redirect_uris, post_logout_redirect_uris or allowed_groups', 2)
|
||||
connection.modify_s(dn_of(name), changes)
|
||||
return summary(entry(connection, name))
|
||||
|
||||
@@ -605,12 +677,13 @@ def unlock(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.
|
||||
"""Check the user's password, and the TOTP code of an enrolled user, with a bind as the user; a service account may be
|
||||
named by a GUID.
|
||||
|
||||
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:
|
||||
if not isinstance(name, str) or not (NAME.match(name) or GUID.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
|
||||
@@ -789,6 +862,13 @@ def groups_of(connection, name):
|
||||
return {attributes['cn'][0].decode(): member_names(attributes) for _, attributes in found}
|
||||
|
||||
|
||||
def allowing(connection, name):
|
||||
"""The names of the OpenID Provider's clients whose allowed groups have the group, sorted."""
|
||||
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL,
|
||||
f'(vpnRouterAllowedGroup={ldap.filter.escape_filter_chars(group_dn(name))})', ['uid'])
|
||||
return sorted(attributes['uid'][0].decode() for _, attributes in found)
|
||||
|
||||
|
||||
def list_groups(connection):
|
||||
found = connection.search_s(vpnrouter_ldap.GROUPS, ldap.SCOPE_ONELEVEL, '(objectClass=groupOfNames)', GROUP_ATTRIBUTES)
|
||||
return {'groups': sorted((group_summary(attributes) for _, attributes in found), key=lambda group: group['name'])}
|
||||
@@ -815,7 +895,8 @@ def group_add(connection, name, description, members):
|
||||
|
||||
def group_update(connection, name, new_name=None, description=None):
|
||||
"""Rename the group or change its description ('' removes it); at least one must be given. Renaming ADMIN_GROUP
|
||||
switches the console login of its members off."""
|
||||
switches the console login of its members off; renaming a group a client of the OpenID Provider allows renames it
|
||||
there."""
|
||||
if new_name is None and description is None:
|
||||
raise Failure('give a new name or a description', 2)
|
||||
members = member_names(group_entry(connection, name))
|
||||
@@ -830,6 +911,9 @@ def group_update(connection, name, new_name=None, description=None):
|
||||
connection.rename_s(group_dn(name), f'cn={new_name}', delold=1)
|
||||
except ldap.ALREADY_EXISTS:
|
||||
raise Failure(f'group {new_name} exists', 4)
|
||||
for client in allowing(connection, name):
|
||||
connection.modify_s(dn_of(client), [(ldap.MOD_DELETE, 'vpnRouterAllowedGroup', [group_dn(name).encode()]),
|
||||
(ldap.MOD_ADD, 'vpnRouterAllowedGroup', [group_dn(new_name).encode()])])
|
||||
if name == ADMIN_GROUP:
|
||||
console_off(connection, members)
|
||||
name = new_name
|
||||
@@ -837,8 +921,12 @@ def group_update(connection, name, new_name=None, description=None):
|
||||
|
||||
|
||||
def group_remove(connection, name):
|
||||
"""Remove the group; removing ADMIN_GROUP switches the console login of its members off."""
|
||||
"""Remove the group, unless a client of the OpenID Provider allows it; removing ADMIN_GROUP switches the console login
|
||||
of its members off."""
|
||||
members = member_names(group_entry(connection, name))
|
||||
clients = allowing(connection, name)
|
||||
if clients:
|
||||
raise Failure(f'group {name} is an allowed group of the clients {", ".join(clients)}; take it out of them first', 2)
|
||||
connection.delete_s(group_dn(name))
|
||||
if name == ADMIN_GROUP:
|
||||
console_off(connection, members)
|
||||
|
||||
@@ -50,6 +50,10 @@ Pages:
|
||||
/postgresql
|
||||
the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see
|
||||
postgresql_databases()); /config has the clusters' settings
|
||||
/.well-known/openid-configuration, /oidc/...
|
||||
while oidc.enabled is true, the OpenID Provider (vpnrouter_oidc), answered only on the
|
||||
loopback address, where Caddy passes the requests of the HTTPS site; POST /oidc/login counts
|
||||
towards the limit of /auth calls
|
||||
|
||||
/status/full, /config, /metrics/history, /postgresql, /users, /service-accounts, /groups, /site-to-site, /backup, /remote-access, /ca, /shell 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;
|
||||
@@ -71,8 +75,8 @@ whether each service runs, whether Docker is installed, how many of the site-to-
|
||||
(site_to_site: connections and established, null when swanctl cannot be asked), and road-warrior (IPsec P2S), OpenVPN
|
||||
and WireGuard. No configuration data, key or connection name is shown, and no address but the client's.
|
||||
|
||||
The service first initializes, without listening: it makes the signing key and the refresh
|
||||
tokens' database (vpnrouter_auth), retrying every INIT_RETRY_SECONDS until both are ready and
|
||||
The service first initializes, without listening: it makes the signing key, the OpenID Provider's key
|
||||
(vpnrouter_oidc) and the refresh tokens' database (vpnrouter_auth), retrying every INIT_RETRY_SECONDS until all are ready and
|
||||
logging each failure. Then it listens. Its unit starts it only once wait-for-libpq has found libpq
|
||||
in the linker cache, where psycopg looks it up.
|
||||
|
||||
@@ -120,6 +124,7 @@ import vpnrouter_auth
|
||||
import vpnrouter_backup
|
||||
import vpnrouter_metrics
|
||||
import vpnrouter_net
|
||||
import vpnrouter_oidc
|
||||
import vpnrouter_passkeys
|
||||
import vpnrouter_pki
|
||||
import vpnrouter_serve
|
||||
@@ -587,20 +592,32 @@ def users(state, method, path, body):
|
||||
return (201 if route == ('POST', 1, None) else 200), document
|
||||
|
||||
|
||||
def oidc_settings():
|
||||
"""{"enabled", "issuer"} of the OpenID Provider, issuer null while it is disabled."""
|
||||
cfg = vpnrouter.load_config()
|
||||
enabled = cfg['oidc']['enabled']
|
||||
return {'enabled': enabled, 'issuer': vpnrouter.oidc_issuer(cfg) if enabled else None}
|
||||
|
||||
|
||||
def service_accounts(method, path, body):
|
||||
"""Answer a /service-accounts request with vpnrouter_users: the HTTP status and the JSON document.
|
||||
|
||||
GET /service-accounts the service accounts: {"service_accounts": [...]}
|
||||
POST /service-accounts add one: {"name", "password", "display_name"?, "description"?, "email"?}
|
||||
PUT /service-accounts/<name> change any of {"display_name", "description", "email"}; "" removes one
|
||||
GET /service-accounts the service accounts: {"service_accounts": [...], "oidc": {"enabled",
|
||||
"issuer"}}, issuer that of the OpenID Provider, null while it is disabled
|
||||
POST /service-accounts add one: {"name", "password", "display_name"?, "description"?, "email"?,
|
||||
"redirect_uris"?, "post_logout_redirect_uris"?, "allowed_groups"?}
|
||||
PUT /service-accounts/<name> change any of {"display_name", "description", "email", "redirect_uris",
|
||||
"post_logout_redirect_uris", "allowed_groups"}; "" removes one of the
|
||||
first three, [] the values of the others
|
||||
PUT /service-accounts/<name>/password set the password: {"password": ...}
|
||||
POST /service-accounts/<name>/disable lock it out
|
||||
POST /service-accounts/<name>/enable let it in again
|
||||
POST /service-accounts/<name>/unlock end a lockout after failed logins
|
||||
DELETE /service-accounts/<name> remove it
|
||||
|
||||
A name that is not a service account is 404. The internal service accounts (vpnrouter_users.internal_accounts),
|
||||
which the router manages, are only listed: every other request on one is refused with 403.
|
||||
A service account with redirect URIs is a client of the OpenID Provider (vpnrouter_users.client_settings). A name
|
||||
that is not a service account is 404. The internal service accounts (vpnrouter_users.internal_accounts), which
|
||||
the router manages, are only listed: every other request on one is refused with 403.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
@@ -608,10 +625,12 @@ def service_accounts(method, path, body):
|
||||
parts = path.strip('/').split('/')
|
||||
route = (method, len(parts), parts[2] if len(parts) == 3 else None)
|
||||
name = parts[1] if len(parts) > 1 else None
|
||||
settings = {key: body.get(key) for key in ('email', 'display_name', 'description')}
|
||||
settings = {key: body.get(key) for key in ('email', 'display_name', 'description', 'redirect_uris',
|
||||
'post_logout_redirect_uris', 'allowed_groups')}
|
||||
actions = {
|
||||
('GET', 1, None): lambda connection: {
|
||||
'service_accounts': [user for user in vpnrouter_users.list_users(connection)['users'] if user['service']]},
|
||||
'service_accounts': [user for user in vpnrouter_users.list_users(connection)['users'] if user['service']],
|
||||
'oidc': oidc_settings()},
|
||||
('POST', 1, None): lambda connection: vpnrouter_users.add(connection, body.get('name'), body.get('password'), settings,
|
||||
service=True),
|
||||
('PUT', 2, None): lambda connection: vpnrouter_users.update_service(connection, name, settings),
|
||||
@@ -634,6 +653,8 @@ def service_accounts(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 SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
return (201 if route == ('POST', 1, None) else 200), document
|
||||
|
||||
|
||||
@@ -1460,6 +1481,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if path == f'/{name}' or path.startswith(f'/{name}/')), None)
|
||||
protected = path in ('/status/full', '/config', '/metrics/history', '/postgresql') or directory is not None
|
||||
signing = path in ('/auth', '/auth/passkey', '/auth/passkey/complete', '/auth/refresh', '/auth/logout')
|
||||
oidc = (path == '/.well-known/openid-configuration' or path.startswith('/oidc/')) and \
|
||||
self.server.server_address[0] == vpnrouter_net.LOOPBACK
|
||||
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
|
||||
@@ -1523,6 +1546,15 @@ class Handler(BaseHTTPRequestHandler):
|
||||
elif directory is not None:
|
||||
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 oidc:
|
||||
wait = auth_wait(self.client()) if (self.command, path) == ('POST', '/oidc/login') else 0
|
||||
if wait:
|
||||
code, kind = 429, 'application/json'
|
||||
body = json.dumps({'error': f'too many sign-in calls; retry in {wait} seconds'}).encode() + b'\n'
|
||||
extra.append(('Retry-After', str(wait)))
|
||||
else:
|
||||
code, kind, body, headers = vpnrouter_oidc.answer(self.command, self.path, self.headers, self.raw_body())
|
||||
extra += headers
|
||||
elif self.command not in ('GET', 'HEAD'):
|
||||
body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
|
||||
elif path.startswith('/pki/') and vpnrouter.PKI_NAME.match(path[5:-4]) and path[-4:] in PUBLISHED and \
|
||||
@@ -1550,6 +1582,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if send_body:
|
||||
self.wfile.write(body)
|
||||
|
||||
def raw_body(self):
|
||||
"""The request body's bytes, b'' without one or over BODY_LIMIT."""
|
||||
try:
|
||||
size = int(self.headers.get('Content-Length') or 0)
|
||||
except ValueError:
|
||||
return b''
|
||||
return self.rfile.read(size) if 0 < size <= BODY_LIMIT else b''
|
||||
|
||||
def request_body(self):
|
||||
"""The request body as JSON, None when there is none, or the string 'invalid'."""
|
||||
length = self.headers.get('Content-Length')
|
||||
@@ -1588,7 +1628,8 @@ def log(message):
|
||||
|
||||
|
||||
def initialize():
|
||||
"""Make the signing key and the refresh tokens' database, retrying every INIT_RETRY_SECONDS until both are ready."""
|
||||
"""Make the signing key, the OpenID Provider's key and the refresh tokens' database, retrying every
|
||||
INIT_RETRY_SECONDS until all are ready."""
|
||||
reported = None
|
||||
while True:
|
||||
phase = 'reading the configuration'
|
||||
@@ -1596,6 +1637,8 @@ def initialize():
|
||||
cfg = vpnrouter.load_config()
|
||||
phase = 'making the signing key'
|
||||
vpnrouter_auth.ensure_key()
|
||||
phase = 'making the OpenID Provider key'
|
||||
vpnrouter_oidc.ensure_key()
|
||||
phase = 'creating the token database'
|
||||
vpnrouter_auth.provision(cfg)
|
||||
phase = 'connecting to the token database'
|
||||
|
||||
@@ -12,9 +12,15 @@ user exists, 1 anything else. -h or --help shows the commands and their options.
|
||||
vpn-router-user [--json] add <name> --first-name F --last-name L --email E (password on stdin)
|
||||
vpn-router-user [--json] add <name> [--email E] (a Manager-only account; password on stdin)
|
||||
vpn-router-user [--json] add <name> --service [--display-name N] [--description D] [--email E]
|
||||
[--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]
|
||||
(a service account; password on stdin)
|
||||
vpn-router-user [--json] update <name> [--first-name F] [--last-name L] [--email E]
|
||||
vpn-router-user [--json] update <name> [--display-name N] [--description D] [--email E] (a service account)
|
||||
vpn-router-user [--json] update <name> [--display-name N] [--description D] [--email E]
|
||||
[--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]
|
||||
(a service account)
|
||||
|
||||
A service account with a redirect URI is a client of the OpenID Provider; each of the three options may be repeated,
|
||||
and replaces all values of its kind; one '' removes them.
|
||||
vpn-router-user [--json] passwd <name> (password on stdin)
|
||||
vpn-router-user [--json] disable <name>
|
||||
vpn-router-user [--json] enable <name>
|
||||
@@ -69,6 +75,13 @@ PERSON_HELP = {'first_name': 'the first name', 'last_name': 'the last name', 'em
|
||||
'display_name': "a service account's display name; '' removes it",
|
||||
'description': "a service account's description; '' removes it"}
|
||||
|
||||
#: The OpenID Provider client settings of a service account, their options and help.
|
||||
CLIENT_OPTIONS = (
|
||||
('redirect_uris', 'redirect-uri', 'a redirect URI of the OpenID Provider client'),
|
||||
('post_logout_redirect_uris', 'post-logout-redirect-uri', 'a post-logout redirect URI of the OpenID Provider client'),
|
||||
('allowed_groups', 'allowed-group', 'a group whose members may sign in to the OpenID Provider client'),
|
||||
)
|
||||
|
||||
|
||||
class Parser(argparse.ArgumentParser):
|
||||
"""An argument parser whose errors are Failures with status 2, so that --json answers them as JSON."""
|
||||
@@ -88,6 +101,9 @@ def parse(argv):
|
||||
if command in ('add', 'update'):
|
||||
for key, _ in vpnrouter_users.PERSON + vpnrouter_users.LABELS:
|
||||
sub.add_argument(f'--{key.replace("_", "-")}', dest=key, metavar='TEXT', help=PERSON_HELP[key])
|
||||
for key, option, text in CLIENT_OPTIONS:
|
||||
sub.add_argument(f'--{option}', dest=key, action='append', metavar='VALUE',
|
||||
help=f"{text}, of a service account; repeat it for more, '' for none")
|
||||
if command == 'add':
|
||||
sub.add_argument('--service', action='store_true', help='add a service account, which cannot sign in to the web service')
|
||||
if command == 'otp-enrol':
|
||||
@@ -111,6 +127,10 @@ def run(connection, options, secret):
|
||||
if command == 'list':
|
||||
return vpnrouter_users.list_users(connection)
|
||||
values = {key: getattr(options, key, None) for key, _ in vpnrouter_users.PERSON + vpnrouter_users.LABELS}
|
||||
for key, _, _ in CLIENT_OPTIONS:
|
||||
given = getattr(options, key, None)
|
||||
if given is not None:
|
||||
values[key] = [value for value in given if value]
|
||||
if command == 'add':
|
||||
return vpnrouter_users.add(connection, options.name, secret, values, service=options.service)
|
||||
if command == 'update':
|
||||
@@ -136,6 +156,15 @@ def console_text(user):
|
||||
return text if user['otp'] else f'{text}, refused: no OTP'
|
||||
|
||||
|
||||
def client_text(user):
|
||||
"""The OpenID Provider client settings of a service account as a cell: -, or the numbers of its URIs and its allowed
|
||||
groups."""
|
||||
if not user.get('redirect_uris'):
|
||||
return '-'
|
||||
groups = ','.join(user['allowed_groups']) or 'all users'
|
||||
return f'{len(user["redirect_uris"])} redirect, {len(user["post_logout_redirect_uris"])} logout, {groups}'
|
||||
|
||||
|
||||
def text_of(answer):
|
||||
"""The answer as text for people."""
|
||||
if 'profile' in answer:
|
||||
@@ -147,12 +176,13 @@ def text_of(answer):
|
||||
users = answer['users'] if 'users' in answer else [answer]
|
||||
if not users:
|
||||
return 'no users'
|
||||
head = ('USER', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'PASSKEYS', 'CONSOLE', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
|
||||
head = ('USER', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'PASSKEYS', 'CONSOLE', 'FIRST NAME', 'LAST NAME', 'E-MAIL',
|
||||
'OIDC CLIENT')
|
||||
rows = [(user['name'], 'internal' if user['internal'] else 'service' if user['service'] else '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 '-', str(len(user['passkeys'])) if user['passkeys'] else '-', console_text(user),
|
||||
user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-')
|
||||
user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-', client_text(user))
|
||||
for user in users]
|
||||
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
|
||||
return '\n'.join(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows))
|
||||
|
||||
@@ -76,6 +76,9 @@
|
||||
"manager_port": 7443,
|
||||
"caddy_source": "ubuntu"
|
||||
},
|
||||
"oidc": {
|
||||
"enabled": false
|
||||
},
|
||||
"pki": {
|
||||
"root_name": "VPN Router Root CA",
|
||||
"root_days": 7300,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
-- The state of the router's OpenID Provider (vpnrouter_oidc) in the database of web.database.
|
||||
-- Codes, tokens and sessions are kept as the SHA-256 of their values; expired rows are purged when new ones are saved.
|
||||
-- The whole file runs as one transaction, serialized by an advisory lock, so that two processes can load it at once.
|
||||
|
||||
SELECT pg_advisory_xact_lock(hashtext('vpn-router oidc schema'));
|
||||
|
||||
-- An authorization code, valid until it is exchanged for tokens or expires.
|
||||
CREATE TABLE IF NOT EXISTS oidc_codes (
|
||||
hash text PRIMARY KEY,
|
||||
client_id text NOT NULL,
|
||||
name text NOT NULL,
|
||||
redirect_uri text NOT NULL,
|
||||
scopes text[] NOT NULL,
|
||||
nonce text,
|
||||
code_challenge text NOT NULL,
|
||||
code_challenge_method text NOT NULL,
|
||||
auth_time timestamptz NOT NULL,
|
||||
amr text[] NOT NULL,
|
||||
expires timestamptz NOT NULL
|
||||
);
|
||||
|
||||
-- An access token or a refresh token (kind) a client was issued for a user.
|
||||
CREATE TABLE IF NOT EXISTS oidc_tokens (
|
||||
hash text PRIMARY KEY,
|
||||
kind text NOT NULL CHECK (kind IN ('access', 'refresh')),
|
||||
client_id text NOT NULL,
|
||||
name text NOT NULL,
|
||||
scopes text[] NOT NULL,
|
||||
auth_time timestamptz NOT NULL,
|
||||
amr text[] NOT NULL,
|
||||
expires timestamptz NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS oidc_tokens_name ON oidc_tokens (name);
|
||||
|
||||
-- A user's sign-in at the OpenID Provider, which the session cookie names.
|
||||
CREATE TABLE IF NOT EXISTS oidc_sessions (
|
||||
hash text PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
auth_time timestamptz NOT NULL,
|
||||
amr text[] NOT NULL,
|
||||
expires timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_code_save(p_hash text, p_client_id text, p_name text, p_redirect_uri text,
|
||||
p_scopes text[], p_nonce text, p_code_challenge text,
|
||||
p_code_challenge_method text, p_auth_time timestamptz, p_amr text[],
|
||||
p_seconds integer)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_codes WHERE expires < now();
|
||||
INSERT INTO oidc_codes VALUES (p_hash, p_client_id, p_name, p_redirect_uri, p_scopes, p_nonce, p_code_challenge,
|
||||
p_code_challenge_method, p_auth_time, p_amr, now() + make_interval(secs => p_seconds));
|
||||
$$;
|
||||
|
||||
-- The unexpired code.
|
||||
CREATE OR REPLACE FUNCTION oidc_code_get(p_hash text)
|
||||
RETURNS TABLE (client_id text, name text, redirect_uri text, scopes text[], nonce text, code_challenge text,
|
||||
code_challenge_method text, auth_time timestamptz, amr text[])
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT client_id, name, redirect_uri, scopes, nonce, code_challenge, code_challenge_method, auth_time, amr
|
||||
FROM oidc_codes WHERE hash = p_hash AND expires > now();
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_code_delete(p_hash text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_codes WHERE hash = p_hash;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_token_save(p_hash text, p_kind text, p_client_id text, p_name text, p_scopes text[],
|
||||
p_auth_time timestamptz, p_amr text[], p_seconds integer)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_tokens WHERE expires < now();
|
||||
INSERT INTO oidc_tokens VALUES (p_hash, p_kind, p_client_id, p_name, p_scopes, p_auth_time, p_amr,
|
||||
now() + make_interval(secs => p_seconds));
|
||||
$$;
|
||||
|
||||
-- The unexpired token of the kind.
|
||||
CREATE OR REPLACE FUNCTION oidc_token_get(p_hash text, p_kind text)
|
||||
RETURNS TABLE (client_id text, name text, scopes text[], auth_time timestamptz, amr text[])
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT client_id, name, scopes, auth_time, amr FROM oidc_tokens
|
||||
WHERE hash = p_hash AND kind = p_kind AND expires > now();
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_token_delete(p_hash text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_tokens WHERE hash = p_hash;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_session_save(p_hash text, p_name text, p_amr text[], p_seconds integer)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_sessions WHERE expires < now();
|
||||
INSERT INTO oidc_sessions VALUES (p_hash, p_name, now(), p_amr, now() + make_interval(secs => p_seconds));
|
||||
$$;
|
||||
|
||||
-- The unexpired session.
|
||||
CREATE OR REPLACE FUNCTION oidc_session_get(p_hash text)
|
||||
RETURNS TABLE (name text, auth_time timestamptz, amr text[])
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT name, auth_time, amr FROM oidc_sessions WHERE hash = p_hash AND expires > now();
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_session_delete(p_hash text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_sessions WHERE hash = p_hash;
|
||||
$$;
|
||||
|
||||
-- Remove every code, token and session of the user.
|
||||
CREATE OR REPLACE PROCEDURE oidc_revoke(p_name text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_codes WHERE name = p_name;
|
||||
DELETE FROM oidc_tokens WHERE name = p_name;
|
||||
DELETE FROM oidc_sessions WHERE name = p_name;
|
||||
$$;
|
||||
@@ -0,0 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>{{ title }}</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; background: #f4f5f7; color: #1f2937; margin: 0; }
|
||||
main { max-width: 22rem; margin: 4rem auto; background: #fff; padding: 2rem; border-radius: 0.5rem; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); }
|
||||
h1 { font-size: 1.25rem; margin: 0 0 0.25rem; }
|
||||
p { margin: 0 0 1rem; color: #4b5563; }
|
||||
label { display: block; font-size: 0.875rem; margin: 0.75rem 0 0.25rem; }
|
||||
input { box-sizing: border-box; width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.25rem; font-size: 1rem; }
|
||||
button { width: 100%; margin-top: 1.25rem; padding: 0.6rem; border: 0; border-radius: 0.25rem; font-size: 1rem; cursor: pointer; background: #1d4ed8; color: #fff; }
|
||||
button.secondary { background: #fff; color: #1d4ed8; border: 1px solid #1d4ed8; margin-top: 0.75rem; }
|
||||
.error { background: #fee2e2; color: #991b1b; padding: 0.5rem 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem; }
|
||||
.issuer { font-size: 0.75rem; color: #6b7280; margin: 1.5rem 0 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>{{ title }}</h1>
|
||||
{%- if message %}
|
||||
<p>{{ message }}</p>
|
||||
{%- endif %}
|
||||
{%- if error %}
|
||||
<div class="error" role="alert">{{ error }}</div>
|
||||
{%- endif %}
|
||||
{%- if form %}
|
||||
<form method="post" action="/oidc/login">
|
||||
<input type="hidden" name="request" value="{{ request }}">
|
||||
<label for="name">Name</label>
|
||||
<input id="name" name="name" value="{{ name }}" autocomplete="username webauthn" autocapitalize="none" spellcheck="false" required autofocus>
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||
<label for="code">Code from the authenticator, when you have one</label>
|
||||
<input id="code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code">
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
<form id="passkey-form" method="post" action="/oidc/login" hidden>
|
||||
<input type="hidden" name="request" value="{{ request }}">
|
||||
<input type="hidden" name="passkey_id">
|
||||
<input type="hidden" name="credential">
|
||||
</form>
|
||||
<button id="passkey" class="secondary" type="button" hidden>Sign in with a passkey</button>
|
||||
<script>
|
||||
// A passkey sign-in begins at /oidc/passkey and posts the browser's answer to /oidc/login with the authorization request.
|
||||
(function () {
|
||||
var button = document.getElementById('passkey');
|
||||
if (!window.PublicKeyCredential || !PublicKeyCredential.parseRequestOptionsFromJSON) {
|
||||
return;
|
||||
}
|
||||
button.hidden = false;
|
||||
button.addEventListener('click', async function () {
|
||||
button.disabled = true;
|
||||
try {
|
||||
var begun = await (await fetch('/oidc/passkey', { method: 'POST' })).json();
|
||||
var credential = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(begun.options.publicKey) });
|
||||
var form = document.getElementById('passkey-form');
|
||||
form.elements.passkey_id.value = begun.id;
|
||||
form.elements.credential.value = JSON.stringify(credential.toJSON());
|
||||
form.submit();
|
||||
} catch (error) {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{%- endif %}
|
||||
<p class="issuer">{{ issuer }}</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user