prof-rossetti / intro-to-python

An Introduction to Programming in Python
Other
97 stars 244 forks source link

Web App - Login w/ Google #89

Open s2t2 opened 3 years ago

s2t2 commented 3 years ago

Add instructions to the web app exercise, for students looking for further exploration. Snippets below:

Requirements.txt file (uses Authlib package):

# ...

# web app oauth login with google:
Authlib==0.15.3

# ...

Init file (web application factory pattern):

import os
from flask import Flask
from authlib.integrations.flask_client import OAuth
from dotenv import load_dotenv

from app import APP_ENV, APP_VERSION
#from app.firebase_service import FirebaseService
#from app.gcal_service import SCOPES as GCAL_SCOPES #, GoogleCalendarService
from web_app.routes.home_routes import home_routes
from web_app.routes.user_routes import user_routes
from web_app.routes.calendar_routes import calendar_routes

load_dotenv()

GA_TRACKER_ID = os.getenv("GA_TRACKER_ID", default="G-OOPS")
SECRET_KEY = os.getenv("SECRET_KEY", default="super secret") # IMPORTANT: override in production

GOOGLE_OAUTH_CLIENT_ID = os.getenv("GOOGLE_OAUTH_CLIENT_ID")
GOOGLE_OAUTH_CLIENT_SECRET = os.getenv("GOOGLE_OAUTH_CLIENT_SECRET")

def create_app():
    app = Flask(__name__)
    app.config["APP_ENV"] = APP_ENV
    app.config["APP_VERSION"] = APP_VERSION
    app.config["APP_TITLE"] = "Our Gym Calendar"
    #app.config["GA_TRACKER_ID"] = GA_TRACKER_ID # for client-side google analytics
    app.config["SECRET_KEY"] = SECRET_KEY # for flask flash messaging
    #app.config["FIREBASE_SERVICE"] = FirebaseService()
    #app.config["GCAL_SERVICE"] = GoogleCalendarService()

    # GOOGLE LOGIN
    oauth = OAuth(app)
    oauth_scopes = "openid email profile" +  " " + " ".join(GCAL_SCOPES)
    print("OAUTH SCOPES:", oauth_scopes)
    oauth.register(
        name="google",
        client_id=GOOGLE_OAUTH_CLIENT_ID,
        client_secret=GOOGLE_OAUTH_CLIENT_SECRET,
        server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
        client_kwargs={"scope": oauth_scopes},
        authorize_params={"access_type": "offline"} # give us the refresh token! see: https://stackoverflow.com/questions/62293888/obtaining-and-storing-refresh-token-using-authlib-with-flask
    ) # now you can also access via: oauth.google (the name specified during registration)
    app.config["OAUTH"] = oauth

    app.register_blueprint(home_routes)
    app.register_blueprint(user_routes)
    app.register_blueprint(calendar_routes)

    return app

if __name__ == "__main__":
    my_app = create_app()
    my_app.run(debug=True)

Authentication wrapper for protected routes (requires google login to view, otherwise will be redirected home):

# web_app/routes/auth.py or something

import functools
from flask import session, flash, redirect

def authenticated_route(view):
    """
    Wrap a route with this decorator and assume it will have access to the "current_user"
    See: https://flask.palletsprojects.com/en/2.0.x/tutorial/views/#require-authentication-in-other-views
    """
    @functools.wraps(view)
    def wrapped_view(**kwargs):
        if session.get("current_user"):
            return view(**kwargs)
        else:
            print("UNAUTHENTICATED...")
            flash("Unauthenticated. Please login!", "warning")
            return redirect("/user/login")
    return wrapped_view

Login routes:

# web_app/routes/user_routes.py or something

# SEE:
# ... https://docs.authlib.org/en/stable/client/flask.html
# ... https://github.com/authlib/demo-oauth-client/tree/master/flask-google-login
# ... https://github.com/Vuka951/tutorial-code/blob/master/flask-google-oauth2/app.py
# ... https://flask.palletsprojects.com/en/2.0.x/tutorial/views/

from flask import Blueprint, render_template, flash, redirect, current_app, url_for, session #, jsonify

from web_app.routes.auth import authenticated_route

user_routes = Blueprint("user_routes", __name__)

@user_routes.route("/user/login")
@user_routes.route("/user/login/form")
def login_form():
    print("LOGIN FORM...")
    return render_template("user_login_form.html")

@user_routes.route("/user/login/redirect", methods=["POST"])
def login_redirect():
    print("LOGIN REDIRECT...")
    oauth = current_app.config["OAUTH"]
    redirect_uri = url_for("user_routes.login_callback", _external=True)
    return oauth.google.authorize_redirect(redirect_uri)

