2020-07-02 04:21:35 +03:00
|
|
|
|
# API Key Based Authorization
|
|
|
|
|
#
|
|
|
|
|
# Copyright (C) 2020 Eric Callahan <arksine.code@gmail.com>
|
|
|
|
|
#
|
|
|
|
|
# This file may be distributed under the terms of the GNU GPLv3 license
|
2021-05-13 17:48:57 +03:00
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
2021-07-10 19:47:18 +03:00
|
|
|
|
import asyncio
|
2020-07-02 04:21:35 +03:00
|
|
|
|
import base64
|
|
|
|
|
import uuid
|
2021-04-15 22:48:07 +03:00
|
|
|
|
import hashlib
|
|
|
|
|
import secrets
|
2020-07-02 04:21:35 +03:00
|
|
|
|
import os
|
|
|
|
|
import time
|
2021-04-15 22:48:07 +03:00
|
|
|
|
import datetime
|
2020-07-28 17:54:48 +03:00
|
|
|
|
import ipaddress
|
2020-11-16 01:13:21 +03:00
|
|
|
|
import re
|
2021-04-30 02:16:57 +03:00
|
|
|
|
import socket
|
2020-07-02 04:21:35 +03:00
|
|
|
|
import logging
|
2021-06-02 03:06:53 +03:00
|
|
|
|
import json
|
2021-04-15 22:48:07 +03:00
|
|
|
|
from tornado.web import HTTPError
|
2021-06-02 03:06:53 +03:00
|
|
|
|
from libnacl.sign import Signer, Verifier
|
2021-05-13 17:48:57 +03:00
|
|
|
|
|
|
|
|
|
# Annotation imports
|
|
|
|
|
from typing import (
|
|
|
|
|
TYPE_CHECKING,
|
|
|
|
|
Any,
|
|
|
|
|
Tuple,
|
|
|
|
|
Set,
|
|
|
|
|
Optional,
|
|
|
|
|
Union,
|
|
|
|
|
Dict,
|
|
|
|
|
List,
|
|
|
|
|
)
|
2022-06-10 14:56:27 +03:00
|
|
|
|
|
2021-05-13 17:48:57 +03:00
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
from confighelper import ConfigHelper
|
2023-01-19 18:54:11 +03:00
|
|
|
|
from websockets import WebRequest, WebsocketManager
|
2021-05-13 17:48:57 +03:00
|
|
|
|
from tornado.httputil import HTTPServerRequest
|
|
|
|
|
from tornado.web import RequestHandler
|
2022-06-10 14:56:27 +03:00
|
|
|
|
from .database import MoonrakerDatabase as DBComp
|
|
|
|
|
from .ldap import MoonrakerLDAP
|
2021-05-13 17:48:57 +03:00
|
|
|
|
IPAddr = Union[ipaddress.IPv4Address, ipaddress.IPv6Address]
|
|
|
|
|
IPNetwork = Union[ipaddress.IPv4Network, ipaddress.IPv6Network]
|
2021-07-10 19:47:18 +03:00
|
|
|
|
OneshotToken = Tuple[IPAddr, Optional[Dict[str, Any]], asyncio.Handle]
|
2020-07-02 04:21:35 +03:00
|
|
|
|
|
2021-06-02 03:06:53 +03:00
|
|
|
|
# Helpers for base64url encoding and decoding
|
|
|
|
|
def base64url_encode(data: bytes) -> bytes:
|
|
|
|
|
return base64.urlsafe_b64encode(data).rstrip(b"=")
|
|
|
|
|
|
|
|
|
|
def base64url_decode(data: str) -> bytes:
|
|
|
|
|
pad_cnt = len(data) % 4
|
|
|
|
|
if pad_cnt:
|
|
|
|
|
data += "=" * (4 - pad_cnt)
|
|
|
|
|
return base64.urlsafe_b64decode(data)
|
|
|
|
|
|
2021-05-23 16:08:49 +03:00
|
|
|
|
|
2021-04-15 22:48:07 +03:00
|
|
|
|
ONESHOT_TIMEOUT = 5
|
|
|
|
|
TRUSTED_CONNECTION_TIMEOUT = 3600
|
2021-12-09 14:26:58 +03:00
|
|
|
|
PRUNE_CHECK_TIME = 300.
|
2020-07-02 04:21:35 +03:00
|
|
|
|
|
2022-06-10 14:56:27 +03:00
|
|
|
|
AUTH_SOURCES = ["moonraker", "ldap"]
|
2021-04-15 22:48:07 +03:00
|
|
|
|
HASH_ITER = 100000
|
|
|
|
|
API_USER = "_API_KEY_USER_"
|
|
|
|
|
TRUSTED_USER = "_TRUSTED_USER_"
|
|
|
|
|
RESERVED_USERS = [API_USER, TRUSTED_USER]
|
|
|
|
|
JWT_EXP_TIME = datetime.timedelta(hours=1)
|
|
|
|
|
JWT_HEADER = {
|
2021-06-02 03:06:53 +03:00
|
|
|
|
'alg': "EdDSA",
|
2021-04-15 22:48:07 +03:00
|
|
|
|
'typ': "JWT"
|
|
|
|
|
}
|
|
|
|
|
|
2020-07-02 04:21:35 +03:00
|
|
|
|
class Authorization:
|
2021-05-13 17:48:57 +03:00
|
|
|
|
def __init__(self, config: ConfigHelper) -> None:
|
2021-04-09 15:45:40 +03:00
|
|
|
|
self.server = config.get_server()
|
2021-04-15 22:48:07 +03:00
|
|
|
|
self.login_timeout = config.getint('login_timeout', 90)
|
2021-05-20 02:05:48 +03:00
|
|
|
|
self.force_logins = config.getboolean('force_logins', False)
|
2022-06-10 14:56:27 +03:00
|
|
|
|
self.default_source = config.get('default_source', "moonraker").lower()
|
2022-11-21 20:31:25 +03:00
|
|
|
|
self.enable_api_key = config.getboolean('enable_api_key', True)
|
2022-11-26 14:16:44 +03:00
|
|
|
|
self.max_logins = config.getint("max_login_attempts", None, above=0)
|
|
|
|
|
self.failed_logins: Dict[IPAddr, int] = {}
|
2022-06-10 14:56:27 +03:00
|
|
|
|
if self.default_source not in AUTH_SOURCES:
|
|
|
|
|
raise config.error(
|
|
|
|
|
"[authorization]: option 'default_source' - Invalid "
|
|
|
|
|
f"value '{self.default_source}'"
|
|
|
|
|
)
|
|
|
|
|
self.ldap: Optional[MoonrakerLDAP] = None
|
|
|
|
|
if config.has_section("ldap"):
|
|
|
|
|
self.ldap = self.server.load_component(config, "ldap", None)
|
|
|
|
|
if self.default_source == "ldap" and self.ldap is None:
|
|
|
|
|
self.server.add_warning(
|
|
|
|
|
"[authorization]: Option 'default_source' set to 'ldap',"
|
|
|
|
|
" however [ldap] section failed to load or not configured"
|
|
|
|
|
)
|
2021-05-13 17:48:57 +03:00
|
|
|
|
database: DBComp = self.server.lookup_component('database')
|
2021-04-15 22:48:07 +03:00
|
|
|
|
database.register_local_namespace('authorized_users', forbidden=True)
|
2022-01-31 04:21:24 +03:00
|
|
|
|
self.user_db = database.wrap_namespace('authorized_users')
|
|
|
|
|
self.users: Dict[str, Dict[str, Any]] = self.user_db.as_dict()
|
2021-05-13 17:48:57 +03:00
|
|
|
|
api_user: Optional[Dict[str, Any]] = self.users.get(API_USER, None)
|
2021-04-15 22:48:07 +03:00
|
|
|
|
if api_user is None:
|
2021-04-12 15:30:35 +03:00
|
|
|
|
self.api_key = uuid.uuid4().hex
|
2021-04-15 22:48:07 +03:00
|
|
|
|
self.users[API_USER] = {
|
|
|
|
|
'username': API_USER,
|
|
|
|
|
'api_key': self.api_key,
|
|
|
|
|
'created_on': time.time()
|
|
|
|
|
}
|
|
|
|
|
else:
|
|
|
|
|
self.api_key = api_user['api_key']
|
2021-11-25 13:43:49 +03:00
|
|
|
|
hi = self.server.get_host_info()
|
|
|
|
|
self.issuer = f"http://{hi['hostname']}:{hi['port']}"
|
2021-06-02 03:06:53 +03:00
|
|
|
|
self.public_jwks: Dict[str, Dict[str, Any]] = {}
|
2021-05-23 16:08:49 +03:00
|
|
|
|
for username, user_info in list(self.users.items()):
|
|
|
|
|
if username == API_USER:
|
2021-11-15 14:00:59 +03:00
|
|
|
|
# Validate the API User
|
|
|
|
|
for item in ["username", "api_key", "created_on"]:
|
|
|
|
|
if item not in user_info:
|
|
|
|
|
self.users[API_USER] = {
|
|
|
|
|
'username': API_USER,
|
|
|
|
|
'api_key': self.api_key,
|
|
|
|
|
'created_on': time.time()
|
|
|
|
|
}
|
|
|
|
|
break
|
2021-05-23 16:08:49 +03:00
|
|
|
|
continue
|
2021-11-15 14:00:59 +03:00
|
|
|
|
else:
|
|
|
|
|
# validate created users
|
|
|
|
|
valid = True
|
|
|
|
|
for item in ["username", "password", "salt", "created_on"]:
|
|
|
|
|
if item not in user_info:
|
|
|
|
|
logging.info(
|
|
|
|
|
f"Authorization: User {username} does not "
|
|
|
|
|
f"contain field {item}, removing")
|
|
|
|
|
del self.users[username]
|
|
|
|
|
valid = False
|
|
|
|
|
break
|
|
|
|
|
if not valid:
|
|
|
|
|
continue
|
|
|
|
|
# generate jwks for valid users
|
2021-05-23 16:08:49 +03:00
|
|
|
|
if 'jwt_secret' in user_info:
|
|
|
|
|
try:
|
|
|
|
|
priv_key = self._load_private_key(user_info['jwt_secret'])
|
2021-06-02 03:06:53 +03:00
|
|
|
|
jwk_id = user_info['jwk_id']
|
2021-06-04 21:48:21 +03:00
|
|
|
|
except (self.server.error, KeyError):
|
2021-05-23 16:08:49 +03:00
|
|
|
|
logging.info("Invalid key found for user, removing")
|
|
|
|
|
user_info.pop('jwt_secret', None)
|
2021-06-02 03:06:53 +03:00
|
|
|
|
user_info.pop('jwk_id', None)
|
2021-05-23 16:08:49 +03:00
|
|
|
|
self.users[username] = user_info
|
|
|
|
|
continue
|
2021-06-02 03:06:53 +03:00
|
|
|
|
self.public_jwks[jwk_id] = self._generate_public_jwk(priv_key)
|
2022-01-31 04:21:24 +03:00
|
|
|
|
# sync user changes to the database
|
|
|
|
|
self.user_db.sync(self.users)
|
2021-05-13 17:48:57 +03:00
|
|
|
|
self.trusted_users: Dict[IPAddr, Any] = {}
|
|
|
|
|
self.oneshot_tokens: Dict[str, OneshotToken] = {}
|
|
|
|
|
self.permitted_paths: Set[str] = set()
|
2020-07-02 04:21:35 +03:00
|
|
|
|
|
2020-11-12 04:44:27 +03:00
|
|
|
|
# Get allowed cors domains
|
2021-05-13 17:48:57 +03:00
|
|
|
|
self.cors_domains: List[str] = []
|
2021-11-20 16:58:23 +03:00
|
|
|
|
for domain in config.getlist('cors_domains', []):
|
2021-03-11 02:10:03 +03:00
|
|
|
|
bad_match = re.search(r"^.+\.[^:]*\*", domain)
|
|
|
|
|
if bad_match is not None:
|
|
|
|
|
raise config.error(
|
|
|
|
|
f"Unsafe CORS Domain '{domain}'. Wildcards are not"
|
|
|
|
|
" permitted in the top level domain.")
|
2021-07-12 22:47:38 +03:00
|
|
|
|
if domain.endswith("/"):
|
|
|
|
|
self.server.add_warning(
|
2021-12-20 16:37:54 +03:00
|
|
|
|
f"[authorization]: Invalid domain '{domain}' in option "
|
|
|
|
|
"'cors_domains'. Domain's cannot contain a trailing "
|
|
|
|
|
"slash.")
|
2021-07-12 22:47:38 +03:00
|
|
|
|
else:
|
|
|
|
|
self.cors_domains.append(
|
|
|
|
|
domain.replace(".", "\\.").replace("*", ".*"))
|
2020-11-12 04:44:27 +03:00
|
|
|
|
|
2020-08-06 03:49:56 +03:00
|
|
|
|
# Get Trusted Clients
|
2021-05-13 17:48:57 +03:00
|
|
|
|
self.trusted_ips: List[IPAddr] = []
|
|
|
|
|
self.trusted_ranges: List[IPNetwork] = []
|
|
|
|
|
self.trusted_domains: List[str] = []
|
2021-11-20 16:58:23 +03:00
|
|
|
|
for val in config.getlist('trusted_clients', []):
|
2020-08-06 03:49:56 +03:00
|
|
|
|
# Check IP address
|
2020-07-28 17:54:48 +03:00
|
|
|
|
try:
|
2021-04-30 02:16:57 +03:00
|
|
|
|
tc = ipaddress.ip_address(val)
|
2020-07-28 17:54:48 +03:00
|
|
|
|
except ValueError:
|
2021-04-30 02:16:57 +03:00
|
|
|
|
pass
|
2020-08-06 03:49:56 +03:00
|
|
|
|
else:
|
|
|
|
|
self.trusted_ips.append(tc)
|
2021-04-30 02:16:57 +03:00
|
|
|
|
continue
|
|
|
|
|
# Check ip network
|
|
|
|
|
try:
|
2022-06-18 00:56:47 +03:00
|
|
|
|
tn = ipaddress.ip_network(val)
|
2021-12-20 16:37:54 +03:00
|
|
|
|
except ValueError as e:
|
|
|
|
|
if "has host bits set" in str(e):
|
|
|
|
|
self.server.add_warning(
|
|
|
|
|
f"[authorization]: Invalid CIDR expression '{val}' "
|
|
|
|
|
"in option 'trusted_clients'")
|
|
|
|
|
continue
|
2021-04-30 02:16:57 +03:00
|
|
|
|
pass
|
|
|
|
|
else:
|
2022-06-18 00:56:47 +03:00
|
|
|
|
self.trusted_ranges.append(tn)
|
2021-04-30 02:16:57 +03:00
|
|
|
|
continue
|
|
|
|
|
# Check hostname
|
2021-12-20 16:37:54 +03:00
|
|
|
|
match = re.match(r"([a-z0-9]+(-[a-z0-9]+)*\.?)+[a-z]{2,}$", val)
|
|
|
|
|
if match is not None:
|
|
|
|
|
self.trusted_domains.append(val.lower())
|
|
|
|
|
else:
|
|
|
|
|
self.server.add_warning(
|
|
|
|
|
f"[authorization]: Invalid domain name '{val}' "
|
|
|
|
|
"in option 'trusted_clients'")
|
2020-08-06 03:49:56 +03:00
|
|
|
|
|
2020-08-14 00:49:29 +03:00
|
|
|
|
t_clients = "\n".join(
|
|
|
|
|
[str(ip) for ip in self.trusted_ips] +
|
2021-04-30 02:16:57 +03:00
|
|
|
|
[str(rng) for rng in self.trusted_ranges] +
|
|
|
|
|
self.trusted_domains)
|
2020-11-17 14:05:24 +03:00
|
|
|
|
c_domains = "\n".join(self.cors_domains)
|
2020-08-14 00:49:29 +03:00
|
|
|
|
|
2020-07-02 04:21:35 +03:00
|
|
|
|
logging.info(
|
2020-08-14 00:49:29 +03:00
|
|
|
|
f"Authorization Configuration Loaded\n"
|
2020-11-17 14:05:24 +03:00
|
|
|
|
f"Trusted Clients:\n{t_clients}\n"
|
|
|
|
|
f"CORS Domains:\n{c_domains}")
|
2020-07-02 04:21:35 +03:00
|
|
|
|
|
2021-12-09 14:26:58 +03:00
|
|
|
|
eventloop = self.server.get_event_loop()
|
|
|
|
|
self.prune_timer = eventloop.register_timer(
|
|
|
|
|
self._prune_conn_handler)
|
2020-08-06 03:49:56 +03:00
|
|
|
|
|
2020-07-02 04:21:35 +03:00
|
|
|
|
# Register Authorization Endpoints
|
2021-05-29 04:03:03 +03:00
|
|
|
|
self.permitted_paths.add("/server/redirect")
|
2021-04-15 22:48:07 +03:00
|
|
|
|
self.permitted_paths.add("/access/login")
|
|
|
|
|
self.permitted_paths.add("/access/refresh_jwt")
|
2022-06-17 18:19:12 +03:00
|
|
|
|
self.permitted_paths.add("/access/info")
|
2021-04-15 22:48:07 +03:00
|
|
|
|
self.server.register_endpoint(
|
2021-05-13 21:44:08 +03:00
|
|
|
|
"/access/login", ['POST'], self._handle_login,
|
2022-11-21 23:14:48 +03:00
|
|
|
|
transports=['http', 'websocket'])
|
2021-04-15 22:48:07 +03:00
|
|
|
|
self.server.register_endpoint(
|
2021-05-13 21:44:08 +03:00
|
|
|
|
"/access/logout", ['POST'], self._handle_logout,
|
2022-11-21 23:14:48 +03:00
|
|
|
|
transports=['http', 'websocket'])
|
2021-04-15 22:48:07 +03:00
|
|
|
|
self.server.register_endpoint(
|
2021-05-13 21:44:08 +03:00
|
|
|
|
"/access/refresh_jwt", ['POST'], self._handle_refresh_jwt,
|
2022-11-21 23:14:48 +03:00
|
|
|
|
transports=['http', 'websocket'])
|
2021-04-15 22:48:07 +03:00
|
|
|
|
self.server.register_endpoint(
|
|
|
|
|
"/access/user", ['GET', 'POST', 'DELETE'],
|
2022-11-21 23:14:48 +03:00
|
|
|
|
self._handle_user_request, transports=['http', 'websocket'])
|
2021-04-15 22:48:07 +03:00
|
|
|
|
self.server.register_endpoint(
|
2021-05-13 21:44:08 +03:00
|
|
|
|
"/access/users/list", ['GET'], self._handle_list_request,
|
2022-11-21 23:14:48 +03:00
|
|
|
|
transports=['http', 'websocket'])
|
2021-05-13 21:44:08 +03:00
|
|
|
|
self.server.register_endpoint(
|
|
|
|
|
"/access/user/password", ['POST'], self._handle_password_reset,
|
2022-11-21 23:14:48 +03:00
|
|
|
|
transports=['http', 'websocket'])
|
2021-04-09 15:45:40 +03:00
|
|
|
|
self.server.register_endpoint(
|
2020-09-03 14:23:59 +03:00
|
|
|
|
"/access/api_key", ['GET', 'POST'],
|
2022-11-21 23:14:48 +03:00
|
|
|
|
self._handle_apikey_request, transports=['http', 'websocket'])
|
2021-04-09 15:45:40 +03:00
|
|
|
|
self.server.register_endpoint(
|
2020-09-03 14:23:59 +03:00
|
|
|
|
"/access/oneshot_token", ['GET'],
|
2022-11-21 23:14:48 +03:00
|
|
|
|
self._handle_oneshot_request, transports=['http', 'websocket'])
|
2022-06-17 18:19:12 +03:00
|
|
|
|
self.server.register_endpoint(
|
|
|
|
|
"/access/info", ['GET'],
|
2022-11-21 23:14:48 +03:00
|
|
|
|
self._handle_info_request, transports=['http', 'websocket'])
|
2023-01-19 18:54:11 +03:00
|
|
|
|
wsm: WebsocketManager = self.server.lookup_component("websockets")
|
|
|
|
|
wsm.register_notification("authorization:user_created")
|
|
|
|
|
wsm.register_notification(
|
|
|
|
|
"authorization:user_deleted", event_type="logout"
|
|
|
|
|
)
|
|
|
|
|
wsm.register_notification(
|
|
|
|
|
"authorization:user_logged_out", event_type="logout"
|
|
|
|
|
)
|
2020-07-02 04:21:35 +03:00
|
|
|
|
|
2022-08-20 03:46:02 +03:00
|
|
|
|
def register_permited_path(self, path: str) -> None:
|
|
|
|
|
self.permitted_paths.add(path)
|
|
|
|
|
|
2022-11-21 23:14:48 +03:00
|
|
|
|
def is_path_permitted(self, path: str) -> bool:
|
|
|
|
|
return path in self.permitted_paths
|
|
|
|
|
|
2022-01-31 04:21:24 +03:00
|
|
|
|
def _sync_user(self, username: str) -> None:
|
|
|
|
|
self.user_db[username] = self.users[username]
|
|
|
|
|
|
2022-02-05 03:17:43 +03:00
|
|
|
|
async def component_init(self) -> None:
|
|
|
|
|
self.prune_timer.start(delay=PRUNE_CHECK_TIME)
|
|
|
|
|
|
2021-05-13 17:48:57 +03:00
|
|
|
|
async def _handle_apikey_request(self, web_request: WebRequest) -> str:
|
2020-11-09 15:00:53 +03:00
|
|
|
|
action = web_request.get_action()
|
|
|
|
|
if action.upper() == 'POST':
|
2021-04-12 15:30:35 +03:00
|
|
|
|
self.api_key = uuid.uuid4().hex
|
2022-01-31 04:21:24 +03:00
|
|
|
|
self.users[API_USER]['api_key'] = self.api_key
|
|
|
|
|
self._sync_user(API_USER)
|
2020-07-02 04:21:35 +03:00
|
|
|
|
return self.api_key
|
|
|
|
|
|
2021-05-23 21:24:45 +03:00
|
|
|
|
async def _handle_oneshot_request(self, web_request: WebRequest) -> str:
|
2021-04-15 22:48:07 +03:00
|
|
|
|
ip = web_request.get_ip_address()
|
2021-05-13 17:48:57 +03:00
|
|
|
|
assert ip is not None
|
2021-04-15 22:48:07 +03:00
|
|
|
|
user_info = web_request.get_current_user()
|
|
|
|
|
return self.get_oneshot_token(ip, user_info)
|
|
|
|
|
|
2021-05-13 17:48:57 +03:00
|
|
|
|
async def _handle_login(self, web_request: WebRequest) -> Dict[str, Any]:
|
2022-11-26 14:16:44 +03:00
|
|
|
|
ip = web_request.get_ip_address()
|
|
|
|
|
if ip is not None and self.check_logins_maxed(ip):
|
|
|
|
|
raise HTTPError(
|
|
|
|
|
401, "Unauthorized, Maximum Login Attempts Reached"
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
ret = await self._login_jwt_user(web_request)
|
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
|
raise
|
|
|
|
|
except Exception:
|
|
|
|
|
if ip is not None:
|
|
|
|
|
failed = self.failed_logins.get(ip, 0)
|
|
|
|
|
self.failed_logins[ip] = failed + 1
|
|
|
|
|
raise
|
|
|
|
|
if ip is not None:
|
|
|
|
|
self.failed_logins.pop(ip, None)
|
|
|
|
|
return ret
|
2021-04-15 22:48:07 +03:00
|
|
|
|
|
2021-05-13 17:48:57 +03:00
|
|
|
|
async def _handle_logout(self, web_request: WebRequest) -> Dict[str, str]:
|
2021-04-15 22:48:07 +03:00
|
|
|
|
user_info = web_request.get_current_user()
|
|
|
|
|
if user_info is None:
|
|
|
|
|
raise self.server.error("No user logged in")
|
2021-05-13 17:48:57 +03:00
|
|
|
|
username: str = user_info['username']
|
2021-04-15 22:48:07 +03:00
|
|
|
|
if username in RESERVED_USERS:
|
|
|
|
|
raise self.server.error(
|
|
|
|
|
f"Invalid log out request for user {username}")
|
2022-01-31 04:21:24 +03:00
|
|
|
|
self.users[username].pop("jwt_secret", None)
|
|
|
|
|
jwk_id: str = self.users[username].pop("jwk_id", None)
|
|
|
|
|
self._sync_user(username)
|
2021-06-02 03:06:53 +03:00
|
|
|
|
self.public_jwks.pop(jwk_id, None)
|
2023-01-19 18:54:11 +03:00
|
|
|
|
eventloop = self.server.get_event_loop()
|
|
|
|
|
eventloop.delay_callback(
|
|
|
|
|
.005, self.server.send_event, "authorization:user_logged_out",
|
|
|
|
|
{'username': username}
|
|
|
|
|
)
|
2021-04-15 22:48:07 +03:00
|
|
|
|
return {
|
|
|
|
|
"username": username,
|
|
|
|
|
"action": "user_logged_out"
|
|
|
|
|
}
|
|
|
|
|
|
2022-06-18 00:07:58 +03:00
|
|
|
|
async def _handle_info_request(
|
|
|
|
|
self, web_request: WebRequest
|
|
|
|
|
) -> Dict[str, Any]:
|
2022-06-17 18:19:12 +03:00
|
|
|
|
sources = ["moonraker"]
|
|
|
|
|
if self.ldap is not None:
|
|
|
|
|
sources.append("ldap")
|
|
|
|
|
return {
|
|
|
|
|
"default_source": self.default_source,
|
|
|
|
|
"available_sources": sources
|
|
|
|
|
}
|
|
|
|
|
|
2021-05-13 17:48:57 +03:00
|
|
|
|
async def _handle_refresh_jwt(self,
|
|
|
|
|
web_request: WebRequest
|
|
|
|
|
) -> Dict[str, str]:
|
|
|
|
|
refresh_token: str = web_request.get_str('refresh_token')
|
2021-06-02 03:06:53 +03:00
|
|
|
|
try:
|
2022-11-21 23:14:48 +03:00
|
|
|
|
user_info = self.decode_jwt(refresh_token, token_type="refresh")
|
2021-06-02 03:06:53 +03:00
|
|
|
|
except Exception:
|
|
|
|
|
raise self.server.error("Invalid Refresh Token", 401)
|
2021-05-13 17:48:57 +03:00
|
|
|
|
username: str = user_info['username']
|
2021-06-02 03:06:53 +03:00
|
|
|
|
if 'jwt_secret' not in user_info or "jwk_id" not in user_info:
|
2021-05-23 16:08:49 +03:00
|
|
|
|
raise self.server.error("User not logged in", 401)
|
2021-06-02 03:06:53 +03:00
|
|
|
|
private_key = self._load_private_key(user_info['jwt_secret'])
|
|
|
|
|
jwk_id: str = user_info['jwk_id']
|
|
|
|
|
token = self._generate_jwt(username, jwk_id, private_key)
|
2021-04-15 22:48:07 +03:00
|
|
|
|
return {
|
|
|
|
|
'username': username,
|
|
|
|
|
'token': token,
|
2022-06-10 14:56:27 +03:00
|
|
|
|
'source': user_info.get("source", "moonraker"),
|
2021-04-15 22:48:07 +03:00
|
|
|
|
'action': 'user_jwt_refresh'
|
|
|
|
|
}
|
|
|
|
|
|
2021-05-13 17:48:57 +03:00
|
|
|
|
async def _handle_user_request(self,
|
|
|
|
|
web_request: WebRequest
|
|
|
|
|
) -> Dict[str, Any]:
|
2021-04-15 22:48:07 +03:00
|
|
|
|
action = web_request.get_action()
|
|
|
|
|
if action == "GET":
|
|
|
|
|
user = web_request.get_current_user()
|
|
|
|
|
if user is None:
|
|
|
|
|
return {
|
|
|
|
|
'username': None,
|
2022-06-10 14:56:27 +03:00
|
|
|
|
'source': None,
|
2021-04-15 22:48:07 +03:00
|
|
|
|
'created_on': None,
|
|
|
|
|
}
|
|
|
|
|
else:
|
|
|
|
|
return {
|
|
|
|
|
'username': user['username'],
|
2022-06-10 14:56:27 +03:00
|
|
|
|
'source': user.get("source", "moonraker"),
|
2021-04-15 22:48:07 +03:00
|
|
|
|
'created_on': user.get('created_on')
|
|
|
|
|
}
|
|
|
|
|
elif action == "POST":
|
|
|
|
|
# Create User
|
2022-06-10 14:56:27 +03:00
|
|
|
|
return await self._login_jwt_user(web_request, create=True)
|
2021-04-15 22:48:07 +03:00
|
|
|
|
elif action == "DELETE":
|
|
|
|
|
# Delete User
|
|
|
|
|
return self._delete_jwt_user(web_request)
|
2021-05-13 17:48:57 +03:00
|
|
|
|
raise self.server.error("Invalid Request Method")
|
2021-04-15 22:48:07 +03:00
|
|
|
|
|
2021-05-13 17:48:57 +03:00
|
|
|
|
async def _handle_list_request(self,
|
|
|
|
|
web_request: WebRequest
|
|
|
|
|
) -> Dict[str, List[Dict[str, Any]]]:
|
2021-05-14 00:57:34 +03:00
|
|
|
|
user_list = []
|
|
|
|
|
for user in self.users.values():
|
|
|
|
|
if user['username'] == API_USER:
|
|
|
|
|
continue
|
|
|
|
|
user_list.append({
|
|
|
|
|
'username': user['username'],
|
2022-06-10 14:56:27 +03:00
|
|
|
|
'source': user.get("source", "moonraker"),
|
2021-05-14 00:57:34 +03:00
|
|
|
|
'created_on': user['created_on']
|
|
|
|
|
})
|
2021-05-13 21:44:08 +03:00
|
|
|
|
return {
|
|
|
|
|
'users': user_list
|
|
|
|
|
}
|
|
|
|
|
|
2021-05-13 17:48:57 +03:00
|
|
|
|
async def _handle_password_reset(self,
|
|
|
|
|
web_request: WebRequest
|
|
|
|
|
) -> Dict[str, str]:
|
|
|
|
|
password: str = web_request.get_str('password')
|
|
|
|
|
new_pass: str = web_request.get_str('new_password')
|
2021-04-15 22:48:07 +03:00
|
|
|
|
user_info = web_request.get_current_user()
|
|
|
|
|
if user_info is None:
|
|
|
|
|
raise self.server.error("No Current User")
|
|
|
|
|
username = user_info['username']
|
2022-06-10 14:56:27 +03:00
|
|
|
|
if user_info.get("source", "moonraker") == "ldap":
|
|
|
|
|
raise self.server.error(
|
|
|
|
|
f"Can´t Reset password for ldap user {username}")
|
2021-04-15 22:48:07 +03:00
|
|
|
|
if username in RESERVED_USERS:
|
|
|
|
|
raise self.server.error(
|
|
|
|
|
f"Invalid Reset Request for user {username}")
|
|
|
|
|
salt = bytes.fromhex(user_info['salt'])
|
|
|
|
|
hashed_pass = hashlib.pbkdf2_hmac(
|
|
|
|
|
'sha256', password.encode(), salt, HASH_ITER).hex()
|
|
|
|
|
if hashed_pass != user_info['password']:
|
|
|
|
|
raise self.server.error("Invalid Password")
|
|
|
|
|
new_hashed_pass = hashlib.pbkdf2_hmac(
|
|
|
|
|
'sha256', new_pass.encode(), salt, HASH_ITER).hex()
|
2022-01-31 04:21:24 +03:00
|
|
|
|
self.users[username]['password'] = new_hashed_pass
|
|
|
|
|
self._sync_user(username)
|
2021-04-15 22:48:07 +03:00
|
|
|
|
return {
|
|
|
|
|
'username': username,
|
|
|
|
|
'action': "user_password_reset"
|
|
|
|
|
}
|
|
|
|
|
|
2022-06-10 14:56:27 +03:00
|
|
|
|
async def _login_jwt_user(
|
|
|
|
|
self, web_request: WebRequest, create: bool = False
|
|
|
|
|
) -> Dict[str, Any]:
|
2021-05-13 17:48:57 +03:00
|
|
|
|
username: str = web_request.get_str('username')
|
|
|
|
|
password: str = web_request.get_str('password')
|
2022-06-10 14:56:27 +03:00
|
|
|
|
source: str = web_request.get_str(
|
|
|
|
|
'source', self.default_source
|
|
|
|
|
).lower()
|
|
|
|
|
if source not in AUTH_SOURCES:
|
|
|
|
|
raise self.server.error(f"Invalid 'source': {source}")
|
2021-05-13 17:48:57 +03:00
|
|
|
|
user_info: Dict[str, Any]
|
2021-04-15 22:48:07 +03:00
|
|
|
|
if username in RESERVED_USERS:
|
|
|
|
|
raise self.server.error(
|
|
|
|
|
f"Invalid Request for user {username}")
|
2022-06-10 14:56:27 +03:00
|
|
|
|
if source == "ldap":
|
|
|
|
|
if create:
|
|
|
|
|
raise self.server.error("Cannot Create LDAP User")
|
|
|
|
|
if self.ldap is None:
|
|
|
|
|
raise self.server.error(
|
|
|
|
|
"LDAP authentication not available", 401
|
|
|
|
|
)
|
|
|
|
|
await self.ldap.authenticate_ldap_user(username, password)
|
|
|
|
|
if username not in self.users:
|
|
|
|
|
create = True
|
2021-04-15 22:48:07 +03:00
|
|
|
|
if create:
|
|
|
|
|
if username in self.users:
|
|
|
|
|
raise self.server.error(f"User {username} already exists")
|
|
|
|
|
salt = secrets.token_bytes(32)
|
|
|
|
|
hashed_pass = hashlib.pbkdf2_hmac(
|
|
|
|
|
'sha256', password.encode(), salt, HASH_ITER).hex()
|
|
|
|
|
user_info = {
|
|
|
|
|
'username': username,
|
|
|
|
|
'password': hashed_pass,
|
|
|
|
|
'salt': salt.hex(),
|
2022-06-10 14:56:27 +03:00
|
|
|
|
'source': source,
|
2021-04-15 22:48:07 +03:00
|
|
|
|
'created_on': time.time()
|
|
|
|
|
}
|
|
|
|
|
self.users[username] = user_info
|
2022-01-31 04:21:24 +03:00
|
|
|
|
self._sync_user(username)
|
2021-04-15 22:48:07 +03:00
|
|
|
|
action = "user_created"
|
2022-06-10 14:56:27 +03:00
|
|
|
|
if source == "ldap":
|
|
|
|
|
# Dont notify user created
|
|
|
|
|
action = "user_logged_in"
|
|
|
|
|
create = False
|
2021-04-15 22:48:07 +03:00
|
|
|
|
else:
|
|
|
|
|
if username not in self.users:
|
|
|
|
|
raise self.server.error(f"Unregistered User: {username}")
|
|
|
|
|
user_info = self.users[username]
|
2022-06-10 14:56:27 +03:00
|
|
|
|
auth_src = user_info.get("source", "moonraker")
|
|
|
|
|
if auth_src != source:
|
|
|
|
|
raise self.server.error(
|
|
|
|
|
f"Moonraker cannot authenticate user '{username}', must "
|
|
|
|
|
f"specify source '{auth_src}'", 401
|
|
|
|
|
)
|
2021-04-15 22:48:07 +03:00
|
|
|
|
salt = bytes.fromhex(user_info['salt'])
|
|
|
|
|
hashed_pass = hashlib.pbkdf2_hmac(
|
|
|
|
|
'sha256', password.encode(), salt, HASH_ITER).hex()
|
|
|
|
|
action = "user_logged_in"
|
2022-06-10 14:56:27 +03:00
|
|
|
|
if hashed_pass != user_info['password']:
|
|
|
|
|
raise self.server.error("Invalid Password")
|
2021-05-23 16:08:49 +03:00
|
|
|
|
jwt_secret_hex: Optional[str] = user_info.get('jwt_secret', None)
|
|
|
|
|
if jwt_secret_hex is None:
|
2021-06-02 03:06:53 +03:00
|
|
|
|
private_key = Signer()
|
|
|
|
|
jwk_id = base64url_encode(secrets.token_bytes()).decode()
|
|
|
|
|
user_info['jwt_secret'] = private_key.hex_seed().decode()
|
|
|
|
|
user_info['jwk_id'] = jwk_id
|
2021-04-15 22:48:07 +03:00
|
|
|
|
self.users[username] = user_info
|
2022-01-31 04:21:24 +03:00
|
|
|
|
self._sync_user(username)
|
2021-06-02 03:06:53 +03:00
|
|
|
|
self.public_jwks[jwk_id] = self._generate_public_jwk(private_key)
|
2021-05-23 16:08:49 +03:00
|
|
|
|
else:
|
|
|
|
|
private_key = self._load_private_key(jwt_secret_hex)
|
2021-06-02 03:06:53 +03:00
|
|
|
|
jwk_id = user_info['jwk_id']
|
|
|
|
|
token = self._generate_jwt(username, jwk_id, private_key)
|
2021-04-15 22:48:07 +03:00
|
|
|
|
refresh_token = self._generate_jwt(
|
2021-06-02 03:06:53 +03:00
|
|
|
|
username, jwk_id, private_key, token_type="refresh",
|
2021-04-15 22:48:07 +03:00
|
|
|
|
exp_time=datetime.timedelta(days=self.login_timeout))
|
2022-11-21 23:14:48 +03:00
|
|
|
|
conn = web_request.get_client_connection()
|
2021-05-13 21:54:18 +03:00
|
|
|
|
if create:
|
2021-07-10 19:47:18 +03:00
|
|
|
|
event_loop = self.server.get_event_loop()
|
|
|
|
|
event_loop.delay_callback(
|
2021-05-13 21:54:18 +03:00
|
|
|
|
.005, self.server.send_event,
|
|
|
|
|
"authorization:user_created",
|
|
|
|
|
{'username': username})
|
2022-11-21 23:14:48 +03:00
|
|
|
|
elif conn is not None:
|
|
|
|
|
conn.user_info = user_info
|
2021-04-15 22:48:07 +03:00
|
|
|
|
return {
|
|
|
|
|
'username': username,
|
|
|
|
|
'token': token,
|
2022-06-10 14:56:27 +03:00
|
|
|
|
'source': user_info.get("source", "moonraker"),
|
2021-04-15 22:48:07 +03:00
|
|
|
|
'refresh_token': refresh_token,
|
|
|
|
|
'action': action
|
|
|
|
|
}
|
|
|
|
|
|
2021-05-13 17:48:57 +03:00
|
|
|
|
def _delete_jwt_user(self, web_request: WebRequest) -> Dict[str, str]:
|
|
|
|
|
username: str = web_request.get_str('username')
|
2021-05-14 18:47:56 +03:00
|
|
|
|
current_user = web_request.get_current_user()
|
|
|
|
|
if current_user is not None:
|
|
|
|
|
curname = current_user.get('username', None)
|
|
|
|
|
if curname is not None and curname == username:
|
|
|
|
|
raise self.server.error(
|
|
|
|
|
f"Cannot delete logged in user {curname}")
|
2021-04-15 22:48:07 +03:00
|
|
|
|
if username in RESERVED_USERS:
|
|
|
|
|
raise self.server.error(
|
2021-05-14 18:47:56 +03:00
|
|
|
|
f"Invalid Request for reserved user {username}")
|
2021-05-13 17:48:57 +03:00
|
|
|
|
user_info: Optional[Dict[str, Any]] = self.users.get(username)
|
2021-05-14 18:47:56 +03:00
|
|
|
|
if user_info is None:
|
|
|
|
|
raise self.server.error(f"No registered user: {username}")
|
2022-01-31 04:21:24 +03:00
|
|
|
|
if 'jwk_id' in user_info:
|
|
|
|
|
self.public_jwks.pop(user_info['jwk_id'], None)
|
2021-04-15 22:48:07 +03:00
|
|
|
|
del self.users[username]
|
2022-01-31 04:21:24 +03:00
|
|
|
|
del self.user_db[username]
|
2021-07-10 19:47:18 +03:00
|
|
|
|
event_loop = self.server.get_event_loop()
|
|
|
|
|
event_loop.delay_callback(
|
2021-05-13 21:54:18 +03:00
|
|
|
|
.005, self.server.send_event,
|
|
|
|
|
"authorization:user_deleted",
|
|
|
|
|
{'username': username})
|
2021-04-15 22:48:07 +03:00
|
|
|
|
return {
|
|
|
|
|
"username": username,
|
|
|
|
|
"action": "user_deleted"
|
|
|
|
|
}
|
|
|
|
|
|
2021-05-13 17:48:57 +03:00
|
|
|
|
def _generate_jwt(self,
|
|
|
|
|
username: str,
|
2021-06-02 03:06:53 +03:00
|
|
|
|
jwk_id: str,
|
|
|
|
|
private_key: Signer,
|
2021-05-23 15:11:46 +03:00
|
|
|
|
token_type: str = "access",
|
2021-05-13 17:48:57 +03:00
|
|
|
|
exp_time: datetime.timedelta = JWT_EXP_TIME
|
|
|
|
|
) -> str:
|
2021-06-02 03:06:53 +03:00
|
|
|
|
curtime = int(time.time())
|
2021-04-15 22:48:07 +03:00
|
|
|
|
payload = {
|
2021-05-23 15:11:46 +03:00
|
|
|
|
'iss': self.issuer,
|
|
|
|
|
'aud': "Moonraker",
|
2021-04-15 22:48:07 +03:00
|
|
|
|
'iat': curtime,
|
2021-06-02 03:06:53 +03:00
|
|
|
|
'exp': curtime + int(exp_time.total_seconds()),
|
2021-04-15 22:48:07 +03:00
|
|
|
|
'username': username,
|
|
|
|
|
'token_type': token_type
|
|
|
|
|
}
|
2021-06-02 03:06:53 +03:00
|
|
|
|
header = {'kid': jwk_id}
|
|
|
|
|
header.update(JWT_HEADER)
|
|
|
|
|
jwt_header = base64url_encode(json.dumps(header).encode())
|
|
|
|
|
jwt_payload = base64url_encode(json.dumps(payload).encode())
|
|
|
|
|
jwt_msg = b".".join([jwt_header, jwt_payload])
|
|
|
|
|
sig = private_key.signature(jwt_msg)
|
|
|
|
|
jwt_sig = base64url_encode(sig)
|
|
|
|
|
return b".".join([jwt_msg, jwt_sig]).decode()
|
2021-04-15 22:48:07 +03:00
|
|
|
|
|
2022-11-21 23:14:48 +03:00
|
|
|
|
def decode_jwt(
|
|
|
|
|
self, token: str, token_type: str = "access"
|
|
|
|
|
) -> Dict[str, Any]:
|
2021-06-02 03:06:53 +03:00
|
|
|
|
message, sig = token.rsplit('.', maxsplit=1)
|
|
|
|
|
enc_header, enc_payload = message.split('.')
|
|
|
|
|
header: Dict[str, Any] = json.loads(base64url_decode(enc_header))
|
|
|
|
|
sig_bytes = base64url_decode(sig)
|
|
|
|
|
|
|
|
|
|
# verify header
|
|
|
|
|
if header.get('typ') != "JWT" or header.get('alg') != "EdDSA":
|
2021-04-15 22:48:07 +03:00
|
|
|
|
raise self.server.error("Invalid JWT header")
|
2021-06-02 03:06:53 +03:00
|
|
|
|
jwk_id = header.get('kid')
|
|
|
|
|
if jwk_id not in self.public_jwks:
|
|
|
|
|
raise self.server.error("Invalid key ID")
|
|
|
|
|
|
|
|
|
|
# validate signature
|
|
|
|
|
public_key = self._public_key_from_jwk(self.public_jwks[jwk_id])
|
|
|
|
|
public_key.verify(sig_bytes + message.encode())
|
|
|
|
|
|
|
|
|
|
# validate claims
|
|
|
|
|
payload: Dict[str, Any] = json.loads(base64url_decode(enc_payload))
|
|
|
|
|
if payload['token_type'] != token_type:
|
2021-04-15 22:48:07 +03:00
|
|
|
|
raise self.server.error(
|
|
|
|
|
f"JWT Token type mismatch: Expected {token_type}, "
|
2021-06-02 03:06:53 +03:00
|
|
|
|
f"Recd: {payload['token_type']}", 401)
|
|
|
|
|
if payload['iss'] != self.issuer:
|
|
|
|
|
raise self.server.error("Invalid JWT Issuer", 401)
|
|
|
|
|
if payload['aud'] != "Moonraker":
|
|
|
|
|
raise self.server.error("Invalid JWT Audience", 401)
|
|
|
|
|
if payload['exp'] < int(time.time()):
|
|
|
|
|
raise self.server.error("JWT Expired", 401)
|
|
|
|
|
|
|
|
|
|
# get user
|
|
|
|
|
user_info: Optional[Dict[str, Any]] = self.users.get(
|
|
|
|
|
payload.get('username', ""), None)
|
2021-04-15 22:48:07 +03:00
|
|
|
|
if user_info is None:
|
2021-06-02 03:06:53 +03:00
|
|
|
|
raise self.server.error("Unknown user", 401)
|
2021-04-15 22:48:07 +03:00
|
|
|
|
return user_info
|
|
|
|
|
|
2022-11-26 14:16:44 +03:00
|
|
|
|
def validate_jwt(self, token: str) -> Dict[str, Any]:
|
|
|
|
|
try:
|
|
|
|
|
user_info = self.decode_jwt(token)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
if isinstance(e, self.server.error):
|
|
|
|
|
raise
|
|
|
|
|
raise self.server.error(
|
|
|
|
|
f"Failed to decode JWT: {e}", 401
|
|
|
|
|
) from e
|
|
|
|
|
return user_info
|
|
|
|
|
|
2023-01-19 00:44:58 +03:00
|
|
|
|
def validate_api_key(self, api_key: str) -> Dict[str, Any]:
|
|
|
|
|
if not self.enable_api_key:
|
|
|
|
|
raise self.server.error("API Key authentication is disabled", 401)
|
|
|
|
|
if api_key and api_key == self.api_key:
|
|
|
|
|
return self.users[API_USER]
|
|
|
|
|
raise self.server.error("Invalid API Key", 401)
|
|
|
|
|
|
2021-06-02 03:06:53 +03:00
|
|
|
|
def _load_private_key(self, secret: str) -> Signer:
|
2021-05-23 16:08:49 +03:00
|
|
|
|
try:
|
2021-06-02 03:06:53 +03:00
|
|
|
|
key = Signer(bytes.fromhex(secret))
|
2021-05-23 16:08:49 +03:00
|
|
|
|
except Exception:
|
|
|
|
|
raise self.server.error(
|
|
|
|
|
"Error decoding private key, user data may"
|
|
|
|
|
" be corrupt", 500) from None
|
2021-06-02 03:06:53 +03:00
|
|
|
|
return key
|
|
|
|
|
|
|
|
|
|
def _generate_public_jwk(self, private_key: Signer) -> Dict[str, Any]:
|
|
|
|
|
public_key = private_key.vk
|
|
|
|
|
return {
|
|
|
|
|
'x': base64url_encode(public_key).decode(),
|
|
|
|
|
'kty': "OKP",
|
|
|
|
|
'crv': "Ed25519",
|
|
|
|
|
'use': "sig"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def _public_key_from_jwk(self, jwk: Dict[str, Any]) -> Verifier:
|
|
|
|
|
if jwk.get('kty') != "OKP":
|
|
|
|
|
raise self.server.error("Not an Octet Key Pair")
|
|
|
|
|
if jwk.get('crv') != "Ed25519":
|
|
|
|
|
raise self.server.error("Invalid Curve")
|
|
|
|
|
if 'x' not in jwk:
|
|
|
|
|
raise self.server.error("No 'x' argument in jwk")
|
|
|
|
|
key = base64url_decode(jwk['x'])
|
|
|
|
|
return Verifier(key.hex().encode())
|
2021-05-23 16:08:49 +03:00
|
|
|
|
|
2021-12-09 14:26:58 +03:00
|
|
|
|
def _prune_conn_handler(self, eventtime: float) -> float:
|
2021-04-15 22:48:07 +03:00
|
|
|
|
cur_time = time.time()
|
|
|
|
|
for ip, user_info in list(self.trusted_users.items()):
|
2021-05-13 17:48:57 +03:00
|
|
|
|
exp_time: float = user_info['expires_at']
|
2021-04-15 22:48:07 +03:00
|
|
|
|
if cur_time >= exp_time:
|
|
|
|
|
self.trusted_users.pop(ip, None)
|
|
|
|
|
logging.info(
|
|
|
|
|
f"Trusted Connection Expired, IP: {ip}")
|
2021-12-09 14:26:58 +03:00
|
|
|
|
return eventtime + PRUNE_CHECK_TIME
|
2021-04-15 22:48:07 +03:00
|
|
|
|
|
|
|
|
|
def _oneshot_token_expire_handler(self, token):
|
|
|
|
|
self.oneshot_tokens.pop(token, None)
|
|
|
|
|
|
2021-05-13 17:48:57 +03:00
|
|
|
|
def get_oneshot_token(self,
|
|
|
|
|
ip_addr: IPAddr,
|
|
|
|
|
user: Optional[Dict[str, Any]]
|
|
|
|
|
) -> str:
|
2021-04-15 22:48:07 +03:00
|
|
|
|
token = base64.b32encode(os.urandom(20)).decode()
|
2021-07-10 19:47:18 +03:00
|
|
|
|
event_loop = self.server.get_event_loop()
|
|
|
|
|
hdl = event_loop.delay_callback(
|
2021-04-15 22:48:07 +03:00
|
|
|
|
ONESHOT_TIMEOUT, self._oneshot_token_expire_handler, token)
|
|
|
|
|
self.oneshot_tokens[token] = (ip_addr, user, hdl)
|
|
|
|
|
return token
|
|
|
|
|
|
2021-05-13 17:48:57 +03:00
|
|
|
|
def _check_json_web_token(self,
|
|
|
|
|
request: HTTPServerRequest
|
|
|
|
|
) -> Optional[Dict[str, Any]]:
|
|
|
|
|
auth_token: Optional[str] = request.headers.get("Authorization")
|
2021-04-15 22:48:07 +03:00
|
|
|
|
if auth_token is None:
|
|
|
|
|
auth_token = request.headers.get("X-Access-Token")
|
2021-08-27 13:11:55 +03:00
|
|
|
|
if auth_token is None:
|
|
|
|
|
qtoken = request.query_arguments.get('access_token', None)
|
|
|
|
|
if qtoken is not None:
|
|
|
|
|
auth_token = qtoken[-1].decode()
|
2022-11-22 03:15:52 +03:00
|
|
|
|
elif auth_token.startswith("Bearer "):
|
|
|
|
|
auth_token = auth_token[7:]
|
2021-05-20 02:18:23 +03:00
|
|
|
|
else:
|
2022-11-22 03:15:52 +03:00
|
|
|
|
return None
|
2021-05-20 02:18:23 +03:00
|
|
|
|
if auth_token:
|
2021-04-15 22:48:07 +03:00
|
|
|
|
try:
|
2022-11-21 23:14:48 +03:00
|
|
|
|
return self.decode_jwt(auth_token)
|
2021-08-27 13:11:55 +03:00
|
|
|
|
except Exception:
|
|
|
|
|
logging.exception(f"JWT Decode Error {auth_token}")
|
2022-11-22 03:15:52 +03:00
|
|
|
|
raise HTTPError(401, "JWT Decode Error")
|
2021-04-15 22:48:07 +03:00
|
|
|
|
return None
|
2020-07-02 04:21:35 +03:00
|
|
|
|
|
2021-05-13 17:48:57 +03:00
|
|
|
|
def _check_authorized_ip(self, ip: IPAddr) -> bool:
|
2020-07-28 17:54:48 +03:00
|
|
|
|
if ip in self.trusted_ips:
|
|
|
|
|
return True
|
|
|
|
|
for rng in self.trusted_ranges:
|
|
|
|
|
if ip in rng:
|
|
|
|
|
return True
|
2021-04-30 02:16:57 +03:00
|
|
|
|
fqdn = socket.getfqdn(str(ip)).lower()
|
|
|
|
|
if fqdn in self.trusted_domains:
|
|
|
|
|
return True
|
2020-07-28 17:54:48 +03:00
|
|
|
|
return False
|
|
|
|
|
|
2021-05-13 17:48:57 +03:00
|
|
|
|
def _check_trusted_connection(self,
|
|
|
|
|
ip: Optional[IPAddr]
|
|
|
|
|
) -> Optional[Dict[str, Any]]:
|
2020-07-02 04:21:35 +03:00
|
|
|
|
if ip is not None:
|
2021-04-15 22:48:07 +03:00
|
|
|
|
curtime = time.time()
|
|
|
|
|
exp_time = curtime + TRUSTED_CONNECTION_TIMEOUT
|
|
|
|
|
if ip in self.trusted_users:
|
|
|
|
|
self.trusted_users[ip]['expires_at'] = exp_time
|
|
|
|
|
return self.trusted_users[ip]
|
2020-07-28 17:54:48 +03:00
|
|
|
|
elif self._check_authorized_ip(ip):
|
2020-07-02 04:21:35 +03:00
|
|
|
|
logging.info(
|
2020-08-11 19:59:47 +03:00
|
|
|
|
f"Trusted Connection Detected, IP: {ip}")
|
2021-04-15 22:48:07 +03:00
|
|
|
|
self.trusted_users[ip] = {
|
|
|
|
|
'username': TRUSTED_USER,
|
|
|
|
|
'password': None,
|
|
|
|
|
'created_on': curtime,
|
|
|
|
|
'expires_at': exp_time
|
|
|
|
|
}
|
|
|
|
|
return self.trusted_users[ip]
|
|
|
|
|
return None
|
2020-07-02 04:21:35 +03:00
|
|
|
|
|
2021-05-13 17:48:57 +03:00
|
|
|
|
def _check_oneshot_token(self,
|
|
|
|
|
token: str,
|
2022-06-18 00:07:58 +03:00
|
|
|
|
cur_ip: Optional[IPAddr]
|
2021-05-13 17:48:57 +03:00
|
|
|
|
) -> Optional[Dict[str, Any]]:
|
2021-04-15 22:48:07 +03:00
|
|
|
|
if token in self.oneshot_tokens:
|
|
|
|
|
ip_addr, user, hdl = self.oneshot_tokens.pop(token)
|
2021-07-10 19:47:18 +03:00
|
|
|
|
hdl.cancel()
|
2021-04-15 22:48:07 +03:00
|
|
|
|
if cur_ip != ip_addr:
|
|
|
|
|
logging.info(f"Oneshot Token IP Mismatch: expected{ip_addr}"
|
|
|
|
|
f", Recd: {cur_ip}")
|
|
|
|
|
return None
|
|
|
|
|
return user
|
2020-07-02 04:21:35 +03:00
|
|
|
|
else:
|
2021-04-15 22:48:07 +03:00
|
|
|
|
return None
|
2020-07-02 04:21:35 +03:00
|
|
|
|
|
2023-01-24 15:08:23 +03:00
|
|
|
|
def check_logins_maxed(self, ip_addr: IPAddr) -> bool:
|
2022-11-26 14:16:44 +03:00
|
|
|
|
if self.max_logins is None:
|
|
|
|
|
return False
|
|
|
|
|
return self.failed_logins.get(ip_addr, 0) >= self.max_logins
|
|
|
|
|
|
2021-05-13 17:48:57 +03:00
|
|
|
|
def check_authorized(self,
|
|
|
|
|
request: HTTPServerRequest
|
|
|
|
|
) -> Optional[Dict[str, Any]]:
|
2022-06-10 14:56:27 +03:00
|
|
|
|
if (
|
|
|
|
|
request.path in self.permitted_paths
|
|
|
|
|
or request.method == "OPTIONS"
|
|
|
|
|
):
|
2021-04-15 22:48:07 +03:00
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# Check JSON Web Token
|
|
|
|
|
jwt_user = self._check_json_web_token(request)
|
|
|
|
|
if jwt_user is not None:
|
|
|
|
|
return jwt_user
|
|
|
|
|
|
2020-07-28 17:54:48 +03:00
|
|
|
|
try:
|
2022-06-18 00:07:58 +03:00
|
|
|
|
ip = ipaddress.ip_address(request.remote_ip) # type: ignore
|
2020-07-28 17:54:48 +03:00
|
|
|
|
except ValueError:
|
|
|
|
|
logging.exception(
|
2020-08-11 19:59:47 +03:00
|
|
|
|
f"Unable to Create IP Address {request.remote_ip}")
|
2020-07-28 17:54:48 +03:00
|
|
|
|
ip = None
|
2021-04-15 22:48:07 +03:00
|
|
|
|
|
|
|
|
|
# Check oneshot access token
|
2021-05-13 17:48:57 +03:00
|
|
|
|
ost: Optional[List[bytes]] = request.arguments.get('token', None)
|
2021-04-15 22:48:07 +03:00
|
|
|
|
if ost is not None:
|
|
|
|
|
ost_user = self._check_oneshot_token(ost[-1].decode(), ip)
|
|
|
|
|
if ost_user is not None:
|
|
|
|
|
return ost_user
|
2020-07-02 04:21:35 +03:00
|
|
|
|
|
|
|
|
|
# Check API Key Header
|
2022-11-21 20:31:25 +03:00
|
|
|
|
if self.enable_api_key:
|
|
|
|
|
key: Optional[str] = request.headers.get("X-Api-Key")
|
|
|
|
|
if key and key == self.api_key:
|
|
|
|
|
return self.users[API_USER]
|
2020-07-02 04:21:35 +03:00
|
|
|
|
|
2021-05-20 02:05:48 +03:00
|
|
|
|
# If the force_logins option is enabled and at least one
|
|
|
|
|
# user is created this is an unauthorized request
|
|
|
|
|
if self.force_logins and len(self.users) > 1:
|
2022-11-26 14:16:44 +03:00
|
|
|
|
raise HTTPError(401, "Unauthorized, Force Logins Enabled")
|
2021-05-20 02:05:48 +03:00
|
|
|
|
|
2021-04-15 22:48:07 +03:00
|
|
|
|
# Check if IP is trusted
|
|
|
|
|
trusted_user = self._check_trusted_connection(ip)
|
|
|
|
|
if trusted_user is not None:
|
|
|
|
|
return trusted_user
|
|
|
|
|
|
|
|
|
|
raise HTTPError(401, "Unauthorized")
|
2020-07-02 04:21:35 +03:00
|
|
|
|
|
2021-05-13 17:48:57 +03:00
|
|
|
|
def check_cors(self,
|
|
|
|
|
origin: Optional[str],
|
|
|
|
|
req_hdlr: Optional[RequestHandler] = None
|
|
|
|
|
) -> bool:
|
2021-03-11 02:17:32 +03:00
|
|
|
|
if origin is None or not self.cors_domains:
|
2020-11-12 04:44:27 +03:00
|
|
|
|
return False
|
2020-11-16 01:13:21 +03:00
|
|
|
|
for regex in self.cors_domains:
|
|
|
|
|
match = re.match(regex, origin)
|
2020-11-17 14:05:24 +03:00
|
|
|
|
if match is not None:
|
|
|
|
|
if match.group() == origin:
|
|
|
|
|
logging.debug(f"CORS Pattern Matched, origin: {origin} "
|
|
|
|
|
f" | pattern: {regex}")
|
2021-05-13 17:48:57 +03:00
|
|
|
|
self._set_cors_headers(origin, req_hdlr)
|
2020-11-17 14:05:24 +03:00
|
|
|
|
return True
|
|
|
|
|
else:
|
|
|
|
|
logging.debug(f"Partial Cors Match: {match.group()}")
|
2020-11-16 01:13:21 +03:00
|
|
|
|
else:
|
2021-03-11 02:17:32 +03:00
|
|
|
|
# Check to see if the origin contains an IP that matches a
|
|
|
|
|
# current trusted connection
|
2021-03-11 04:34:01 +03:00
|
|
|
|
match = re.search(r"^https?://([^/:]+)", origin)
|
2021-03-11 02:17:32 +03:00
|
|
|
|
if match is not None:
|
|
|
|
|
ip = match.group(1)
|
|
|
|
|
try:
|
|
|
|
|
ipaddr = ipaddress.ip_address(ip)
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass
|
|
|
|
|
else:
|
|
|
|
|
if self._check_authorized_ip(ipaddr):
|
|
|
|
|
logging.debug(
|
|
|
|
|
f"Cors request matched trusted IP: {ip}")
|
2021-05-13 17:48:57 +03:00
|
|
|
|
self._set_cors_headers(origin, req_hdlr)
|
2021-03-11 02:17:32 +03:00
|
|
|
|
return True
|
2020-11-17 14:05:24 +03:00
|
|
|
|
logging.debug(f"No CORS match for origin: {origin}\n"
|
|
|
|
|
f"Patterns: {self.cors_domains}")
|
2020-11-16 01:13:21 +03:00
|
|
|
|
return False
|
2020-11-12 04:44:27 +03:00
|
|
|
|
|
2021-05-13 17:48:57 +03:00
|
|
|
|
def _set_cors_headers(self,
|
|
|
|
|
origin: str,
|
|
|
|
|
req_hdlr: Optional[RequestHandler]
|
|
|
|
|
) -> None:
|
|
|
|
|
if req_hdlr is None:
|
2020-11-12 04:44:27 +03:00
|
|
|
|
return
|
2021-05-13 17:48:57 +03:00
|
|
|
|
req_hdlr.set_header("Access-Control-Allow-Origin", origin)
|
2021-05-24 03:36:26 +03:00
|
|
|
|
if req_hdlr.request.method == "OPTIONS":
|
|
|
|
|
req_hdlr.set_header(
|
|
|
|
|
"Access-Control-Allow-Methods",
|
|
|
|
|
"GET, POST, PUT, DELETE, OPTIONS")
|
|
|
|
|
req_hdlr.set_header(
|
|
|
|
|
"Access-Control-Allow-Headers",
|
|
|
|
|
"Origin, Accept, Content-Type, X-Requested-With, "
|
|
|
|
|
"X-CRSF-Token, Authorization, X-Access-Token, "
|
|
|
|
|
"X-Api-Key")
|
2022-07-19 21:25:18 +03:00
|
|
|
|
if req_hdlr.request.headers.get(
|
|
|
|
|
"Access-Control-Request-Private-Network", None) == "true":
|
|
|
|
|
req_hdlr.set_header(
|
|
|
|
|
"Access-Control-Allow-Private-Network",
|
|
|
|
|
"true")
|
2020-11-12 04:44:27 +03:00
|
|
|
|
|
2022-03-30 21:35:39 +03:00
|
|
|
|
def cors_enabled(self) -> bool:
|
|
|
|
|
return self.cors_domains is not None
|
|
|
|
|
|
2021-05-13 17:48:57 +03:00
|
|
|
|
def close(self) -> None:
|
2021-12-09 14:26:58 +03:00
|
|
|
|
self.prune_timer.stop()
|
2020-07-02 04:21:35 +03:00
|
|
|
|
|
2020-11-14 23:53:42 +03:00
|
|
|
|
|
2021-05-13 17:48:57 +03:00
|
|
|
|
def load_component(config: ConfigHelper) -> Authorization:
|
2021-04-09 15:45:40 +03:00
|
|
|
|
return Authorization(config)
|