WIP: authentication rework

This commit is contained in:
Francesco Tolomei 2022-04-07 00:05:31 +02:00
parent 8f04b34092
commit a2c2b78f89
No known key found for this signature in database
GPG key ID: 413C3451D6179D06
6 changed files with 50 additions and 101 deletions

View file

@ -1,44 +0,0 @@
import sys
import json
import logging
from .mc.proto.play.clientbound import PacketChat
from .client import MinecraftClient
from .server import MinecraftServer
from .util.helpers import parse_chat
if __name__ == "__main__":
logging.basicConfig(level=logging.DEBUG)
if sys.argv[1] == "--server":
host = sys.argv[2] if len(sys.argv) > 2 else "localhost"
port = int(sys.argv[3]) if len(sys.argv) > 3 else 25565
serv = MinecraftServer(host, port=port)
serv.run() # will block and start asyncio event loop
else:
username = sys.argv[1]
pwd = sys.argv[2]
server = sys.argv[3]
color = not (len(sys.argv) > 4 and sys.argv[4] == "--no-color" )
if ":" in server:
_host, _port = server.split(":")
host = _host.strip()
port = int(_port)
else:
host = server.strip()
port = 25565
client = MinecraftClient(host, port, username=username, password=pwd)
@client.on_packet(PacketChat)
async def print_chat(packet: PacketChat):
msg = parse_chat(packet.message, ansi_color=color)
print(f"[{packet.position}] {msg}")
client.run() # will block and start asyncio event loop
logging.info("Terminated")

View file

@ -27,18 +27,9 @@ from .util import encryption, helpers
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@dataclass
class ClientOptions:
reconnect : bool = True
reconnect_delay : float = 10.0
keep_alive : bool = True
poll_interval : float = 1.0
use_packet_whitelist : bool = True
class MinecraftClient: class MinecraftClient:
host:str host:str
port:int port:int
options:ClientOptions
username:str username:str
online_mode:bool online_mode:bool
authenticator:Optional[AuthInterface] authenticator:Optional[AuthInterface]
@ -54,7 +45,6 @@ class MinecraftClient:
online_mode:bool = True, online_mode:bool = True,
authenticator:AuthInterface=None, authenticator:AuthInterface=None,
username:str = "", username:str = "",
**kwargs
): ):
super().__init__() super().__init__()
if ":" in server: if ":" in server:
@ -65,8 +55,6 @@ class MinecraftClient:
self.host = server.strip() self.host = server.strip()
self.port = 25565 self.port = 25565
self.options = ClientOptions(**kwargs)
self.username = username self.username = username
self.online_mode = online_mode self.online_mode = online_mode
self.authenticator = authenticator self.authenticator = authenticator
@ -90,15 +78,12 @@ class MinecraftClient:
try: try:
await self.authenticator.validate() # will raise an exc if token is invalid await self.authenticator.validate() # will raise an exc if token is invalid
except AuthException: except AuthException:
if self.code: if self.authenticator.refreshable:
await self.authenticator.login(self.code)
self.code = None
self.logger.info("Logged in with OAuth code")
elif self.authenticator.refreshable:
await self._authenticator.refresh() await self._authenticator.refresh()
self.logger.warning("Refreshed Token") self.logger.warning("Refreshed Token")
else: else:
raise ValueError("No refreshable auth or code to login") await self.authenticator.login()
self.logger.info("Logged in")
self._authenticated = True self._authenticated = True
async def info(self, host:str="", port:int=0, proto:int=0, ping:bool=False) -> Dict[str, Any]: async def info(self, host:str="", port:int=0, proto:int=0, ping:bool=False) -> Dict[str, Any]:
@ -122,7 +107,6 @@ class MinecraftClient:
host=self.host, host=self.host,
port=self.port, port=self.port,
proto=proto, proto=proto,
queue_timeout=self.options.poll_interval,
packet_whitelist=packet_whitelist packet_whitelist=packet_whitelist
) )
await self._handshake(ConnectionState.LOGIN) await self._handshake(ConnectionState.LOGIN)
@ -176,7 +160,7 @@ class MinecraftClient:
await self.dispatcher.write( await self.dispatcher.write(
PacketLoginStart( PacketLoginStart(
self.dispatcher.proto, self.dispatcher.proto,
username=self.authenticator.selectedProfile.name if self.online_mode else self._username username=self.authenticator.selectedProfile.name if self.online_mode and self.authenticator else self.username
) )
) )
async for packet in self.dispatcher.packets(): async for packet in self.dispatcher.packets():
@ -195,7 +179,7 @@ class MinecraftClient:
) )
if packet.serverId != '-': if packet.serverId != '-':
try: try:
await self._authenticator.join( await self.authenticator.join(
encryption.generate_verification_hash( encryption.generate_verification_hash(
packet.serverId, packet.serverId,
secret, secret,
@ -236,7 +220,6 @@ class MinecraftClient:
self.logger.info("Compression updated") self.logger.info("Compression updated")
self.dispatcher.compression = packet.threshold self.dispatcher.compression = packet.threshold
elif isinstance(packet, PacketKeepAlive): elif isinstance(packet, PacketKeepAlive):
if self.options.keep_alive:
keep_alive_packet = PacketKeepAliveResponse(340, keepAliveId=packet.keepAliveId) keep_alive_packet = PacketKeepAliveResponse(340, keepAliveId=packet.keepAliveId)
await self.dispatcher.write(keep_alive_packet) await self.dispatcher.write(keep_alive_packet)
elif isinstance(packet, PacketKickDisconnect): elif isinstance(packet, PacketKickDisconnect):

View file

@ -95,7 +95,6 @@ class Dispatcher:
host:Optional[str] = None, host:Optional[str] = None,
port:Optional[int] = None, port:Optional[int] = None,
proto:Optional[int] = None, proto:Optional[int] = None,
queue_timeout:float = 1,
queue_size:int = 100, queue_size:int = 100,
packet_whitelist : Set[Type[Packet]] = None packet_whitelist : Set[Type[Packet]] = None
): ):
@ -128,14 +127,13 @@ class Dispatcher:
proto : Optional[int] = None, proto : Optional[int] = None,
reader : Optional[StreamReader] = None, reader : Optional[StreamReader] = None,
writer : Optional[StreamWriter] = None, writer : Optional[StreamWriter] = None,
queue_timeout : float = 1,
queue_size : int = 100, queue_size : int = 100,
packet_whitelist : Set[Type[Packet]] = None, packet_whitelist : Set[Type[Packet]] = None,
): ):
if self.connected: if self.connected:
raise InvalidState("Dispatcher already connected") raise InvalidState("Dispatcher already connected")
self._prepare(host, port, proto, queue_timeout, queue_size, packet_whitelist) self._prepare(host, port, proto, queue_size, packet_whitelist)
if reader and writer: if reader and writer:
self._down, self._up = reader, writer self._down, self._up = reader, writer
@ -147,7 +145,7 @@ class Dispatcher:
self._dispatching = True self._dispatching = True
self._reader = asyncio.get_event_loop().create_task(self._down_worker()) self._reader = asyncio.get_event_loop().create_task(self._down_worker())
self._writer = asyncio.get_event_loop().create_task(self._up_worker(timeout=queue_timeout)) self._writer = asyncio.get_event_loop().create_task(self._up_worker())
self._logger.info("Connected") self._logger.info("Connected")
async def disconnect(self, block:bool=True): async def disconnect(self, block:bool=True):

View file