@user_routes.route("/user/login/callback")
def login_callback():
    print("LOGIN CALLBACK...")

    oauth = current_app.config["OAUTH"]

    # user info (below) needs this to be invoked, even if not directly using the token
    # avoids... authlib.integrations.base_client.errors.MissingTokenError: missing_token
    token = oauth.google.authorize_access_token()
    print("TOKEN:", token["token_type"], token["scope"], token["expires_in"] )
    #> {
    #>     'access_token': '___.___-___-___-___-___-___',
    #>     'expires_at': 1621201708,
    #>     'expires_in': 3599,
    #>     'id_token': '___.___.___-___-___-___-___',
    #>     'refresh_token': "____",
    #>     'scope': 'https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email openid',
    #>     'token_type': 'Bearer'
    #> }

    session["bearer_token"] = token
    #print(token)

    user = oauth.google.userinfo()
    user = dict(user)
    #print("USER INFO:", type(user)) #> <class 'authlib.oidc.core.claims.UserInfo'>
    print(user)
    #> {
    #>     'email': 'hello@example.com',
    #>     'email_verified': True,
    #>     'family_name': 'Student',
    #>     'given_name': 'Sammy S',
    #>     'locale': 'en',
    #>     'name': 'Sammy S Student',
    #>     'picture': 'https://lh3.googleusercontent.com/a-/___=___-___',
    #>     'sub': 'abc123def456789'
    #> }

    # store user info in the session:
    session["current_user"] = user

    flash(f"Login success. Welcome, {user['given_name']}!", "success")
    return redirect("/user/profile")

@user_routes.route("/user/profile")
@authenticated_route
def profile():
    print("PROFILE...")
    current_user = session.get("current_user")
    return render_template("user_profile.html", user=current_user)

@user_routes.route("/user/logout")
def logout():
    print("LOGOUT...")
    session.clear() # FYI: this clears the flash as well
    #flash("Logout success!", "success")
    return redirect("/user/login")

Login form (view)

{% extends "bootstrap_5_layout.html" %}
{% set active_page = "user_login" %}

{% block content %}

    <h1>User Login</h1>

    <p>To access application functionality, first login with your Google Account...</p>

    <form action="/user/login/redirect" method="POST">
        <button>Login w/ Google</button>
    </form>

{% endblock %}

User profile page:

{% extends "bootstrap_5_layout.html" %}
{% set active_page = "user_profile" %}

{% block content %}

    <h1>User Profile</h1>

    <p>Name: <code>{{ user["name"] }}</code> </p>
    <p>Email: <code>{{ user["email"] }}</code> </p>
    <p>Locale: <code>{{ user["locale"] }}</code> </p>

    <p><a href="/user/logout">Logout</a></p>
{% endblock %}

Navbar with user icon and detection of current logged-in user (goes in twitter bootstrap layout file):

<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
        <div class="container-fluid">
            <a class="navbar-brand" href="/">
                <i class="bi-calendar3" style="font-size: 1.7rem; color: white;"></i>
                &nbsp;
                {{ config.APP_TITLE }}
            </a>

            <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
                <span class="navbar-toggler-icon"></span>
            </button>

            <div class="collapse navbar-collapse" id="navbarSupportedContent">
                <ul class="navbar-nav ms-auto mb-2 mb-lg-0">

                {% if session["current_user"] %}

                    <!-- PROTECTED NAV -->
                    {% for href, page_id, link_text in protected_nav %}
                        {% if page_id == active_page %}
                            {% set is_active = "active" -%}
                        {% else %}
                            {% set is_active = "" -%}
                        {% endif %}
                        <li class="nav-item">
                            <a class="nav-link {{ is_active }}" href="{{href}}">{{link_text}}</a>
                        </li>
                    {% endfor %}

                    <a href="/user/profile" style="padding:5px">
                        <img class="rounded-circle" src="{{ session['current_user']['picture'] }}" alt="profile photo" height="32px" width="32px">
                    </a>

                {% else %}

                    <!-- PUBLIC NAV -->
                    {% for href, page_id, link_text in public_nav %}
                        {% if page_id == active_page %}
                            {% set is_active = "active" -%}
                        {% else %}
                            {% set is_active = "" -%}
                        {% endif %}
                        <li class="nav-item">
                            <a class="nav-link {{ is_active }}" href="{{href}}">{{link_text}}</a>
                        </li>
                    {% endfor %}

                {% endif %}

                </ul>
            </div>
        </div>
    </nav>