diff --git a/monkey/monkey_island/cc/app.py b/monkey/monkey_island/cc/app.py index 79edccffa..6647d4b10 100644 --- a/monkey/monkey_island/cc/app.py +++ b/monkey/monkey_island/cc/app.py @@ -10,7 +10,8 @@ from monkey_island.cc.consts import MONKEY_ISLAND_ABS_PATH from monkey_island.cc.database import database, mongo from monkey_island.cc.resources.attack.attack_config import AttackConfiguration from monkey_island.cc.resources.attack.attack_report import AttackReport -from monkey_island.cc.resources.auth.auth import init_jwt +from monkey_island.cc.resources.auth.auth import Authenticate, init_jwt +from monkey_island.cc.resources.auth.registration import Registration from monkey_island.cc.resources.bootloader import Bootloader from monkey_island.cc.resources.client_run import ClientRun from monkey_island.cc.resources.edge import Edge @@ -31,7 +32,6 @@ from monkey_island.cc.resources.node import Node from monkey_island.cc.resources.node_states import NodeStates from monkey_island.cc.resources.pba_file_download import PBAFileDownload from monkey_island.cc.resources.pba_file_upload import FileUpload -from monkey_island.cc.resources.registration import Registration from monkey_island.cc.resources.remote_run import RemoteRun from monkey_island.cc.resources.reporting.report import Report from monkey_island.cc.resources.root import Root @@ -71,9 +71,12 @@ def serve_home(): def init_app_config(app, mongo_url): app.config['MONGO_URI'] = mongo_url - app.config['SECRET_KEY'] = str(uuid.getnode()) - app.config['JWT_AUTH_URL_RULE'] = '/api/auth' - app.config['JWT_EXPIRATION_DELTA'] = env_singleton.env.get_auth_expiration_time() + + # See https://flask-jwt-extended.readthedocs.io/en/stable/options + app.config['JWT_ACCESS_TOKEN_EXPIRES'] = env_singleton.env.get_auth_expiration_time() + # Invalidate the signature of JWTs if the server process restarts. This avoids the edge case of getting a JWT, + # deciding to reset credentials and then still logging in with the old JWT. + app.config['JWT_SECRET_KEY'] = str(uuid.uuid4()) def init_app_services(app): @@ -96,6 +99,7 @@ def init_app_url_rules(app): def init_api_resources(api): api.add_resource(Root, '/api') api.add_resource(Registration, '/api/registration') + api.add_resource(Authenticate, '/api/auth') api.add_resource(Environment, '/api/environment') api.add_resource(Monkey, '/api/monkey', '/api/monkey/', '/api/monkey/') api.add_resource(Bootloader, '/api/bootloader/') diff --git a/monkey/monkey_island/cc/environment/__init__.py b/monkey/monkey_island/cc/environment/__init__.py index e35233c69..fcaa4e156 100644 --- a/monkey/monkey_island/cc/environment/__init__.py +++ b/monkey/monkey_island/cc/environment/__init__.py @@ -23,7 +23,7 @@ class Environment(object, metaclass=ABCMeta): _MONGO_URL = os.environ.get("MONKEY_MONGO_URL", "mongodb://{0}:{1}/{2}".format(_MONGO_DB_HOST, _MONGO_DB_PORT, str(_MONGO_DB_NAME))) _DEBUG_SERVER = False - _AUTH_EXPIRATION_TIME = timedelta(hours=1) + _AUTH_EXPIRATION_TIME = timedelta(minutes=30) _testing = False diff --git a/monkey/monkey_island/cc/resources/attack/attack_config.py b/monkey/monkey_island/cc/resources/attack/attack_config.py index e8889a487..532b1fb4f 100644 --- a/monkey/monkey_island/cc/resources/attack/attack_config.py +++ b/monkey/monkey_island/cc/resources/attack/attack_config.py @@ -8,7 +8,7 @@ __author__ = "VakarisZ" class AttackConfiguration(flask_restful.Resource): - @jwt_required() + @jwt_required def get(self): return current_app.response_class(json.dumps({"configuration": AttackConfig.get_config()}, indent=None, @@ -16,7 +16,7 @@ class AttackConfiguration(flask_restful.Resource): sort_keys=False) + "\n", mimetype=current_app.config['JSONIFY_MIMETYPE']) - @jwt_required() + @jwt_required def post(self): """ Based on request content this endpoint either resets ATT&CK configuration or updates it. diff --git a/monkey/monkey_island/cc/resources/attack/attack_report.py b/monkey/monkey_island/cc/resources/attack/attack_report.py index e113dfa76..779c436c5 100644 --- a/monkey/monkey_island/cc/resources/attack/attack_report.py +++ b/monkey/monkey_island/cc/resources/attack/attack_report.py @@ -10,7 +10,7 @@ __author__ = "VakarisZ" class AttackReport(flask_restful.Resource): - @jwt_required() + @jwt_required def get(self): response_content = {'techniques': AttackReportService.get_latest_report()['techniques'], 'schema': SCHEMA} return current_app.response_class(json.dumps(response_content, diff --git a/monkey/monkey_island/cc/resources/auth/auth.py b/monkey/monkey_island/cc/resources/auth/auth.py index 24176cdf6..71611221c 100644 --- a/monkey/monkey_island/cc/resources/auth/auth.py +++ b/monkey/monkey_island/cc/resources/auth/auth.py @@ -1,40 +1,67 @@ +import json +import logging from functools import wraps -from flask import abort, current_app -from flask_jwt import JWT, JWTError, _jwt_required +import flask_jwt_extended +import flask_restful +from flask import make_response, request +from flask_jwt_extended.exceptions import JWTExtendedException +from jwt import PyJWTError from werkzeug.security import safe_str_cmp import monkey_island.cc.environment.environment_singleton as env_singleton import monkey_island.cc.resources.auth.user_store as user_store -__author__ = 'itay.mizeretz' +logger = logging.getLogger(__name__) def init_jwt(app): user_store.UserStore.set_users(env_singleton.env.get_auth_users()) + _ = flask_jwt_extended.JWTManager(app) + logger.debug("Initialized JWT with secret key that started with " + app.config["JWT_SECRET_KEY"][:4]) - def authenticate(username, secret): + +class Authenticate(flask_restful.Resource): + """ + Resource for user authentication. The user provides the username and hashed password and we give them a JWT. + See `AuthService.js` file for the frontend counterpart for this code. + """ + @staticmethod + def _authenticate(username, secret): user = user_store.UserStore.username_table.get(username, None) if user and safe_str_cmp(user.secret.encode('utf-8'), secret.encode('utf-8')): return user - def identity(payload): - user_id = payload['identity'] - return user_store.UserStore.user_id_table.get(user_id, None) - - JWT(app, authenticate, identity) + def post(self): + """ + Example request: + { + "username": "my_user", + "password": "343bb87e553b05430e5c44baf99569d4b66..." + } + """ + credentials = json.loads(request.data) + # Unpack auth info from request + username = credentials["username"] + secret = credentials["password"] + # If the user and password have been previously registered + if self._authenticate(username, secret): + access_token = flask_jwt_extended.create_access_token(identity=user_store.UserStore.username_table[username].id) + logger.debug(f"Created access token for user {username} that begins with {access_token[:4]}") + return make_response({"access_token": access_token, "error": ""}, 200) + else: + return make_response({"error": "Invalid credentials"}, 401) -def jwt_required(realm=None): - def wrapper(fn): - @wraps(fn) - def decorator(*args, **kwargs): - try: - _jwt_required(realm or current_app.config['JWT_DEFAULT_REALM']) - return fn(*args, **kwargs) - except JWTError: - abort(401) - - return decorator +# See https://flask-jwt-extended.readthedocs.io/en/stable/custom_decorators/ +def jwt_required(fn): + @wraps(fn) + def wrapper(*args, **kwargs): + try: + flask_jwt_extended.verify_jwt_in_request() + return fn(*args, **kwargs) + # Catch authentication related errors in the verification or inside the called function. All other exceptions propagate + except (JWTExtendedException, PyJWTError) as e: + return make_response({"error": f"Authentication error: {str(e)}"}, 401) return wrapper diff --git a/monkey/monkey_island/cc/resources/registration.py b/monkey/monkey_island/cc/resources/auth/registration.py similarity index 100% rename from monkey/monkey_island/cc/resources/registration.py rename to monkey/monkey_island/cc/resources/auth/registration.py diff --git a/monkey/monkey_island/cc/resources/island_configuration.py b/monkey/monkey_island/cc/resources/island_configuration.py index deda3e251..b8a556016 100644 --- a/monkey/monkey_island/cc/resources/island_configuration.py +++ b/monkey/monkey_island/cc/resources/island_configuration.py @@ -8,12 +8,12 @@ from monkey_island.cc.services.config import ConfigService class IslandConfiguration(flask_restful.Resource): - @jwt_required() + @jwt_required def get(self): return jsonify(schema=ConfigService.get_config_schema(), configuration=ConfigService.get_config(False, True, True)) - @jwt_required() + @jwt_required def post(self): config_json = json.loads(request.data) if 'reset' in config_json: diff --git a/monkey/monkey_island/cc/resources/island_logs.py b/monkey/monkey_island/cc/resources/island_logs.py index 5ef64789b..5d1d6d276 100644 --- a/monkey/monkey_island/cc/resources/island_logs.py +++ b/monkey/monkey_island/cc/resources/island_logs.py @@ -11,7 +11,7 @@ logger = logging.getLogger(__name__) class IslandLog(flask_restful.Resource): - @jwt_required() + @jwt_required def get(self): try: return IslandLogService.get_log_file() diff --git a/monkey/monkey_island/cc/resources/log.py b/monkey/monkey_island/cc/resources/log.py index 67f4e5e47..0d437d174 100644 --- a/monkey/monkey_island/cc/resources/log.py +++ b/monkey/monkey_island/cc/resources/log.py @@ -14,7 +14,7 @@ __author__ = "itay.mizeretz" class Log(flask_restful.Resource): - @jwt_required() + @jwt_required def get(self): monkey_id = request.args.get('id') exists_monkey_id = request.args.get('exists') diff --git a/monkey/monkey_island/cc/resources/monkey_configuration.py b/monkey/monkey_island/cc/resources/monkey_configuration.py index d692b8690..e6b94cf81 100644 --- a/monkey/monkey_island/cc/resources/monkey_configuration.py +++ b/monkey/monkey_island/cc/resources/monkey_configuration.py @@ -10,11 +10,11 @@ __author__ = 'Barak' class MonkeyConfiguration(flask_restful.Resource): - @jwt_required() + @jwt_required def get(self): return jsonify(schema=ConfigService.get_config_schema(), configuration=ConfigService.get_config(False, True)) - @jwt_required() + @jwt_required def post(self): config_json = json.loads(request.data) if 'reset' in config_json: diff --git a/monkey/monkey_island/cc/resources/netmap.py b/monkey/monkey_island/cc/resources/netmap.py index d9aad5bcc..899dc478c 100644 --- a/monkey/monkey_island/cc/resources/netmap.py +++ b/monkey/monkey_island/cc/resources/netmap.py @@ -8,7 +8,7 @@ __author__ = 'Barak' class NetMap(flask_restful.Resource): - @jwt_required() + @jwt_required def get(self, **kw): net_nodes = NetNodeService.get_all_net_nodes() net_edges = NetEdgeService.get_all_net_edges() diff --git a/monkey/monkey_island/cc/resources/node.py b/monkey/monkey_island/cc/resources/node.py index 6816e7142..ff630b9a4 100644 --- a/monkey/monkey_island/cc/resources/node.py +++ b/monkey/monkey_island/cc/resources/node.py @@ -8,7 +8,7 @@ __author__ = 'Barak' class Node(flask_restful.Resource): - @jwt_required() + @jwt_required def get(self): node_id = request.args.get('id') if node_id: diff --git a/monkey/monkey_island/cc/resources/node_states.py b/monkey/monkey_island/cc/resources/node_states.py index a75eb3ec7..0b50ac34c 100644 --- a/monkey/monkey_island/cc/resources/node_states.py +++ b/monkey/monkey_island/cc/resources/node_states.py @@ -6,6 +6,6 @@ from monkey_island.cc.services.utils.node_states import \ class NodeStates(flask_restful.Resource): - @jwt_required() + @jwt_required def get(self): return {'node_states': [state.value for state in NodeStateList]} diff --git a/monkey/monkey_island/cc/resources/pba_file_upload.py b/monkey/monkey_island/cc/resources/pba_file_upload.py index 1b63d3a7b..b18fd7b2f 100644 --- a/monkey/monkey_island/cc/resources/pba_file_upload.py +++ b/monkey/monkey_island/cc/resources/pba_file_upload.py @@ -27,7 +27,7 @@ class FileUpload(flask_restful.Resource): # Create all directories on the way if they don't exist UPLOADS_DIR.mkdir(parents=True, exist_ok=True) - @jwt_required() + @jwt_required def get(self, file_type): """ Sends file to filepond @@ -41,7 +41,7 @@ class FileUpload(flask_restful.Resource): filename = ConfigService.get_config_value(copy.deepcopy(PBA_WINDOWS_FILENAME_PATH)) return send_from_directory(UPLOADS_DIR, filename) - @jwt_required() + @jwt_required def post(self, file_type): """ Receives user's uploaded file from filepond @@ -55,7 +55,7 @@ class FileUpload(flask_restful.Resource): status=200, mimetype='text/plain') return response - @jwt_required() + @jwt_required def delete(self, file_type): """ Deletes file that has been deleted on the front end diff --git a/monkey/monkey_island/cc/resources/remote_run.py b/monkey/monkey_island/cc/resources/remote_run.py index fce91098a..0e80f25c0 100644 --- a/monkey/monkey_island/cc/resources/remote_run.py +++ b/monkey/monkey_island/cc/resources/remote_run.py @@ -24,7 +24,7 @@ class RemoteRun(flask_restful.Resource): island_ip = request_body.get('island_ip') return RemoteRunAwsService.run_aws_monkeys(instances, island_ip) - @jwt_required() + @jwt_required def get(self): action = request.args.get('action') if action == 'list_aws': @@ -43,7 +43,7 @@ class RemoteRun(flask_restful.Resource): return {} - @jwt_required() + @jwt_required def post(self): body = json.loads(request.data) resp = {} diff --git a/monkey/monkey_island/cc/resources/reporting/report.py b/monkey/monkey_island/cc/resources/reporting/report.py index ca1ce395f..a0ea8b0b9 100644 --- a/monkey/monkey_island/cc/resources/reporting/report.py +++ b/monkey/monkey_island/cc/resources/reporting/report.py @@ -21,7 +21,7 @@ __author__ = ["itay.mizeretz", "shay.nehmad"] class Report(flask_restful.Resource): - @jwt_required() + @jwt_required def get(self, report_type=SECURITY_REPORT_TYPE, report_data=None): if report_type == SECURITY_REPORT_TYPE: return ReportService.get_report() diff --git a/monkey/monkey_island/cc/resources/root.py b/monkey/monkey_island/cc/resources/root.py index d3a374454..7463a9857 100644 --- a/monkey/monkey_island/cc/resources/root.py +++ b/monkey/monkey_island/cc/resources/root.py @@ -26,15 +26,15 @@ class Root(flask_restful.Resource): if not action: return self.get_server_info() elif action == "reset": - return jwt_required()(Database.reset_db)() + return jwt_required(Database.reset_db)() elif action == "killall": - return jwt_required()(InfectionLifecycle.kill_all)() + return jwt_required(InfectionLifecycle.kill_all)() elif action == "is-up": return {'is-up': True} else: return make_response(400, {'error': 'unknown action'}) - @jwt_required() + @jwt_required def get_server_info(self): return jsonify( ip_addresses=local_ip_addresses(), diff --git a/monkey/monkey_island/cc/resources/telemetry.py b/monkey/monkey_island/cc/resources/telemetry.py index f6c58af40..efdeb34b3 100644 --- a/monkey/monkey_island/cc/resources/telemetry.py +++ b/monkey/monkey_island/cc/resources/telemetry.py @@ -20,7 +20,7 @@ logger = logging.getLogger(__name__) class Telemetry(flask_restful.Resource): - @jwt_required() + @jwt_required def get(self, **kw): monkey_guid = request.args.get('monkey_guid') telem_category = request.args.get('telem_category') diff --git a/monkey/monkey_island/cc/resources/telemetry_feed.py b/monkey/monkey_island/cc/resources/telemetry_feed.py index 3814c841a..c278d2f36 100644 --- a/monkey/monkey_island/cc/resources/telemetry_feed.py +++ b/monkey/monkey_island/cc/resources/telemetry_feed.py @@ -16,7 +16,7 @@ __author__ = 'itay.mizeretz' class TelemetryFeed(flask_restful.Resource): - @jwt_required() + @jwt_required def get(self, **kw): timestamp = request.args.get('timestamp') if "null" == timestamp or timestamp is None: # special case to avoid ugly JS code... diff --git a/monkey/monkey_island/cc/resources/test/clear_caches.py b/monkey/monkey_island/cc/resources/test/clear_caches.py index b2f23de0a..34401b318 100644 --- a/monkey/monkey_island/cc/resources/test/clear_caches.py +++ b/monkey/monkey_island/cc/resources/test/clear_caches.py @@ -17,7 +17,7 @@ class ClearCaches(flask_restful.Resource): so we use this to clear the caches. :note: DO NOT CALL THIS IN PRODUCTION CODE as this will slow down the user experience. """ - @jwt_required() + @jwt_required def get(self, **kw): try: logger.warning("Trying to clear caches! Make sure this is not production") diff --git a/monkey/monkey_island/cc/resources/test/log_test.py b/monkey/monkey_island/cc/resources/test/log_test.py index 79f82f5c9..a9c4f8b62 100644 --- a/monkey/monkey_island/cc/resources/test/log_test.py +++ b/monkey/monkey_island/cc/resources/test/log_test.py @@ -7,7 +7,7 @@ from monkey_island.cc.resources.auth.auth import jwt_required class LogTest(flask_restful.Resource): - @jwt_required() + @jwt_required def get(self): find_query = json_util.loads(request.args.get('find_query')) log = mongo.db.log.find_one(find_query) diff --git a/monkey/monkey_island/cc/resources/test/monkey_test.py b/monkey/monkey_island/cc/resources/test/monkey_test.py index b97589d24..da8333479 100644 --- a/monkey/monkey_island/cc/resources/test/monkey_test.py +++ b/monkey/monkey_island/cc/resources/test/monkey_test.py @@ -7,7 +7,7 @@ from monkey_island.cc.resources.auth.auth import jwt_required class MonkeyTest(flask_restful.Resource): - @jwt_required() + @jwt_required def get(self, **kw): find_query = json_util.loads(request.args.get('find_query')) return {'results': list(mongo.db.monkey.find(find_query))} diff --git a/monkey/monkey_island/cc/resources/zero_trust/finding_event.py b/monkey/monkey_island/cc/resources/zero_trust/finding_event.py index 0725723d5..8a1879c9c 100644 --- a/monkey/monkey_island/cc/resources/zero_trust/finding_event.py +++ b/monkey/monkey_island/cc/resources/zero_trust/finding_event.py @@ -9,6 +9,6 @@ from monkey_island.cc.services.reporting.zero_trust_service import \ class ZeroTrustFindingEvent(flask_restful.Resource): - @jwt_required() + @jwt_required def get(self, finding_id: str): return {'events_json': json.dumps(ZeroTrustService.get_events_by_finding(finding_id), default=str)} diff --git a/monkey/monkey_island/cc/ui/src/components/pages/RegisterPage.js b/monkey/monkey_island/cc/ui/src/components/pages/RegisterPage.js index 62ff0e170..88905d805 100644 --- a/monkey/monkey_island/cc/ui/src/components/pages/RegisterPage.js +++ b/monkey/monkey_island/cc/ui/src/components/pages/RegisterPage.js @@ -26,13 +26,13 @@ class RegisterPageComponent extends React.Component { }; setNoAuth = () => { - let options = {} + let options = {}; options['headers'] = { 'Accept': 'application/json', 'Content-Type': 'application/json' }; - options['method'] = 'PATCH' - options['body'] = JSON.stringify({'server_config': 'standard'}) + options['method'] = 'PATCH'; + options['body'] = JSON.stringify({'server_config': 'standard'}); return fetch(this.NO_AUTH_API_ENDPOINT, options) .then(res => { diff --git a/monkey/monkey_island/cc/ui/src/services/AuthService.js b/monkey/monkey_island/cc/ui/src/services/AuthService.js index d6ecb24ef..e1db4186c 100644 --- a/monkey/monkey_island/cc/ui/src/services/AuthService.js +++ b/monkey/monkey_island/cc/ui/src/services/AuthService.js @@ -83,7 +83,7 @@ export default class AuthService { }; if (this._loggedIn()) { - headers['Authorization'] = 'JWT ' + this._getToken(); + headers['Authorization'] = 'Bearer ' + this._getToken(); } if (options.hasOwnProperty('headers')) { @@ -97,6 +97,9 @@ export default class AuthService { return fetch(url, options) .then(res => { if (res.status === 401) { + res.clone().json().then(res_json => { + console.log('Got 401 from server while trying to authFetch: ' + JSON.stringify(res_json)); + }); this._removeToken(); } return res; @@ -156,6 +159,4 @@ export default class AuthService { _toHexStr(byteArr) { return byteArr.reduce((acc, x) => (acc + ('0' + x.toString(0x10)).slice(-2)), ''); } - - } diff --git a/monkey/monkey_island/requirements.txt b/monkey/monkey_island/requirements.txt index 59428bd0d..88af6bad0 100644 --- a/monkey/monkey_island/requirements.txt +++ b/monkey/monkey_island/requirements.txt @@ -1,4 +1,4 @@ -Flask-JWT>=0.3.2 +Flask-JWT-Extended==3.24.1 Flask-Pymongo>=2.3.0 Flask-Restful>=0.3.8 PyInstaller==3.6 @@ -25,3 +25,5 @@ tqdm>=4.47 virtualenv>=20.0.26 werkzeug>=1.0.1 wheel>=0.34.2 + +pyjwt>=1.5.1 # not directly required, pinned by Snyk to avoid a vulnerability \ No newline at end of file