diff --git a/.gitignore b/.gitignore index f09233f1d..b6a83014d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ learning_observer/learning_observer/static_data/dash_assets/ learning_observer/learning_observer/static_data/courses.json learning_observer/learning_observer/static_data/students.json learning_observer/passwd.lo +learning_observer/learning_observer/auth/blacklisting_patterns.yaml --* .venv/ .vscode/ diff --git a/extension/writing-process/src/background.js b/extension/writing-process/src/background.js index 75bc9de72..b03f6d578 100644 --- a/extension/writing-process/src/background.js +++ b/extension/writing-process/src/background.js @@ -9,9 +9,14 @@ var RAW_DEBUG = false; /* This variable must be manually updated to specify the server that * the data will be sent to. */ -var WEBSOCKET_SERVER_URL = "wss://learning-observer.org/wsapi/in/" +var WEBSOCKET_SERVER_URL = "wss://learning-observer.org/wsapi/in/" import { googledocs_id_from_url } from './writing_common'; +var ALLOW = "allow" +var DENY = "deny" +var DENY_FOR_TWO_DAYS = "deny_for_two_days" + + /* TODO: FSM @@ -96,6 +101,23 @@ function websocket_logger(server) { event = JSON.stringify(event); queue.push(event); }; + socket.onmessage = function(event) { + const jsonData = JSON.parse(event.data); + + if (jsonData && jsonData.status) { + // Store the 'status' value in the local storage. + chrome.storage.local.set({ authStatus: jsonData.status }).then(() => { + console.log("Set the authStatus to localstorage"); + }); + + if (jsonData.status === DENY_FOR_TWO_DAYS) { + // Store the 'timestamp' value in the local storage. + chrome.storage.local.set({ authResponseTimeStamp: jsonData.timestamp }).then(() => { + console.log("Set the authResponseTimeStamp to localstorage"); + }); + } + } + } socket.onclose = function(event) { console.log("Lost connection"); var event = { "issue": "Lost connection", "code": event.code }; @@ -189,11 +211,56 @@ function websocket_logger(server) { } return function(data) { - queue.push(data); - dequeue(); + chrome.storage.local.get(["authStatus", "authResponseTimeStamp"]).then((result) => { + const authStatus = result.authStatus + switch (authStatus) { + case DENY: // don't update/empty the queue + break; + case DENY_FOR_TWO_DAYS: // check back after two days to continue updating/emptying the queue + const currentDate = new Date(); + const authResponseDate = convertLocalDateToUTC(new Date(result.authResponseTimeStamp)); + + // Calculate the date 2 days after the authResponseDate + const twoDaysAfterAuthResponseDate = new Date(authResponseDate) + .setDate(authResponseDate.getDate() + 2); + + // Compare the date 2 days after the authResponseDate with the current date + if (currentDate >= twoDaysAfterAuthResponseDate) { + queue.push(data); + dequeue(); + } else { + console.log("I am being denied for 2 days") + break; + } + case ALLOW: + queue.push(data); + dequeue(); + default: + // if authStatus does not exist, still push the events to the queue + queue.push(data); + dequeue(); + } + }); } } +function convertLocalDateToUTC(inputDateStr) { + /* + The returned server timestamp is in UTC and local time often time is not + This function converts the local time to UTC format to ensure accurate + time difference + */ + const date = new Date(inputDateStr); + + // Extract the time zone offset from the input date string + const timeZoneOffset = date.getTimezoneOffset(); + + // Calculate the UTC date by sub the time zone offset + const utcDate = new Date(date.getTime() - timeZoneOffset * 60000); // Convert minutes to milliseconds + + return utcDate; +} + function ajax_logger(ajax_server) { /* HTTP event per request. diff --git a/learning_observer/learning_observer/auth/blacklisting_patterns.yaml.template b/learning_observer/learning_observer/auth/blacklisting_patterns.yaml.template new file mode 100644 index 000000000..c5c4eb8ed --- /dev/null +++ b/learning_observer/learning_observer/auth/blacklisting_patterns.yaml.template @@ -0,0 +1,19 @@ +# This file serves as a configuration file for blacklisting/authentication rules and their associated patterns. +# Each rule type (e.g. DENY, etc.) contains a list of rules defined as follows: +# - field (str): The field to match against in incoming payloads, e.g., 'email', 'google_id', etc. +# - patterns (list of str): An array of regular expression patterns used for matching against the field value. +# +# Curly-braces are used for variables which ought to be filled + +deny: + - field: {field} + patterns: + - {pattern} + - {pattern} + - field: {field} + patterns: + - {pattern} +deny_for_two_days: + - field: {field} + patterns: + - {pattern1} diff --git a/learning_observer/learning_observer/auth/blacklisting_settings.py b/learning_observer/learning_observer/auth/blacklisting_settings.py new file mode 100644 index 000000000..3d4653a00 --- /dev/null +++ b/learning_observer/learning_observer/auth/blacklisting_settings.py @@ -0,0 +1,113 @@ +""" +This file contains constants, patterns, and functions related to blacklisting/authentication rules and responses. + +RULES_RESPONSES +A dictionary containing responses for different rule types, including: +- type (str) +- msg (str) +- status_code (int) + +RULE_TYPES_BY_PRIORITIES +A list defining the priority order of rule types for sorting. + +load_patterns(file_path='blacklisting_patterns.yaml') +A function that loads blacklisting patterns from a YAML file and returns them as a dictionary. + +authenticate_payload(payload) +A function that evaluates a payload against a set of rules and determines the authentication response. +""" + + +import os +import re +import yaml +from learning_observer.log_event import debug_log +import learning_observer.paths as paths + +ALLOW = "allow" +DENY = "deny" +DENY_FOR_TWO_DAYS = "deny_for_two_days" + +# Priority order of rule types for sorting +RULE_TYPES_BY_PRIORITIES = [DENY, DENY_FOR_TWO_DAYS] + +# Responses for different rule types +RULES_RESPONSES = { + ALLOW: { + "type": ALLOW, + "msg": "Allow events to be sent", + "status_code": 200 + }, + DENY: { + "type": DENY, + "msg": "Deny events from being sent", + "status_code": 403 + }, + DENY_FOR_TWO_DAYS: { + "type": DENY_FOR_TWO_DAYS, + "msg": "Deny events from being sent temporarily for two days", + "status_code": 403 + } +} + + +def load_patterns(file_name='blacklisting_patterns.yaml'): + """ + Load blacklisting patterns from a YAML config file. + + Args: + file_name (str, optional): The name of the YAML file containing the blacklisting patterns. + Defaults to 'blacklisting_patterns.yaml'. + + Returns: + dict: A dictionary containing the loaded blacklisting patterns, or an empty dictionary + if the file is not found or there is an error loading the patterns. + """ + pathname = os.path.join(os.path.dirname(paths.base_path()), 'learning_observer/auth', file_name) + try: + with open(pathname, 'r') as file: + rules_patterns = yaml.safe_load(file) + debug_log("Blacklisting patterns loaded") + return rules_patterns + except FileNotFoundError: + debug_log(f"No blacklisting patterns file added: '{pathname}' not found.") + return {} + + +def authenticate_payload(payload): + ''' + Evaluate a payload against a set of rules and determine the authentication response. + + This function iterates through a list of rules for various fields in the payload. + For each field, it checks if the value matches any of the specified patterns. + If a match is found, the associated rule type is added to a list of failed rule types. + The failed rule types are then sorted by priority, and the highest priority failed rule + determines the authentication response. + + If no rules fail, the function returns the 'allow' response. + + Args: + payload (dict): The payload containing data to be authenticated. + + Returns: + str: The authentication response based on the payload and rule evaluation. + ''' + RULES_PATTERNS = load_patterns() + failed_rule_types = [] # A list to store rule types that the payload fails to comply with + for rule_type, rules in RULES_PATTERNS.items(): + for rule in rules: + field = rule["field"] # Get the field to be looked up in the payload + patterns = rule["patterns"] # Get the patterns to match against for the payload value of the field + value = payload.get(field) # Get the value of the field from the payload + if value: + for pattern in patterns: + # If there is a pattern match, add the rule type to the failed list + if re.match(pattern, value): + failed_rule_types.append(rule_type) + + # Sort the failed rule types based on their priority order + sorted_failed_rule_types = sorted(failed_rule_types, key=RULE_TYPES_BY_PRIORITIES.index) + # Determine the response key based on the highest priority failed rule, or 'allow' if no rule failed + response_key = sorted_failed_rule_types[0] if sorted_failed_rule_types else ALLOW + # Return the appropriate response based on the response key + return RULES_RESPONSES[response_key] diff --git a/learning_observer/learning_observer/auth/events.py b/learning_observer/learning_observer/auth/events.py index df7be787b..e6e55f1c0 100644 --- a/learning_observer/learning_observer/auth/events.py +++ b/learning_observer/learning_observer/auth/events.py @@ -24,6 +24,7 @@ import urllib.parse import secrets import sys +import json import aiohttp_session import aiohttp.web @@ -36,6 +37,8 @@ from learning_observer.log_event import debug_log +from learning_observer.auth.blacklisting_settings import authenticate_payload + AUTH_METHODS = {} @@ -230,8 +233,11 @@ async def chromebook_auth(request, headers, first_event, source): if untrusted_google_id is None: return False + payload_for_validation = authdata.get('chrome_identity', {}) + auth_response = authenticate_payload(payload_for_validation) gc_uid = learning_observer.auth.utils.google_id_to_user_id(untrusted_google_id) return { + 'auth_response': auth_response, 'sec': auth, 'user_id': gc_uid, 'safe_user_id': gc_uid, @@ -321,6 +327,10 @@ async def authenticate(request, headers, first_event, source): for auth_method in learning_observer.settings.settings['event_auth']: auth_metadata = await AUTH_METHODS[auth_method](request, headers, first_event, source) if auth_metadata: + auth_response = auth_metadata.get('auth_response') + if auth_response and "status_code" in auth_response and auth_response.get("status_code") == 403: + debug_log("Auth Forbidden: the returned response code given the rules is 403") + raise aiohttp.web.HTTPForbidden(reason=json.dumps(auth_response)) if "safe_user_id" not in auth_metadata: auth_metadata['safe_user_id'] = encode_id( source=auth_metadata["providence"], diff --git a/learning_observer/learning_observer/incoming_student_event.py b/learning_observer/learning_observer/incoming_student_event.py index efcd66a4a..9f384f782 100644 --- a/learning_observer/learning_observer/incoming_student_event.py +++ b/learning_observer/learning_observer/incoming_student_event.py @@ -345,12 +345,24 @@ async def incoming_websocket_handler(request): event_metadata['source'] = first_event['source'] # We authenticate the student - event_metadata['auth'] = await learning_observer.auth.events.authenticate( - request=request, - headers=header_events, - first_event=first_event, # This is obsolete - source=json_msg['source'] - ) + try: + event_metadata['auth'] = await learning_observer.auth.events.authenticate( + request=request, + headers=header_events, + first_event=first_event, # This is obsolete + source=json_msg['source'] + ) + except aiohttp.web.HTTPForbidden as e: + auth_response = json.loads(e.reason) + # Send the status error type and current timestamp to the client (chrome extension) + await ws.send_json({ + "status": auth_response.get('type'), + "timestamp": datetime.datetime.utcnow().isoformat() + }) + debug_log(auth_response.get('msg')) + # We don't close the websocket connection here because the client needs to be able + # to reauthenticate or handle other temporary permission denied auth status + return ws print(event_metadata['auth'])