From b567213e5e08d5cb90c94ca9d83254d5b1057de4 Mon Sep 17 00:00:00 2001 From: Igor Pissolati Date: Wed, 17 Jul 2019 13:35:58 -0300 Subject: [PATCH] Accounts update --- .gitignore | 3 +- README.md | 3 + Tests/.gitignore | 2 + Tests/captcha_test.py | 113 ++++++++++++++++++++++++++++++++++ datastore.py | 84 +++++++++++++++++++------- server.py | 137 +++++++++++++++++++++++++++++++++++------- 6 files changed, 297 insertions(+), 45 deletions(-) create mode 100644 Tests/.gitignore create mode 100644 Tests/captcha_test.py diff --git a/.gitignore b/.gitignore index 35679a7..0360983 100644 --- a/.gitignore +++ b/.gitignore @@ -105,4 +105,5 @@ venv.bak/ # local files blocked.json -server.cfg \ No newline at end of file +server.cfg +server.dat \ No newline at end of file diff --git a/README.md b/README.md index cc3c867..af4fa35 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,9 @@ This project uses Python 3.7 and the following dependencies: - emoji - configparser - jsonschema +- discord_webhook **(OPTIONAL)** +- captcha **(OPTIONAL)** +- argon2 **(OPTIONAL)** If you are on Windows, the module pypiwin32 may also be required. diff --git a/Tests/.gitignore b/Tests/.gitignore new file mode 100644 index 0000000..dac0cf8 --- /dev/null +++ b/Tests/.gitignore @@ -0,0 +1,2 @@ +*.png +*.wav \ No newline at end of file diff --git a/Tests/captcha_test.py b/Tests/captcha_test.py new file mode 100644 index 0000000..e1ad221 --- /dev/null +++ b/Tests/captcha_test.py @@ -0,0 +1,113 @@ +''' +Created on Aug 23, 2018 +@author: zhaosong +''' + +from captcha.image import ImageCaptcha +from captcha.audio import AudioCaptcha +import random +from io import BytesIO + +# The number list, lower case character list and upper case character list are used to generate captcha text. +number_list = ['0','1','2','3','4','5','6','7','8','9'] + +alphabet_lowercase = ['a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z'] + +alphabet_uppercase = ['A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z'] + +# This function will create a random captcha string text based on above three list. +# The input parameter is the captcha text length. +def create_random_captcha_text(captcha_string_size=5): + + captcha_string_list = [] + + base_char = alphabet_lowercase + alphabet_uppercase + number_list + + for i in range(captcha_string_size): + + # Select one character randomly. + char = random.choice(base_char) + + # Append the character to the list. + captcha_string_list.append(char) + + captcha_string = '' + + # Change the character list to string. + for item in captcha_string_list: + captcha_string += str(item) + + return captcha_string + +# This function will create a fully digital captcha string text. +def create_random_digital_text(captcha_string_size=5): + + captcha_string_list = [] + # Loop in the number list and return a digital captcha string list + for i in range(captcha_string_size): + char = random.choice(number_list) + captcha_string_list.append(char) + + captcha_string = '' + + # Convert the digital list to string. + for item in captcha_string_list: + captcha_string += str(item) + + return captcha_string + +# Create an image captcha with special text. +def create_image_captcha(captcha_text): + image_captcha = ImageCaptcha() + # Create the captcha image. + image = image_captcha.generate_image(captcha_text) + + # Add noise curve for the image. + # image_captcha.create_noise_curve(image, image.getcolors()) + + # Add noise dots for the image. + # image_captcha.create_noise_dots(image, image.getcolors()) + + # Save the image to a png file. + image_file = "./captcha_"+captcha_text + ".png" + imgByteArr = BytesIO() + image.save(imgByteArr, format='PNG') + imgByteArr = imgByteArr.getvalue() + open("test.png", "wb").write(imgByteArr) + #image_captcha.write(captcha_text, image_file) + + print(image_file + " has been created.") + +# Create an audio captcha file. +def create_audio_captcha(): + + # Create the audio captcha with the specified voice wav file library folder. + # Each captcha char should has it's own directory under the specified folder ( such as ./voices), + # for example ./voices/a/a.wav will be played when the character is a. + # If you do not specify your own voice file library folder, the default built-in voice library which has only digital voice file will be used. + # audio_captcha = AudioCaptcha(voicedir='./voices') + + # Create an audio captcha which use digital voice file only. + audio_captcha = AudioCaptcha() + + # Because we use the default module voice library, so we can only generate digital text voice. + captcha_text = create_random_digital_text() + + # Generate the audio captcha file. + audio_data = audio_captcha.generate(captcha_text) + + # Save the autiod captcha file. + audio_file = "./captcha_"+captcha_text+'.wav' + audio_captcha.write(captcha_text, audio_file) + + print(audio_file + " has been created.") + +if __name__ == '__main__': + # Create random text. + captcha_text = create_random_captcha_text() + + # Create image captcha. + create_image_captcha(captcha_text) + + # Create audio captcha. + create_audio_captcha() diff --git a/datastore.py b/datastore.py index 92271df..0581dfc 100644 --- a/datastore.py +++ b/datastore.py @@ -1,13 +1,22 @@ import os import hashlib -import argon2 +try: + import argon2 + A2_IMPORT = True +except: + # Maybe we can switch to a built-in passwordHasher? + print("Can't import argon2-cffi, accounts functioning will be disabled.") + A2_IMPORT = False import pickle import secrets accounts = {} session = {} -ph = argon2.PasswordHasher() +if A2_IMPORT: + ph = argon2.PasswordHasher() +else: + ph = None def loadState(): global accounts @@ -15,7 +24,6 @@ def loadState(): if os.path.exists("server.dat"): with open("server.dat", "rb") as f: accounts = pickle.load(f) - #print(str(len(accounts)) + " accounts") except Exception as e: print(e) @@ -24,59 +32,91 @@ def persistState(): pickle.dump(accounts, f) def register(username, password): - if len(username) < 5: - return (False, "username too short") + if ph is None: + return False, "account system disabled" + if len(username) < 3: + return False, "username too short" + if len(username) > 20: + return False, "username too long" if len(password) < 8: - return (False, "password too short") + return False, "password too short" + if len(password) > 120: + return False, "password too long" if username in accounts: - return (False, "account already registered") + return False, "account already registered" + salt = hashlib.sha256(os.urandom(60)).hexdigest().encode('ascii') pwdhash = ph.hash(password.encode('utf-8')+salt) - acc = {"salt" : salt, "pwdhash" : pwdhash, "nickname": username, "skin": 0, "squad": "" } + + acc = { "salt": salt, + "pwdhash": pwdhash, + "nickname": username, + "skin": 0, + "squad": "" } accounts[username] = acc + persistState() + acc2 = acc.copy() del acc2["salt"] del acc2["pwdhash"] + token = secrets.token_urlsafe(32) - acc2["session"] = token session[token] = username - persistState() - return (True, acc2) + acc2["session"] = token + return True, acc2 def login(username, password): + if ph is None: + return False, "account system disabled" + invalidMsg = "invalid user name or password" - if not username in accounts: - return (False, invalidMsg) + if len(username) < 3: + return False, invalidMsg + if len(username) > 20: + return False, invalidMsg + if len(password) < 8: + return False, invalidMsg + if len(password) > 120: + return False, invalidMsg + if username not in accounts: + return False, invalidMsg acc = accounts[username] + try: ph.verify(acc["pwdhash"], password.encode('utf-8')+acc["salt"]) except: - return (False, invalidMsg) + return False, invalidMsg + acc2 = acc.copy() del acc2["salt"] del acc2["pwdhash"] + token = secrets.token_urlsafe(32) - acc2["session"] = token session[token] = username - return (True, acc2) + acc2["session"] = token + return True, acc2 def resumeSession(token): - if not token in session: - return (False, "session expired, please log in") + if token not in session: + return False, "session expired, please log in" + username = session[token] - if not username in accounts: - return (False, "invalid user name or password") + if username not in accounts: + return False, "invalid user name or password" acc = accounts[username] + acc2 = acc.copy() del acc2["salt"] del acc2["pwdhash"] + acc2["username"] = username acc2["session"] = token - return (True, acc2) + return True, acc2 def updateAccount(username, data): - if not username in accounts: + if username not in accounts: return + acc = accounts[username] if "nickname" in data: acc["nickname"] = data["nickname"] diff --git a/server.py b/server.py index 4bd2a8d..2c50b85 100644 --- a/server.py +++ b/server.py @@ -24,18 +24,28 @@ print("Can't import discord_webhook, discord functioning will be disabled.") DWH_IMPORT = False +try: + from captcha.image import ImageCaptcha + CP_IMPORT = True +except: + print("Can't import captcha, captcha functioning will be disabled.") + CP_IMPORT = False + from autobahn.twisted.websocket import WebSocketServerProtocol, WebSocketServerFactory from twisted.internet.protocol import Factory import json +import string import random +import base64 import hashlib import traceback import configparser +from io import BytesIO from buffer import Buffer from player import Player from match import Match -NUM_SKINS = 22 #temporary until shop is implemented +NUM_SKINS = 33 #temporary until shop is implemented class MyServerProtocol(WebSocketServerProtocol): def __init__(self, server): @@ -92,6 +102,12 @@ def onClose(self, wasClean, code, reason): pass self.stopDCTimer() + if self.address in self.server.captchas: + del self.server.captchas[self.address] + + if self.username != "" and self.username in self.server.authd: + self.server.authd.remove(self.username) + if self.stat == "g" and self.player != None: self.server.players.remove(self.player) self.player.match.removePlayer(self.player) @@ -163,11 +179,10 @@ def onTextMessage(self, payload): if self.stat == "l": if type == "l00": # Input state ready - if self.pendingStat is None: + if self.player is not None or self.pendingStat is None: self.sendClose() return self.pendingStat = None - self.stopDCTimer() if self.address != "127.0.0.1" and self.server.getPlayerCountByAddress(self.address) >= self.server.maxSimulIP: @@ -181,14 +196,14 @@ def onTextMessage(self, payload): self.setState("g") # Ingame return + name = packet["name"] team = packet["team"][:3].strip().upper() - if len(team) == 0: - team = self.server.defaultTeam - skin = int(packet["skin"] if "skin" in packet else 0) + priv = packet["private"] + skin = int(packet["skin"]) if "skin" in packet else 0 self.player = Player(self, - packet["name"], - team, - self.server.getMatch(team, packet["private"] if "private" in packet else False), + name if self.username != "" else ("*"+name), + team if team != "" else self.server.defaultTeam, + self.server.getMatch(team, priv if "private" in packet else False), skin if skin in range(NUM_SKINS) else 0) self.loginSuccess() self.server.players.append(self.player) @@ -196,39 +211,112 @@ def onTextMessage(self, payload): self.setState("g") # Ingame elif type == "llg": #login + if self.username != "" or self.player is not None or self.pendingStat is None: + self.sendClose() + return self.stopDCTimer() - status, msg = datastore.login(packet["username"], packet["password"]) - self.sendJSON({"type": "llg", "status": status, "msg": msg}) - if (status): - self.username = packet["username"] + + username = packet["username"].upper() + if self.address in self.server.loginBlocked: + self.sendJSON({"type": "llg", "status": False, "msg": "max login tries reached.\ntry again in one minute."}) + return + elif username in self.server.authd: + self.sendJSON({"type": "llg", "status": False, "msg": "account already in use"}) + return + + status, msg = datastore.login(username, packet["password"]) + + if status: + self.username = username self.session = msg["session"] + self.server.authd.append(self.username) + else: + if self.address not in self.server.maxLoginTries: + self.server.maxLoginTries[self.address] = 1 + else: + self.server.maxLoginTries[self.address] += 1 + if self.server.maxLoginTries[self.address] >= 4: + del self.server.maxLoginTries[self.address] + self.server.loginBlocked.append(self.address) + reactor.callLater(60, self.server.loginBlocked.remove, self.address) + self.sendJSON({"type": "llg", "status": status, "msg": msg}) elif type == "llo": #logout + if self.username == "" or self.player is not None or self.pendingStat is None: + self.sendClose() + return + datastore.logout(self.session) self.sendJSON({"type": "llo"}) elif type == "lrg": #register + if self.username != "" or self.address not in self.server.captchas or self.player is not None or self.pendingStat is None: + self.sendClose() + return self.stopDCTimer() - if self.server.checkCurse(packet["username"]): - status, msg = (False, "please choose a different username") + + username = packet["username"].upper() + if CP_IMPORT and len(packet["captcha"]) != 5: + status, msg = False, "invalid captcha" + elif CP_IMPORT and packet["captcha"].upper() != self.server.captchas[self.address]: + status, msg = False, "incorrect captcha" + elif self.server.checkCurse(username): + status, msg = False, "please choose a different username" else: - status, msg = datastore.register(packet["username"], packet["password"]) - self.sendJSON({"type": "lrg", "status": status, "msg": msg}) - if (status): - self.username = packet["username"] + status, msg = datastore.register(username, packet["password"]) + + if status: + del self.server.captchas[self.address] + self.username = username self.session = msg["session"] + self.server.authd.append(self.username) + self.sendJSON({"type": "lrg", "status": status, "msg": msg}) + + elif type == "lrc": #request captcha + if self.username != "" or self.player is not None or self.pendingStat is None: + self.sendClose() + return + if not CP_IMPORT: + self.server.captchas[self.address] = "" + self.sendJSON({"type": "lrc", "data": ""}) + return + self.stopDCTimer() + + cp = ''.join(random.SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(5)) + self.server.captchas[self.address] = cp + + imageCaptcha = ImageCaptcha() + image = imageCaptcha.generate_image(cp) + + imgByteArr = BytesIO() + image.save(imgByteArr, format='PNG') + imgByteArr = imgByteArr.getvalue() + + self.sendJSON({"type": "lrc", "data": base64.b64encode(imgByteArr).decode("utf-8")}) + elif type == "lrs": #resume session + if self.username != "" or self.player is not None or self.pendingStat is None: + self.sendClose() + return self.stopDCTimer() + status, msg = datastore.resumeSession(packet["session"]) - self.sendJSON({"type": "lrs", "status": status, "msg": msg}) - if (status): + + if status: + if msg["username"] in self.server.authd: + self.sendJSON({"type": "lrs", "status": False, "msg": "account already in use"}) + return self.username = msg["username"] self.session = msg["session"] + self.server.authd.append(self.username) + self.sendJSON({"type": "lrs", "status": status, "msg": msg}) elif type == "lpr": #update profile - if self.username == "": + if self.username == "" or self.player is not None or self.pendingStat is None: + self.sendClose() return + datastore.updateAccount(self.username, packet) elif self.stat == "g": @@ -348,6 +436,11 @@ def __init__(self, url): self.randomWorldList = list() + self.maxLoginTries = {} + self.loginBlocked = [] + self.captchas = {} + self.authd = [] + self.in_messages = 0 self.out_messages = 0