From 567e92c6fab971d54e863608b52d13d7e2d37546 Mon Sep 17 00:00:00 2001 From: Luca de Alfaro Date: Wed, 26 Jul 2023 15:03:02 -0700 Subject: [PATCH 1/5] Fixed auth enforcer so uses plugins if available --- py4web/utils/auth.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/py4web/utils/auth.py b/py4web/utils/auth.py index 018a841f1..e5d7e37f0 100644 --- a/py4web/utils/auth.py +++ b/py4web/utils/auth.py @@ -92,13 +92,32 @@ def abort_or_redirect(self, page, message=""): ) ) + def goto_login(self, message=""): + """Goes to the proper login page.""" + # If a plugin can handle requests, redirects to the login url for the login. + for plugin in self.auth.plugins.values(): + if hasattr(plugin, "handle_request"): + if re.search(REGEX_APPJSON, + request.headers.get("accept", "")) and ( + request.headers.get("json-redirects", "") != "on" + ): + raise HTTP(403) + redirect_next = request.fullpath + if request.query_string: + redirect_next = redirect_next + "?{}".format(request.query_string) + redirect( + URL(self.auth.route, "plugin", plugin.name, "login", + vars=dict(next=redirect_next))) + # Otherwise, uses the normal login. + self.abort_or_redirect("login", message=message) + def on_request(self, context): """Checks that we have a user in the session and the condition is met""" user = self.auth.session.get("user") if not user or not user.get("id"): self.auth.session["recent_activity"] = None - self.abort_or_redirect("login", "User not logged in") + self.goto_login(message="User not logged in") activity = self.auth.session.get("recent_activity") time_now = calendar.timegm(time.gmtime()) # enforce the optionl auth session expiration time @@ -108,6 +127,8 @@ def on_request(self, context): and time_now - activity > self.auth.param.login_expiration_time ): del self.auth.session["user"] + self.goto_login(message="Login expired") + # Otherwise, uses the normal login. self.abort_or_redirect("login", "Login expired") # record the time of the latest activity for logged in user (with throttling) if not activity or time_now - activity > 6: @@ -1183,6 +1204,7 @@ def login(auth): data = auth._error( auth.param.messages["errors"].get("invalid_credentials") ) + # Else use normal login else: user, error = auth.login(username, password) From f1beebb5bf8aea35712751daa39b0af2a0e9dd65 Mon Sep 17 00:00:00 2001 From: Luca de Alfaro Date: Thu, 17 Aug 2023 16:37:55 -0700 Subject: [PATCH 2/5] Better error catching in login module --- deployment_tools/gae/app.yaml | 2 +- deployment_tools/gae/main.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/deployment_tools/gae/app.yaml b/deployment_tools/gae/app.yaml index e9677ce82..f7ca4c66e 100644 --- a/deployment_tools/gae/app.yaml +++ b/deployment_tools/gae/app.yaml @@ -1,4 +1,4 @@ -runtime: python37 +runtime: python311 # Handlers define how to route requests to your application. handlers: diff --git a/deployment_tools/gae/main.py b/deployment_tools/gae/main.py index a9b53dfd3..c5592d3a4 100644 --- a/deployment_tools/gae/main.py +++ b/deployment_tools/gae/main.py @@ -2,10 +2,11 @@ import site site.addsitedir(os.path.join(os.path.dirname(__file__), 'lib')) from py4web.core import Reloader, bottle, Session -os.environ['PY4WEB_DASHBOARD_MODE'] = 'demo' +os.environ['PY4WEB_DASHBOARD_MODE'] = 'none' os.environ['PY4WEB_SERVICE_DB_URI'] = 'sqlite:memory' os.environ['PY4WEB_APPS_FOLDER'] = os.path.join(os.path.dirname(__file__), 'apps') os.environ['PY4WEB_SERVICE_FOLDER'] = os.path.join(os.path.dirname(__file__), 'apps/.service') -Session.SECRET = open(os.path.join(os.path.dirname(__file__), 'apps/.service/session.secret'), 'rb').read() +# Session.SECRET = open(os.path.join(os.path.dirname(__file__), 'apps/.service/session.secret'), 'rb').read() +Session.SECRET = "81738cc8-44a3-4bfd-b535-9973492gfufg6374" Reloader.import_apps() app = bottle.default_app() From f06c2c30456c03cb755811bac41a4596d9f3f6f2 Mon Sep 17 00:00:00 2001 From: Luca de Alfaro Date: Wed, 4 Oct 2023 14:19:41 -0700 Subject: [PATCH 3/5] Ok --- deployment_tools/gae/main.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/deployment_tools/gae/main.py b/deployment_tools/gae/main.py index c5592d3a4..724fb73e5 100644 --- a/deployment_tools/gae/main.py +++ b/deployment_tools/gae/main.py @@ -5,8 +5,7 @@ os.environ['PY4WEB_DASHBOARD_MODE'] = 'none' os.environ['PY4WEB_SERVICE_DB_URI'] = 'sqlite:memory' os.environ['PY4WEB_APPS_FOLDER'] = os.path.join(os.path.dirname(__file__), 'apps') -os.environ['PY4WEB_SERVICE_FOLDER'] = os.path.join(os.path.dirname(__file__), 'apps/.service') -# Session.SECRET = open(os.path.join(os.path.dirname(__file__), 'apps/.service/session.secret'), 'rb').read() -Session.SECRET = "81738cc8-44a3-4bfd-b535-9973492gfufg6374" +os.environ['PY4WEB_SERVICE_FOLDER'] = os.path.join(os.path.dirname(__file__), '.service') +Session.SECRET = open(os.path.join(os.path.dirname(__file__), '.service/session.secret'), 'rb').read() Reloader.import_apps() app = bottle.default_app() From ec333f17703eeadc617526c1d5480e7addf1cde7 Mon Sep 17 00:00:00 2001 From: Luca de Alfaro Date: Wed, 4 Oct 2023 20:10:07 -0700 Subject: [PATCH 4/5] Removed some bugs --- py4web/utils/auth.py | 5 ++- .../utils/auth_plugins/oauth2google_scoped.py | 41 ++++++++++++++++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/py4web/utils/auth.py b/py4web/utils/auth.py index dfd36e379..47f4904e9 100644 --- a/py4web/utils/auth.py +++ b/py4web/utils/auth.py @@ -259,6 +259,7 @@ def __init__( two_factor_required=two_factor_required, two_factor_send=two_factor_send, two_factor_tries=3, + auth_enforcer=None, ) # callbacks for forms @@ -472,11 +473,11 @@ def signature(self): @property def user(self): """Use as @action.uses(auth.user)""" - return AuthEnforcer(self) + return self.param.auth_enforcer if self.param.auth_enforcer else AuthEnforcer(self) def condition(self, condition): """Use as @action.uses(auth.condition(lambda user: True))""" - return AuthEnforcer(self, condition) + return self.param.auth_enforcer if self.param.auth_enforcer else AuthEnforcer(self, condition) # utilities def get_user(self, safe=True): diff --git a/py4web/utils/auth_plugins/oauth2google_scoped.py b/py4web/utils/auth_plugins/oauth2google_scoped.py index d67150701..fe7324956 100644 --- a/py4web/utils/auth_plugins/oauth2google_scoped.py +++ b/py4web/utils/auth_plugins/oauth2google_scoped.py @@ -7,15 +7,54 @@ import calendar import json +import re import time import uuid import google_auth_oauthlib.flow import google.oauth2.credentials from googleapiclient.discovery import build +from google.auth.exceptions import RefreshError from py4web import request, redirect, URL, HTTP from pydal import Field +from py4web.utils.auth import AuthEnforcer, REGEX_APPJSON + + +class AuthEnforcerGoogleScoped(AuthEnforcer): + """This class catches certain invalid access errors Google generates + when credentials get stale, and forces the user to login again. + Pass it to Auth as param.auth_enfoercer, as in: + auth.param.auth_enforcer = AuthEnforcerGoogleScoped(auth) + """ + + def __init__(self, auth, condition=None, error_page=None): + super().__init__(auth, condition=condition) + self.error_page = error_page + assert error_page is not None, "You need to specify an error page; can't use login." + + def on_error(self, context): + if isinstance(context.get("exception"), RefreshError): + del context["exception"] + self.auth.session.clear() + if re.search(REGEX_APPJSON, + request.headers.get("accept", "")) and ( + request.headers.get("json-redirects", "") != "on" + ): + raise HTTP(403) + redirect_next = request.fullpath + if request.query_string: + redirect_next = redirect_next + "?{}".format( + request.query_string) + self.auth.flash.set("Invalid credentials") + redirect( + URL( + self.error_page, + vars=dict(next=redirect_next), + use_appname=self.auth.param.use_appname_in_redirects, + ) + ) + class OAuth2GoogleScoped(object): """Class that enables google login via oauth2 with additional scopes. @@ -70,7 +109,7 @@ def _define_tables(self): self._db.define_table('auth_credentials', [ Field('email'), Field('name'), # First and last names, all together. - Field('profile_pic'), # URL of profile pic. + Field('profile_pic', 'text'), # URL of profile pic. Field('credentials', 'text') # Credentials for access, stored in Json for generality. ]) From c5cae42b6c11b082a24e82d407c8e7daa47ef654 Mon Sep 17 00:00:00 2001 From: Luca de Alfaro Date: Sat, 7 Oct 2023 16:42:35 -0700 Subject: [PATCH 5/5] Improved the documentation --- .../utils/auth_plugins/oauth2google_scoped.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/py4web/utils/auth_plugins/oauth2google_scoped.py b/py4web/utils/auth_plugins/oauth2google_scoped.py index fe7324956..ebb1aba29 100644 --- a/py4web/utils/auth_plugins/oauth2google_scoped.py +++ b/py4web/utils/auth_plugins/oauth2google_scoped.py @@ -58,7 +58,22 @@ def on_error(self, context): class OAuth2GoogleScoped(object): """Class that enables google login via oauth2 with additional scopes. - The authorization info is saved so the scopes can be used later on.""" + The authorization info is saved so the scopes can be used later on. + + NOTE: if you use this plugin, it is also recommended that you set: + + auth.param.auth_enforcer = AuthEnforcerGoogleScoped(auth, error_page="credentials_error") + + and that you create a page at URL("credentials_error") to explain the user + that their credentials have expired, and that they must log in again. + + This because sometimes, when one tries to use the credentials, Google + complains that the refresh action fails due to missing credentials. + This can happen if the user, or Google, has revoked credentials. + We need to catch this error, and log out the user, so the user + can decide whether they want to login (and create credentials) again. + + """ # These values are used for the plugin registration. name = "oauth2googlescoped"