WIP: authentication rework
This commit is contained in:
parent
8f04b34092
commit
a2c2b78f89
6 changed files with 50 additions and 101 deletions
|
@ -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")
|
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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':
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue