Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improved Google Oauth with credentials #811

Merged
merged 7 commits into from
Oct 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -15,7 +11,14 @@
"apps"
],
"console": "integratedTerminal",
"justMyCode": true,
"justMyCode": true
},
{
"name": "Python: File",
"type": "python",
"request": "launch",
"program": "${file}",
"justMyCode": true
}
]
}
2 changes: 1 addition & 1 deletion deployment_tools/gae/app.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
runtime: python37
runtime: python311

# Handlers define how to route requests to your application.
handlers:
Expand Down
6 changes: 3 additions & 3 deletions deployment_tools/gae/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
5 changes: 3 additions & 2 deletions py4web/utils/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
58 changes: 56 additions & 2 deletions py4web/utils/auth_plugins/oauth2google_scoped.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
])

Expand Down
Loading