Updated authentication.py and made it work with the rest of pyCraft

This commit is contained in:
Jeppe Klitgaard 2015-04-01 23:38:10 +02:00
parent edacd24c81
commit 6cf6110bc5
5 changed files with 299 additions and 115 deletions

View File

@ -1,106 +1,277 @@
import urllib2 """
Handles authentication with the Mojang authentication server.
"""
import requests
import json import json
from .exceptions import YggdrasilError
import collections
#: The base url for Yggdrasil requests AUTHSERVER = "https://authserver.mojang.com"
BASE_URL = 'https://authserver.mojang.com/' SESSIONSERVER = "https://sessionserver.mojang.com/session/minecraft"
AGENT_INFO = {"name": "Minecraft", "version": 1} # Need this content type, or authserver will complain
CONTENT_TYPE = "application/json"
HEADERS = {"content-type": CONTENT_TYPE}
class YggdrasilError(Exception): class Profile(object):
"""Signals some sort of problem while handling a request to
the Yggdrasil service
:ivar human_error: A human readable description of the problem
:ivar error: A short description of the problem
""" """
Container class for a MineCraft Selected profile.
def __init__(self, error, human_readable_error): See: `<http://wiki.vg/Authentication>`_
self.error = error
self.human_readable_error = human_readable_error
def make_request(url, payload):
"""Makes an http request to the Yggdrasil authentication service
If there is an error then it will raise a :exc:`.YggdrasilError`
:param url: The fully formed url to the Yggdrasil endpoint, for example:
https://authserver.mojang.com/authenticate
You may use the :attr:`.BASE_URL` constant here as a shortcut
:param payload: The payload to send with the request, will be interpreted
as a JSON object so be careful.
Example: {"username": username, "password": password, "agent": AGENT_INFO}
:return: A :class:`.Response` object.
""" """
response = Response() def __init__(self, id_=None, name=None):
self.id = id_
self.name = name
try: def to_dict(self):
header = {'Content-Type': 'application/json'}
data = json.dumps(payload)
req = urllib2.Request(url, data, header)
opener = urllib2.build_opener()
http_response = opener.open(req, None, 10)
http_response = http_response.read()
except urllib2.HTTPError, e:
error = e.read()
error = json.loads(error)
response.human_error = error['errorMessage']
response.error = error['error']
raise YggdrasilError(error['error'], error['errorMessage'])
except urllib2.URLError, e:
raise YggdrasilError(e.reason, e.reason)
# ohey, everything didn't end up crashing and burning
if http_response == "":
http_response = "{}"
try:
json_response = json.loads(http_response)
except ValueError, e:
raise YggdrasilError(e.message, "JSON parsing exception on data: " + http_response)
response.payload = json_response
return response
class Response(object):
"""Class to hold responses from Yggdrasil
:ivar payload: The raw payload returned by Yggdrasil
""" """
payload = None Returns ``self`` in dictionary-form, which can be serialized by json.
"""
if self:
return {"id": self.id,
"name": self.name}
else:
raise AttributeError("Profile is not yet populated.")
def __bool__(self):
bool_state = self.id is not None and self.name is not None
return bool_state
# Python 2 support
def __nonzero__(self):
return self.__bool__()
class LoginResponse(object): class AuthenticationToken(object):
"""A container class to hold information received from Yggdrasil """
upon logging into an account. Represents an authentication token.
:ivar username: The actual in game username of the user See http://wiki.vg/Authentication.
:ivar access_token: The access token of the user, used in place of the password """
:ivar profile_id: The selected profile id AGENT_NAME = "Minecraft"
AGENT_VERSION = 1
def __init__(self, username=None, access_token=None, client_token=None):
"""
Constructs an `AuthenticationToken` based on `access_token` and
`client_token`.
Parameters:
access_token - An `str` object containing the `access_token`.
client_token - An `str` object containing the `client_token`.
Returns:
A `AuthenticationToken` with `access_token` and `client_token` set.
"""
self.username = username
self.access_token = access_token
self.client_token = client_token
self.profile = Profile()
@property
def authenticated(self):
"""
Attribute which is ``True`` when the token is authenticated and
``False`` when it isn't.
"""
# TODO
return True
def authenticate(self, username, password):
"""
Authenticates the user against https://authserver.mojang.com using
`username` and `password` parameters.
Parameters:
username - An `str` object with the username (unmigrated accounts)
or email address for a Mojang account.
password - An `str` object with the password.
Returns:
Returns `True` if successful.
Otherwise it will raise an exception.
Raises:
minecraft.exceptions.YggdrasilError
"""
payload = {
"agent": {
"name": self.AGENT_NAME,
"version": self.AGENT_VERSION
},
"username": username,
"password": password
}
req = _make_request(AUTHSERVER, "authenticate", payload)
_raise_from_request(req)
json_resp = req.json()
self.username = username
self.access_token = json_resp["accessToken"]
self.client_token = json_resp["clientToken"]
self.profile.id = json_resp["selectedProfile"]["id"]
self.profile.name = json_resp["selectedProfile"]["name"]
return True
def refresh(self):
"""
Refreshes the `AuthenticationToken`. Used to keep a user logged in
between sessions and is preferred over storing a user's password in a
file.
Returns:
Returns `True` if `AuthenticationToken` was successfully refreshed.
Otherwise it raises an exception.
Raises:
minecraft.exceptions.YggdrasilError
ValueError - if `AuthenticationToken.access_token` or
`AuthenticationToken.client_token` isn't set.
"""
if self.access_token is None:
raise ValueError("'access_token' not set!'")
if self.client_token is None:
raise ValueError("'client_token' is not set!")
req = _make_request(AUTHSERVER,
"refresh", {"accessToken": self.access_token,
"clientToken": self.client_token})
_raise_from_request(req)
json_resp = req.json()
self.access_token = json_resp["accessToken"]
self.client_token = json_resp["clientToken"]
self.profile.id = json_resp["selectedProfile"]["id"]
self.profile.name = json_resp["selectedProfile"]["name"]
return True
def validate(self):
"""
Validates the AuthenticationToken.
`AuthenticationToken.access_token` must be set!
Returns:
Returns `True` if `AuthenticationToken` is valid.
Otherwise it will raise an exception.
Raises:
minecraft.exceptions.YggdrasilError
ValueError - if `AuthenticationToken.access_token` is not set.
"""
if self.access_token is None:
raise ValueError("'access_token' not set!")
req = _make_request(AUTHSERVER, "validate",
{"accessToken": self.access_token})
if _raise_from_request(req) is None:
return True
@staticmethod
def sign_out(username, password):
"""
Invalidates `access_token`s using an account's
`username` and `password`.
Parameters:
TODO
Returns:
Returns `True` if sign out was successful.
Otherwise it will raise an exception.
Raises:
minecraft.exceptions.YggdrasilError
"""
req = _make_request(AUTHSERVER, "signout", {"username": username,
"password": password})
if _raise_from_request(req) is None:
return True
def invalidate(self):
"""
Invalidates `access_token`s using the token pair stored in
the `AuthenticationToken`.
Returns:
TODO
Raises:
TODO
""" """
pass pass
def join(self, server_id):
def login_to_minecraft(username, password):
""" """
Logs in to a minecraft account Informs the Mojang session-server that we're joining the
Will raise a :exc:`.YggdrasilError` on failure MineCraft server with id ``server_id``.
Parameters:
server_id - ``str`` with the server id
Returns:
``True`` if no errors occured
Raises:
:class:`minecraft.exceptions.YggdrasilError`
:param username: The mojang account username
:param password: The password for the account
:return: A :class:`.LoginResponse` object
""" """
payload = {"username": username, "password": password, "agent": AGENT_INFO} req = _make_request(SESSIONSERVER, "join",
response = make_request(BASE_URL + "authenticate", payload) {"accessToken": self.access_token,
"selectedProfile": self.profile.to_dict(),
"serverId": server_id})
login_response = LoginResponse() if req.status_code == requests.codes.ok:
payload = response.payload return True
else:
return req
err = "Failed to join game. Status code: {}"
raise YggdrasilError(err.format(str(req.status_code)))
login_response.access_token = payload["accessToken"]
login_response.profile_id = payload["selectedProfile"]["id"]
login_response.username = payload["selectedProfile"]["name"]
return login_response def _make_request(server, endpoint, data):
"""
Fires a POST with json-packed data to the given endpoint and returns
response.
Parameters:
endpoint - An `str` object with the endpoint, e.g. "authenticate"
data - A `dict` containing the payload data.
Returns:
A `requests.Request` object.
"""
req = requests.post(server + "/" + endpoint, data=json.dumps(data),
headers=HEADERS)
return req
def _raise_from_request(req):
"""
Raises an appropriate `YggdrasilError` based on the `status_code` and
`json` of a `requests.Request` object.
"""
if req.status_code == requests.codes.ok:
return None
json_resp = req.json()
if "error" not in json_resp and "errorMessage" not in json_resp:
raise YggdrasilError("Malformed error message.")
message = "[{status_code}] {error}: '{error_message}'"
message = message.format(status_code=str(req.status_code),
error=json_resp["error"],
error_message=json_resp["errorMessage"])
raise YggdrasilError(message)

