Skip to content
This repository has been archived by the owner on Oct 11, 2023. It is now read-only.

Pre-authorized worker login #282

Merged
merged 2 commits into from
Mar 24, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions deploy/web/server/run_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def read_secrets():
if not os.path.exists(loc):
return {
"cookie_secret": "0123456789",
"preauth_secret": "0123456789",
}
with open(loc, "r") as secret_file:
for line in secret_file:
Expand All @@ -64,6 +65,7 @@ def read_secrets():
"compiled_template_cache": False,
"debug": "/dbg/" in __file__,
"login_url": "/login",
"preauth_secret": SECRETS["preauth_secret"],
"template_path": get_path("static"),
}

Expand Down
115 changes: 109 additions & 6 deletions deploy/web/server/tornado_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import uuid
import warnings
import asyncio
import hashlib
from collections import defaultdict
from zmq.eventloop import ioloop

Expand Down Expand Up @@ -118,6 +119,12 @@ def warn_once(msg, warningtype=None):
warnings.warn(msg, warningtype, stacklevel=2)


def get_salted_hash(in_string):
"""Return a hash string for the given string using sha-256"""
salted_string = in_string + tornado_settings["preauth_secret"] + in_string
return hashlib.sha256(salted_string.encode("utf-8")).hexdigest()[:20]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why only first 20 chars?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

URL length concerns. This should be long enough though that people can't just guess access tokens wildly.



def get_rand_id():
return str(uuid.uuid4())

Expand Down Expand Up @@ -204,8 +211,32 @@ def set_player(self, player):
self.player = player

def open(self, game_id):
user_json = self.get_secure_cookie("user")
if user_json:
"""
Open a websocket, validated either by a valid user cookie or
by a validated preauth.
"""
preauth_context = self.get_secure_cookie("preauth_context")
user = None
if preauth_context is not None: # If there is any preauth
preauth = self.get_secure_cookie("preauth")
user = json.loads(preauth)

# See if the context matches our generated hash
context_token = json.loads(self.get_secure_cookie("context_token"))
preauth_context = json.loads(preauth_context)
context_hash = get_salted_hash(preauth_context)
if context_hash != context_token:
# User created their own context cookie
print(f"Logged in user {user} tried to use invalid preauth context!")
self.close()
return
else:
user_json = self.get_secure_cookie("user")
if user_json is not None:
user = json.loads(user_json)

print("Requesting for user", user)
if user is not None:
logging.info("Opened new socket from ip: {}".format(self.request.remote_ip))
logging.info("For game: {}".format(game_id))
if game_id not in self.app.graphs:
Expand All @@ -218,7 +249,8 @@ def open(self, game_id):
self,
graph_purgatory,
db=self.db,
user=json.loads(user_json),
user=user,
context=preauth_context,
)
new_player.init_soul()
self.app.graphs[game_id].players.append(new_player)
Expand Down Expand Up @@ -258,7 +290,9 @@ def get_login_url(self):
return "/#/login"

def get_current_user(self):
user_json = self.get_secure_cookie("user")
user_json = self.get_secure_cookie(
"user"
) # Need to refactor into 'get_identity', then have base and preauth handler implementations
if user_json:
user_decoded = tornado.escape.json_decode(user_json)
if len(user_decoded) == 0:
Expand Down Expand Up @@ -291,7 +325,6 @@ def write_error(self, status_code, **kwargs):
)
if self.settings.get("debug") and "exc_info" in kwargs:
logging.error("rendering error page")
import traceback

exc_info = kwargs["exc_info"]
# exc_info is a tuple consisting of:
Expand Down Expand Up @@ -405,6 +438,11 @@ def get_handlers(self, database, hostname=DEFAULT_HOSTNAME, password="LetsPlay")
(r"/#(.*)", LandingHandler, {"database": database}),
(r"/#/login", LandingHandler, {"database": database}),
(r"/#/error", NotFoundHandler, {"database": database}),
(
r"/preauth/(.*)/(.*)/(.*)/",
PreauthGameHandler,
{"database": database, "hostname": hostname},
),
(r"/play", GameHandler, {"database": database}),
(r"/play/?id=.*", GameHandler, {"database": database}),
(r"/play/*", GameHandler, {"database": database}),
Expand Down Expand Up @@ -461,6 +499,66 @@ def get(self):
self.render(here + "/../build/game.html")


class PreauthGameHandler(BaseHandler):
def initialize(
self,
database,
hostname=DEFAULT_HOSTNAME,
):
self.db = database
self.hostname = hostname

def validate_login_details(self, user_id, context_id, auth_token) -> bool:
"""
Check if the provided details are correct, as the user id + context id + secret
should hash to the auth token.
"""
combo_string = f"{user_id}-{context_id}"
hashed_key = get_salted_hash(combo_string)
return hashed_key == auth_token

def get(self, user_id, context_id, auth_token):
"""
Preauth access requires that the salted hash of user_id and context_id matches
the provided auth_token, which ensures that our server was the one to generate
the auth token.

We then set a cookie with a preauth user id, the context of the preauth, and
a context auth token we generate the hash for (this way we can assert the
cookie contents weren't edited).
"""
if self.validate_login_details(user_id, context_id, auth_token):
user_hash = get_salted_hash(user_id)
context_hash = get_salted_hash(context_id)
hashed_user_id = f"preauth-{user_hash}"
with self.db as ldb:
_ = ldb.create_user(hashed_user_id)
self.set_secure_cookie(
"preauth",
tornado.escape.json_encode(hashed_user_id),
expires_days=1,
domain=self.hostname,
httponly=True,
)
self.set_secure_cookie(
"preauth_context",
tornado.escape.json_encode(context_id),
expires_days=1,
domain=self.hostname,
httponly=True,
)
self.set_secure_cookie(
"context_token",
tornado.escape.json_encode(context_hash),
expires_days=1,
domain=self.hostname,
httponly=True,
)
self.render(here + "/../build/game.html")
else:
self.redirect("/#/error")


class NotFoundHandler(BaseHandler):
def get(self):
self.redirect("/#/error")
Expand Down Expand Up @@ -588,7 +686,7 @@ class TornadoPlayerProvider(PlayerProvider):
Player Provider for the web app
"""

def __init__(self, socket, purgatory, db=None, user=None):
def __init__(self, socket, purgatory, db=None, user=None, context=None):
self.socket = socket
self.player_soul = None
self.purgatory = purgatory
Expand All @@ -598,6 +696,7 @@ def __init__(self, socket, purgatory, db=None, user=None):
socket.send_alive()
self.db = db
self.user = user
self.context = context
# TODO a TornadoPlayerProvider refactor is likely desired, combining
# the APIs for socket and HTTP requests to use logged in user
# and their state in the world at the same time.
Expand Down Expand Up @@ -657,6 +756,8 @@ def init_soul(self):
self.purgatory.world
)
self.player_soul.handle_act("look")
self.player_soul.target_node.user_id = self.user
self.player_soul.target_node.context_id = self.context

def is_alive(self):
return self.socket.alive
Expand All @@ -672,6 +773,8 @@ def on_reap_soul(self, soul):
"for your soul, if you'd like?\" Send anything to respawn."
),
)
soul.target_node.user_id = None
soul.target_node.context_id = None
dat = action.to_frontend_form(soul.target_node)
self.socket.safe_write_message(
json.dumps({"command": "actions", "data": [dat]})
Expand Down