authorization: add support for JWT User Authorizaton

Signed-off-by:  Eric Callahan <arksine.code@gmail.com>
This commit is contained in:
Arksine 2021-04-15 15:48:07 -04:00 committed by Arksine
parent 43a8d25619
commit 7eba8e58e3
3 changed files with 352 additions and 66 deletions

View File

@ -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:

View File

@ -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:

View File

@ -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)