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

Google Scoped Login #804

Merged
merged 7 commits into from
Aug 6, 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
17 changes: 14 additions & 3 deletions apps/_scaffold/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,17 @@
)
)

if settings.OAUTH2GOOGLE_SCOPED_CREDENTIALS_FILE:
from py4web.utils.auth_plugins.oauth2google_scoped import OAuth2GoogleScoped # TESTED

auth.register_plugin(
OAuth2GoogleScoped(
secrets_file=settings.OAUTH2GOOGLE_SCOPED_CREDENTIALS_FILE,
scopes=[], # Put here any scopes you want in addition to login
db=db, # Needed to store credentials in auth_credentials
)
)

if settings.OAUTH2GITHUB_CLIENT_ID:
from py4web.utils.auth_plugins.oauth2github import OAuth2Github # TESTED

Expand Down Expand Up @@ -172,10 +183,10 @@
# files uploaded and reference by Field(type='upload')
# #######################################################
if settings.UPLOAD_FOLDER:
@action('download/<filename>')
@action.uses(db)
@action('download/<filename>')
@action.uses(db)
def download(filename):
return downloader(db, settings.UPLOAD_FOLDER, filename)
return downloader(db, settings.UPLOAD_FOLDER, filename)
# To take advantage of this in Form(s)
# for every field of type upload you MUST specify:
#
Expand Down
8 changes: 6 additions & 2 deletions apps/_scaffold/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@
REQUIRES_APPROVAL = False

# auto login after registration
# requires False VERIFY_EMAIL & REQUIRES_APPROVAL
# requires False VERIFY_EMAIL & REQUIRES_APPROVAL
LOGIN_AFTER_REGISTRATION = False

# ALLOWED_ACTIONS in API / default Forms:
# ["all"]
# ["all"]
# ["login", "logout", "request_reset_password", "reset_password", \
# "change_password", "change_email", "profile", "config", "register",
# "verify_email", "unsubscribe"]
Expand Down Expand Up @@ -68,6 +68,10 @@
OAUTH2GOOGLE_CLIENT_ID = None
OAUTH2GOOGLE_CLIENT_SECRET = None

# Single sign on Google, with stored credentials for scopes (will be used if provided).
# set it to something like os.path.join(APP_FOLDER, "private/credentials.json"
OAUTH2GOOGLE_SCOPED_CREDENTIALS_FILE = None

# single sign on Okta (will be used if provided. Please also add your tenant
# name to py4web/utils/auth_plugins/oauth2okta.py. You can replace the XXX
# instances with your tenant name.)
Expand Down
24 changes: 22 additions & 2 deletions py4web/utils/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -108,7 +127,7 @@ def on_request(self, context):
and time_now - activity > self.auth.param.login_expiration_time
):
del self.auth.session["user"]
self.abort_or_redirect("login", "Login expired")
self.goto_login(message="Login expired")
# record the time of the latest activity for logged in user (with throttling)
if not activity or time_now - activity > 6:
self.auth.session["recent_activity"] = time_now
Expand Down Expand Up @@ -1183,6 +1202,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)
Expand Down
199 changes: 199 additions & 0 deletions py4web/utils/auth_plugins/oauth2google_scoped.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
# This is a version of google login that also enables the use of other
# authenticatio scopes (e.g., Google Drive, etc). The credentials for the
# scopes are stored, so that the application can access them and use to
# operate on the scopes (e.g., create files on Google Drive on behalf of
# the user).
# See https://developers.google.com/identity/protocols/oauth2/web-server#python

import calendar
import json
import time
import uuid

import google_auth_oauthlib.flow
import google.oauth2.credentials
from googleapiclient.discovery import build

from py4web import request, redirect, URL, HTTP
from pydal import Field

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."""

# These values are used for the plugin registration.
name = "oauth2googlescoped"
label = "Google Scoped"
callback_url = "auth/plugin/oauth2googlescoped/callback"

def __init__(self, secrets_file=None, scopes=None, db=None,
define_tables=True, delete_credentials_on_logout=True):
"""
Creates an authorization object for Google with Oauth2 and paramters.

