Files
crafty-4/app/classes/web/base_handler.py

364 lines
13 KiB
Python

import logging
import re
import typing as t
import orjson
import nh3
import tornado.web
from app.classes.models.crafty_permissions import EnumPermissionsCrafty
from app.classes.models.server_permissions import EnumPermissionsServer
from app.classes.models.users import ApiKeys
from app.classes.helpers.helpers import Helpers
from app.classes.helpers.file_helpers import FileHelpers
from app.classes.shared.main_controller import Controller
from app.classes.shared.translation import Translation
from app.classes.shared.main_models import DatabaseShortcuts
from app.classes.models.users import DoesNotExist
logger = logging.getLogger(__name__)
auth_log = logging.getLogger("auth")
bearer_pattern = re.compile(r"^Bearer ", flags=re.IGNORECASE)
class BaseHandler(tornado.web.RequestHandler):
nobleach = {bool, type(None)}
redactables = ("pass", "api")
helper: Helpers
controller: Controller
translator: Translation
file_helper: FileHelpers
# noinspection PyAttributeOutsideInit
def initialize(
self,
helper=None,
controller=None,
tasks_manager=None,
translator=None,
file_helper=None,
):
self.helper = helper
self.controller = controller
self.tasks_manager = tasks_manager
self.translator = translator
self.file_helper = file_helper
def set_default_headers(self) -> None:
"""
Fix CORS
"""
self.set_header("Access-Control-Allow-Origin", "*")
self.set_header(
"Access-Control-Allow-Headers",
"Content-Type, x-requested-with, Authorization",
)
self.set_header(
"Access-Control-Allow-Methods", "POST, GET, PUT, DELETE, OPTIONS"
)
def options(self, *_, **__):
"""
Fix CORS
"""
# no body
self.set_status(204)
self.finish()
def get_remote_ip(self):
remote_ip = (
self.request.headers.get("X-Real-IP")
or self.request.headers.get("X-Forwarded-For")
or self.request.remote_ip
)
return remote_ip
current_user: t.Tuple[t.Optional[ApiKeys], t.Dict[str, t.Any], t.Dict[str, t.Any]]
"""
A variable that contains the current user's data. Please see
Please only use this with routes using the `@tornado.web.authenticated` decorator.
"""
def get_current_user(
self,
) -> t.Optional[
t.Tuple[t.Optional[ApiKeys], t.Dict[str, t.Any], t.Dict[str, t.Any]]
]:
"""
Get the token's API key, the token's payload and user data.
Returns:
t.Optional[ApiKeys]: The API key of the token.
t.Dict[str, t.Any]: The token's payload.
t.Dict[str, t.Any]: The user's data from the database.
"""
try:
return self.controller.authentication.check(self.get_cookie("token"))
except DoesNotExist:
return None
def autobleach(self, name, text):
for r in self.redactables:
if r in name:
logger.debug(f"Auto-bleaching {name}: [**REDACTED**]")
break
logger.debug(f"Auto-bleaching {name}: {text}")
if type(text) in self.nobleach:
logger.debug("Auto-bleaching - bypass type")
return text
return nh3.clean(text) # pylint: disable=no-member
def get_argument(
self,
name: str,
default: t.Union[
None, str, tornado.web._ArgDefaultMarker
] = tornado.web._ARG_DEFAULT,
strip: bool = True,
) -> t.Optional[str]:
arg = self._get_argument(name, default, self.request.arguments, strip)
bleached = self.autobleach(name, arg)
if "&" in str(bleached):
bleached = bleached.replace("&", "&")
return bleached
def get_arguments(self, name: str, strip: bool = True) -> t.List[str]:
if not isinstance(strip, bool):
raise AssertionError
args = self._get_arguments(name, self.request.arguments, strip)
args_ret = []
for arg in args:
args_ret += self.autobleach(name, arg)
return args_ret
def access_denied(self, user: t.Optional[str], reason: t.Optional[str]):
ip = self.get_remote_ip()
route = self.request.path
if user is not None:
user_data = f"User {user} from IP {ip}"
else:
user_data = f"An unknown user from IP {ip}"
if reason:
ending = f"to the API route {route} because {reason}"
else:
ending = f"to the API route {route}"
logger.info(f"{user_data} was denied access {ending}")
self.finish_json(
403,
{
"status": "error",
"error": "ACCESS_DENIED",
"info": "You were denied access to the requested resource",
},
)
def _auth_get_api_token(self) -> t.Optional[str]:
"""Get an API token from the request
The API token is searched in the following order:
1. The `token` query parameter
2. The `Authorization` header
3. The `token` cookie
Returns:
t.Optional[str]: The API token or None if no token was found.
"""
logger.debug("Searching for specified token")
api_token = self.get_query_argument("token", None)
if api_token is None and self.request.headers.get("Authorization"):
api_token = bearer_pattern.sub(
"", self.request.headers.get("Authorization")
)
elif api_token is None:
api_token = self.get_cookie("token")
return api_token
def is_totp_method(self):
if (
re.match(
r"^/api/v2/(users/.+/totp/|users/[^/]+/totp/.+/verify/)$",
self.request.path,
)
and self.request.method == "POST"
) or (
re.match(r"^/api/v2/users/[^/]+/totp/recovery/renew/$", self.request.path)
and self.request.method == "GET"
):
return True
return False
def is_mfa_not_present(self, user, token_data) -> bool:
"""Checks to see if panel settings or role settings require user
to have MFA enabled and passed in token. Checks token to see
if user has signed in with MFA.
Args:
user (dict): dictionary of user object
token_data (dict): decoded token data
Returns:
bool: Returns False if user has signed in with MFA or if they have not and
it is not required. Returns True if user is required to have MFA and
has not signed in with it.
"""
su_mfa = self.helper.get_setting(
"superMFA"
) # Get super user forced MFA setting
role_mfa = False
for role in self.controller.users.get_user_roles_id(user["user_id"]):
if self.controller.roles.get_role(role)["mfa_required"]:
role_mfa = True
break
return ((user["superuser"] and su_mfa) or role_mfa) and not token_data.get(
"mfa"
)
def authenticate_user(
self,
) -> t.Optional[
t.Tuple[
t.List,
t.List[EnumPermissionsCrafty],
t.List[str],
bool,
t.Dict[str, t.Any],
str,
]
]:
try:
api_key, token_data, user = self.controller.authentication.check_err(
self._auth_get_api_token()
)
if not user["enabled"]:
# Log the user out and send error if disabled
self.clear_cookie("token")
return self.finish_json(
403,
{
"status": "error",
"error": "ACCESS_DENIED",
"error_data": self.helper.translation.translate(
"login", "accountDisabled", user["lang"]
),
},
)
if token_data.get("token_id") and token_data.get(
"session_id"
): # Check to see if token defines session_id and token_id
return self.finish_json(
403,
{
"status": "error",
"error": "ACCESS_DENIED",
"error_data": self.helper.translation.translate(
"error", "duplicateId", user["lang"]
),
},
)
if self.is_mfa_not_present(user, token_data) and (
not self.is_totp_method()
and not token_data.get("token_id")
and user["username"] != "anti-lockout-user"
):
# check to see if user is superuser
# and MFA is not in token.
# Also check to see if user is trying to add MFA or access backup codes.
# Check for token ID because only API keys will have this.
warning = self.helper.translation.translate(
"otp", "mfaWarn", user["lang"]
)
goto = self.helper.translation.translate(
"otp", "goToPage", user["lang"]
)
url = f"/panel/edit_user_otp?id={user['user_id']}"
return self.finish_json(
403,
{
"status": "error",
"error": "ACCESS_DENIED",
"error_data": (f"{warning} <br><a href='{url}'>{goto}</a>"),
},
)
superuser = user["superuser"]
server_permissions_api_mask = ""
if api_key is not None:
superuser = superuser and api_key.full_access
server_permissions_api_mask = api_key.server_permissions
if api_key.full_access:
server_permissions_api_mask = "1" * len(EnumPermissionsServer)
exec_user_role = set()
if superuser:
authorized_servers = self.controller.servers.get_all_defined_servers()
exec_user_role.add("Super User")
exec_user_crafty_permissions = (
self.controller.crafty_perms.list_defined_crafty_permissions()
)
else:
if api_key is not None:
exec_user_crafty_permissions = (
self.controller.crafty_perms.get_api_key_permissions_list(
api_key
)
)
else:
exec_user_crafty_permissions = (
self.controller.crafty_perms.get_crafty_permissions_list(
user["user_id"]
)
)
logger.debug(user["roles"])
for r in user["roles"]:
role = self.controller.roles.get_role(r)
exec_user_role.add(role["role_name"])
authorized_servers = self.controller.servers.get_authorized_servers(
user["user_id"] # TODO: API key authorized servers?
)
authorized_servers = [
DatabaseShortcuts.get_data_obj(x.server_object)
for x in authorized_servers
]
logger.debug("Checking results")
if user:
return (
authorized_servers,
exec_user_crafty_permissions,
exec_user_role,
superuser,
user,
server_permissions_api_mask,
)
logging.debug("Auth unsuccessful")
auth_log.error(
f"Authentication attempted from {self.get_remote_ip()}. Invalid token"
)
self.access_denied(None, "the user provided an invalid token")
return None
except Exception as auth_exception:
auth_log.error(
f"Authentication attempted from {self.get_remote_ip()}."
f" Error: {auth_exception}"
)
logger.debug(
"An error occured while authenticating an API user:",
exc_info=auth_exception,
)
self.finish_json(
403,
{
"status": "error",
"error": "ACCESS_DENIED",
"error_data": "An error occured while authenticating the user",
},
)
return None
def finish_json(self, status: int, data: t.Dict[str, t.Any]):
self.set_status(status)
self.set_header("Content-Type", "application/json")
self.finish(orjson.dumps(data))