diff --git a/.vscode/launch.json b/.vscode/launch.json index 14ab7f41c..4bc3c33db 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,10 +1,6 @@ { - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ - { "name": "Python: py4web", "type": "python", @@ -15,7 +11,14 @@ "apps" ], "console": "integratedTerminal", - "justMyCode": true, + "justMyCode": true + }, + { + "name": "Python: File", + "type": "python", + "request": "launch", + "program": "${file}", + "justMyCode": true } ] } \ No newline at end of file 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..724fb73e5 100644 --- a/deployment_tools/gae/main.py +++ b/deployment_tools/gae/main.py @@ -2,10 +2,10 @@ 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() +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() diff --git a/py4web/utils/auth.py b/py4web/utils/auth.py index c47d10cb6..9d53c259f 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..ebb1aba29 100644 --- a/py4web/utils/auth_plugins/oauth2google_scoped.py +++ b/py4web/utils/auth_plugins/oauth2google_scoped.py @@ -7,19 +7,73 @@ 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. - 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" @@ -70,7 +124,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. ])