diff --git a/local_dev.sh b/local_dev.sh index d9037e3..999054c 100755 --- a/local_dev.sh +++ b/local_dev.sh @@ -1,3 +1,3 @@ export FLASK_APP=yt_pubsub_handler export FLASK_ENV=development -flask run +flask run --cert=adhoc diff --git a/requirements.txt b/requirements.txt index 3ca7a9d..a0439fa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,11 +4,14 @@ attrs==20.3.0 boto3==1.17.84 botocore==1.20.84 certifi==2020.12.5 +cffi==1.14.6 cfn-flip==1.2.3 chardet==4.0.0 click==7.1.2 +cryptography==3.4.7 durationpy==0.5 Flask==1.1.2 +Flask-Login==0.5.0 Flask-Migrate==3.0.1 flask-ngrok==0.0.25 Flask-SQLAlchemy==2.5.1 @@ -25,6 +28,7 @@ kappa==0.6.0 Mako==1.1.4 MarkupSafe==1.1.1 mysql-connector-python==8.0.25 +oauthlib==3.1.1 packaging==20.9 pep517==0.10.0 pip-tools==6.1.0 @@ -36,6 +40,8 @@ protobuf==3.17.3 psycopg2-binary==2.8.6 py==1.10.0 pycodestyle==2.7.0 +pycparser==2.20 +pyOpenSSL==20.0.1 pyparsing==2.4.7 pytest==6.2.3 python-dateutil==2.8.1 diff --git a/yt_pubsub_handler/__init__.py b/yt_pubsub_handler/__init__.py index cb2eb23..990c529 100644 --- a/yt_pubsub_handler/__init__.py +++ b/yt_pubsub_handler/__init__.py @@ -1,10 +1,31 @@ import os from flask import Flask, render_template +from flask_login import ( + LoginManager, + current_user +) +from oauthlib.oauth2 import WebApplicationClient + from . import models +# Configuration +GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", None) +GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", None) +GOOGLE_DISCOVERY_URL = ( + "https://accounts.google.com/.well-known/openid-configuration" +) db = models.db +login_manager = LoginManager() +client = WebApplicationClient(GOOGLE_CLIENT_ID) + + +# Flask-Login helper to retrieve a user from our db +@login_manager.user_loader +def load_user(user_id): + from .user import User + return User.get(user_id) def create_app(test_config=None): @@ -30,7 +51,7 @@ def create_app(test_config=None): @app.route("/") def index(): - return render_template("index.html") + return render_template("index.html", current_user=current_user) @app.route("/renew_leases") @@ -43,13 +64,22 @@ def renew_lease(): db.app = app db.init_app(app) # sets up flask init-db cmd + # create a local sqlite db if in dev mode from . import db_utils + if os.getenv("FLASK_ENV") == "development": + db_utils.init_db() db_utils.init_app(app) from . import pubsubhub app.register_blueprint(pubsubhub.bp) from . import subscriptions app.register_blueprint(subscriptions.bp) + from . import auth + app.register_blueprint(auth.bp) from flask_migrate import Migrate migrate = Migrate(app, db) + + # user session management setup + login_manager.init_app(app) + return app diff --git a/yt_pubsub_handler/auth.py b/yt_pubsub_handler/auth.py new file mode 100644 index 0000000..ef0a974 --- /dev/null +++ b/yt_pubsub_handler/auth.py @@ -0,0 +1,90 @@ +from enum import unique +import json +from flask_login.utils import login_required + +import requests +from flask import Blueprint, request, current_app, render_template, url_for, redirect +from flask_login import login_user, login_required, logout_user + +from yt_pubsub_handler import GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_DISCOVERY_URL, client +from . import models +from .user import User + +bp = Blueprint("auth", __name__, url_prefix="/auth") + + +def get_google_provider_cfg(): + # TODO: make this more resilient + return requests.get(GOOGLE_DISCOVERY_URL, timeout=60).json() + + +@bp.route("/login") +def login(): + # find out which URL to hit for Google login + google_provider_cfg = get_google_provider_cfg() + auth_endpoint = google_provider_cfg["authorization_endpoint"] + + # Use library to construc tht request for Google login + # and provide scopes that let you retrieve user's profile from Google + request_uri = client.prepare_request_uri( + auth_endpoint, + redirect_uri=request.url_root + "auth/login/callback", + scope=["openid", "email"] + ) + return redirect(request_uri) + + +@bp.route("/login/callback") +def callback(): + # Get authorization code Google sends back + code = request.args.get("code") + + google_provider_cfg = get_google_provider_cfg() + token_endpoint = google_provider_cfg["token_endpoint"] + + token_url, headers, body = client.prepare_token_request( + token_endpoint, + authorization_response=request.url, + redirect_url=request.base_url, + code=code + ) + + token_response = requests.post( + token_url, + headers=headers, + data=body, + auth=(GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET) + ) + + # parse the tokens + client.parse_request_body_response(json.dumps(token_response.json())) + # get user info + userinfo_endpoint = google_provider_cfg["userinfo_endpoint"] + uri, headers, body = client.add_token(userinfo_endpoint) + userinfo_response = requests.get(uri, headers=headers, data=body) + + # ensure that email is verified + if userinfo_response.json().get("email_verified"): + unique_id = userinfo_response.json()["sub"] + users_email = userinfo_response.json()["email"] + else: + return "User email not available or not verified by Google.", 400 + + # create a user in the db + user = User( + id_=unique_id, + email=users_email + ) + + if not User.get(unique_id): + User.create(unique_id, users_email) + + login_user(user) + return redirect(url_for("index")) + + +@bp.route("/logout") +@login_required +def logout(): + logout_user() + return redirect(url_for("index")) diff --git a/yt_pubsub_handler/models.py b/yt_pubsub_handler/models.py index 54032c4..5251142 100644 --- a/yt_pubsub_handler/models.py +++ b/yt_pubsub_handler/models.py @@ -35,3 +35,11 @@ class Subscription(db.Model): def __repr__(self): return f"Subscription {self.id} {self.channel_id} {self.subreddit}>" + + +class User(db.Model): + id = db.Column(db.String(), primary_key=True) + email = db.Column(db.String(), unique=True, nullable=False) + + def __repr__(self): + return f"User {self.id} {self.email}>" diff --git a/yt_pubsub_handler/user.py b/yt_pubsub_handler/user.py new file mode 100644 index 0000000..d2c7fd3 --- /dev/null +++ b/yt_pubsub_handler/user.py @@ -0,0 +1,29 @@ +from flask_login import UserMixin +from yt_pubsub_handler import db +from . import models + + +class User(UserMixin): + def __init__(self, id_, email): + self.id = id_ + self.email = email + + @staticmethod + def get(user_id): + user = models.User.query.filter_by(id=user_id).first() + if not user: + return None + user = User( + id_=user.id, + email=user.email + ) + return user + + @staticmethod + def create(id_, email): + new_user = models.User( + id=str(id_), + email=email + ) + db.session.add(new_user) + db.session.commit()