9
minecraft/exceptions.py Normal file
View File

@ -0,0 +1,9 @@
"""
Contains the `Exceptions` used by this library.
"""
class YggdrasilError(Exception):
"""
Base `Exception` for the Yggdrasil authentication service.
"""

View File

@ -39,17 +39,17 @@ class Connection(object):
#: Indicates if this connection is spawned in the Minecraft game world #: Indicates if this connection is spawned in the Minecraft game world
spawned = False spawned = False
def __init__(self, address, port, login_response): def __init__(self, address, port, auth_token):
"""Sets up an instance of this object to be able to connect to a minecraft server. """Sets up an instance of this object to be able to connect to a minecraft server.
The connect method needs to be called in order to actually begin the connection The connect method needs to be called in order to actually begin the connection
:param address: address of the server to connect to :param address: address of the server to connect to
:param port(int): port of the server to connect to :param port(int): port of the server to connect to
:param login_response: :class:`authentication.LoginResponse` object as obtained from the authentication package :param auth_token: :class:`authentication.AuthenticationToken` object.
""" """
self.options.address = address self.options.address = address
self.options.port = port self.options.port = port
self.login_response = login_response self.auth_token = auth_token
self.reactor = PacketReactor(self) self.reactor = PacketReactor(self)
def _start_network_thread(self): def _start_network_thread(self):
@ -120,7 +120,7 @@ class Connection(object):
self.reactor = LoginReactor(self) self.reactor = LoginReactor(self)
self._start_network_thread() self._start_network_thread()
login_start_packet = LoginStartPacket() login_start_packet = LoginStartPacket()
login_start_packet.name = self.login_response.username login_start_packet.name = self.auth_token.username
self.write_packet(login_start_packet) self.write_packet(login_start_packet)
def _connect(self): def _connect(self):
@ -246,13 +246,9 @@ class LoginReactor(PacketReactor):
# A server id of '-' means the server is in offline mode # A server id of '-' means the server is in offline mode
if packet.server_id != '-': if packet.server_id != '-':
url = "https://sessionserver.mojang.com/session/minecraft/join"
server_id = encryption.generate_verification_hash(packet.server_id, secret, packet.public_key) server_id = encryption.generate_verification_hash(packet.server_id, secret, packet.public_key)
payload = {'accessToken': self.connection.login_response.access_token,
'selectedProfile': self.connection.login_response.profile_id,
'serverId': server_id}
authentication.make_request(url, payload) self.connection.auth_token.join(server_id)
encryption_response = EncryptionResponsePacket() encryption_response = EncryptionResponsePacket()
encryption_response.shared_secret = encrypted_secret encryption_response.shared_secret = encrypted_secret

