diff --git a/README.md b/README.md index 13ccf38..72e4525 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ communication with the web client is done by a (far from any standard and almost -**register as player and in game queue:** +**register as player:** ```json { @@ -88,4 +88,170 @@ response: } ``` -(response?) \ No newline at end of file +(response?) + + + +## new version: + +**json match state:** + +```json +{ + complete_field: '[[...],[...],...]', + global_field: '[[...],[...],...]', + last_move: { + "sub_x": "...", + "sub_y": "...", + "x": "...", + "y": "..." + } + game_over: , + player_won: > + current_player: > +} +``` + +**new temp session** + +client + +```json +{ + "type": "temp_session", + "data": { + "name": "" + } +} +``` + +server response: + +```json +{ + "type": "temp_session_response", + "data": { + "success": , + "id": "", + "message": "..." + } +} +``` + +**connect by session id** + +client + +```json +{ + "type": "reconnect", + "data": { + "id": "", + } +} +``` + +server response: + +```json +{ + "type": "reconnect_response", + "data": { + "success": , + "msg": "..." + } +} +``` + +**register**: + +TODO + + + +**match_request**: + +client + +```json +{ + "type": "match_request", + "data": { + "player": > + } +} +``` + +server_response: + +```json +{ + "type": "match_request_response", + "data": { + "success": + "msg": "..." + } +} +``` + + + +**match_move**: + +client + +```json +{ + "type": "move", + "data": { + "sub_x": "...", + "sub_y": "...", + "x": "...", + "y": "..." + } +} +``` + +server response: (maybe useless?) + +```json +{ + "type": "move_response", + "data": { + "success": true, + "msg": "..." + } +} +``` + + + +**match update** + +(also send on match start and send for all matches after login) + +server: + +```json +{ + "type": "match_update", + "data": { + "id": "", + "match_state": "" + } +} +``` + +**match close** + +client: + +```json +{ + "type": "end_match", + "data": { + "id": "" + } +} +``` + diff --git a/connection_handler.py b/connection_handler.py new file mode 100644 index 0000000..1223d5e --- /dev/null +++ b/connection_handler.py @@ -0,0 +1,227 @@ +import asyncio +import websockets +import json +from session_manager import SessionManager +from user_manager import UserManager +from match_manager import MatchManager +from match import Match + + +def parse_message(msg: str): + # TODO: make it more robust by validating each part of a message + msg_obj = json.loads(msg) + if "type" not in msg_obj or "data" not in msg_obj: + print("got strange message") + return None + + return msg_obj + + +class Connection(object): + def __init__(self, + id, + user_name: str, + registered: bool, + websocket: websocket.WebSocketServerProtocol): + self.id = id + self.user_name = user_name + self.websocket = websocket + self.is_registered_user = registered + + +class ConnectionHandler(object): + def __init__(self, + session_manager: SessionManager, + user_manager: UserManager, + match_manager: MatchManager): + self.session_manager = session_manager + self.user_manager = user_manager + self.match_manager = match_manager + + self.open_connections_by_user = {} + self.open_connections_by_id = {} + + self.match_queue = set() + + def reconnect_session(self, + socket: websockets.WebSocketServerProtocol, + session_id: str): + # check whether id exists + tmp = self.session_manager.get_session_by_id(session_id) + if len(tmp) == 0: + # session not available! + return None + + session_obj = tmp[0] + + is_registerd = session_obj['registered_user'] is not None + + user_name = session_obj['registered_user'] if is_registerd else session_obj['temp_user'] + + conn = Connection(id=session_id, + user_name=user_name, + registered=is_registerd, + websocket=socket) + + self.session_manager.touch_session(session_id) + + return conn + + def close_connection(self, conn: Connection): + if conn.id in self.open_connections_by_id: + del(self.open_connections_by_id[conn.id]) + del(self.open_connections_by_user[conn.user_name]) + conn.websocket.close() + + def close_session(self, id): + tmp = self.session_manager.get_session_by_id(id) + if len(tmp) == 0: + return + + if id in self.open_connections_by_id: + self.close_connection([id]) + + self.session_manager.delete_session(id) + + def _add_connection(self, conn): + self.open_connections_by_id[conn.id] = conn + self.open_connections_by_user[conn.user] = conn + + def _del_connection(self, conn): + del(self.open_connections_by_id[conn.id]) + del(self.open_connections_by_user[conn.user]) + + async def new_connection(self, + socket: websockets.WebSocketServerProtocol, + login_msg: str): + + msg = parse_message(login_msg) + + if msg is None: + return None + + if msg['type'] == 'reconnect': + conn = self.reconnect_session(socket, msg['data']['id']) + if conn is not None: + self._add_connection(conn) + await conn.websocket.send(json.dumps({ + "type": "reconnect_response", + "data": { + "success": True, + "msg": "" + } + })) + return conn + await conn.websocket.send(json.dumps({ + "type": "reconnect_response", + "data": { + "success": False, + "msg": "session not available" + } + })) + return None + + elif msg['type'] == 'temp_session': + name = msg['data']['name'] + if len(self.session_manager.get_session_by_temp_user(name)) == 0: + if len(self.user_manager.get_user(name)) == 0: + if len(msg['data']['name']) < 16 and ';' not in name and '\'' not in name and '\"' not in name: + id = self.session_manager.create_session_for_temp_user( + name) + conn = Connection( + id=id, user_name=name, registered=False, websocket=socket) + self._add_connection(conn) + await socket.send(json.dumps({ + "type": "temp_session_response", + "data": { + "success": True, + "id": id, + "message": "logged in as temporary user " + name + } + })) + return conn + + await socket.send(json.dumps({ + "type": "temp_session_response", + "data": { + "success": False, + "id": None, + "message": "user name not available" + } + })) + return None + + elif msg['type'] == 'login': + # TODO + pass + + elif msg['type'] == 'register': + # TODO + pass + + async def _start_match(self, user_a, user_b): + m = self.match_manager.create_new_match(user_a, user_b) + state = m.to_json_state() + if user_a in self.open_connections_by_user: + await self.open_connections_by_user[user_a].websocket.send( + json.dumps({ + { + "type": "match_update", + "data": { + "id": m.id, + "match_state": state + } + } + }) + ) + if user_b in self.open_connections_by_user: + await self.open_connections_by_user[user_b].websocket.send( + json.dumps({ + { + "type": "match_update", + "data": { + "id": m.id, + "match_state": state + } + } + }) + ) + + async def _on_match_req(self, conn, data): + if len(self.match_queue) > 0: + # it's a match! + user_a = self.match_queue.pop() + self._start_match(user_a, conn.user_name) + + else: + self.match_queue.append(conn.user_name) + + async def _on_match_move(self, conn, data): + pass + + async def _on_match_close(self, conn, data): + pass + + async def disconnect(self, conn): + self._del_connection(conn) + + async def handle_message(self, conn, msg_str): + msg = parse_message(msg_str) + + if msg is None: + return None + + t = msg['type'] + + self.user_manager.touch_user(conn.user_name) + if t == "match_request": + await self._on_match_req(conn, msg['data']) + + elif t == "move": + await self._on_match_move(conn, msg['data']) + + elif t == "end_match": + await self._on_match_close(conn, msg['data']) + + else: + print("could not interpret message: " + msg_str) diff --git a/create_database.py b/create_database.py new file mode 100755 index 0000000..c2adf94 --- /dev/null +++ b/create_database.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +# this script creates the necessary tables in the databse + +import settings +from database_connection import DatabaseConnection + + +def create_tables(): + + DatabaseConnection(settings.db_host, + settings.db_port, + settings.db_user, + settings.db_pw, + settings.db_db, + settings.db_charset) + + queries = [ + "DROP TABLE IF EXISTS matches", + "DROP TABLE IF EXISTS sessions", + "DROP TABLE IF EXISTS users", + "CREATE TABLE users (name varchar(16) NOT NULL, pw_hash varchar(128) NOT NULL, pw_salt varchar(32) NOT NULL, last_seen datetime NOT NULL, PRIMARY KEY (name)) CHARACTER SET " + settings.db_charset, + "CREATE TABLE matches (id varchar(32) NOT NULL, user_a varchar(16) NOT NULL, user_b varchar(16) NOT NULL, match_state varchar(4096) NOT NULL, active_user varchar(16), last_active datetime NOT NULL, FOREIGN KEY (user_a) REFERENCES users(name), FOREIGN KEY (user_b) REFERENCES users(name), FOREIGN KEY (active_user) REFERENCES users(name)) CHARACTER SET " + settings.db_charset, + "CREATE TABLE sessions (id varchar(32) NOT NULL, registered_user varchar(16), temp_user varchar(16), last_seen datetime NOT NULL, PRIMARY KEY (id), FOREIGN KEY(registered_user) REFERENCES users(name)) CHARACTER SET " + settings.db_charset + ] + + for query in queries: + DatabaseConnection.global_single_execution(query) + + DatabaseConnection.global_close() + + +if __name__ == "__main__": + create_tables() diff --git a/database_connection.py b/database_connection.py new file mode 100644 index 0000000..6784c1b --- /dev/null +++ b/database_connection.py @@ -0,0 +1,106 @@ +import pymysql.cursors +import sys + + +def get_sql_time(datetime_object): + return datetime_object.strftime('%Y-%m-%d %H:%M:%S') + + +class SQLInjectionError(Exception): + def __init__(self): + + # Call the base class constructor with the parameters it needs + super().__init__("Detected possible SQL injection attack!") + + +class DatabaseConnection(object): + """ + a singleton class for a global database connection + """ + + instance = None + + @staticmethod + def global_cursor(): + assert DatabaseConnection.instance is not None + return DatabaseConnection.instance.get_cursor() + + @staticmethod + def global_close(): + assert DatabaseConnection.instance is not None + DatabaseConnection.instance.close() + + @staticmethod + def global_commit(): + assert DatabaseConnection.instance is not None + DatabaseConnection.instance.commit() + + @staticmethod + def global_single_query(query): + if ';' in query: + # Possible injection! + raise SQLInjectionError() + + with DatabaseConnection.global_cursor() as c: + c.execute(query) + return c.fetchall() + + @staticmethod + def global_single_execution(sql_statement): + if ';' in sql_statement: + # Possible injection detected! + raise SQLInjectionError() + + with DatabaseConnection.global_cursor() as c: + c.execute(sql_statement) + DatabaseConnection.global_commit() + + def __init__(self, + host: str, + port: int, + user: str, + password: str, + db: str, + charset: str): + + assert DatabaseConnection.instance is None + try: + self.connection = pymysql.connect( + host=host, + port=port, + user=user, + password=password, + db=db, + charset=charset, + cursorclass=pymysql.cursors.DictCursor) + DatabaseConnection.instance = self + except Exception as e: + sys.stderr.write("could not connect to database '" + + str(db) + + "' at " + + user + + "@" + + host + + ":" + + str(port) + + "\nCheck the configuration in settings.py!\n") + raise Exception('could not connec to database') + + def get_cursor(self): + return self.connection.cursor() + + def close(self): + self.connection.close() + DatabaseConnection.instance = None + + def commit(self): + self.connection.commit() + +def test_connection(): + import settings + DatabaseConnection(settings.db_host, + settings.db_port, + settings.db_user, + settings.db_pw, + settings.db_db, + settings.db_charset) \ No newline at end of file diff --git a/main.py b/main.py index 7068a18..ac582ce 100644 --- a/main.py +++ b/main.py @@ -64,7 +64,7 @@ async def socket_worker(websocket, path): ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ssl_context.load_cert_chain(cert_file , keyfile=key_file) -start_server = websockets.serve(socket_worker, host='', port=5555, ssl=ssl_context) +start_server = websockets.serve(socket_worker, host='', port=server_port, ssl=ssl_context) asyncio.get_event_loop().run_until_complete(start_server) asyncio.get_event_loop().run_forever() diff --git a/match.py b/match.py new file mode 100644 index 0000000..3a84ae7 --- /dev/null +++ b/match.py @@ -0,0 +1,188 @@ + +import numpy as np +import json +import base64 + +# decoder/encoder took from: https://stackoverflow.com/a/19271311 + + +def Base64Encode(ndarray): + return json.dumps([str(ndarray.dtype), base64.b64encode(ndarray), ndarray.shape]) + + +def Base64Decode(jsonDump): + loaded = json.loads(jsonDump) + dtype = np.dtype(loaded[0]) + arr = np.frombuffer(base64.decodestring(loaded[1]), dtype) + if len(loaded) > 2: + return arr.reshape(loaded[2]) + return arr + + +def SimpleEncode(ndarray): + return json.dumps(ndarray.tolist()) + + +def SimpleDecode(jsonDump): + return np.array(json.loads(jsonDump)) + + +FIELD_EMPTY = 0 +FIELD_USER_A = 1 +FIELD_USER_B = 2 +FIELD_DRAW = 3 + + +class Match(object): + def __init__(self, n, match_id, player_a_name, player_b_name, json_state=None): + self.n = n + self.id = match_id + self.complete_field = np.zeros(shape=(n*n, n*n), dtype=int) + self.global_field = np.zeros(shape=(n, n), dtype=int) + self.player_won = None + self.game_over = False + self.last_move = None + self.is_player_a = True + self.player_a_name = player_a_name + self.player_b_name = player_b_name + + if json_state is not None: + self.from_json_state(json_state) + + def from_json_state(self, json_state): + match_obj = json.loads(json_state) + self.complete_field = np.array(match_obj['complete_field'], dtype=int) + self.global_field = np.array(match_obj['global_field'], dtype=int) + self.player_won = match_obj['player_won'] + self.game_over = match_obj['game_over'] + self.last_move = match_obj['last_move'] + self.is_player_a = match_obj['active_player'] == self.player_a_name + + def to_json_state(self): + match_obj = { + 'complete_field': self.complete_field.tolist(), + 'global_field': self.global_field.tolist(), + 'last_move': self.last_move, + 'game_over': self.game_over, + 'player_won': self.player_won, + 'active_player': self.player_a_name if self.is_player_a else self.player_b_name + } + + return json.dumps(match_obj) + + def switch_player_names(self): + tmp = self.player_a_name + self.player_a_name = self.player_b_name + self.player_b_name = tmp + + def get_current_player(self): + return self.player_a_name if self.is_player_a else self.player_b_name + + def is_move_valid(self, sub_x, sub_y, x, y): + if sub_x < 0 or sub_x >= self.n: + return False + if sub_y < 0 or sub_y >= self.n: + return False + + if x < 0 or x >= self.n: + return False + if y < 0 or y >= self.n: + return False + + if (self.last_move is not None): + last_x = self.last_move['x'] + last_y = self.last_move['y'] + last_sub_x = self.last_move['sub_x'] + last_sub_y = self.last_move['sub_y'] + + if sub_x != last_x and self.global_field[last_sub_y, last_sub_x] == FIELD_EMPTY: + # user is not allowed to place everywhere! wrong move! + return False + + if sub_y != last_y and self.global_field[last_sub_y, last_sub_x] == FIELD_EMPTY: + return False + + if self.complete_field[sub_y * self.n + y][sub_x * self.n + x] != FIELD_EMPTY: + return False + + def is_full(self, field): + return not field.__contains__(FIELD_EMPTY) + + def check_win(self, field, x, y): + is_col = True + is_row = True + is_main_diag = False + is_sec_diag = False + + val = field[y, x] + + for i in range(self.n): + if (field[i, x] != val): + is_col = False + break + + for i in range(self.n): + if (field[y, i] != val): + is_row = False + break + + if x == y: + is_main_diag = True + for i in range(self.n): + if field[i, i] != val: + is_main_diag = False + break + + if x + y == self.n - 1: + is_sec_diag = True + for i in range(self.n): + if field[i, -i] != val: + is_sec_diag = False + break + + return is_col or is_row or is_main_diag or is_sec_diag + + def move(self, move_dict): + sub_x = move_dict['sub_x'] + sub_y = move_dict['sub_y'] + x = move_dict['x'] + y = move_dict['y'] + + abs_x = sub_x * self.n + x + abs_y = sub_y * self.n + y + + player_mark = FIELD_USER_A if self.is_player_a else FIELD_USER_B + + if not self.is_move_valid(sub_x, sub_y, x, y): + return False + + # else: move! + self.complete_field[abs_y, abs_x] = player_mark + + # encode move: + self.last_move = move_dict + + # check whether this indicates changes in the global field: + assert self.global_field[sub_y, sub_x] == FIELD_EMPTY + + subgrid = self.complete_field[sub_y * self.n: ( + sub_y + 1) * self.n, sub_x * self.n: (sub_x + 1) * self.n] + + if self.check_win(subgrid, x, y): + self.global_field[sub_x, sub_y] = player_mark + + elif self.is_full(subgrid): + self.global_field[sub_x, sub_y] = FIELD_DRAW + + # check global state: + if self.check_win(self.global_field, sub_x, sub_y): + self.game_over = True + self.player_won = self.player_a_name if self.is_player_a else self.player_b_name + + elif self.is_full(self.global_field): + self.game_over = True + self.player_won = None + + self.is_player_a = not self.is_player_a + + return True diff --git a/match_manager.py b/match_manager.py new file mode 100644 index 0000000..ab75d45 --- /dev/null +++ b/match_manager.py @@ -0,0 +1,46 @@ +from database_connection import DatabaseConnection, SQLInjectionError, get_sql_time +from user import User +import datetime +import uuid +import settings +from match import Match + + +class MatchManager(object): + def __init__(self): + pass + + def get_match(self, id): + query = f"SELECT * FROM matches WHERE id='{id}'" + result = DatabaseConnection.global_single_query(query) + if len(result) == 0: + return None + match = Match(n=settings.n, match_id=id, player_a_name=result[0]['user_a'], + player_b_name=result[0]['user_b'], json_state=result[0]['match_state']) + return match + + def get_matches_for_user(self, user_name): + query = f"SELECT * FROM matches WHERE user_a='{user_name}' OR user_b='{user_name}'" + return DatabaseConnection.global_single_query(query) + + def create_new_match(self, user_a, user_b): + match_id = uuid.uuid4().hex + # check if already existent (but should not be the case) + if len(DatabaseConnection.global_single_query(f"SELECT id FROM matches WHERE id='{match_id}'")) > 0: + return self.create_new_match(user_a, user_b) + + match = Match(n=settings.n, match_id=match_id, player_a_name=user_a, player_b_name=user_b) + now = datetime.datetime.now() + query = f"INSERT INTO matches (id, user_a, user_b, match_state, active_user, last_active) VALUES ('{match_id}', '{user_a}', '{user_b}', '{match.to_json_state()}', '{match.get_current_player()}','{get_sql_time(now)}')" + DatabaseConnection.global_single_execution(query) + return match + + def update_match(self, match_id, match): + now = get_sql_time(datetime.datetime.now()) + query = f"UPDATE matches SET match_state='{match.to_json_state()}', active_user='{match.get_current_player()}', last_active='{now}' WHERE id='{match_id}'" + DatabaseConnection.global_single_execution(query) + + def delete_match(self, match_id): + query = f"DELETE FROM matches WHERE id='{match_id}'" + DatabaseConnection.global_single_execution(query) + diff --git a/message_handler.py b/message_handler.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/message_handler.py @@ -0,0 +1 @@ + diff --git a/session_manager.py b/session_manager.py new file mode 100644 index 0000000..a8d316e --- /dev/null +++ b/session_manager.py @@ -0,0 +1,59 @@ +from database_connection import DatabaseConnection, SQLInjectionError, get_sql_time +from user import User +import datetime +import uuid + + +class SessionManager(object): + def __init__(self, session_lifespan_timedelta): + self.session_lifespan_timedelta = session_lifespan_timedelta + + def get_session_by_id(self, session_id): + query = f"SELECT * FROM sessions WHERE id='{session_id}'" + return DatabaseConnection.global_single_query(query) + + def touch_session(self, session_id): + query = f"UPDATE sessions SET last_seen='{datetime.datetime.now()}' WHERE id='{session_id}'" + DatabaseConnection.global_single_execution(query) + + def get_session_by_registered_user(self, user_name): + query = f"SELECT * FROM sessions WHERE registered_user='{user_name}'" + return DatabaseConnection.global_single_query(query) + + def get_session_by_temp_user(self, user_name): + query = f"SELECT * FROM sessions WHERE temp_user='{user_name}'" + return DatabaseConnection.global_single_query(query) + + def create_session_for_registered_user(self, user_name): + new_id = uuid.uuid4().hex + # check if already existent (but should not be the case) + if len(DatabaseConnection.global_single_query(f"SELECT id FROM sessions WHERE id='{new_id}'")) > 0: + # okay, next try: + return self.create_session_for_registered_user(self, user_name) + + query = f"INSERT INTO sessions (id, registered_user, temp_user, last_seen) VALUES ( '{new_id}', '{user_name}', NULL, '{datetime.datetime.now()}')" + DatabaseConnection.global_single_execution(query) + return new_id + + def create_session_for_temp_user(self, user_name): + new_id = uuid.uuid4().hex + # check if already existent (but should not be the case) + if len(DatabaseConnection.global_single_query(f"SELECT id FROM sessions WHERE id='{new_id}'")) > 0: + # okay, next try: + return self.create_session_for_registered_user(self, user_name) + + query = f"INSERT INTO sessions (id, registered_user, temp_user, last_seen) VALUES ( '{new_id}', NULL, '{user_name}', '{datetime.datetime.now()}')" + DatabaseConnection.global_single_execution(query) + return new_id + + def delete_session(self, session_id): + query = f"DELETE FROM sessions WHERE id='{session_id}'" + DatabaseConnection.global_single_execution(query) + + def revoke_inactive_sessions(self): + revoke_time = datetime.datetime.now() - self.session_lifespan_timedelta + query = f"SELECT * from sessions WHERE last_seen < '{get_sql_time(revoke_time)}'" + revoked_sessions = DatabaseConnection.global_single_query(query) + query = f"DELETE FROM sessions WHERE last_seen < '{get_sql_time(revoke_time)}'" + DatabaseConnection.global_single_execution(query) + return revoked_sessions diff --git a/user.py b/user.py new file mode 100644 index 0000000..e3deb64 --- /dev/null +++ b/user.py @@ -0,0 +1,10 @@ +class User(object): + def __init__(self, id, name): + self.id = id + self.name = name + + def get_id(self): + return self.id + + def get_name(self): + return self.name diff --git a/user_manager.py b/user_manager.py new file mode 100644 index 0000000..c5a4e4e --- /dev/null +++ b/user_manager.py @@ -0,0 +1,65 @@ +from database_connection import DatabaseConnection, SQLInjectionError +from user import User +import datetime +import hashlib +import uuid + + +class UserManager(object): + def __init__(self): + pass + + def get_user(self, user_name): + query = f"SELECT name, last_seen FROM users where name='{user_name}'" + return DatabaseConnection.global_single_query(query) + + def delete_user(self, user_name): + query = f"DELETE FROM users where name='{user_name}'" + DatabaseConnection.global_single_execution(query) + + def verify_user(self, user_name, pw): + query = f"SELECT * FROM users where name='{user_name}'" + users = DatabaseConnection.global_single_query(query) + if len(self.get_user(user_name)) == 0: + return False + + user = users[0] + pw_salt = user['pw_salt'] + stored_pw_hash = user['pw_hash'] + + pw_hash = hashlib.sha512(pw.encode() + pw_salt.encode()).hexdigest() + return stored_pw_hash == pw_hash + + def create_user(self, user_name, pw): + assert len(self.get_user(user_name)) == 0 + pw_salt = uuid.uuid4().hex + pw_hash = hashlib.sha512(pw.encode() + pw_salt.encode()).hexdigest() + query = f"INSERT INTO users (name, pw_hash, pw_salt, last_seen) VALUES ( '{user_name}', '{pw_hash}', '{pw_salt}', '{datetime.datetime.now()}')" + DatabaseConnection.global_single_execution(query) + + def touch_user(self, user_name): + matches = self.get_user(user_name) + assert len(matches) == 1 + query = f"UPDATE users SET last_seen='{datetime.datetime.now()}' WHERE name='{user_name}'" + DatabaseConnection.global_single_execution(query) + + def get_all_users(self): + query = "SELECT name, last_seen FROM users" + return DatabaseConnection.global_single_query(query) + + +def create_test_manager(): + import settings + DatabaseConnection(settings.db_host, + settings.db_port, + settings.db_user, + settings.db_pw, + settings.db_db, + settings.db_charset) + um = UserManager() + return um + + +# test routines: +if __name__ == "__main__": + pass