diff --git a/Pipfile b/Pipfile index dc52b7b..bd0d5dc 100644 --- a/Pipfile +++ b/Pipfile @@ -14,6 +14,7 @@ future = "*" "discord.py" = {git = "https://github.com/Rapptz/discord.py.git"} sqlalchemy = "*" mcstatus = "*" +quarry = "*" [requires] python_version = "3.6" diff --git a/Pipfile.lock b/Pipfile.lock index 0a92f12..259cf0d 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "b74ed57bc94dedc5e0cb39283d2d16b952c17d6db29c6a0c51956b5607f54017" + "sha256": "45a63e42d93560ed412ddaeed6b5962c849eeaf9424c12f63e4a6c3aea903377" }, "pipfile-spec": 6, "requires": { @@ -23,6 +23,20 @@ ], "version": "==0.24.0" }, + "attrs": { + "hashes": [ + "sha256:10cbf6e27dbce8c30807caf056c8eb50917e0eaafe86347671b57254006c3e69", + "sha256:ca4be454458f9dec299268d472aaa5a11f67a4ff70093396e1ceae9c76cf4bbb" + ], + "version": "==18.2.0" + }, + "automat": { + "hashes": [ + "sha256:cbd78b83fa2d81fe2a4d23d258e1661dd7493c9a50ee2f1a5b2cac61c1793b0e", + "sha256:fdccab66b68498af9ecfa1fa43693abe546014dd25cf28543cbe9d1334916a58" + ], + "version": "==0.7.0" + }, "certifi": { "hashes": [ "sha256:376690d6f16d32f9d1fe8932551d80b23e9d393a8578c5633a2ed39a64861638", @@ -81,6 +95,13 @@ ], "version": "==7.0" }, + "constantly": { + "hashes": [ + "sha256:586372eb92059873e29eba4f9dec8381541b4d3834660707faf8ba59146dfc35", + "sha256:dd2fa9d6b1a51a83f0d7dd76293d734046aa176e384bf6e33b7e44880eb37c5d" + ], + "version": "==15.1.0" + }, "cryptography": { "hashes": [ "sha256:02602e1672b62e803e08617ec286041cc453e8d43f093a5f4162095506bc0beb", @@ -129,6 +150,13 @@ "index": "pypi", "version": "==0.16.0" }, + "hyperlink": { + "hashes": [ + "sha256:98da4218a56b448c7ec7d2655cb339af1f7d751cf541469bb4fc28c4a4245b34", + "sha256:f01b4ff744f14bc5d0a22a6b9f1525ab7d6312cb0ff967f59414bbac52f0a306" + ], + "version": "==18.0.0" + }, "idna": { "hashes": [ "sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e", @@ -136,6 +164,13 @@ ], "version": "==2.7" }, + "incremental": { + "hashes": [ + "sha256:717e12246dddf231a349175f48d74d93e2897244939173b01974ab6661406b9f", + "sha256:7b751696aaf36eebfab537e458929e194460051ccad279c72b755a167eebd4b3" + ], + "version": "==17.5.0" + }, "mcstatus": { "hashes": [ "sha256:dc0e9fe79d8e3d4c7f3e218921b82b50ab31c991e88cb86831377bd5d2789c12" @@ -146,6 +181,42 @@ "minecraft": { "git": "https://github.com/ammaraskar/pyCraft.git" }, + "pyasn1": { + "hashes": [ + "sha256:0ad0fe0593dde1e599cac0bf65bb1a4ec663032f0bc68ee44850db4251e8c501", + "sha256:13794d835643ee970b2c059dbfe4eb5d751e16c693c8baee61c526abd209e5c7", + "sha256:49a8ed515f26913049113820b462f698e6ed26df62c389dafb6fa3685ddca8de", + "sha256:74ac8521a0480f228549be20bea555ae35678f0e754c2fbc6f1576b0959bec43", + "sha256:89399ca8ecd4524f974e926d4ef9e7a787903e01f0a9cdff3131ad1361792fe5", + "sha256:8f291e0338d519a1a0d07f0b9d03c9265f6be26eb32fdd21af6d3259d14ea49c", + "sha256:b9d3abc5031e61927c82d4d96c1cec1e55676c1a991623cfed28faea73cdd7ca", + "sha256:d3bbd726c1a760d4ca596a4d450c380b81737612fe0182f5bb3caebc17461fd9", + "sha256:dea873d6c907c1cf1341fd88742a61efce33227d7743cb37564ab7d7e77dd9fd", + "sha256:ded5eea5cb88bc1ce9aa074b5a3092f95ce4741887e317e9b49c7ece75d7ea0e", + "sha256:e8b69ea2200d42201cbedd486eedb8980f320d4534f83ce2fb468e96aa5545d0", + "sha256:edad117649643230493aeb4955456ce19ab4b12e94489dde6f7094cdb5a3c87e", + "sha256:f58f2a3d12fd754aa123e9fa74fb7345333000a035f3921dbdaa08597aa53137" + ], + "version": "==0.4.4" + }, + "pyasn1-modules": { + "hashes": [ + "sha256:077250b34432520430bc1c80dcbda4e354090785567c33ded35faa6df8d24753", + "sha256:0da2f947e8ad2697e86fe5fd0e55a4093a2fd79d839c9e19c34e28097db7002c", + "sha256:35ff894a0b5df8e28b700126b2869c7dcfb2b2db5bc82e5d5e82547069241553", + "sha256:44688b94841349648b1e1a5a7a3d96e6596d5d4f21d0b59a82307e153c4dc74b", + "sha256:833716dde880a7f2f2ccdeea9a096842626981ff2a477d8b318c0906367ac11b", + "sha256:a0cf3e1842e7c60fde97cb22d275eb6f9524f5c5250489e292529de841417547", + "sha256:a38a8811ea784c0136abfdba73963876328f66172db21a05a82f9515909bfb4e", + "sha256:a728bb9502d1fdc104c66f24a176b6a70a32e89d1d8a5b55c959233ed51c67be", + "sha256:c30a098435ea0989c37005a971843e9d3966c7f6d056ddbf052e5061c06e3291", + "sha256:c355a45b32c5bc1d9893eceb704b0cfcd1126f91b5a7b9ee64c1c05383283381", + "sha256:e64679de1940f41ead5170fce364d54e7b9e2e862f064727b6bcb5cee753b7a2", + "sha256:ed71d20225c356881c29f0b1d7a0d6521563a389d9478e8f95d798cc5ba07b88", + "sha256:f183f0940b9f5ed2ad9d04c80cab2451440fa9af4fc959d85113fadd2e777962" + ], + "version": "==0.2.2" + }, "pycparser": { "hashes": [ "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3" @@ -155,6 +226,30 @@ "pycraft": { "git": "https://github.com/ammaraskar/pyCraft.git" }, + "pyhamcrest": { + "hashes": [ + "sha256:6b672c02fdf7470df9674ab82263841ce8333fb143f32f021f6cb26f0e512420", + "sha256:7a4bdade0ed98c699d728191a058a60a44d2f9c213c51e2dd1e6fb42f2c6128a", + "sha256:8ffaa0a53da57e89de14ced7185ac746227a8894dbd5a3c718bf05ddbd1d56cd", + "sha256:bac0bea7358666ce52e3c6c85139632ed89f115e9af52d44b3c36e0bf8cf16a9", + "sha256:f30e9a310bcc1808de817a92e95169ffd16b60cbc5a016a49c8d0e8ababfae79" + ], + "version": "==1.9.0" + }, + "pyopenssl": { + "hashes": [ + "sha256:26ff56a6b5ecaf3a2a59f132681e2a80afcc76b4f902f612f518f92c2a1bf854", + "sha256:6488f1423b00f73b7ad5167885312bb0ce410d3312eb212393795b53c8caa580" + ], + "version": "==18.0.0" + }, + "quarry": { + "hashes": [ + "sha256:981ee6b2ca05f3ee94098ded0e9ae052ddec0d62448e0419a2cd7d306b55cd11" + ], + "index": "pypi", + "version": "==1.1.1" + }, "requests": { "hashes": [ "sha256:63b52e3c866428a224f97cab011de738c36aec0185aa91cfacd418b5d58911d1", @@ -163,6 +258,13 @@ "index": "pypi", "version": "==2.19.1" }, + "service-identity": { + "hashes": [ + "sha256:0e76f3c042cc0f5c7e6da002cf646f59dc4023962d1d1166343ce53bdad39e17", + "sha256:4001fbb3da19e0df22c47a06d29681a398473af4aa9d745eca525b3b2c2302ab" + ], + "version": "==17.0.0" + }, "six": { "hashes": [ "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", @@ -177,12 +279,43 @@ "index": "pypi", "version": "==1.2.12" }, + "twisted": { + "hashes": [ + "sha256:5de7b79b26aee64efe63319bb8f037af88c21287d1502b39706c818065b3d5a4", + "sha256:95ae985716e8107816d8d0df249d558dbaabb677987cc2ace45272c166b267e4" + ], + "version": "==18.7.0" + }, "urllib3": { "hashes": [ "sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf", "sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5" ], "version": "==1.23" + }, + "zope.interface": { + "hashes": [ + "sha256:21506674d30c009271fe68a242d330c83b1b9d76d62d03d87e1e9528c61beea6", + "sha256:3d184aff0756c44fff7de69eb4cd5b5311b6f452d4de28cb08343b3f21993763", + "sha256:40c89d3a4e7e6c20db80be638a0a2be103b659ae3b2fc92e25ef1c7ac11be798", + "sha256:425e1458d7b893f3ae2fb1bd8344e7bb0beaa114cdc91299c797442638021a17", + "sha256:467d364b24cb398f76ad5e90398d71b9325eb4232be9e8a50d6a3b3c7a1c8789", + "sha256:4d17483ec4177ee085e690d8d4104de913392250bc50f41fb240c9f378bfb88b", + "sha256:57c38470d9f57e37afb460c399eb254e7193ac7fb8042bd09bdc001981a9c74c", + "sha256:727aa737a48dd33d6859296f15edb54e85ccdfa5667513f7a50daf362b3df75b", + "sha256:7307f7e341f47f057756e1e0e9f5bad85d4cf55a6a8aaafe681e2e5e5dd79446", + "sha256:9ada83f4384bbb12dedc152bcdd46a3ac9f5f7720d43ac3ce3e8e8b91d733c10", + "sha256:a1daf9c5120f3cc6f2b5fef8e1d2a3fb7bbbb20ed4bfdc25bc8364bc62dcf54b", + "sha256:aa1f22d5380d440c78bd9b13dbe697292422277a30eeabcd0ba66b6e37a25ef1", + "sha256:b445e2d58d8faa94a9ae863ed10c661e05d7ba6ce00548867a0153e6d927e4a2", + "sha256:c2a0ba07228921393b0e28d7306bf8652d5d392cff7eea3f4c0ba599b28f85de", + "sha256:e6b77ae84f2b8502d99a7855fa33334a1eb6159de45626905cb3e454c023f339", + "sha256:e881ef610ff48aece2f4ee2af03d2db1a146dc7c705561bd6089b2356f61641f", + "sha256:ec3cdfb92f714fc02aaf390ee34e7fa636a0478a0976733ac2b39bc23b11a095", + "sha256:ef4b9bf3ff28a874a2becda91c281ef4dbc89f9ceacfb1d11040893080a48e18", + "sha256:f41037260deaacb875db250021fe883bf536bf6414a4fd25b25059b02e31b120" + ], + "version": "==4.5.0" } }, "develop": {} diff --git a/auth_server.py b/auth_server.py new file mode 100644 index 0000000..ee1d93a --- /dev/null +++ b/auth_server.py @@ -0,0 +1,77 @@ +import logging +from datetime import datetime, timezone + +from quarry.net.server import ServerFactory, ServerProtocol + +from database import AccountLinkToken, MinecraftAccount, DiscordAccount +import database_session + + +class AuthProtocol(ServerProtocol): + def player_joined(self): + # This method gets called when a player successfully joins the server. + # If we're in online mode (the default), this means auth with the + # session server was successful and the user definitely owns the + # display name they claim to. + + # Call super. This switches us to "play" mode, marks the player as + # in-game, and does some logging. + ServerProtocol.player_joined(self) + + # Define your own logic here. It could be an HTTP request to an API, + # or perhaps an update to a database table. + display_name = self.display_name + uuid = self.uuid + + # Monkey Patch for Forge deciding to append "\x00FML\x00" to the connection string + if self.connect_host.endswith("\x00FML\x00"): + ip_addr = self.connect_host[:-5] + else: + ip_addr = self.connect_host + + connect_port = self.connect_port + + self.logger.info("[AUTH SERVER] {} ({}) connected to address {}:{}".format(display_name, uuid, ip_addr, connect_port)) + try: + connection_token = ip_addr.split(".")[0] + session = database_session.get_session() + token = session.query(AccountLinkToken).filter_by(token=connection_token).first() + if not token: + self.close("You have connected with an invalid token!") + return + discord_account = session.query(DiscordAccount).filter_by(link_token_id=token.id).first() + if not discord_account: + self.close("You have connected with an invalid token!") + return + if datetime.utcnow() < token.expiry: + # Check if they already have a linked account and are re-linking + if discord_account.minecraft_account_id != None: + existing_account = session.query(MinecraftAccount).filter_by(id=discord_account.minecraft_account_id).first() + self.logger.info("[AUTH SERVER] unlinking existing {} account and replacing it with {}".format(existing_account.minecraft_uuid, str(uuid))) + session.delete(existing_account) + mc_account = MinecraftAccount(str(uuid), discord_account.id) + discord_account.minecraft_account = mc_account + session.add(mc_account) + session.delete(token) + session.commit() + session.close() + self.close("Your minecraft account has successfully been linked to your discord account!") + return + else: + session.delete(token) + session.commit() + session.close() + self.close("You have connected with an expired token! Please run the mc!register command again to get a new token.") + return + + except Exception as e: + self.logger.error(e) + + + # Kick the player. + self.close("This shouldn't happen!") + + +class AuthFactory(ServerFactory): + protocol = AuthProtocol + motd = "Auth Server" \ No newline at end of file diff --git a/config.example.json b/config.example.json index 244ef8a..6b5bdbb 100644 --- a/config.example.json +++ b/config.example.json @@ -9,6 +9,11 @@ "DISCORD_APP_TOKEN": "", "LOG_LEVEL": "INFO" }, + "AUTH_SERVER": { + "BIND_IP": "", + "PORT": 9822, + "DNS_WILDCARD": "" + }, "DATABASE": { "CONNECTION_STRING": "sqlite:////data/db.sqlite" } diff --git a/config.py b/config.py index b394564..e301aaa 100644 --- a/config.py +++ b/config.py @@ -14,6 +14,9 @@ class Configuration(object): self.mc_online = self._config["MAIN"]["MC_ONLINE"] self.discord_token = self._config["MAIN"]["DISCORD_APP_TOKEN"] self.logging_level = self._config["MAIN"]["LOG_LEVEL"] + self.auth_ip = self._config["AUTH_SERVER"]["BIND_IP"] + self.auth_port = self._config["AUTH_SERVER"]["PORT"] + self.auth_dns = self._config["AUTH_SERVER"]["DNS_WILDCARD"] self.database_connection_string = self._config["DATABASE"]["CONNECTION_STRING"] else: print("error reading config") diff --git a/database.py b/database.py index 0247a6d..407d1ec 100644 --- a/database.py +++ b/database.py @@ -1,5 +1,9 @@ -from sqlalchemy import Column, String, Integer, Date +from datetime import datetime, timedelta, timezone + +from sqlalchemy import Column, String, Integer, DateTime, ForeignKey from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import relationship + from database_session import Base class DiscordChannel(Base): @@ -9,4 +13,47 @@ class DiscordChannel(Base): channel_id = Column(Integer) def __init__(self, channel_id): - self.channel_id = channel_id \ No newline at end of file + self.channel_id = channel_id + + +class AccountLinkToken(Base): + __tablename__ = 'account_link_tokens' + + id = Column(Integer, primary_key=True) + discord_account = relationship("DiscordAccount", back_populates="link_token") + token = Column(String) + expiry = Column(DateTime) + + def __init__(self, discord_id, token): + self.discord_id = discord_id + self.token = token + now = datetime.now(timezone.utc) + # Token expires an hour from now + then = now + timedelta(hours=1) + self.expiry = then + + +class MinecraftAccount(Base): + __tablename__ = 'minecraft_accounts' + + id = Column(Integer, primary_key=True) + minecraft_uuid = Column(String) + discord_account = relationship("DiscordAccount", back_populates="minecraft_account") + + def __init__(self, minecraft_uuid, discord_id): + self.minecraft_uuid = minecraft_uuid + self.discord_account_id = discord_id + + +class DiscordAccount(Base): + __tablename__ = 'discord_accounts' + + id = Column(Integer, primary_key=True) + discord_id = Column(Integer) + link_token_id = Column(Integer, ForeignKey('account_link_tokens.id')) + minecraft_account_id = Column(Integer, ForeignKey('minecraft_accounts.id')) + link_token = relationship("AccountLinkToken", uselist=False, foreign_keys=[link_token_id], back_populates="discord_account") + minecraft_account = relationship("MinecraftAccount", uselist=False, foreign_keys=[minecraft_account_id], back_populates="discord_account") + + def __init__(self, discord_id): + self.discord_id = discord_id \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index c93309d..8bddcf3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,6 +2,8 @@ version: '2.3' services: webhook-bridge: build: . + ports: + - 9822:9822 volumes: - ./:/app:Z - ./:/data:Z diff --git a/webhook-bridge.py b/webhook-bridge.py index 1fc77a8..dd4a85a 100755 --- a/webhook-bridge.py +++ b/webhook-bridge.py @@ -9,9 +9,12 @@ import requests import json import time import logging +import random +import string +from threading import Thread from optparse import OptionParser from config import Configuration -from database import DiscordChannel +from database import DiscordChannel, AccountLinkToken, MinecraftAccount, DiscordAccount import database_session from minecraft import authentication @@ -38,6 +41,31 @@ def setup_logging(level): stdout_logger.setFormatter(logging.Formatter(log_format)) logging.getLogger().addHandler(stdout_logger) + +def run_auth_server(port): + # We need to import twisted after setting up the logger because twisted hijacks our logging + # TODO: Fix this in a cleaner way + from twisted.internet import reactor + from auth_server import AuthFactory, AuthProtocol + + # Create factory + factory = AuthFactory() + + # Listen + logging.info("Starting authentication server on port {}".format(port)) + + factory.listen("", port) + try: + reactor.run(installSignalHandlers=False) + except KeyboardInterrupt: + reactor.stop() + + +def generate_random_auth_token(length): + letters = string.ascii_lowercase + string.digits + string.ascii_uppercase + return ''.join(random.choice(letters) for i in range(length)) + + def main(): config = Configuration("config.json") setup_logging(config.logging_level) @@ -46,6 +74,9 @@ def main(): database_session.initialize(config) + reactor_thread = Thread(target=run_auth_server, args=(config.auth_port,)) + reactor_thread.start() + def handle_disconnect(join_game_packet): logging.info('Disconnected.') connection.disconnect(immediate=True) @@ -82,6 +113,8 @@ def main(): except YggdrasilError as e: logging.info(e) sys.exit() + global BOT_USERNAME + BOT_USERNAME = auth_token.username logging.info("Logged in as %s..." % auth_token.username) if not is_server_online(): logging.info('Not connecting to server because it appears to be offline.') @@ -203,7 +236,33 @@ def main(): @discord_bot.event async def on_message(message): + # We do not want the bot to reply to itself + if message.author == discord_bot.user: + return this_channel = message.channel.id + if message.channel.is_private: + if message.content.startswith("mc!register"): + session = database_session.get_session() + discord_account = session.query(DiscordAccount).filter_by(discord_id=message.author.id).first() + if not discord_account: + new_discord_account = DiscordAccount(message.author.id) + session.add(new_discord_account) + session.commit() + discord_account = session.query(DiscordAccount).filter_by(discord_id=message.author.id).first() + + new_token = generate_random_auth_token(16) + account_link_token = AccountLinkToken(message.author.id, new_token) + discord_account.link_token = account_link_token + session.add(account_link_token) + session.commit() + msg = "Please connect your minecraft account to `{}.{}:{}` in order to link it to this bridge!".format(new_token, config.auth_dns, config.auth_port) + await discord_bot.send_message(message.channel, msg) + session.close() + return + else: + msg = "Unknown command, type `mc!help` for a list of commands." + await discord_bot.send_message(message.channel, msg) + return if message.content.startswith("mc!chathere"): session = database_session.get_session() channels = session.query(DiscordChannel).filter_by(channel_id=this_channel).all()