There are some differences between this plugin and other Oauth2 plugins:
- The plugin uses the database, and creates an auth_credentials table to
store the credentials for the scopes requested.
- The plugin relies on some google libraries (see on top), so these
need to be installed.
- The plugin takes in input a .json credentials file that can be
downloaded from Google Cloud when creating the OAuth2 credentials.
Args:
secrets_file: file with secrets for Oauth2.
scopes: scopes desired.
See https://developers.google.com/drive/api/guides/api-specific-auth
and https://developers.google.com/identity/protocols/oauth2/scopes
db: Database handle.
define_tables: Define the tables for storing credentials?
delete_credentials_on_logout: if True, the credentials are cleared
when the user logs out. If False, the app keeps a copy of the
credentials, so it can do work on behalf of the user using
those credentials after logout. This can obviously
generate security concerns.
"""

# Local secrets to be able to access.
assert secrets_file is not None, "Missing secrets file"
self._secrets_file = secrets_file
# Scopes for which we ask authorization
scopes = scopes or []
self._scopes = ["openid",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile"] + scopes
self._db = db
if db and define_tables:
self._define_tables()
self._delete_credentials_on_logout = delete_credentials_on_logout


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('credentials') # Credentials for access, stored in Json for generality.
])


def handle_request(self, auth, path, get_vars, post_vars):
"""Handles the login request or the callback."""
if path == "login":
auth.session["_next"] = request.query.get("next") or URL("index")
redirect(self._get_login_url(auth))
elif path == "callback":
self._handle_callback(auth, get_vars)
elif path == "logout":
# Deletes the credentials, and clears the session.
if self._delete_credentials_on_logout:
email = auth.current_user.get('email') if auth.current_user else None
if email is not None:
self._db(self._db.auth_credentials.email == email).delete()
self._db.commit()
auth.session.clear()
next = request.query.get("next") or URL("index")
redirect(next)
else:
raise HTTP(404)


def _get_login_url(self, auth, state=None):
# Creates a flow.
flow = google_auth_oauthlib.flow.Flow.from_client_secrets_file(
self._secrets_file, scopes=self._scopes)
# Sets its callback URL. This is the local URL that will be called
# once the user gives permission.
"""Returns the URL to which the user is directed."""
flow.redirect_uri = URL(self.callback_url, scheme=True)
authorization_url, state = flow.authorization_url(
# Enable offline access so that you can refresh an access token without
# re-prompting the user for permission. Recommended for web server apps.
access_type='offline',
# Enable incremental authorization. Recommended as a best practice.
include_granted_scopes='true')
auth.session["oauth2googlescoped:state"] = state
return authorization_url

def _handle_callback(self, auth, get_vars):
# Builds a flow again, this time with the state in it.
state = auth.session["oauth2googlescoped:state"]
flow = google_auth_oauthlib.flow.Flow.from_client_secrets_file(
self._secrets_file, scopes=self._scopes, state=state)
flow.redirect_uri = URL(self.callback_url, scheme=True)
# Use the authorization server's response to fetch the OAuth 2.0 tokens.
if state and get_vars.get('state', None) != state:
raise HTTP(401, "Invalid state")
error = get_vars.get("error")
if error:
if isinstance(error, str):
code, msg = 401, error
else:
code = error.get("code", 401)
msg = error.get("message", "Unknown error")
raise HTTP(code, msg)
if not 'code' in get_vars:
raise HTTP(401, "Missing code parameter in response.")
code = get_vars.get('code')
flow.fetch_token(code=code)
# We got the credentials!
credentials = flow.credentials
# Now we must use the credentials to check the user identity.
# see https://github.com/googleapis/google-api-python-client/pull/1088/files
# and https://github.com/googleapis/google-api-python-client/issues/1071
# and ??
user_info_service = build('oauth2', 'v2', credentials=credentials)
user_info = user_info_service.userinfo().get().execute()
email = user_info.get("email")
if email is None:
raise HTTP(401, "Missing email")
# Finally, we store the credentials, so we can re-use them in order
# to use the scopes we requested.
if self._db:
credentials_json=json.dumps(self.credentials_to_dict(credentials))
self._db.auth_credentials.update_or_insert(
self._db.auth_credentials.email == email,
email=email,
name=user_info.get("name"),
credentials=credentials_json,
profile_pic=user_info.get("picture"),
)
self._db.commit()
# Logs in the user.
if auth.db:
user = {
"email": user_info.get("email"),
"first_name": user_info.get("given_name"),
"last_name": user_info.get("family_name"),
}
data = auth.get_or_register_user(user)
user["id"] = data.get("id")
else:
# WIP Allow login without DB
user = dict(user_info)
if not "id" in user:
user["id"] = user.get("username") or user.get("email")
# Stores the user in the session. We do it here, so we store
# the complete details, and not just the user_id.
auth.session["user"] = user
auth.session["recent_activity"] = calendar.timegm(time.gmtime())
auth.session["uuid"] = str(uuid.uuid1())
# Finally, redirects to next.
if "_next" in auth.session:
next = auth.session.get("_next")
del auth.session["_next"]
else:
next = URL("index")
redirect(next)


@staticmethod
def credentials_to_dict(credentials):
return {'token': credentials.token,
'refresh_token': credentials.refresh_token,
'token_uri': credentials.token_uri,
'client_id': credentials.client_id,
'client_secret': credentials.client_secret,
'scopes': credentials._scopes}

@staticmethod
def credentials_from_dict(credentials_dict):
return google.oauth2.credentials.Credentials(**credentials_dict)
Loading