diff --git a/Pipfile b/Pipfile index cfa9f5c1f..8aba7dc76 100644 --- a/Pipfile +++ b/Pipfile @@ -20,11 +20,12 @@ trueskill = "*" aiocron = "*" oauthlib = "*" sqlalchemy = "*" -twilio = "*" +twilio = ">=6.0.0,<6.51.0" # See https://github.com/twilio/twilio-python/issues/556 humanize = ">=2.6.0" aiomysql = {editable = true, git = "https://github.com/aio-libs/aiomysql"} pyyaml = "*" aio_pika = "*" +pyjwt = {version = ">=2", extras = ["crypto"]} [dev-packages] pytest = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 4616aa54e..b20d94933 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "2a7076b2bc5b2fb38eeaed3232642fc0cd6a8e79ed5368d413410d70de3d93b3" + "sha256": "b53ed2a6e5084cc0fbb710d089ceff3a51d466ef20f82c0631befa0c099de165" }, "pipfile-spec": 6, "requires": { @@ -111,6 +111,60 @@ ], "version": "==2021.5.30" }, + "cffi": { + "hashes": [ + "sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813", + "sha256:04c468b622ed31d408fea2346bec5bbffba2cc44226302a0de1ade9f5ea3d373", + "sha256:06d7cd1abac2ffd92e65c0609661866709b4b2d82dd15f611e602b9b188b0b69", + "sha256:06db6321b7a68b2bd6df96d08a5adadc1fa0e8f419226e25b2a5fbf6ccc7350f", + "sha256:0857f0ae312d855239a55c81ef453ee8fd24136eaba8e87a2eceba644c0d4c06", + "sha256:0f861a89e0043afec2a51fd177a567005847973be86f709bbb044d7f42fc4e05", + "sha256:1071534bbbf8cbb31b498d5d9db0f274f2f7a865adca4ae429e147ba40f73dea", + "sha256:158d0d15119b4b7ff6b926536763dc0714313aa59e320ddf787502c70c4d4bee", + "sha256:1bf1ac1984eaa7675ca8d5745a8cb87ef7abecb5592178406e55858d411eadc0", + "sha256:1f436816fc868b098b0d63b8920de7d208c90a67212546d02f84fe78a9c26396", + "sha256:24a570cd11895b60829e941f2613a4f79df1a27344cbbb82164ef2e0116f09c7", + "sha256:24ec4ff2c5c0c8f9c6b87d5bb53555bf267e1e6f70e52e5a9740d32861d36b6f", + "sha256:2894f2df484ff56d717bead0a5c2abb6b9d2bf26d6960c4604d5c48bbc30ee73", + "sha256:29314480e958fd8aab22e4a58b355b629c59bf5f2ac2492b61e3dc06d8c7a315", + "sha256:293e7ea41280cb28c6fcaaa0b1aa1f533b8ce060b9e701d78511e1e6c4a1de76", + "sha256:34eff4b97f3d982fb93e2831e6750127d1355a923ebaeeb565407b3d2f8d41a1", + "sha256:35f27e6eb43380fa080dccf676dece30bef72e4a67617ffda586641cd4508d49", + "sha256:3c3f39fa737542161d8b0d680df2ec249334cd70a8f420f71c9304bd83c3cbed", + "sha256:3d3dd4c9e559eb172ecf00a2a7517e97d1e96de2a5e610bd9b68cea3925b4892", + "sha256:43e0b9d9e2c9e5d152946b9c5fe062c151614b262fda2e7b201204de0b99e482", + "sha256:48e1c69bbacfc3d932221851b39d49e81567a4d4aac3b21258d9c24578280058", + "sha256:51182f8927c5af975fece87b1b369f722c570fe169f9880764b1ee3bca8347b5", + "sha256:58e3f59d583d413809d60779492342801d6e82fefb89c86a38e040c16883be53", + "sha256:5de7970188bb46b7bf9858eb6890aad302577a5f6f75091fd7cdd3ef13ef3045", + "sha256:65fa59693c62cf06e45ddbb822165394a288edce9e276647f0046e1ec26920f3", + "sha256:681d07b0d1e3c462dd15585ef5e33cb021321588bebd910124ef4f4fb71aef55", + "sha256:69e395c24fc60aad6bb4fa7e583698ea6cc684648e1ffb7fe85e3c1ca131a7d5", + "sha256:6c97d7350133666fbb5cf4abdc1178c812cb205dc6f41d174a7b0f18fb93337e", + "sha256:6e4714cc64f474e4d6e37cfff31a814b509a35cb17de4fb1999907575684479c", + "sha256:72d8d3ef52c208ee1c7b2e341f7d71c6fd3157138abf1a95166e6165dd5d4369", + "sha256:8ae6299f6c68de06f136f1f9e69458eae58f1dacf10af5c17353eae03aa0d827", + "sha256:8b198cec6c72df5289c05b05b8b0969819783f9418e0409865dac47288d2a053", + "sha256:99cd03ae7988a93dd00bcd9d0b75e1f6c426063d6f03d2f90b89e29b25b82dfa", + "sha256:9cf8022fb8d07a97c178b02327b284521c7708d7c71a9c9c355c178ac4bbd3d4", + "sha256:9de2e279153a443c656f2defd67769e6d1e4163952b3c622dcea5b08a6405322", + "sha256:9e93e79c2551ff263400e1e4be085a1210e12073a31c2011dbbda14bda0c6132", + "sha256:9ff227395193126d82e60319a673a037d5de84633f11279e336f9c0f189ecc62", + "sha256:a465da611f6fa124963b91bf432d960a555563efe4ed1cc403ba5077b15370aa", + "sha256:ad17025d226ee5beec591b52800c11680fca3df50b8b29fe51d882576e039ee0", + "sha256:afb29c1ba2e5a3736f1c301d9d0abe3ec8b86957d04ddfa9d7a6a42b9367e396", + "sha256:b85eb46a81787c50650f2392b9b4ef23e1f126313b9e0e9013b35c15e4288e2e", + "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991", + "sha256:cbde590d4faaa07c72bf979734738f328d239913ba3e043b1e98fe9a39f8b2b6", + "sha256:cc5a8e069b9ebfa22e26d0e6b97d6f9781302fe7f4f2b8776c3e1daea35f1adc", + "sha256:cd2868886d547469123fadc46eac7ea5253ea7fcb139f12e1dfc2bbd406427d1", + "sha256:d42b11d692e11b6634f7613ad8df5d6d5f8875f5d48939520d351007b3c13406", + "sha256:df5052c5d867c1ea0b311fb7c3cd28b19df469c056f7fdcfe88c7473aa63e333", + "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d", + "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c" + ], + "version": "==1.14.5" + }, "chardet": { "hashes": [ "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", @@ -127,6 +181,23 @@ "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.0.13" }, + "cryptography": { + "hashes": [ + "sha256:0f1212a66329c80d68aeeb39b8a16d54ef57071bf22ff4e521657b27372e327d", + "sha256:1e056c28420c072c5e3cb36e2b23ee55e260cb04eee08f702e0edfec3fb51959", + "sha256:240f5c21aef0b73f40bb9f78d2caff73186700bf1bc6b94285699aff98cc16c6", + "sha256:26965837447f9c82f1855e0bc8bc4fb910240b6e0d16a664bb722df3b5b06873", + "sha256:37340614f8a5d2fb9aeea67fd159bfe4f5f4ed535b1090ce8ec428b2f15a11f2", + "sha256:3d10de8116d25649631977cb37da6cbdd2d6fa0e0281d014a5b7d337255ca713", + "sha256:3d8427734c781ea5f1b41d6589c293089704d4759e34597dce91014ac125aad1", + "sha256:7ec5d3b029f5fa2b179325908b9cd93db28ab7b85bb6c1db56b10e0b54235177", + "sha256:8e56e16617872b0957d1c9742a3f94b43533447fd78321514abbe7db216aa250", + "sha256:de4e5f7f68220d92b7637fc99847475b59154b7a1b3868fb7385337af54ac9ca", + "sha256:eb8cc2afe8b05acbd84a43905832ec78e7b3873fb124ca190f574dca7389a87d", + "sha256:ee77aa129f481be46f8d92a1a7db57269a2f23052d5f2433b4621bb457081cc9" + ], + "version": "==3.4.7" + }, "docopt": { "hashes": [ "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491" @@ -207,11 +278,11 @@ }, "importlib-metadata": { "hashes": [ - "sha256:960d52ba7c21377c990412aca380bf3642d734c2eaab78a2c39319f67c6a5786", - "sha256:e592faad8de1bda9fe920cf41e15261e7131bcf266c30306eec00e8e225c1dd5" + "sha256:833b26fb89d5de469b24a390e9df088d4e52e4ba33b01dc5e0e4f41b81a16c00", + "sha256:b142cc1dd1342f31ff04bb7d022492b09920cb64fed867cd3ea6f80fe3ebd139" ], "markers": "python_version < '3.8'", - "version": "==4.4.0" + "version": "==4.5.0" }, "maxminddb": { "hashes": [ @@ -286,12 +357,24 @@ "index": "pypi", "version": "==0.11.0" }, + "pycparser": { + "hashes": [ + "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", + "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.20" + }, "pyjwt": { + "extras": [ + "crypto" + ], "hashes": [ - "sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e", - "sha256:8d59a976fb773f3e6a39c85636357c4f0e242707394cadadd9814f5cbaa20e96" + "sha256:934d73fbba91b0483d3857d1aff50e96b2a892384ee2c17417ed3203f173fca1", + "sha256:fba44e7898bbca160a2b2b501f492824fc8382485d3a6f11ba5d0c1937ce6130" ], - "version": "==1.7.1" + "index": "pypi", + "version": "==2.1.0" }, "pymysql": { "hashes": [ @@ -305,7 +388,7 @@ "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", "version": "==2.8.1" }, "pytz": { @@ -363,7 +446,7 @@ "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", "version": "==1.16.0" }, "sqlalchemy": { @@ -411,10 +494,10 @@ }, "twilio": { "hashes": [ - "sha256:f6cdd2d814c8db411cc6e55145e48491c145af60f5c024e5582578039d0b9141" + "sha256:dd8371c9b4ea422d6de7526b63b587da82e8488f2b3f6b1258d2cad6e4006a65" ], "index": "pypi", - "version": "==6.59.1" + "version": "==6.50.1" }, "typing": { "hashes": [ @@ -430,7 +513,7 @@ "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342", "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84" ], - "markers": "python_version < '3.8'", + "markers": "python_version < '3.8' and python_version < '3.8'", "version": "==3.10.0.0" }, "tzlocal": { @@ -518,7 +601,6 @@ "version": "==21.2.0" }, "coverage": { - "extras": [], "hashes": [ "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c", "sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6", @@ -578,19 +660,19 @@ }, "hypothesis": { "hashes": [ - "sha256:a4b1dbaff6ed1b2d5e5137014edf5d357e0d0c07daa7801fb7c18dbd74a36cd1", - "sha256:bc05fd13a3adcedaf73b6c1b1b1c06a92bc3f550aefb5d34432ef199f71efaaf" + "sha256:cca0e54d769214849755f7a125a2f759615e0386844eb42bd30ca2633d249b61", + "sha256:eeb8311d1477de4974ed3dba5c8e758023c17bb4c804f2e5d5cc4f9a065cbeba" ], "index": "pypi", - "version": "==6.13.11" + "version": "==6.13.12" }, "importlib-metadata": { "hashes": [ - "sha256:960d52ba7c21377c990412aca380bf3642d734c2eaab78a2c39319f67c6a5786", - "sha256:e592faad8de1bda9fe920cf41e15261e7131bcf266c30306eec00e8e225c1dd5" + "sha256:833b26fb89d5de469b24a390e9df088d4e52e4ba33b01dc5e0e4f41b81a16c00", + "sha256:b142cc1dd1342f31ff04bb7d022492b09920cb64fed867cd3ea6f80fe3ebd139" ], "markers": "python_version < '3.8'", - "version": "==4.4.0" + "version": "==4.5.0" }, "iniconfig": { "hashes": [ @@ -699,7 +781,7 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", "version": "==2.4.7" }, "pytest": { @@ -745,7 +827,7 @@ "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", "version": "==0.10.2" }, "typing-extensions": { @@ -754,7 +836,7 @@ "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342", "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84" ], - "markers": "python_version < '3.8'", + "markers": "python_version < '3.8' and python_version < '3.8'", "version": "==3.10.0.0" }, "vulture": { diff --git a/server/__init__.py b/server/__init__.py index e08778f68..455890aeb 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -108,6 +108,7 @@ from .ladder_service import LadderService from .lobbyconnection import LobbyConnection from .message_queue_service import MessageQueueService +from .oauth_service import OAuthService from .party_service import PartyService from .player_service import PlayerService from .protocol import Protocol, QDataStreamProtocol @@ -129,6 +130,7 @@ "GeoIpService", "LadderService", "MessageQueueService", + "OAuthService", "PartyService", "PlayerService", "RatingService", @@ -189,7 +191,8 @@ def __init__( nts_client=twilio_nts, players=self.services["player_service"], ladder_service=self.services["ladder_service"], - party_service=self.services["party_service"] + party_service=self.services["party_service"], + oauth_service=self.services["oauth_service"] ) def write_broadcast( diff --git a/server/config.py b/server/config.py index 411d77579..39306a5c8 100644 --- a/server/config.py +++ b/server/config.py @@ -57,6 +57,8 @@ def __init__(self): self.API_TOKEN_URI = "https://api.test.faforever.com/oauth/token" self.API_BASE_URL = "https://api.test.faforever.com/" self.USE_API = True + # Location of the OAuth jwks + self.HYDRA_JWKS_URI = "https://hydra.faforever.com/.well-known/jwks.json" self.MQ_USER = "faf-python-server" self.MQ_PASSWORD = "banana" diff --git a/server/exceptions.py b/server/exceptions.py index e87809710..a36f81b43 100644 --- a/server/exceptions.py +++ b/server/exceptions.py @@ -50,6 +50,7 @@ class AuthenticationError(Exception): """ The operation failed to authenticate. """ - def __init__(self, message, *args, **kwargs): + def __init__(self, message, method, *args, **kwargs): super().__init__(*args, **kwargs) self.message = message + self.method = method diff --git a/server/lobbyconnection.py b/server/lobbyconnection.py index 36062b614..809fe72c7 100644 --- a/server/lobbyconnection.py +++ b/server/lobbyconnection.py @@ -4,13 +4,15 @@ import asyncio import contextlib -import hashlib import json +import os import random import urllib.parse import urllib.request +from binascii import hexlify from datetime import datetime from functools import wraps +from hashlib import md5 from typing import Optional import aiohttp @@ -48,6 +50,7 @@ from .ice_servers.coturn import CoturnHMAC from .ice_servers.nts import TwilioNTS from .ladder_service import LadderService +from .oauth_service import OAuthService from .party_service import PartyService from .player_service import PlayerService from .players import Player, PlayerState @@ -67,7 +70,8 @@ def __init__( nts_client: Optional[TwilioNTS], geoip: GeoIpService, ladder_service: LadderService, - party_service: PartyService + party_service: PartyService, + oauth_service: OAuthService ): self._db = database self.geoip_service = geoip @@ -77,6 +81,7 @@ def __init__( self.coturn_generator = CoturnHMAC(config.COTURN_HOSTS, config.COTURN_KEYS) self.ladder_service = ladder_service self.party_service = party_service + self.oauth_service = oauth_service self._authenticated = False self.player = None # type: Player self.game_connection = None # type: GameConnection @@ -121,7 +126,15 @@ async def abort(self, logspam=""): async def ensure_authenticated(self, cmd): if not self._authenticated: - if cmd not in ["hello", "ask_session", "create_account", "ping", "pong", "Bottleneck"]: # Bottleneck is sent by the game during reconnect + if cmd not in ( + "Bottleneck", # sent by the game during reconnect + "ask_session", + "auth", + "create_account", + "hello", + "ping", + "pong", + ): metrics.unauth_messages.labels(cmd).inc() await self.abort("Message invalid for unauthenticated connection: %s" % cmd) return False @@ -153,6 +166,7 @@ async def on_message_received(self, message): await handler(message) except AuthenticationError as ex: + metrics.user_logins.labels("failure", ex.method).inc() await self.send({ "command": "authentication_failed", "text": ex.message @@ -372,11 +386,11 @@ async def check_user_login(self, conn, username, password): .order_by(lobby_ban.c.expires_at.desc()) ) + auth_method = "password" auth_error_message = "Login not found or password incorrect. They are case sensitive." row = await result.fetchone() if not row: - metrics.user_logins.labels("failure").inc() - raise AuthenticationError(auth_error_message) + raise AuthenticationError(auth_error_message, auth_method) player_id = row[t_login.c.id] real_username = row[t_login.c.login] @@ -387,8 +401,7 @@ async def check_user_login(self, conn, username, password): ban_expiry = row[lobby_ban.c.expires_at] if dbPassword != password: - metrics.user_logins.labels("failure").inc() - raise AuthenticationError(auth_error_message) + raise AuthenticationError(auth_error_message, auth_method) now = datetime.utcnow() if ban_reason is not None and now < ban_expiry: @@ -404,8 +417,6 @@ async def check_user_login(self, conn, username, password): 'Unfortunately, you must currently link your account to Steam in order to play Forged Alliance Forever. You can do so on {steamlink_url}.'.format(steamlink_url=config.WWW_URL + "/account/link"), recoverable=False) - self._logger.debug("Login from: %s, %s, %s", player_id, username, self.session) - return player_id, real_username, steamid def _set_user_agent_and_version(self, user_agent, version): @@ -505,14 +516,75 @@ async def check_policy_conformity(self, player_id, uid_hash, session, ignore_res return response.get("result", "") == "honest" + async def command_auth(self, message): + token = message["token"] + unique_id = message["unique_id"] + player_id = await self.oauth_service.get_player_id_from_token(token) + auth_method = "token" + + async with self._db.acquire() as conn: + result = await conn.execute( + select([t_login.c.login, t_login.c.steamid]) + .where(t_login.c.id == player_id) + ) + row = await result.fetchone() + + if not row: + self._logger.warning("User id not found in database possible fraudulent token: %s", player_id) + raise AuthenticationError("Cannot find user id", auth_method) + + username = row.login + steamid = row.steamid + + new_irc_password = hexlify(os.urandom(16)).decode() + await self.send({ + "command": "irc_password", + "password": new_irc_password + }) + + await self.on_player_login( + player_id, username, new_irc_password, steamid, unique_id, auth_method + ) + async def command_hello(self, message): login = message["login"].strip() password = message["password"] + unique_id = message["unique_id"] async with self._db.acquire() as conn: - player_id, login, steamid = await self.check_user_login(conn, login, password) - metrics.user_logins.labels("success").inc() + player_id, username, steamid = await self.check_user_login( + conn, login, password + ) + + await self.on_player_login( + player_id, username, password, steamid, unique_id, "password" + ) + + async def on_player_login( + self, + player_id: int, + username: str, + password: str, + steamid: int, + unique_id: str, + method: str + ): + conforms_policy = await self.check_policy_conformity( + player_id, unique_id, self.session, + ignore_result=( + steamid is not None or + self.player_service.is_uniqueid_exempt(player_id) + ) + ) + if not conforms_policy: + return + + self._logger.debug( + "Login from: %s, %s, %s, %s", player_id, username, method, self.session + ) + metrics.user_logins.labels("success", method).inc() + async with self._db.acquire() as conn: await conn.execute( t_login.update().where( t_login.c.id == player_id @@ -522,37 +594,10 @@ async def command_hello(self, message): last_login=func.now() ) ) - - conforms_policy = await self.check_policy_conformity( - player_id, message["unique_id"], self.session, - ignore_result=( - steamid is not None or - self.player_service.is_uniqueid_exempt(player_id) - ) - ) - if not conforms_policy: - return - - # Update the user's IRC registration (why the fuck is this here?!) - m = hashlib.md5() - m.update(password.encode()) - passwordmd5 = m.hexdigest() - m = hashlib.md5() - # Since the password is hashed on the client, what we get at this point is really - # md5(md5(sha256(password))). This is entirely insane. - m.update(passwordmd5.encode()) - irc_pass = "md5:" + str(m.hexdigest()) - - try: - await conn.execute( - "UPDATE anope.anope_db_NickCore SET pass = %s WHERE display = %s", - (irc_pass, login) - ) - except (pymysql.OperationalError, pymysql.ProgrammingError): - self._logger.error("Failure updating NickServ password for %s", login) + await self.update_irc_password(conn, username, password) self.player = Player( - login=str(login), + login=username, session=self.session, player_id=player_id, lobby_connection=self @@ -588,7 +633,7 @@ async def command_hello(self, message): # For backwards compatibility for old clients. For now. "id": self.player.id, - "login": login + "login": username }) # Tell player about everybody online. This must happen after "welcome". @@ -645,6 +690,20 @@ async def command_hello(self, message): await self.send_game_list() + async def update_irc_password(self, conn, login, password): + # Since the password is hashed on the client, what we get at this point + # is really md5(md5(sha256(password))). This is entirely insane. + temp = md5(password.encode()).hexdigest() + irc_pass = "md5:" + md5(temp.encode()).hexdigest() + + try: + await conn.execute( + "UPDATE anope.anope_db_NickCore SET pass = %s WHERE display = %s", + (irc_pass, login) + ) + except (pymysql.OperationalError, pymysql.ProgrammingError): + self._logger.error("Failure updating NickServ password for %s", login) + async def command_restore_game_session(self, message): assert self.player is not None diff --git a/server/metrics.py b/server/metrics.py index c82a9cbe8..18df124c0 100644 --- a/server/metrics.py +++ b/server/metrics.py @@ -51,7 +51,7 @@ ) user_logins = Counter( - "server_user_logins_total", "Total number of login attempts made", ["status"] + "server_user_logins_total", "Total number of login attempts made", ["status", "method"] ) user_agent_version = Counter( diff --git a/server/oauth_service.py b/server/oauth_service.py new file mode 100644 index 000000000..6e7de1861 --- /dev/null +++ b/server/oauth_service.py @@ -0,0 +1,52 @@ +import aiocron +import aiohttp +import jwt +from jwt import InvalidTokenError +from jwt.algorithms import RSAAlgorithm + +from server.config import config + +from .core import Service +from .decorators import with_logger +from .exceptions import AuthenticationError + + +@with_logger +class OAuthService(Service, name="oauth_service"): + """ + Service for managing the OAuth token logins and verification. + """ + + def __init__(self): + self.public_keys = {} + + async def initialize(self) -> None: + await self.retrieve_public_keys() + # crontab: min hour day month day_of_week + # Run every 10 minutes to update public keys. + self._update_cron = aiocron.crontab( + "*/10 * * * *", func=self.retrieve_public_keys + ) + + async def retrieve_public_keys(self) -> None: + """ + Get the latest jwks from the hydra endpoint + """ + async with aiohttp.ClientSession(raise_for_status=True) as session: + async with session.get(config.HYDRA_JWKS_URI) as resp: + jwks = await resp.json() + self.public_keys = { + jwk["kid"]: RSAAlgorithm.from_jwk(jwk) + for jwk in jwks["keys"] + } + + async def get_player_id_from_token(self, token: str) -> int: + """ + Decode the JWT to get the player_id + """ + try: + kid = jwt.get_unverified_header(token)["kid"] + key = self.public_keys[kid] + return int(jwt.decode(token, key=key, algorithms="RS256", options={"verify_aud": False})["sub"]) + except (InvalidTokenError, KeyError, ValueError): + raise AuthenticationError("Token signature was invalid", "token") diff --git a/tests/conftest.py b/tests/conftest.py index ae19ae8b6..b10964f39 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,6 +33,7 @@ from server.lobbyconnection import LobbyConnection from server.matchmaker import MatchmakerQueue from server.message_queue_service import MessageQueueService +from server.oauth_service import OAuthService from server.player_service import PlayerService from server.players import Player, PlayerState from server.rating import RatingType @@ -403,6 +404,11 @@ def achievement_service(api_accessor): return AchievementService(api_accessor) +@pytest.fixture +def oauth_service(): + return OAuthService() + + @pytest.fixture def game_stats_service(event_service, achievement_service): return GameStatsService(event_service, achievement_service) diff --git a/tests/integration_tests/conftest.py b/tests/integration_tests/conftest.py index c65dec73a..5e422eaf9 100644 --- a/tests/integration_tests/conftest.py +++ b/tests/integration_tests/conftest.py @@ -2,6 +2,7 @@ import hashlib import json import logging +import textwrap from collections import defaultdict from typing import Any, Callable, Dict, Tuple from unittest import mock @@ -70,6 +71,26 @@ async def broadcast_service( await service.shutdown() +@pytest.fixture +def jwk_priv_key(): + return textwrap.dedent(""" + -----BEGIN RSA PRIVATE KEY----- + MIIBOgIBAAJBAKia//Uh/0nwtCI2QEaorc4voP5Xx+68M/AHLsvzxe7qLut64+O3 + vHlYp9B9wClxxp3unphCZDe+JIzRieCz14UCAwEAAQJAWh5G0uox/n5meabPojTE + eWFhxrB6j7MOe6wLKj4IvJKWxoxLuMoOWmqWcWLiFw4pXKFtjv6bOGW8uUyDZDQt + vQIhANt1HM3WPoFsvdnnqLH6PILfDRzal5Kjv1Ua97b7q2qLAiEAxK4zrououc6a + I+uVxvsTnU88DeydN2sTroc36YfC2C8CIQCuZg4i4ZxAnBrvfPKJpXPLCNjR0kDb + 7rcROeIbjzp06wIgcZfXG5lnwqDTn6lh4QGEC5gGrFgbWTWLsYJBRax2WVsCIFeL + KtHOf7sc9jf0k73eooPK8b+g4pssztR4GObEThZh + -----END RSA PRIVATE KEY----- + """) + + +@pytest.fixture +def jwk_kid(): + return "L7wdUtrDssMTb57A_TNAI79DQCdp0T2-KUrSUoDJBhk" + + @pytest.fixture async def lobby_server( event_loop, @@ -82,12 +103,19 @@ async def lobby_server( rating_service, message_queue_service, party_service, - policy_server + oauth_service, + policy_server, + jwks_server ): - with mock.patch( + mock_policy = mock.patch( "server.lobbyconnection.config.FAF_POLICY_SERVER_BASE_URL", f"http://{policy_server.host}:{policy_server.port}" - ): + ) + mock_jwk = mock.patch( + "server.oauth_service.config.HYDRA_JWKS_URI", + f"http://{jwks_server.host}:{jwks_server.port}/jwks" + ) + with mock_policy, mock_jwk: instance = ServerInstance( "UnitTestServer", database, @@ -102,9 +130,9 @@ async def lobby_server( "ladder_service": ladder_service, "rating_service": rating_service, "message_queue_service": message_queue_service, - "party_service": party_service - } - ) + "party_service": party_service, + "oauth_service": oauth_service + }) # Set up the back reference broadcast_service.server = instance @@ -176,6 +204,52 @@ async def token(request): await runner.cleanup() +@pytest.fixture +async def jwks_server(jwk_kid): + host = "localhost" + port = 4080 + + app = web.Application() + routes = web.RouteTableDef() + + class Handle(object): + def __init__(self): + self.host = host + self.port = port + self.result = { + "keys": [{ + "kty": "RSA", + "e": "AQAB", + "use": "sig", + "kid": jwk_kid, + "alg": "RS256", + "n": "qJr_9SH_SfC0IjZARqitzi-g_lfH7rwz8Acuy_PF7uou63rj47e8eVin0H3AKXHGne6emEJkN74kjNGJ4LPXhQ" + }] + } + self.verify = mock.Mock() + + handle = Handle() + + @routes.get("/jwks") + async def get(request): + # Register that the endpoint was called using a Mock + handle.verify() + + return web.json_response(handle.result) + + app.add_routes(routes) + + runner = web.AppRunner(app) + + await runner.setup() + site = web.TCPSite(runner, host, port) + await site.start() + + yield handle + + await runner.cleanup() + + @pytest.fixture async def tmp_user(database): user_ids = defaultdict(lambda: 1) diff --git a/tests/integration_tests/test_login.py b/tests/integration_tests/test_login.py index 74e651d9b..b00b91ac3 100644 --- a/tests/integration_tests/test_login.py +++ b/tests/integration_tests/test_login.py @@ -1,3 +1,6 @@ +from time import time + +import jwt import pytest from .conftest import ( @@ -221,3 +224,135 @@ async def test_server_double_login(lobby_server): "style": "error", "text": "You have been signed out because you signed in elsewhere." } + + +async def test_server_valid_login_with_token(lobby_server, jwk_priv_key, jwk_kid): + proto = await connect_client(lobby_server) + await proto.send_message({ + "command": "auth", + "version": "1.0.0-dev", + "user_agent": "faf-client", + "token": jwt.encode({ + "sub": 3, + "user_name": "Rhiza", + "scope": [], + "exp": int(time() + 1000), + "authorities": [], + "non_locked": True, + "jti": "", + "client_id": "" + }, jwk_priv_key, algorithm="RS256", headers={"kid": jwk_kid}), + "unique_id": "some_id" + }) + + msg = await proto.read_message() + assert msg["command"] == "irc_password" + msg = await proto.read_message() + me = { + "id": 3, + "login": "Rhiza", + "clan": "123", + "country": "", + "ratings": { + "global": { + "rating": [1650.0, 62.52], + "number_of_games": 2 + }, + "ladder_1v1": { + "rating": [1650.0, 62.52], + "number_of_games": 2 + } + }, + "global_rating": [1650.0, 62.52], + "ladder_rating": [1650.0, 62.52], + "number_of_games": 2 + } + assert msg == { + "command": "welcome", + "me": me, + "id": 3, + "login": "Rhiza" + } + msg = await proto.read_message() + assert msg == { + "command": "player_info", + "players": [me] + } + msg = await proto.read_message() + assert msg == { + "command": "social", + "autojoin": ["#123_clan"], + "channels": ["#123_clan"], + "friends": [], + "foes": [], + "power": 0 + } + + +async def test_server_login_bad_id_in_token(lobby_server, jwk_priv_key, jwk_kid): + proto = await connect_client(lobby_server) + await proto.send_message({ + "command": "auth", + "version": "1.0.0-dev", + "user_agent": "faf-client", + "token": jwt.encode({ + "sub": -1, + "user_name": "Rhiza", + "scope": [], + "exp": int(time() + 1000), + "authorities": [], + "non_locked": True, + "jti": "", + "client_id": "" + }, jwk_priv_key, algorithm="RS256", headers={"kid": jwk_kid}), + "unique_id": "some_id" + }) + + msg = await proto.read_message() + assert msg == { + "command": "authentication_failed", + "text": "Cannot find user id" + } + + +async def test_server_login_expired_token(lobby_server, jwk_priv_key, jwk_kid): + proto = await connect_client(lobby_server) + await proto.send_message({ + "command": "auth", + "version": "1.0.0-dev", + "user_agent": "faf-client", + "token": jwt.encode({ + "sub": 1, + "user_name": "test", + "exp": int(time() - 10) + }, jwk_priv_key, algorithm="RS256", headers={"kid": jwk_kid}), + "unique_id": "some_id" + }) + + msg = await proto.read_message() + assert msg == { + "command": "authentication_failed", + "text": "Token signature was invalid" + } + + +async def test_server_login_malformed_token(lobby_server, jwk_priv_key, jwk_kid): + """This scenario could only happen if the hydra signed a token that + was missing critical data""" + proto = await connect_client(lobby_server) + await proto.send_message({ + "command": "auth", + "version": "1.0.0-dev", + "user_agent": "faf-client", + "token": jwt.encode( + {"exp": int(time() + 10)}, jwk_priv_key, algorithm="RS256", + headers={"kid": jwk_kid} + ), + "unique_id": "some_id" + }) + + msg = await proto.read_message() + assert msg == { + "command": "authentication_failed", + "text": "Token signature was invalid" + } diff --git a/tests/integration_tests/test_server_instance.py b/tests/integration_tests/test_server_instance.py index 7df603de6..01f02f2ee 100644 --- a/tests/integration_tests/test_server_instance.py +++ b/tests/integration_tests/test_server_instance.py @@ -33,6 +33,7 @@ async def test_multiple_contexts( tmp_user, policy_server, party_service, + oauth_service, event_loop ): config.USE_POLICY_SERVER = False @@ -50,6 +51,7 @@ async def test_multiple_contexts( "geo_ip_service": geoip_service, "ladder_service": ladder_service, "party_service": party_service, + "oauth_service": oauth_service } ) broadcast_service.server = instance diff --git a/tests/integration_tests/test_servercontext.py b/tests/integration_tests/test_servercontext.py index bee8b9232..bf7265aa0 100644 --- a/tests/integration_tests/test_servercontext.py +++ b/tests/integration_tests/test_servercontext.py @@ -59,7 +59,8 @@ def make_connection() -> LobbyConnection: nts_client=mock.Mock(), geoip=mock.Mock(), ladder_service=mock.Mock(), - party_service=mock.Mock() + party_service=mock.Mock(), + oauth_service=mock.Mock() ) ctx = ServerContext("TestServer", make_connection, [mock_service]) diff --git a/tests/unit_tests/test_configuration_refresh.py b/tests/unit_tests/test_configuration_refresh.py index 4a8fb95b5..a1468e75e 100644 --- a/tests/unit_tests/test_configuration_refresh.py +++ b/tests/unit_tests/test_configuration_refresh.py @@ -2,7 +2,6 @@ from unittest import mock import pytest -import yaml from asynctest import CoroutineMock from server import config diff --git a/tests/unit_tests/test_lobbyconnection.py b/tests/unit_tests/test_lobbyconnection.py index 270ac3fb0..6bb56664a 100644 --- a/tests/unit_tests/test_lobbyconnection.py +++ b/tests/unit_tests/test_lobbyconnection.py @@ -20,6 +20,7 @@ from server.ladder_service import LadderService from server.lobbyconnection import LobbyConnection from server.matchmaker import Search +from server.oauth_service import OAuthService from server.party_service import PartyService from server.player_service import PlayerService from server.players import PlayerState @@ -105,7 +106,8 @@ def lobbyconnection( players=mock_players, nts_client=mock_nts_client, ladder_service=asynctest.create_autospec(LadderService), - party_service=asynctest.create_autospec(PartyService) + party_service=asynctest.create_autospec(PartyService), + oauth_service=asynctest.create_autospec(OAuthService) ) lc.player = mock_player