From 7eba8e58e390642c4ced930e5d0df86be96db74b Mon Sep 17 00:00:00 2001 From: Arksine <9563098+Arksine@users.noreply.github.com> Date: Thu, 15 Apr 2021 15:48:07 -0400 Subject: [PATCH] authorization: add support for JWT User Authorizaton Signed-off-by: Eric Callahan --- moonraker/app.py | 14 +- moonraker/components/authorization.py | 381 ++++++++++++++++++++++---- moonraker/websockets.py | 23 +- 3 files changed, 352 insertions(+), 66 deletions(-) diff --git a/moonraker/app.py b/moonraker/app.py index 5226603..66e4733 100644 --- a/moonraker/app.py +++ b/moonraker/app.py @@ -274,8 +274,8 @@ class AuthorizedRequestHandler(tornado.web.RequestHandler): def prepare(self): auth = self.server.lookup_component('authorization', None) - if auth is not None and not auth.check_authorized(self.request): - raise tornado.web.HTTPError(401, "Unauthorized") + if auth is not None: + self.current_user = auth.check_authorized(self.request) def options(self, *args, **kwargs): # Enable CORS if configured @@ -326,8 +326,8 @@ class AuthorizedFileHandler(tornado.web.StaticFileHandler): def prepare(self): auth = self.server.lookup_component('authorization', None) - if auth is not None and not auth.check_authorized(self.request): - raise tornado.web.HTTPError(401, "Unauthorized") + if auth is not None: + self.current_user = auth.check_authorized(self.request) def options(self, *args, **kwargs): # Enable CORS if configured @@ -430,12 +430,14 @@ class DynamicRequestHandler(AuthorizedRequestHandler): async def _do_local_request(self, args, conn): return await self.callback( WebRequest(self.request.path, args, self.request.method, - conn=conn, ip_addr=self.request.remote_ip)) + conn=conn, ip_addr=self.request.remote_ip, + user=self.current_user)) async def _do_remote_request(self, args, conn): return await self.server.make_request( WebRequest(self.callback, args, conn=conn, - ip_addr=self.request.remote_ip)) + ip_addr=self.request.remote_ip, + user=self.current_user)) async def _process_http_request(self): if self.request.method not in self.methods: diff --git a/moonraker/components/authorization.py b/moonraker/components/authorization.py index 914e983..c83b5fc 100644 --- a/moonraker/components/authorization.py +++ b/moonraker/components/authorization.py @@ -5,30 +5,64 @@ # This file may be distributed under the terms of the GNU GPLv3 license import base64 import uuid +import hashlib +import hmac +import secrets import os import time +import datetime import ipaddress +import json import re import logging from tornado.ioloop import IOLoop, PeriodicCallback +from tornado.web import HTTPError from utils import ServerError -TOKEN_TIMEOUT = 5 -CONNECTION_TIMEOUT = 3600 +ONESHOT_TIMEOUT = 5 +TRUSTED_CONNECTION_TIMEOUT = 3600 PRUNE_CHECK_TIME = 300 * 1000 +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 = { + 'alg': "HS256", + 'typ': "JWT" +} + +# Helpers for base64url encoding and decoding +def base64url_encode(data): + return base64.urlsafe_b64encode(data).rstrip(b"=") + +def base64url_decode(data): + pad_cnt = len(data) % 4 + if pad_cnt: + data += b"=" * (4 - pad_cnt) + return base64.urlsafe_b64decode(data) + class Authorization: def __init__(self, config): self.server = config.get_server() + self.login_timeout = config.getint('login_timeout', 90) database = self.server.lookup_component('database') - database.register_local_namespace('authorization', forbidden=True) - self.auth_db = database.wrap_namespace('authorization') - self.api_key = self.auth_db.get('api_key', None) - if self.api_key is None: + database.register_local_namespace('authorized_users', forbidden=True) + self.users = database.wrap_namespace('authorized_users') + api_user = self.users.get(API_USER, None) + if api_user is None: self.api_key = uuid.uuid4().hex - self.auth_db['api_key'] = self.api_key - self.trusted_connections = {} - self.access_tokens = {} + self.users[API_USER] = { + 'username': API_USER, + 'api_key': self.api_key, + 'created_on': time.time() + } + else: + self.api_key = api_user['api_key'] + self.trusted_users = {} + self.oneshot_tokens = {} + self.permitted_paths = set() # Get allowed cors domains self.cors_domains = [] @@ -81,6 +115,19 @@ class Authorization: self.prune_handler.start() # Register Authorization Endpoints + self.permitted_paths.add("/access/login") + self.permitted_paths.add("/access/refresh_jwt") + self.server.register_endpoint( + "/access/login", ['POST'], self._handle_login) + self.server.register_endpoint( + "/access/logout", ['POST'], self._handle_logout) + self.server.register_endpoint( + "/access/refresh_jwt", ['POST'], self._handle_refresh_jwt) + self.server.register_endpoint( + "/access/user", ['GET', 'POST', 'DELETE'], + self._handle_user_request) + self.server.register_endpoint( + "/access/user/password", ['POST'], self._handle_password_reset) self.server.register_endpoint( "/access/api_key", ['GET', 'POST'], self._handle_apikey_request, protocol=['http']) @@ -92,11 +139,236 @@ class Authorization: action = web_request.get_action() if action.upper() == 'POST': self.api_key = uuid.uuid4().hex - self.auth_db['api_key'] = self.api_key + self.users[f'{API_USER}.api_key'] = self.api_key return self.api_key async def _handle_token_request(self, web_request): - return self.get_access_token() + ip = web_request.get_ip_address() + user_info = web_request.get_current_user() + return self.get_oneshot_token(ip, user_info) + + async def _handle_login(self, web_request): + return self._login_jwt_user(web_request) + + async def _handle_logout(self, web_request): + user_info = web_request.get_current_user() + if user_info is None: + raise self.server.error("No user logged in") + username = user_info['username'] + if username in RESERVED_USERS: + raise self.server.error( + f"Invalid log out request for user {username}") + self.users.pop(f"{username}.jwt_secret", None) + return { + "username": username, + "action": "user_logged_out" + } + + async def _handle_refresh_jwt(self, web_request): + refresh_token = web_request.get_str('refresh_token') + user_info = self._decode_jwt(refresh_token, token_type="refresh") + username = user_info['username'] + secret = bytes.fromhex(user_info['jwt_secret']) + token = self._generate_jwt(username, secret) + return { + 'username': username, + 'token': token, + 'action': 'user_jwt_refresh' + } + + async def _handle_user_request(self, web_request): + action = web_request.get_action() + if action == "GET": + user = web_request.get_current_user() + if user is None: + return { + 'username': None, + 'created_on': None, + } + else: + return { + 'username': user['username'], + 'created_on': user.get('created_on') + } + elif action == "POST": + # Create User + return self._login_jwt_user(web_request, create=True) + elif action == "DELETE": + # Delete User + return self._delete_jwt_user(web_request) + + async def _handle_password_reset(self, web_request): + password = web_request.get_str('password') + new_pass = web_request.get_str('new_password') + user_info = web_request.get_current_user() + if user_info is None: + raise self.server.error("No Current User") + username = user_info['username'] + 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() + self.users[f'{username}.password'] = new_hashed_pass + return { + 'username': username, + 'action': "user_password_reset" + } + + def _login_jwt_user(self, web_request, create=False): + username = web_request.get_str('username') + password = web_request.get_str('password') + if username in RESERVED_USERS: + raise self.server.error( + f"Invalid Request for user {username}") + 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(), + 'created_on': time.time() + } + self.users[username] = user_info + action = "user_created" + else: + if username not in self.users: + raise self.server.error(f"Unregistered User: {username}") + user_info = self.users[username] + salt = bytes.fromhex(user_info['salt']) + hashed_pass = hashlib.pbkdf2_hmac( + 'sha256', password.encode(), salt, HASH_ITER).hex() + action = "user_logged_in" + if hashed_pass != user_info['password']: + raise self.server.error("Invalid Password") + jwt_secret = user_info.get('jwt_secret', None) + if jwt_secret is None: + jwt_secret = secrets.token_bytes(32) + user_info['jwt_secret'] = jwt_secret.hex() + self.users[username] = user_info + else: + jwt_secret = bytes.fromhex(jwt_secret) + token = self._generate_jwt(username, jwt_secret) + refresh_token = self._generate_jwt( + username, jwt_secret, token_type="refresh", + exp_time=datetime.timedelta(days=self.login_timeout)) + return { + 'username': username, + 'token': token, + 'refresh_token': refresh_token, + 'action': action + } + + def _delete_jwt_user(self, web_request): + password = web_request.get_str('password') + user_info = web_request.get_current_user() + if user_info is None: + raise self.server.error("No Current User") + username = user_info['username'] + if username in RESERVED_USERS: + raise self.server.error( + f"Invalid 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") + del self.users[username] + return { + "username": username, + "action": "user_deleted" + } + + def _generate_jwt(self, username, secret, token_type="auth", + exp_time=JWT_EXP_TIME): + curtime = time.time() + payload = { + 'iss': "Moonraker", + 'iat': curtime, + 'exp': curtime + exp_time.total_seconds(), + 'username': username, + 'token_type': token_type + } + enc_header = base64url_encode(json.dumps(JWT_HEADER).encode()) + enc_payload = base64url_encode(json.dumps(payload).encode()) + message = enc_header + b"." + enc_payload + signature = base64url_encode(hmac.digest(secret, message, "sha256")) + message += b"." + signature + return message.decode() + + def _decode_jwt(self, jwt, token_type="auth"): + parts = jwt.encode().split(b".") + if len(parts) != 3: + raise self.server.error(f"Invalid JWT length of {len(parts)}") + header = json.loads(base64url_decode(parts[0])) + payload = json.loads(base64url_decode(parts[1])) + if header != JWT_HEADER: + raise self.server.error("Invalid JWT header") + recd_type = payload.get('token_type', "") + if token_type != recd_type: + raise self.server.error( + f"JWT Token type mismatch: Expected {token_type}, " + f"Recd: {recd_type}", 401) + if time.time() > payload['exp']: + raise self.server.error("JWT expired", 401) + username = payload.get('username') + user_info = self.users.get(username, None) + if user_info is None: + raise self.server.error( + f"Invalid JWT, no registered user {username}", 401) + jwt_secret = user_info.get('jwt_secret', None) + if jwt_secret is None: + raise self.server.error( + f"Invalid JWT, user {username} not logged in", 401) + secret = bytes.fromhex(jwt_secret) + # Decode and verify signature + signature = base64url_decode(parts[2]) + calc_sig = hmac.digest( + secret, parts[0] + b"." + parts[1], "sha256") + if signature != calc_sig: + raise self.server.error("Invalid JWT signature") + return user_info + + def _prune_conn_handler(self): + cur_time = time.time() + for ip, user_info in list(self.trusted_users.items()): + exp_time = user_info['expires_at'] + if cur_time >= exp_time: + self.trusted_users.pop(ip, None) + logging.info( + f"Trusted Connection Expired, IP: {ip}") + + def _oneshot_token_expire_handler(self, token): + self.oneshot_tokens.pop(token, None) + + def get_oneshot_token(self, ip_addr, user): + token = base64.b32encode(os.urandom(20)).decode() + ioloop = IOLoop.current() + hdl = ioloop.call_later( + ONESHOT_TIMEOUT, self._oneshot_token_expire_handler, token) + self.oneshot_tokens[token] = (ip_addr, user, hdl) + return token + + def _check_json_web_token(self, request): + auth_token = request.headers.get("Authorization") + if auth_token is None: + auth_token = request.headers.get("X-Access-Token") + if auth_token and auth_token.startswith("Bearer "): + auth_token = auth_token[7:] + try: + return self._decode_jwt(auth_token) + except Exception as e: + raise HTTPError(401, str(e)) + return None def _check_authorized_ip(self, ip): if ip in self.trusted_ips: @@ -106,68 +378,71 @@ class Authorization: return True return False - def _prune_conn_handler(self): - cur_time = time.time() - expired_conns = [] - for ip, access_time in self.trusted_connections.items(): - if cur_time - access_time > CONNECTION_TIMEOUT: - expired_conns.append(ip) - for ip in expired_conns: - self.trusted_connections.pop(ip, None) - logging.info( - f"Trusted Connection Expired, IP: {ip}") - - def _token_expire_handler(self, token): - self.access_tokens.pop(token, None) - - def get_access_token(self): - token = base64.b32encode(os.urandom(20)).decode() - ioloop = IOLoop.current() - self.access_tokens[token] = ioloop.call_later( - TOKEN_TIMEOUT, self._token_expire_handler, token) - return token - def _check_trusted_connection(self, ip): if ip is not None: - if ip in self.trusted_connections: - self.trusted_connections[ip] = time.time() - return True + 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] elif self._check_authorized_ip(ip): logging.info( f"Trusted Connection Detected, IP: {ip}") - self.trusted_connections[ip] = time.time() - return True - return False + self.trusted_users[ip] = { + 'username': TRUSTED_USER, + 'password': None, + 'created_on': curtime, + 'expires_at': exp_time + } + return self.trusted_users[ip] + return None - def _check_access_token(self, token): - if token in self.access_tokens: - token_handler = self.access_tokens.pop(token, None) - IOLoop.current().remove_timeout(token_handler) - return True + def _check_oneshot_token(self, token, cur_ip): + if token in self.oneshot_tokens: + ip_addr, user, hdl = self.oneshot_tokens.pop(token) + IOLoop.current().remove_timeout(hdl) + if cur_ip != ip_addr: + logging.info(f"Oneshot Token IP Mismatch: expected{ip_addr}" + f", Recd: {cur_ip}") + return None + return user else: - return False + return None def check_authorized(self, request): - # Check if IP is trusted + if request.path in self.permitted_paths: + return None + + # Check JSON Web Token + jwt_user = self._check_json_web_token(request) + if jwt_user is not None: + return jwt_user + try: ip = ipaddress.ip_address(request.remote_ip) except ValueError: logging.exception( f"Unable to Create IP Address {request.remote_ip}") ip = None - if self._check_trusted_connection(ip): - return True + + # Check oneshot access token + ost = request.arguments.get('token', None) + if ost is not None: + ost_user = self._check_oneshot_token(ost[-1].decode(), ip) + if ost_user is not None: + return ost_user # Check API Key Header key = request.headers.get("X-Api-Key") if key and key == self.api_key: - return True + return self.users[API_USER] - # Check one-shot access token - token = request.arguments.get('token', [b""])[0].decode() - if self._check_access_token(token): - return True - return False + # 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") def check_cors(self, origin, request=None): if origin is None or not self.cors_domains: diff --git a/moonraker/websockets.py b/moonraker/websockets.py index 49e1c55..0c67fb3 100644 --- a/moonraker/websockets.py +++ b/moonraker/websockets.py @@ -5,6 +5,7 @@ # This file may be distributed under the terms of the GNU GPLv3 license import logging +import ipaddress import tornado import json from tornado.ioloop import IOLoop @@ -16,12 +17,16 @@ class Sentinel: class WebRequest: def __init__(self, endpoint, args, action="", - conn=None, ip_addr=""): + conn=None, ip_addr="", user=None): self.endpoint = endpoint self.action = action self.args = args self.conn = conn - self.ip_addr = ip_addr + try: + self.ip_addr = ipaddress.ip_address(ip_addr) + except Exception: + self.ip_addr = None + self.current_user = user def get_endpoint(self): return self.endpoint @@ -38,6 +43,9 @@ class WebRequest: def get_ip_address(self): return self.ip_addr + def get_current_user(self): + return self.current_user + def _get_converted_arg(self, key, default=Sentinel, dtype=str): if key not in self.args: if default == Sentinel: @@ -207,15 +215,16 @@ class WebsocketManager: def _generate_callback(self, endpoint): async def func(ws, **kwargs): result = await self.server.make_request( - WebRequest(endpoint, kwargs, conn=ws, ip_addr=ws.ip_addr)) + WebRequest(endpoint, kwargs, conn=ws, ip_addr=ws.ip_addr, + user=ws.current_user)) return result return func def _generate_local_callback(self, endpoint, request_method, callback): async def func(ws, **kwargs): result = await callback( - WebRequest(endpoint, kwargs, request_method, - ws, ip_addr=ws.ip_addr)) + WebRequest(endpoint, kwargs, request_method, ws, + ip_addr=ws.ip_addr, user=ws.current_user)) return result return func @@ -319,5 +328,5 @@ class WebSocket(WebSocketHandler): # Check Authorized User def prepare(self): auth = self.server.lookup_component('authorization', None) - if auth is not None and not auth.check_authorized(self.request): - raise tornado.web.HTTPError(401, "Unauthorized") + if auth is not None: + self.current_user = auth.check_authorized(self.request)