@ -28,7 +28,7 @@ class AuthInterface:
SESSION_SERVER = "https://sessionserver.mojang.com/session/minecraft" SESSION_SERVER = "https://sessionserver.mojang.com/session/minecraft"
TIMEOUT = aiohttp.ClientTimeout(total=3) TIMEOUT = aiohttp.ClientTimeout(total=3)
async def login(self, *args) -> 'AuthInterface': async def login(self) -> 'AuthInterface':
raise NotImplementedError raise NotImplementedError
async def refresh(self) -> 'AuthInterface': async def refresh(self) -> 'AuthInterface':

View file

@ -18,6 +18,7 @@ class MicrosoftAuthenticator(AuthInterface):
client_id : str client_id : str
client_secret : str client_secret : str
redirect_uri : str redirect_uri : str
code : Optional[str]
accessToken : str accessToken : str
selectedProfile : GameProfile selectedProfile : GameProfile
@ -32,11 +33,13 @@ class MicrosoftAuthenticator(AuthInterface):
def __init__(self, def __init__(self,
client_id:str, client_id:str,
client_secret:str, client_secret:str,
redirect_uri:str="http://localhost" redirect_uri:str="http://localhost",
code:Optional[str]=None
): ):
self.client_id = client_id self.client_id = client_id
self.client_secret = client_secret self.client_secret = client_secret
self.redirect_uri = redirect_uri self.redirect_uri = redirect_uri
self.code = code
self.refreshToken = None self.refreshToken = None
self.accessToken = '' self.accessToken = ''
self.selectedProfile = GameProfile(id='', name='') self.selectedProfile = GameProfile(id='', name='')
@ -68,8 +71,8 @@ class MicrosoftAuthenticator(AuthInterface):
f"&state={state}" f"&state={state}"
) )
async def login(self, code:str): # TODO nicer way to get code? async def login(self): # TODO nicer way to get code?
self.accessToken = await self.authenticate(code) self.accessToken = await self.authenticate(self.code)
prof = await self.fetch_profile() prof = await self.fetch_profile()
self.selectedProfile = GameProfile(id=prof['id'], name=prof['name']) self.selectedProfile = GameProfile(id=prof['id'], name=prof['name'])
logging.info("Successfully logged into Microsoft account") logging.info("Successfully logged into Microsoft account")
@ -83,6 +86,8 @@ class MicrosoftAuthenticator(AuthInterface):
logging.info("Successfully refreshed Microsoft token") logging.info("Successfully refreshed Microsoft token")
async def validate(self): async def validate(self):
if not self.accessToken:
raise AuthException("No access token")
prof = await self.fetch_profile() prof = await self.fetch_profile()
self.selectedProfile = GameProfile(id=prof['id'], name=prof['name']) self.selectedProfile = GameProfile(id=prof['id'], name=prof['name'])
logging.info("Session validated : %s", repr(self.selectedProfile)) logging.info("Session validated : %s", repr(self.selectedProfile))

View file

