From ac9b165ca03ef795f5ff995a36eed5cf88f25480 Mon Sep 17 00:00:00 2001 From: Matthew Welch Date: Wed, 8 Nov 2023 20:15:37 -0800 Subject: [PATCH] Add login with Google --- .../admin/templates/admin/edit_question.html | 6 +++ QuizTheWord/app.py | 49 +++++++++++++++++- QuizTheWord/config.py | 2 + QuizTheWord/database.py | 25 ++++++--- .../static/images/sign_in_with_google.png | Bin 0 -> 3983 bytes QuizTheWord/templates/base.html | 5 ++ QuizTheWord/templates/login.html | 5 ++ requirements.txt | 7 ++- 8 files changed, 90 insertions(+), 9 deletions(-) create mode 100644 QuizTheWord/static/images/sign_in_with_google.png diff --git a/QuizTheWord/admin/templates/admin/edit_question.html b/QuizTheWord/admin/templates/admin/edit_question.html index d781c0c..2bc4431 100644 --- a/QuizTheWord/admin/templates/admin/edit_question.html +++ b/QuizTheWord/admin/templates/admin/edit_question.html @@ -23,6 +23,12 @@
+ + {% if request.path != '/admin/questions/add' %} +
+ Add Another Question +
+ {% endif %} {% endblock %} diff --git a/QuizTheWord/app.py b/QuizTheWord/app.py index 9573fc9..b8c9ccf 100644 --- a/QuizTheWord/app.py +++ b/QuizTheWord/app.py @@ -1,9 +1,11 @@ import os + +import flask from flask import request, render_template, jsonify, Flask, url_for, redirect, flash, session from flask_security import Security, SQLAlchemySessionUserDatastore, login_user, logout_user, current_user from QuizTheWord import database from QuizTheWord.admin import admin -# from QuizTheWord import config +from authlib.integrations.flask_client import OAuth app = Flask(__name__) environment_configuration = os.environ.get('CONFIGURATION_SETUP', "QuizTheWord.config.Development") @@ -11,6 +13,15 @@ with app.app_context(): app.config.from_object(environment_configuration) user_datastore = SQLAlchemySessionUserDatastore(database.get_session(), database.User, database.Role) security = Security(app, user_datastore, False) + CONF_URL = "https://accounts.google.com/.well-known/openid-configuration" + oauth = OAuth(app) + oauth.register( + name="google", + server_metadata_url=CONF_URL, + client_kwargs={ + "scope": "openid email profile" + } + ) app.register_blueprint(admin.Admin) @@ -102,8 +113,22 @@ def next_multiple_choice(): return jsonify(None), 204 +def get_auth_url(redirect_uri, **kwargs): + rv = oauth.google.create_authorization_url(redirect_uri, **kwargs) + + if oauth.google.request_token_url: + request_token = rv.pop('request_token', None) + oauth.google._save_request_token(request_token) + + oauth.google.save_authorize_data(request, redirect_uri=redirect_uri, **rv) + return rv["url"] + + @app.route("/login", methods=["GET", "POST"]) def login(): + redirect_uri = url_for("auth_google", _external=True) + google_url = get_auth_url(redirect_uri) + next_page = request.args.get("next", default=url_for("index")) if request.method == "POST": email = request.form.get("email") @@ -115,7 +140,27 @@ def login(): return redirect(url_for("login")) login_user(user, remember=remember) return redirect(next_page) - return render_template("login.html", title="login") + return render_template("login.html", title="login", google_url=google_url) + + +@app.route("/login/auth/google") +def auth_google(): + token = oauth.google.authorize_access_token() + user = oauth.google.parse_id_token(token) + unique_id = user["sub"] + email = user["email"] + username = user["given_name"] + user = database.get_user(google_id=unique_id) + if user is not None: + login_user(user) + else: + user = database.add_user(email, None, username, google_id=unique_id) + if user is not None: + login_user(user) + else: + flash("That email is already in use. Login with that email or choose a different account.") + return redirect(url_for("login")) + return redirect(url_for("index")) @app.route("/register", methods=["GET", "POST"]) diff --git a/QuizTheWord/config.py b/QuizTheWord/config.py index e7e1f87..77be34c 100644 --- a/QuizTheWord/config.py +++ b/QuizTheWord/config.py @@ -14,6 +14,8 @@ class Config: DB_PORT = environ.get("DB_PORT") DB_NAME = environ.get("DB_NAME") SECURITY_REGISTERABLE = True + GOOGLE_CLIENT_ID = environ.get("GOOGLE_CLIENT_ID") + GOOGLE_CLIENT_SECRET = environ.get("GOOGLE_CLIENT_SECRET") class Production(Config): diff --git a/QuizTheWord/database.py b/QuizTheWord/database.py index 2c05467..4852f6f 100644 --- a/QuizTheWord/database.py +++ b/QuizTheWord/database.py @@ -4,7 +4,7 @@ from flask_security import UserMixin, RoleMixin, SQLAlchemySessionUserDatastore, import sqlalchemy from typing import Union, Optional, Literal, Type, List, Tuple import random -from sqlalchemy import Column, JSON, String, Integer, create_engine, ForeignKey, func, Boolean, UnicodeText, DateTime +from sqlalchemy import Column, JSON, String, Integer, create_engine, ForeignKey, func, Boolean, UnicodeText, DateTime, Numeric, CheckConstraint from sqlalchemy.sql.expression import and_ from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.associationproxy import association_proxy @@ -51,7 +51,7 @@ class User(Base, UserMixin): user_id = Column(Integer, primary_key=True) email = Column(String, unique=True, nullable=False) username = Column(String, unique=True) - password = Column(String, nullable=False) + password = Column(String, nullable=True) active = Column(Boolean, nullable=False) last_login_at = Column(DateTime()) current_login_at = Column(DateTime()) @@ -59,6 +59,12 @@ class User(Base, UserMixin): current_login_ip = Column(String(100)) login_count = Column(Integer) fs_uniquifier = Column(String, unique=True, nullable=False) + google_id = Column(Numeric, nullable=True) + + __table_args__ = ( + CheckConstraint("(password IS NOT NULL) OR (google_id IS NOT NULL)", "password_google_id_null_check"), + ) + roles = relationship("Role", secondary="users_roles") def check_password(self, password): @@ -356,11 +362,14 @@ def update_question(question_id, data): session.commit() -def add_user(email, password): +def add_user(email, password, username=None, **kwargs): session = get_session() user_datastore = get_user_datastore() if user_datastore.find_user(email=email) is None: - user = user_datastore.create_user(email=email, password=hash_password(password), username=email) + password_hash = hash_password(password) if password is not None else None + if username is None: + username = email + user = user_datastore.create_user(email=email, password=password_hash, username=username, **kwargs) session.add(user) role = session.query(Role).filter(Role.name == "basic").one_or_none() if role is not None: @@ -375,9 +384,13 @@ def add_user(email, password): return None -def get_user(user_id) -> User: +def get_user(user_id=None, google_id=None) -> User: session = get_session() - return session.query(User).filter(User.user_id == user_id).one_or_none() + if user_id is not None: + user = session.query(User).filter(User.user_id == user_id).one_or_none() + else: + user = session.query(User).filter(User.google_id == google_id).one_or_none() + return user def update_user(user_id, data): diff --git a/QuizTheWord/static/images/sign_in_with_google.png b/QuizTheWord/static/images/sign_in_with_google.png new file mode 100644 index 0000000000000000000000000000000000000000..b1327b4f7b47c04368da6c4b066645453398eca5 GIT binary patch literal 3983 zcmV;A4{-2_P)Px^Nl8RORCodHT?=$n)w%v==FB6L2}BY?f{qY1j6Ca>0o#JeY@9{|CU8! z5kS)ESnSSmKl}bNXWoH1cMvn7bbiA8FvsLF=tK5a$UQ9th3qQ1|SLMug9 zt{JPQNhlz8L4Ozr-jdPKl|pZ*va%&?{)!#B9|m#HGXb;=K|2E?fA_jI3B)&#Er9F( z)v(X1N$9G-aTmj$myz?WM_|qwl~6WRiRDYOu=1Kj$F_Km4S(3|#kv*+H%_;qasp63 zFNBT1>IW&(=y1`;te9I$XiH6~BKcF1GL34|P%+`=SrW>q`N>?8zsrPGOKhmlR|z`SJ#d|0`CXcx1-mkuVR922tinS*$r zG5M9VufbYakD#R(2$^Bd%SY}HpMhn3?|9x`GY8`Os3fP<79qZZrUL=|?ig_6Y#VBd zOjvNc4ZmOO#|~QjiM0HC|1!A57h^pAb^tFOP>|hDPcx*0CdvO8z`W54wnwaTWf>QP zO~J>k8u??*r_0%~R;-B-zw_N7o_{lhvTLnae3bKRxHz38c*i0m)io09m0+uihPb6F#L-Vh$%E<*%m4Yc-~Ry$B=F{3&c zc&F5YtFJU8*kZ!A55#X9OsM@&2WC$&LkcNq?NH##HNkz%j}^Osx-ZyqnLmWLXidKI@&$8JDEq~4!1x0U#feIU*S}4KS6+*Yog0egl6v7o?& zSy!8|@pTQaRkzx4YlQ_aw&`<+@W&l~{P^v7omJP_@QoS?g)Y8GNxYg524eFe>RrT; z?XDRTT!gdpl#H`Z3yMNwT&csHswykKG1rR1EUmtS`~CRl0K&?Q$*-K-qv9fs^CD+e zJFN3=QPcWAkH38WAE4Y=Zb1zvZ?NU`KNa=Y3R0GY=RQx;?+l{;WC)*K+Y@VQ#RLoT zIVpt^pZdT`C*~Gt#qNL!#q<(F9HX?)vD9_BB+SeM+%!clPQb(|c6{ycGS;+c&&B_m zP&AtQS!PfjUb^mFF=6aaAxS`;(gQxh<-_<`5fE!v26$YwfJ^B_OJD!Q55PV zFIyXL^T9-qxUQ}T>vVc6?#RM2hIK;RUK$EAc=1vva_c=p1^<(US`U0I7h|fV z=XhZ>uw=80s_Aw-$dFBlD9@iDAzvhjeX1}=c=I1}?;@U%1FX+xLiH>Mp7wch_vFIMa-Xnhy$82%3c$^(T_o{xF0kbh;=g}LE5EHisQtW(xTFem?!QSR z{)s(aEO^2Lp)aItrWK{?UH|`NPX7M4)$55sloY*62A;94!H0Q2^WhIiL+BDGJQfqC z%y8g^JEOKS-bPM7t+YszK=miJ{QqxBVNF=RIZ}SsA9wHZn$CacZjWw$!)TA3Iz>Jb z9(;Q>Ngpqbd5wlBl(}MQ=n!l=Rv*VYE|gPmat@HA9!0 zDcrzcZ&g2MjR$fH&1z_b4`uu!g2#&uL6zK~g};*R2`?o2xwBClOZQ0`??eVWj@Ca~ zz9SO0be{@q&x^-;2|ZzCQBIibz~=k2P#5tkv=J#iMXxGSWb|f23Z;};jtM+JJfar) zJSUJ94#8rMS}E;`;v}mWBo6I#>ru&#xY%fVus*98O5Mb;<=EjM)@{+iuigr^TBps^_g-wcK+?6Z*8J${@hwWH=L z8y1R;PzbyGIG&Aq0-=^5X|=83aJzz;46DYB6N@Z_9a}wUA{L*xB@2rum=NTmC}1d< zia2^(Cgxh~JzN5?c(keJWizgcmG^bF(j#^ zPKI-)!A$Zu%cJnnnM&MsvJs0WekwtE!tvbfUNmV7;S~E&rfyz=w-|r# z=5*KOSya-wxjEc>fzG)-7f|SFIog_0iirVCa@Ol?8S>J9C!x09gGr4Jd zkS_I>HU*U>y1sF-WCRqe5SzF2S*8fT^}XSJ82j0WGA>{+Hr)LI`4RC3f*|i5i!(85 zIHUDwX%m*Ma$wwcKei^-tWQRt(dRYp@?-zCPE>PKyz(9==Do|da(K+i6RMaHuRSF2 zTrS3kzMh3eI=q@V$ERtP|9f8u)ioAWFLa`=Tt<%A&=L>1BZ!x{psxKJ%jw;w%y(jA znG6TZRr2d#SKCuDjMrB=$B9joWSp^EP|4FL)R=MGlL7TaFV?>_Z!$K$j{Lc3$o`y4 ze9=~??=0@#`CT-&?d#R_(X%J;z~*<+b~YaJ%&W4*;>%tS3^1dcru&}y09wg9iG&tJ z&LxQMb22v6C7!Nmu3ACuFMK#eH!3WY(AT?gaN%;jB*;H|w6P@UC1@}~>zzRSIA=vf zp^DxE=^x)2A$ZrRkY?rEWHhUlDcIK($59I()Ta2jf^8&~5EoB(D#oa{+Ml%BR`AUY zUc7Ny>?wGnRU&D`=Sk7ALw-CatTfws^jYfgI9T8{g?I_$aO{9TW|j9g`mo_ZNX2m? zkA3sG(%dBe_nznREp;2YCwnQfZz*OQ>PZjt1uhAvJrN8=eLvbGs~)F|7rW!oP_cQ7HS!xgR~lwUTHdrUJ6w#m%NJ3PV=m>GTWDbiBs@+m6_G0)c1vWjC)X$N6^8|G zmJ3q%5>W>?xWa%W;Lk;~X0e3V{>}Kk{8pKs?ye{N=Luz4A6>YI^LhF_ z^VzE(k&KMlezBN=(~XvGE2Jp5#KEx!KRTAc9Z9+PR=jK z#`)hzb-^`#XvDKiaxTXci$9L*^zn4O_#)mh?0J@7_%Ocpk@zkP!yF#tJ^!2sKOZEZ zFXG^aWz!9>_)vF1Mkj5s_`=Y~Z=K&@XtQ!x>Lc8t7{q|Wp5zWSojHyz$9LfPx%bfO zejol&0C|pFROOXpZt)G6!^_N2C=M|bjm=We+U@l?;l=<_6S12ApZ!OOk*hhL%<+^|L`)jM-T!A`A5)X8bj$H z0tWf}hnJx}f)FssKY}LH7)t*TFv#COybSFTgn*v>N=Wv7ApY~W;U>eePs0r(qdyl$ zK=7MdvA6y7FSdm{o3=i-deg177Hd}Q4bux(X=oS-41WYdL2qZr-(Pu@J&nCQq<^izxX_E7qWr%^mC5?bs p0OI}hnB~8uM`Lu}KwuyU{0ol9?A&&aoGkzV002ovPDHLkV1g7pu