View File

@ -1 +1,2 @@
cryptography cryptography
requests

View File

@ -2,12 +2,15 @@ import getpass
import sys import sys
from optparse import OptionParser from optparse import OptionParser
from pprint import pprint
from minecraft import authentication from minecraft import authentication
from minecraft.exceptions import YggdrasilError
from minecraft.networking.connection import Connection from minecraft.networking.connection import Connection
from minecraft.networking.packets import ChatMessagePacket, ChatPacket from minecraft.networking.packets import ChatMessagePacket, ChatPacket
def main(): def get_options():
parser = OptionParser() parser = OptionParser()
parser.add_option("-u", "--username", dest="username", default=None, parser.add_option("-u", "--username", dest="username", default=None,
@ -27,34 +30,38 @@ def main():
if not options.password: if not options.password:
options.password = getpass.getpass("Enter your password: ") options.password = getpass.getpass("Enter your password: ")
try:
login_response = authentication.login_to_minecraft(options.username, options.password)
from pprint import pprint # TODO: remove debug
pprint(vars(login_response)) # TODO: remove debug
except authentication.YggdrasilError as e:
print e.human_readable_error
return
print("Logged in as " + login_response.username)
if not options.server: if not options.server:
options.server = raw_input("Please enter server address (including port): ") options.server = raw_input("Please enter server address (including port): ")
# Try to split out port and address # Try to split out port and address
if ':' in options.server: if ':' in options.server:
server = options.server.split(":") server = options.server.split(":")
address = server[0] options.address = server[0]
port = int(server[1]) options.port = int(server[1])
else: else:
address = options.server options.address = options.server
port = 25565 options.port = 25565
connection = Connection(address, port, login_response) return options
def main():
options = get_options()
auth_token = authentication.AuthenticationToken()
try:
auth_token.authenticate(options.username, options.password)
except YggdrasilError as e:
print(e.error)
sys.exit()
print("Logged in as " + auth_token.username)
connection = Connection(options.address, options.port, auth_token)
connection.connect() connection.connect()
def print_chat(chat_packet): def print_chat(chat_packet):
print "Position: " + str(chat_packet.position) print("Position: " + str(chat_packet.position))
print "Data: " + chat_packet.json_data print("Data: " + chat_packet.json_data)
connection.register_packet_listener(print_chat, ChatMessagePacket) connection.register_packet_listener(print_chat, ChatMessagePacket)
while True: while True:
@ -64,7 +71,7 @@ def main():
packet.message = text packet.message = text
connection.write_packet(packet) connection.write_packet(packet)
except KeyboardInterrupt: except KeyboardInterrupt:
print "Bye!" print("Bye!")
sys.exit() sys.exit()