@ -4,16 +4,17 @@ import uuid
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import Optional, Dict, Any
import aiohttp import aiohttp
from .interface import AuthInterface from .interface import AuthInterface, AuthException
from ..definitions import GameProfile from ..definitions import GameProfile
@dataclass @dataclass
class MojangAuthenticator(AuthInterface): class MojangAuthenticator(AuthInterface):
username : str username : str
password : Optional[str]
accessToken : str accessToken : str
clientToken : str clientToken : str
selectedProfile : GameProfile selectedProfile : GameProfile
@ -24,8 +25,9 @@ class MojangAuthenticator(AuthInterface):
CONTENT_TYPE = "application/json" CONTENT_TYPE = "application/json"
HEADERS = {"content-type": CONTENT_TYPE} HEADERS = {"content-type": CONTENT_TYPE}
def __init__(self): def __init__(self, username:str="", password:Optional[str]=None):
pass self.username = username
self.password = password
def __equals__(self, other) -> bool: def __equals__(self, other) -> bool:
if not isinstance(other, self.__class__): if not isinstance(other, self.__class__):
@ -38,12 +40,12 @@ class MojangAuthenticator(AuthInterface):
return False return False
def __repr__(self) -> str: def __repr__(self) -> str:
return json.dumps(self.as_dict()) return json.dumps(self.serialize())
def __str__(self) -> str: def __str__(self) -> str:
return repr(self) return repr(self)
def serialize(self): def serialize(self) -> Dict[str, Any]:
return { return {
"username":self.username, "username":self.username,
"accessToken":self.accessToken, "accessToken":self.accessToken,
@ -51,30 +53,29 @@ class MojangAuthenticator(AuthInterface):
"selectedProfile": self.selectedProfile.as_dict(), "selectedProfile": self.selectedProfile.as_dict(),
} }
def deserialize(self, data:dict): def deserialize(self, data:Dict[str, Any]) -> AuthInterface:
self.username=data["username"] if "username" in data else data["selectedProfile"]["name"], self.username = data["username"] if "username" in data else data["selectedProfile"]["name"]
self.accessToken=data["accessToken"], self.accessToken = data["accessToken"]
self.clientToken=data["clientToken"], self.clientToken = data["clientToken"]
self.selectedProfile = GameProfile(**data["selectedProfile"]) self.selectedProfile = GameProfile(**data["selectedProfile"])
return self
async def login(self, username:str, password:str, invalidate=False) -> AuthInterface: async def login(self) -> AuthInterface:
payload = { payload = {
"agent": { "agent": {
"name": self.AGENT_NAME, "name": self.AGENT_NAME,
"version": self.AGENT_VERSION "version": self.AGENT_VERSION
}, },
"username": username, "username": self.username,
"password": password "password": self.password
} }
if not invalidate: payload["clientToken"] = uuid.uuid4().hex # don't include this to invalidate all other sessions
payload["clientToken"] = uuid.uuid4().hex
res = await self._post(self.AUTH_SERVER + "/authenticate", payload) res = await self._post(self.AUTH_SERVER + "/authenticate", json=payload)
self.username=username, self.accessToken=res["accessToken"]
self.accessToken=res["accessToken"], self.clientToken=res["clientToken"]
self.clientToken=res["clientToken"],
self.selectedProfile=GameProfile(**res["selectedProfile"]) self.selectedProfile=GameProfile(**res["selectedProfile"])
return self return self
@ -90,7 +91,7 @@ class MojangAuthenticator(AuthInterface):
} }
) )
async def refresh(self, requestUser:bool = False) -> dict: async def refresh(self, requestUser:bool = False) -> AuthInterface:
res = await self._post( res = await self._post(
self.AUTH_SERVER + "/refresh", self.AUTH_SERVER + "/refresh",
headers=self.HEADERS, headers=self.HEADERS,
@ -107,18 +108,23 @@ class MojangAuthenticator(AuthInterface):
if "user" in res: if "user" in res:
self.username = res["user"]["username"] self.username = res["user"]["username"]
async def validate(self, clientToken:bool = True) -> dict: return self
async def validate(self, clientToken:bool = True) -> AuthInterface:
if not self.accessToken:
raise AuthException("/validate", 404, {"message":"No access token"}, {})
payload = { "accessToken": self.accessToken } payload = { "accessToken": self.accessToken }
if clientToken: if clientToken:
payload["clientToken"] = self.clientToken payload["clientToken"] = self.clientToken
return await self._post( await self._post(
self.AUTH_SERVER + "/validate", self.AUTH_SERVER + "/validate",
headers=self.HEADERS, headers=self.HEADERS,
json=payload, json=payload,
) )
return self
async def invalidate(self) -> dict: async def invalidate(self) -> AuthInterface:
return await self._post( await self._post(
self.AUTH_SERVER + "/invalidate", self.AUTH_SERVER + "/invalidate",
headers=self.HEADERS, headers=self.HEADERS,
json= { json= {
@ -126,3 +132,4 @@ class MojangAuthenticator(AuthInterface):
"clientToken": self.clientToken "clientToken": self.clientToken
} }
) )
return self