diff --git a/apps/_scaffold/common.py b/apps/_scaffold/common.py index 51021a0d..245c72a7 100644 --- a/apps/_scaffold/common.py +++ b/apps/_scaffold/common.py @@ -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 @@ -172,10 +183,10 @@ # files uploaded and reference by Field(type='upload') # ####################################################### if settings.UPLOAD_FOLDER: - @action('download/') - @action.uses(db) + @action('download/') + @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: # diff --git a/apps/_scaffold/settings.py b/apps/_scaffold/settings.py index 00b4620c..77bb95a4 100644 --- a/apps/_scaffold/settings.py +++ b/apps/_scaffold/settings.py @@ -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"] @@ -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.) diff --git a/py4web/utils/auth.py b/py4web/utils/auth.py index 018a841f..dfd36e37 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,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 @@ -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) diff --git a/py4web/utils/auth_plugins/oauth2google_scoped.py b/py4web/utils/auth_plugins/oauth2google_scoped.py new file mode 100644 index 00000000..ba6dd2b9 --- /dev/null +++ b/py4web/utils/auth_plugins/oauth2google_scoped.py @@ -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)