diff --git a/busy_beaver/app.py b/busy_beaver/app.py index 9295bfa6..b8b40b9e 100644 --- a/busy_beaver/app.py +++ b/busy_beaver/app.py @@ -12,7 +12,7 @@ from .common.oauth import OAuthError from .config import DATABASE_URI, REDIS_URI, SECRET_KEY from .exceptions import NotAuthorized, ValidationError -from .extensions import db, migrate, rq, talisman +from .extensions import db, login_manager, migrate, rq, talisman from .toolbox import make_response @@ -51,6 +51,7 @@ def create_app(*, testing=False): app.config["RQ_QUEUES"] = ["default"] rq.init_app(app) + login_manager.init_app(app) talisman.init_app(app) app.register_error_handler(NotAuthorized, handle_http_error) diff --git a/busy_beaver/apps/slack_integration/api/oauth.py b/busy_beaver/apps/slack_integration/api/oauth.py index 58106763..eda7332f 100644 --- a/busy_beaver/apps/slack_integration/api/oauth.py +++ b/busy_beaver/apps/slack_integration/api/oauth.py @@ -1,7 +1,8 @@ import logging -from flask import jsonify, request +from flask import jsonify, redirect, request, url_for from flask.views import MethodView +from flask_login import login_user from busy_beaver.apps.slack_integration.oauth.state_machine import ( SlackInstallationOnboardUserWorkflow, @@ -41,8 +42,5 @@ def get(self): callback_url = request.url user = process_slack_sign_in_callback(callback_url, state) - - if user.is_admin: - return jsonify({"message": "You are the admin!"}) - else: - return jsonify({"message": "You are not the admin!"}) + login_user(user) + return redirect(url_for("web.settings_view")) diff --git a/busy_beaver/apps/slack_integration/models.py b/busy_beaver/apps/slack_integration/models.py index 0ed8a13d..e0a5ea07 100644 --- a/busy_beaver/apps/slack_integration/models.py +++ b/busy_beaver/apps/slack_integration/models.py @@ -1,9 +1,10 @@ +from flask_login import UserMixin from sqlalchemy_utils import EncryptedType from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine from busy_beaver.common.models import BaseModel from busy_beaver.config import SECRET_KEY -from busy_beaver.extensions import db +from busy_beaver.extensions import db, login_manager class SlackInstallation(BaseModel): @@ -44,7 +45,7 @@ def __repr__(self): # pragma: no cover ) -class SlackUser(BaseModel): +class SlackUser(UserMixin, BaseModel): """Track users that have interacted with Busy Beaver on Slack""" __tablename__ = "slack_user" @@ -61,3 +62,8 @@ class SlackUser(BaseModel): # Relationships installation = db.relationship("SlackInstallation") + + +@login_manager.user_loader +def load_user(id): + return SlackUser.query.get(int(id)) diff --git a/busy_beaver/apps/slack_integration/oauth/oauth_flow.py b/busy_beaver/apps/slack_integration/oauth/oauth_flow.py index 78316776..7505cd43 100644 --- a/busy_beaver/apps/slack_integration/oauth/oauth_flow.py +++ b/busy_beaver/apps/slack_integration/oauth/oauth_flow.py @@ -131,7 +131,7 @@ def _parse_json_response(self, code): # Slack Sign-in ############### class SlackSignInDetails(NamedTuple): - user_id: str + slack_id: str workspace_id: str scope: str access_token: str @@ -170,7 +170,7 @@ def generate_authentication_tuple(self) -> ExternalOAuthDetails: def process_callback(self, authorization_response_url, state) -> SlackSignInDetails: user_credentials = self._fetch_token(authorization_response_url) return SlackSignInDetails( - user_id=user_credentials["authed_user"]["id"], + slack_id=user_credentials["authed_user"]["id"], workspace_id=user_credentials["team"]["id"], scope=user_credentials["authed_user"]["scope"], access_token=user_credentials["authed_user"]["access_token"], diff --git a/busy_beaver/apps/slack_integration/oauth/workflow.py b/busy_beaver/apps/slack_integration/oauth/workflow.py index b27b3c2f..7e361d31 100644 --- a/busy_beaver/apps/slack_integration/oauth/workflow.py +++ b/busy_beaver/apps/slack_integration/oauth/workflow.py @@ -48,17 +48,13 @@ def process_slack_sign_in_callback(callback_url, state): installation = SlackInstallation.query.filter_by( workspace_id=user_details.workspace_id ).first() - slack = SlackClient(installation.bot_access_token) - is_admin = slack.is_admin(user_details.user_id) + slack_user = SlackUser.query.filter_by( + slack_id=user_details.slack_id, installation=installation + ).first() - extra = { - "user_id": user_details.user_id, - "workspace_id": user_details.workspace_id, - "is_admin": is_admin, - } + extra = {"user_id": slack_user.slack_id, "workspace_id": installation.workspace_id} logger.info("User logged into Busy Beaver", extra=extra) - - return UserDetails(is_admin, user_details) + return slack_user ############## diff --git a/busy_beaver/apps/web/views.py b/busy_beaver/apps/web/views.py index cd8d7d55..62343060 100644 --- a/busy_beaver/apps/web/views.py +++ b/busy_beaver/apps/web/views.py @@ -1,7 +1,8 @@ import logging -from flask import render_template +from flask import redirect, render_template, url_for from flask.views import View +from flask_login import login_required, logout_user from .blueprint import web_bp @@ -22,6 +23,28 @@ def dispatch_request(self): return render_template(self.template_name) +class RenderTemplateLoggedInView(RenderTemplateView): + """Template View requiring logged in user""" + + decorators = [login_required] + + +@web_bp.route("/logout") +@login_required +def logout(): + logout_user() + return redirect(url_for("web.home")) + + web_bp.add_url_rule( "/", view_func=RenderTemplateView.as_view("home", template_name="index.html") ) +web_bp.add_url_rule( + "/login", view_func=RenderTemplateView.as_view("login", template_name="login.html") +) +web_bp.add_url_rule( + "/settings", + view_func=RenderTemplateLoggedInView.as_view( + "settings_view", template_name="settings.html" + ), +) diff --git a/busy_beaver/extensions.py b/busy_beaver/extensions.py index ef0112e6..28101708 100644 --- a/busy_beaver/extensions.py +++ b/busy_beaver/extensions.py @@ -1,3 +1,4 @@ +from flask_login import LoginManager from flask_migrate import Migrate from flask_rq2 import RQ from flask_sqlalchemy import SQLAlchemy @@ -7,3 +8,6 @@ migrate = Migrate() rq = RQ() talisman = Talisman() + +login_manager = LoginManager() +login_manager.login_view = "web.login" diff --git a/busy_beaver/templates/login.html b/busy_beaver/templates/login.html new file mode 100644 index 00000000..97400d39 --- /dev/null +++ b/busy_beaver/templates/login.html @@ -0,0 +1 @@ +Sign in with Slack diff --git a/busy_beaver/templates/settings.html b/busy_beaver/templates/settings.html new file mode 100644 index 00000000..cd087558 --- /dev/null +++ b/busy_beaver/templates/settings.html @@ -0,0 +1 @@ +Hello world! diff --git a/requirements.in b/requirements.in index bea3fa59..a812d173 100644 --- a/requirements.in +++ b/requirements.in @@ -1,6 +1,7 @@ alembic==1.3.2 cryptography==2.7 factory_boy==2.12.0 +flask-login==0.5.0 Flask-Migrate==2.5.2 Flask-RQ2==18.3 Flask-SQLAlchemy==2.4.1 diff --git a/requirements.txt b/requirements.txt index 7329f209..1d257a55 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # pip-compile --output-file=requirements.txt requirements.in # aiohttp==3.5.4 # via slackclient -alembic==1.3.2 +alembic==1.3.2 # via -r requirements.in, flask-migrate asn1crypto==0.24.0 # via cryptography async-timeout==3.0.1 # via aiohttp attrs==19.1.0 # via aiohttp @@ -15,47 +15,48 @@ cffi==1.12.3 # via cryptography chardet==3.0.4 # via aiohttp, requests click==7.0 # via flask, rq croniter==0.3.30 # via rq-scheduler -cryptography==2.7 -factory_boy==2.12.0 +cryptography==2.7 # via -r requirements.in +factory_boy==2.12.0 # via -r requirements.in faker==1.0.7 # via factory-boy -flask-migrate==2.5.2 -flask-rq2==18.3 -flask-sqlalchemy==2.4.1 -flask-talisman==0.7.0 -flask==1.1.1 -gunicorn==20.0.4 +flask-login==0.5.0 # via -r requirements.in +flask-migrate==2.5.2 # via -r requirements.in +flask-rq2==18.3 # via -r requirements.in +flask-sqlalchemy==2.4.1 # via -r requirements.in, flask-migrate +flask-talisman==0.7.0 # via -r requirements.in +flask==1.1.1 # via -r requirements.in, flask-login, flask-migrate, flask-rq2, flask-sqlalchemy, sentry-sdk +gunicorn==20.0.4 # via -r requirements.in idna==2.7 # via requests, yarl -inflect==2.1.0 +inflect==2.1.0 # via -r requirements.in itsdangerous==1.1.0 # via flask jinja2==2.10.1 # via flask mako==1.0.12 # via alembic markupsafe==1.1.1 # via jinja2, mako -marshmallow==3.3.0 +marshmallow==3.3.0 # via -r requirements.in multidict==4.5.2 # via aiohttp, yarl oauthlib==3.0.1 # via requests-oauthlib -psycopg2-binary==2.8.4 +psycopg2-binary==2.8.4 # via -r requirements.in pycparser==2.19 # via cffi pysocks==1.7.0 # via tweepy -python-dateutil==2.8.1 +python-dateutil==2.8.1 # via -r requirements.in, alembic, croniter, faker python-editor==1.0.4 # via alembic -python-json-logger==0.1.11 -pytz==2019.3 -redis==3.4.1 -requests-oauthlib==1.3.0 -requests==2.22.0 +python-json-logger==0.1.11 # via -r requirements.in +pytz==2019.3 # via -r requirements.in +redis==3.4.1 # via -r requirements.in, flask-rq2, rq +requests-oauthlib==1.3.0 # via -r requirements.in, tweepy +requests==2.22.0 # via -r requirements.in, requests-oauthlib, tweepy rq-scheduler==0.9 # via flask-rq2 rq==1.0 # via flask-rq2, rq-scheduler -sentry-sdk[flask]==0.14.0 +sentry-sdk[flask]==0.14.0 # via -r requirements.in six==1.12.0 # via cryptography, faker, flask-talisman, python-dateutil, sqlalchemy-utils, transitions, tweepy -slackclient==2.5.0 -sqlalchemy-utils==0.36.1 -sqlalchemy==1.3.12 +slackclient==2.5.0 # via -r requirements.in +sqlalchemy-utils==0.36.1 # via -r requirements.in +sqlalchemy==1.3.12 # via -r requirements.in, alembic, flask-sqlalchemy, sqlalchemy-utils text-unidecode==1.2 # via faker -transitions==0.7.2 -tweepy==3.8.0 +transitions==0.7.2 # via -r requirements.in +tweepy==3.8.0 # via -r requirements.in urllib3==1.24.3 # via requests, sentry-sdk -werkzeug==0.16.0 -whitenoise==5.0.1 +werkzeug==0.16.0 # via -r requirements.in, flask +whitenoise==5.0.1 # via -r requirements.in yarl==1.3.0 # via aiohttp # The following packages are considered to be unsafe in a requirements file: diff --git a/tests/_utilities/fixtures/database.py b/tests/_utilities/fixtures/database.py index d7dd6984..1fde691c 100644 --- a/tests/_utilities/fixtures/database.py +++ b/tests/_utilities/fixtures/database.py @@ -1,6 +1,7 @@ import pytest from busy_beaver.extensions import db as db +from tests._utilities import FactoryManager @pytest.fixture(name="db", scope="module") @@ -27,3 +28,8 @@ def create_sqlalchemy_scoped_session(db): transaction.rollback() connection.close() session.remove() + + +@pytest.fixture(name="factory") +def factory_manager(session): + yield FactoryManager(session) diff --git a/tests/_utilities/fixtures/flask.py b/tests/_utilities/fixtures/flask.py index b4481b0a..e0835983 100644 --- a/tests/_utilities/fixtures/flask.py +++ b/tests/_utilities/fixtures/flask.py @@ -1,3 +1,4 @@ +from flask_login.test_client import FlaskLoginClient import pytest from busy_beaver.app import create_app @@ -10,6 +11,7 @@ def app(): Establish an application context before running the tests. """ app = create_app(testing=True) + app.test_client_class = FlaskLoginClient ctx = app.app_context() ctx.push() yield app @@ -24,6 +26,17 @@ def client(app): yield client +@pytest.fixture(scope="module") +def login_client(app): + """Create Flask test client where we can trigger test requests to app""" + + def _wrapper(user): + client = app.test_client(user=user) + return client + + yield _wrapper + + @pytest.fixture(scope="module") def runner(app): """Create Flask CliRunner that can be used to invoke commands""" diff --git a/tests/_utilities/fixtures/toolbox.py b/tests/_utilities/fixtures/toolbox.py index d8ae67e4..48f55f41 100644 --- a/tests/_utilities/fixtures/toolbox.py +++ b/tests/_utilities/fixtures/toolbox.py @@ -5,8 +5,6 @@ import pytest -from tests._utilities import FactoryManager - @pytest.fixture def patcher(monkeypatch): @@ -50,8 +48,3 @@ def _create_fake_background_task(): return FakeBackgroundTask() yield _create_fake_background_task - - -@pytest.fixture(name="factory") -def factory_manager(session): - yield FactoryManager(session) diff --git a/tests/apps/slack_integration/functional/test_sign_in_with_slack.py b/tests/apps/slack_integration/functional/test_sign_in_with_slack.py index 5c7434db..e6940f67 100644 --- a/tests/apps/slack_integration/functional/test_sign_in_with_slack.py +++ b/tests/apps/slack_integration/functional/test_sign_in_with_slack.py @@ -91,8 +91,8 @@ def test_slack_sign_in__happy_path( response = client.get("/slack/sign-in-callback", query_string=params) # Assert - assert response.status_code == 200 - assert message in response.get_json()["message"] + assert response.status_code == 302 + assert "/settings" in response.headers["Location"] @pytest.mark.end2end diff --git a/tests/apps/web/test_views.py b/tests/apps/web/test_views.py index dc0d4dc2..24935249 100644 --- a/tests/apps/web/test_views.py +++ b/tests/apps/web/test_views.py @@ -1,3 +1,31 @@ def test_index(client): rv = client.get("/") assert rv.status_code == 200 + + +def test_access_restricted_view(client): + rv = client.get("/settings", follow_redirects=True) + assert rv.status_code == 200 + assert "slack.com/oauth/v2/authorize" in rv.data.decode("utf-8") + + +def test_login_and_access_restricted_view(login_client, factory): + slack_user = factory.SlackUser() + client = login_client(user=slack_user) + + rv = client.get("/settings", follow_redirects=True) + assert rv.status_code == 200 + + +def test_logout_view(login_client, factory): + # Arrange + slack_user = factory.SlackUser() + client = login_client(user=slack_user) + client.get("/logout", follow_redirects=True) + + # Act + rv = client.get("/settings", follow_redirects=True) + + # Assert + assert rv.status_code == 200 + assert "slack.com/oauth/v2/authorize" in rv.data.decode("utf-8")