131 lines
5.8 KiB
Python
131 lines
5.8 KiB
Python
"""/auth, the sign-in, refresh and sign-out of the web service, its rate limit per client address, and the rules of
|
|
who may call a protected path (refusal)."""
|
|
|
|
import collections
|
|
import threading
|
|
import time
|
|
|
|
import ldap
|
|
|
|
import vpnrouter
|
|
import vpnrouter_auth
|
|
import vpnrouter_passkeys
|
|
import vpnrouter_sites
|
|
import vpnrouter_users
|
|
|
|
from .common import USER_STATUS
|
|
from .sites import SITE_FILES
|
|
|
|
#: The calls of /auth allowed per client address in AUTH_WINDOW seconds.
|
|
AUTH_LIMIT = 10
|
|
|
|
AUTH_WINDOW = 60
|
|
|
|
#: The times of the recent /auth calls of each client address.
|
|
_auth_calls = collections.defaultdict(collections.deque)
|
|
|
|
_auth_lock = threading.Lock()
|
|
|
|
|
|
def changes_admins(connection, method, parts, body):
|
|
"""Whether a /users, /service-accounts 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] in ('users', 'service-accounts') 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.
|
|
|
|
The files of a site may also be read and changed by a member of the site's editors group.
|
|
"""
|
|
name = claims.get('sub')
|
|
if path == '/account' or path.startswith('/account/'):
|
|
return '' if isinstance(name, str) else 'the token names no user'
|
|
if claims.get('admin') is True:
|
|
return ''
|
|
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):
|
|
files = SITE_FILES.match(path)
|
|
if files and files.group(1) in vpnrouter_sites.editable(vpnrouter_users.groups_of(connection, name)):
|
|
return ''
|
|
return f'user {name} is not a member of {admins}'
|
|
parts = path.strip('/').split('/')
|
|
if changes_admins(connection, method, parts, body if isinstance(body, dict) else {}):
|
|
return f'only a token of vpn-router-token may change the group {admins} or remove its members'
|
|
if parts[0] == 'users' and len(parts) == 3 and parts[2] == 'console' and method != 'GET':
|
|
return 'only a token of vpn-router-token may change console login'
|
|
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 and the user's name
|
|
POST /auth/passkey begin a sign-in with a passkey: {"id", "options"} for navigator.credentials.get
|
|
POST /auth/passkey/complete
|
|
{"id", "credential"}, credential the browser's answer; the tokens and the user's name
|
|
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'))
|
|
with vpnrouter_users.session() as connection:
|
|
if vpnrouter_users.service(vpnrouter_users.entry(connection, body['name'])):
|
|
raise vpnrouter_users.Failure('the name, the password or the code is wrong', 5)
|
|
return 200, {**vpnrouter_auth.issue(cfg, body['name']), 'name': body['name']}
|
|
if path == '/auth/passkey':
|
|
return 200, vpnrouter_passkeys.login_begin()
|
|
if path == '/auth/passkey/complete':
|
|
with vpnrouter_users.session() as connection:
|
|
name = vpnrouter_passkeys.login_complete(connection, body.get('id'), body.get('credential'))
|
|
return 200, {**vpnrouter_auth.issue(cfg, name), 'name': 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 vpnrouter_auth.DatabaseError as exc:
|
|
return 500, {'error': str(exc)}
|
|
except SystemExit as exc:
|
|
return 500, {'error': str(exc.code)}
|
|
|
|
|
|
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
|