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

Flask-Login for user management #278

Merged
merged 6 commits into from
Jul 6, 2020
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
3 changes: 2 additions & 1 deletion busy_beaver/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
Expand Down
10 changes: 4 additions & 6 deletions busy_beaver/apps/slack_integration/api/oauth.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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"))
10 changes: 8 additions & 2 deletions busy_beaver/apps/slack_integration/models.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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"
Expand All @@ -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))
4 changes: 2 additions & 2 deletions busy_beaver/apps/slack_integration/oauth/oauth_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"],
Expand Down
14 changes: 5 additions & 9 deletions busy_beaver/apps/slack_integration/oauth/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


##############
Expand Down
25 changes: 24 additions & 1 deletion busy_beaver/apps/web/views.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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"
),
)
4 changes: 4 additions & 0 deletions busy_beaver/extensions.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from flask_login import LoginManager
from flask_migrate import Migrate
from flask_rq2 import RQ
from flask_sqlalchemy import SQLAlchemy
Expand All @@ -7,3 +8,6 @@
migrate = Migrate()
rq = RQ()
talisman = Talisman()

login_manager = LoginManager()
login_manager.login_view = "web.login"
1 change: 1 addition & 0 deletions busy_beaver/templates/login.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<a href="https://slack.com/oauth/v2/authorize?response_type=code&client_id=186015429778.488563986231&redirect_uri=http%3A%2F%2F0.0.0.0%3A5000%2Fslack%2Fsign-in-callback&user_scope=identity.basic">Sign in with Slack</a>
1 change: 1 addition & 0 deletions busy_beaver/templates/settings.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Hello world!
1 change: 1 addition & 0 deletions requirements.in
Original file line number Diff line number Diff line change
@@ -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
Expand Down
53 changes: 27 additions & 26 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions tests/_utilities/fixtures/database.py
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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)
13 changes: 13 additions & 0 deletions tests/_utilities/fixtures/flask.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from flask_login.test_client import FlaskLoginClient
import pytest

from busy_beaver.app import create_app
Expand All @@ -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
Expand All @@ -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"""
Expand Down
7 changes: 0 additions & 7 deletions tests/_utilities/fixtures/toolbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@

import pytest

from tests._utilities import FactoryManager


@pytest.fixture
def patcher(monkeypatch):
Expand Down Expand Up @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions tests/apps/web/test_views.py
Original file line number Diff line number Diff line change
@@ -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")