diff --git a/Pipfile b/Pipfile index c72c0f4b..acc32a92 100644 --- a/Pipfile +++ b/Pipfile @@ -9,6 +9,7 @@ webassets = "==0.12.1" Unipath = "==1.1" wtforms-tornado = "*" pendulum = "*" +redis = "*" [dev-packages] pytest = "==3.6.0" diff --git a/Pipfile.lock b/Pipfile.lock index f88ab6f6..7f424577 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "5f91978d61968a720e4edc404ea36123ff96d857c1d7ff865f5aaef9b133db37" + "sha256": "e424765acd32453d13c35cade0b4d0cb57cac5742ac020f32352411f15df71c5" }, "pipfile-spec": 6, "requires": { @@ -46,6 +46,14 @@ ], "version": "==2018.5" }, + "redis": { + "hashes": [ + "sha256:8a1900a9f2a0a44ecf6e8b5eb3e967a9909dfed219ad66df094f27f7d6f330fb", + "sha256:a22ca993cea2962dbb588f9f30d0015ac4afcc45bee27d3978c0dbe9e97c6c0f" + ], + "index": "pypi", + "version": "==2.10.6" + }, "six": { "hashes": [ "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", @@ -293,10 +301,10 @@ }, "py": { "hashes": [ - "sha256:29c9fab495d7528e80ba1e343b958684f4ace687327e6f789a94bf3d1915f881", - "sha256:983f77f3331356039fdd792e9220b7b8ee1aa6bd2b25f567a963ff1de5a64f6a" + "sha256:3fd59af7435864e1a243790d322d763925431213b6b8529c6ca71081ace3bbf7", + "sha256:e31fb2767eb657cbde86c454f02e99cb846d3cd9d61b318525140214fdc0e98e" ], - "version": "==1.5.3" + "version": "==1.5.4" }, "pygments": { "hashes": [ diff --git a/README.md b/README.md index 7b9b4d95..88698d19 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ To enter the virtualenv manually (a la `source .venv/bin/activate`): If you want to automatically load the virtual environment whenever you enter the project directory, take a look at [direnv](https://direnv.net/). An `.envrc` file is included in this repository. direnv will activate and deactivate virtualenvs for you when you enter and leave the directory. +Additionally, ATST requires a redis instance for session management. Have redis installed and running. By default, ATST will try to connect to a redis instance running on localhost on its default port, 6379. + ## Running (development) To start the app and watch for changes: diff --git a/atst/app.py b/atst/app.py index 5e2bb0ff..40e1d96d 100644 --- a/atst/app.py +++ b/atst/app.py @@ -2,6 +2,7 @@ import os from configparser import ConfigParser import tornado.web from tornado.web import url +from redis import StrictRedis from atst.handlers.main import MainHandler from atst.handlers.home import Home @@ -12,6 +13,7 @@ from atst.handlers.request_new import RequestNew from atst.handlers.dev import Dev from atst.home import home from atst.api_client import ApiClient +from atst.sessions import RedisSessions ENV = os.getenv("TORNADO_ENV", "dev") @@ -20,7 +22,12 @@ def make_app(config, deps, **kwargs): routes = [ url(r"/", Home, {"page": "login"}, name="main"), - url(r"/login", Login, {"authnid_client": deps["authnid_client"]}, name="login"), + url( + r"/login", + Login, + {"sessions": deps["sessions"], "authnid_client": deps["authnid_client"]}, + name="login", + ), url(r"/home", MainHandler, {"page": "home"}, name="home"), url( r"/workspaces/blank", @@ -64,7 +71,14 @@ def make_app(config, deps, **kwargs): ] if not ENV == "production": - routes += [url(r"/login-dev", Dev, {"action": "login"}, name="dev-login")] + routes += [ + url( + r"/login-dev", + Dev, + {"action": "login", "sessions": deps["sessions"]}, + name="dev-login", + ) + ] app = tornado.web.Application( routes, @@ -76,12 +90,17 @@ def make_app(config, deps, **kwargs): **kwargs, ) app.config = config + app.sessions = deps["sessions"] return app def make_deps(config): # we do not want to do SSL verify services in test and development validate_cert = ENV == "production" + redis_client = StrictRedis.from_url( + config["default"]["REDIS_URI"], decode_responses=True + ) + return { "authz_client": ApiClient( config["default"]["AUTHZ_BASE_URL"], @@ -98,6 +117,9 @@ def make_deps(config): api_version="v1", validate_cert=validate_cert, ), + "sessions": RedisSessions( + redis_client, config["default"]["SESSION_TTL_SECONDS"] + ), } diff --git a/atst/handler.py b/atst/handler.py index 88ecb61c..6272743c 100644 --- a/atst/handler.py +++ b/atst/handler.py @@ -1,6 +1,7 @@ from webassets import Environment, Bundle import tornado.web from atst.home import home +from atst.sessions import SessionNotFoundError assets = Environment(directory=home.child("scss"), url="/static") css = Bundle( @@ -21,17 +22,19 @@ class BaseHandler(tornado.web.RequestHandler): ns.update(helpers) return ns + def login(self, user): + session_id = self.sessions.start_session(user) + self.set_secure_cookie("atat", session_id) + self.redirect("/home") + def get_current_user(self): - if self.get_secure_cookie("atst"): - return { - "id": "9cb348f0-8102-4962-88c4-dac8180c904c", - "email": "fake.user@mail.com", - "first_name": "Fake", - "last_name": "User", - } + cookie = self.get_secure_cookie("atat") + if cookie: + try: + session = self.application.sessions.get_session(cookie) + except SessionNotFoundError: + self.redirect("/login") else: return None - # this is a temporary implementation until we have real sessions - def _start_session(self): - self.set_secure_cookie("atst", "valid-user-session") + return session["user"] diff --git a/atst/handlers/dev.py b/atst/handlers/dev.py index 3dbd7653..7a1e7f29 100644 --- a/atst/handlers/dev.py +++ b/atst/handlers/dev.py @@ -2,13 +2,10 @@ from atst.handler import BaseHandler class Dev(BaseHandler): - def initialize(self, action): + def initialize(self, action, sessions): self.action = action + self.sessions = sessions def get(self): - if self.action == "login": - self._login() - - def _login(self): - self._start_session() - self.redirect("/home") + user = {"id": "164497f6-c1ea-4f42-a5ef-101da278c012"} + self.login(user) diff --git a/atst/handlers/login.py b/atst/handlers/login.py index 271a806d..4e8b6f2d 100644 --- a/atst/handlers/login.py +++ b/atst/handlers/login.py @@ -3,34 +3,35 @@ from atst.handler import BaseHandler class Login(BaseHandler): - def initialize(self, authnid_client): + def initialize(self, authnid_client, sessions): self.authnid_client = authnid_client + self.sessions = sessions @tornado.gen.coroutine def get(self): token = self.get_query_argument("bearer-token") if token: - valid = yield self._validate_login_token(token) - if valid: - self._start_session() - self.redirect("/home") - return + user = yield self._fetch_user_info(token) + if user: + self.login(user) + else: + self.write_error(401) url = self.get_login_url() self.redirect(url) - return @tornado.gen.coroutine - def _validate_login_token(self, token): + def _fetch_user_info(self, token): try: response = yield self.authnid_client.post( "/validate", json={"token": token} ) - return response.code == 200 + if response.code == 200: + return response.json["user"] except tornado.httpclient.HTTPError as error: if error.response.code == 401: - return False + return None else: raise error diff --git a/atst/sessions.py b/atst/sessions.py new file mode 100644 index 00000000..f7f4dfd8 --- /dev/null +++ b/atst/sessions.py @@ -0,0 +1,71 @@ +from uuid import uuid4 +import json +from redis import exceptions + + +class SessionStorageError(Exception): + pass + + +class SessionNotFoundError(Exception): + pass + + +class Sessions(object): + def start_session(self, user): + raise NotImplementedError() + + def get_session(self, session_id): + raise NotImplementedError() + + def generate_session_id(self): + return str(uuid4()) + + def build_session_dict(self, user=None): + return {"user": user or {}} + + +class DictSessions(Sessions): + def __init__(self): + self.sessions = {} + + def start_session(self, user): + session_id = self.generate_session_id() + self.sessions[session_id] = self.build_session_dict(user=user) + return session_id + + def get_session(self, session_id): + try: + session = self.sessions[session_id] + except KeyError: + raise SessionNotFoundError + + return session + + +class RedisSessions(Sessions): + def __init__(self, redis, ttl_seconds): + self.redis = redis + self.ttl_seconds = ttl_seconds + + def start_session(self, user): + session_id = self.generate_session_id() + session_dict = self.build_session_dict(user=user) + session_serialized = json.dumps(session_dict) + try: + self.redis.setex(session_id, self.ttl_seconds, session_serialized) + except exceptions.ConnectionError: + raise SessionStorageError + return session_id + + def get_session(self, session_id): + try: + session_serialized = self.redis.get(session_id) + except exceptions.ConnectionError: + raise + + if session_serialized: + self.redis.expire(session_id, self.ttl_seconds) + return json.loads(session_serialized) + else: + raise SessionNotFoundError diff --git a/config/base.ini b/config/base.ini index fcb72b4e..993745f3 100644 --- a/config/base.ini +++ b/config/base.ini @@ -8,3 +8,5 @@ COOKIE_SECRET = some-secret-please-replace SECRET = change_me_into_something_secret CAC_URL = https://localhost:8001 REQUESTS_QUEUE_BASE_URL = http://localhost:8003 +REDIS_URI = redis://localhost:6379 +SESSION_TTL_SECONDS = 600 diff --git a/tests/conftest.py b/tests/conftest.py index ad83d6e2..d0da68f2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ import pytest from atst.app import make_app, make_deps, make_config from tests.mocks import MockApiClient, MockRequestsClient +from atst.sessions import DictSessions @pytest.fixture @@ -10,6 +11,7 @@ def app(): "authz_client": MockApiClient("authz"), "requests_client": MockRequestsClient("requests"), "authnid_client": MockApiClient("authnid"), + "sessions": DictSessions(), } config = make_config() diff --git a/tests/test_auth.py b/tests/test_auth.py index f99deee9..67a77e45 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -3,6 +3,10 @@ import pytest import tornado.web import tornado.gen +MOCK_USER = {"user": {"id": "438567dd-25fa-4d83-a8cc-8aa8366cb24a"}} +@tornado.gen.coroutine +def _fetch_user_info(c, t): + return MOCK_USER @pytest.mark.gen_test def test_redirects_when_not_logged_in(http_client, base_url): @@ -17,19 +21,13 @@ def test_redirects_when_not_logged_in(http_client, base_url): @pytest.mark.gen_test def test_login_with_valid_bearer_token(app, monkeypatch, http_client, base_url): - @tornado.gen.coroutine - def _validate_login_token(c, t): - return True - - monkeypatch.setattr( - "atst.handlers.login.Login._validate_login_token", _validate_login_token - ) + monkeypatch.setattr("atst.handlers.login.Login._fetch_user_info", _fetch_user_info) response = yield http_client.fetch( base_url + "/login?bearer-token=abc-123", follow_redirects=False, raise_error=False, ) - assert response.headers["Set-Cookie"].startswith("atst") + assert response.headers["Set-Cookie"].startswith("atat") assert response.headers["Location"] == "/home" assert response.code == 302 @@ -39,7 +37,7 @@ def test_login_via_dev_endpoint(app, http_client, base_url): response = yield http_client.fetch( base_url + "/login-dev", raise_error=False, follow_redirects=False ) - assert response.headers["Set-Cookie"].startswith("atst") + assert response.headers["Set-Cookie"].startswith("atat") assert response.code == 302 assert response.headers["Location"] == "/home" @@ -52,3 +50,14 @@ def test_login_with_invalid_bearer_token(http_client, base_url): raise_error=False, headers={"Cookie": "bearer-token=anything"}, ) + +@pytest.mark.gen_test +def test_valid_login_creates_session(app, monkeypatch, http_client, base_url): + monkeypatch.setattr("atst.handlers.login.Login._fetch_user_info", _fetch_user_info) + assert len(app.sessions.sessions) == 0 + yield http_client.fetch( + base_url + "/login?bearer-token=abc-123", + follow_redirects=False, + raise_error=False, + ) + assert len(app.sessions.sessions) == 1