Merge pull request #44 from dod-ccpo/user

Session management
This commit is contained in:
richard-dds 2018-06-29 16:04:20 -04:00 committed by GitHub
commit 345a50982b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 160 additions and 42 deletions

View File

@ -9,6 +9,7 @@ webassets = "==0.12.1"
Unipath = "==1.1" Unipath = "==1.1"
wtforms-tornado = "*" wtforms-tornado = "*"
pendulum = "*" pendulum = "*"
redis = "*"
[dev-packages] [dev-packages]
pytest = "==3.6.0" pytest = "==3.6.0"

16
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "5f91978d61968a720e4edc404ea36123ff96d857c1d7ff865f5aaef9b133db37" "sha256": "e424765acd32453d13c35cade0b4d0cb57cac5742ac020f32352411f15df71c5"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@ -46,6 +46,14 @@
], ],
"version": "==2018.5" "version": "==2018.5"
}, },
"redis": {
"hashes": [
"sha256:8a1900a9f2a0a44ecf6e8b5eb3e967a9909dfed219ad66df094f27f7d6f330fb",
"sha256:a22ca993cea2962dbb588f9f30d0015ac4afcc45bee27d3978c0dbe9e97c6c0f"
],
"index": "pypi",
"version": "==2.10.6"
},
"six": { "six": {
"hashes": [ "hashes": [
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
@ -293,10 +301,10 @@
}, },
"py": { "py": {
"hashes": [ "hashes": [
"sha256:29c9fab495d7528e80ba1e343b958684f4ace687327e6f789a94bf3d1915f881", "sha256:3fd59af7435864e1a243790d322d763925431213b6b8529c6ca71081ace3bbf7",
"sha256:983f77f3331356039fdd792e9220b7b8ee1aa6bd2b25f567a963ff1de5a64f6a" "sha256:e31fb2767eb657cbde86c454f02e99cb846d3cd9d61b318525140214fdc0e98e"
], ],
"version": "==1.5.3" "version": "==1.5.4"
}, },
"pygments": { "pygments": {
"hashes": [ "hashes": [

View File

@ -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. 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) ## Running (development)
To start the app and watch for changes: To start the app and watch for changes:

View File

@ -2,6 +2,7 @@ import os
from configparser import ConfigParser from configparser import ConfigParser
import tornado.web import tornado.web
from tornado.web import url from tornado.web import url
from redis import StrictRedis
from atst.handlers.main import MainHandler from atst.handlers.main import MainHandler
from atst.handlers.home import Home 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.handlers.dev import Dev
from atst.home import home from atst.home import home
from atst.api_client import ApiClient from atst.api_client import ApiClient
from atst.sessions import RedisSessions
ENV = os.getenv("TORNADO_ENV", "dev") ENV = os.getenv("TORNADO_ENV", "dev")
@ -20,7 +22,12 @@ def make_app(config, deps, **kwargs):
routes = [ routes = [
url(r"/", Home, {"page": "login"}, name="main"), 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"/home", MainHandler, {"page": "home"}, name="home"),
url( url(
r"/workspaces/blank", r"/workspaces/blank",
@ -64,7 +71,14 @@ def make_app(config, deps, **kwargs):
] ]
if not ENV == "production": 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( app = tornado.web.Application(
routes, routes,
@ -76,12 +90,17 @@ def make_app(config, deps, **kwargs):
**kwargs, **kwargs,
) )
app.config = config app.config = config
app.sessions = deps["sessions"]
return app return app
def make_deps(config): def make_deps(config):
# we do not want to do SSL verify services in test and development # we do not want to do SSL verify services in test and development
validate_cert = ENV == "production" validate_cert = ENV == "production"
redis_client = StrictRedis.from_url(
config["default"]["REDIS_URI"], decode_responses=True
)
return { return {
"authz_client": ApiClient( "authz_client": ApiClient(
config["default"]["AUTHZ_BASE_URL"], config["default"]["AUTHZ_BASE_URL"],
@ -98,6 +117,9 @@ def make_deps(config):
api_version="v1", api_version="v1",
validate_cert=validate_cert, validate_cert=validate_cert,
), ),
"sessions": RedisSessions(
redis_client, config["default"]["SESSION_TTL_SECONDS"]
),
} }

View File

@ -1,6 +1,7 @@
from webassets import Environment, Bundle from webassets import Environment, Bundle
import tornado.web import tornado.web
from atst.home import home from atst.home import home
from atst.sessions import SessionNotFoundError
assets = Environment(directory=home.child("scss"), url="/static") assets = Environment(directory=home.child("scss"), url="/static")
css = Bundle( css = Bundle(
@ -21,17 +22,19 @@ class BaseHandler(tornado.web.RequestHandler):
ns.update(helpers) ns.update(helpers)
return ns 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): def get_current_user(self):
if self.get_secure_cookie("atst"): cookie = self.get_secure_cookie("atat")
return { if cookie:
"id": "9cb348f0-8102-4962-88c4-dac8180c904c", try:
"email": "fake.user@mail.com", session = self.application.sessions.get_session(cookie)
"first_name": "Fake", except SessionNotFoundError:
"last_name": "User", self.redirect("/login")
}
else: else:
return None return None
# this is a temporary implementation until we have real sessions return session["user"]
def _start_session(self):
self.set_secure_cookie("atst", "valid-user-session")

View File

@ -2,13 +2,10 @@ from atst.handler import BaseHandler
class Dev(BaseHandler): class Dev(BaseHandler):
def initialize(self, action): def initialize(self, action, sessions):
self.action = action self.action = action
self.sessions = sessions
def get(self): def get(self):
if self.action == "login": user = {"id": "164497f6-c1ea-4f42-a5ef-101da278c012"}
self._login() self.login(user)
def _login(self):
self._start_session()
self.redirect("/home")

View File

@ -3,34 +3,35 @@ from atst.handler import BaseHandler
class Login(BaseHandler): class Login(BaseHandler):
def initialize(self, authnid_client): def initialize(self, authnid_client, sessions):
self.authnid_client = authnid_client self.authnid_client = authnid_client
self.sessions = sessions
@tornado.gen.coroutine @tornado.gen.coroutine
def get(self): def get(self):
token = self.get_query_argument("bearer-token") token = self.get_query_argument("bearer-token")
if token: if token:
valid = yield self._validate_login_token(token) user = yield self._fetch_user_info(token)
if valid: if user:
self._start_session() self.login(user)
self.redirect("/home") else:
return self.write_error(401)
url = self.get_login_url() url = self.get_login_url()
self.redirect(url) self.redirect(url)
return
@tornado.gen.coroutine @tornado.gen.coroutine
def _validate_login_token(self, token): def _fetch_user_info(self, token):
try: try:
response = yield self.authnid_client.post( response = yield self.authnid_client.post(
"/validate", json={"token": token} "/validate", json={"token": token}
) )
return response.code == 200 if response.code == 200:
return response.json["user"]
except tornado.httpclient.HTTPError as error: except tornado.httpclient.HTTPError as error:
if error.response.code == 401: if error.response.code == 401:
return False return None
else: else:
raise error raise error

71
atst/sessions.py Normal file
View File

@ -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

View File

@ -8,3 +8,5 @@ COOKIE_SECRET = some-secret-please-replace
SECRET = change_me_into_something_secret SECRET = change_me_into_something_secret
CAC_URL = https://localhost:8001 CAC_URL = https://localhost:8001
REQUESTS_QUEUE_BASE_URL = http://localhost:8003 REQUESTS_QUEUE_BASE_URL = http://localhost:8003
REDIS_URI = redis://localhost:6379
SESSION_TTL_SECONDS = 600

View File

@ -2,6 +2,7 @@ import pytest
from atst.app import make_app, make_deps, make_config from atst.app import make_app, make_deps, make_config
from tests.mocks import MockApiClient, MockRequestsClient from tests.mocks import MockApiClient, MockRequestsClient
from atst.sessions import DictSessions
@pytest.fixture @pytest.fixture
@ -10,6 +11,7 @@ def app():
"authz_client": MockApiClient("authz"), "authz_client": MockApiClient("authz"),
"requests_client": MockRequestsClient("requests"), "requests_client": MockRequestsClient("requests"),
"authnid_client": MockApiClient("authnid"), "authnid_client": MockApiClient("authnid"),
"sessions": DictSessions(),
} }
config = make_config() config = make_config()

View File

@ -3,6 +3,10 @@ import pytest
import tornado.web import tornado.web
import tornado.gen 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 @pytest.mark.gen_test
def test_redirects_when_not_logged_in(http_client, base_url): 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 @pytest.mark.gen_test
def test_login_with_valid_bearer_token(app, monkeypatch, http_client, base_url): def test_login_with_valid_bearer_token(app, monkeypatch, http_client, base_url):
@tornado.gen.coroutine monkeypatch.setattr("atst.handlers.login.Login._fetch_user_info", _fetch_user_info)
def _validate_login_token(c, t):
return True
monkeypatch.setattr(
"atst.handlers.login.Login._validate_login_token", _validate_login_token
)
response = yield http_client.fetch( response = yield http_client.fetch(
base_url + "/login?bearer-token=abc-123", base_url + "/login?bearer-token=abc-123",
follow_redirects=False, follow_redirects=False,
raise_error=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.headers["Location"] == "/home"
assert response.code == 302 assert response.code == 302
@ -39,7 +37,7 @@ def test_login_via_dev_endpoint(app, http_client, base_url):
response = yield http_client.fetch( response = yield http_client.fetch(
base_url + "/login-dev", raise_error=False, follow_redirects=False 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.code == 302
assert response.headers["Location"] == "/home" assert response.headers["Location"] == "/home"
@ -52,3 +50,14 @@ def test_login_with_invalid_bearer_token(http_client, base_url):
raise_error=False, raise_error=False,
headers={"Cookie": "bearer-token=anything"}, 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