Skip to content

Instantly share code, notes, and snippets.

@ncouture
Last active May 6, 2016 21:07
Show Gist options
  • Save ncouture/d7f8f5459b0b63203f5d52c26bdaca91 to your computer and use it in GitHub Desktop.
Save ncouture/d7f8f5459b0b63203f5d52c26bdaca91 to your computer and use it in GitHub Desktop.

Google Sign-in implementation for Websauna.

Features

  • Persistent offline access to scope
  • On-the-air installs of android apps
  • Everything supported by Google Sign-in

Implementation

This implementation follows the “Google Sign-In for server-side apps”[fn:1] at the exception of verifying user-submitted code with Google.

See below comment “# validate token with Google API Client Library for details.

https://developers.google.com/+/images/server_side_code_flow.png

views.py

import httplib2
import logging

from apiclient import discovery
from oauth2client import client
from websauna.system.core import messages
from websauna.system.http import Request
from websauna.system.core.route import simple_route
from websauna.system.user.utils import get_user_registry
from websauna.system.user.utils import get_login_service

logger = logging.getLogger(__name__)

client_secrets = "/home/self/git/myapp/client_secret.json"
client_id = ""


scopes = [
    "profile",
    "email",
    "https://www.googleapis.com/auth/userinfo.email",
    "https://www.googleapis.com/auth/webmasters",
    "https://www.googleapis.com/auth/user.phonenumbers.read"
]


# one-time-code step #7
@simple_route("/signin",
              route_name="signin",
              renderer="myapp/signin.html", require_csrf=False)
def signin(request: Request, method="POST"):
    """Implements the Google Sign-in one-time-code flow.

    Diagram:
      - https://developers.google.com/+/images/server_side_code_flow.png

    References:
      - https://developers.google.com/identity/sign-in/web/server-side-flow
      - https://developers.google.com/identity/sign-in/web/android-app-installs
    """
    # read the ID token from POST data
    try:
        # byte/octet-stream
        token = request.body_file.read()
        logger.info("received Google Sign-in token")
        logger.debug("token: {}".format(token))
    except Exception as e:
        logger.error("exception: {}".format(str(e)))
        return {}

    try:
        # exchange token for an access_token
        credentials = client.credentials_from_clientsecrets_and_code(
            client_secrets, scopes, token)

        logger.debug("Received credentials token: {}".format(credentials.to_json())
        # validate token with Google API Client Library
        try:
            logging.info('Verifying token')

            idinfo = client.verify_id_token(
                credentials.token_response["id_token"], client_id)

            logger.debug(str(idinfo))

            # If multiple clients access the backend server:
            if idinfo["aud"] not in [client_id]:
                raise crypt.AppIdentityError("Unrecognized client.")
            if idinfo["iss"] not in ["accounts.google.com",
                                     "https://accounts.google.com"]:
                raise crypt.AppIdentityError("Wrong issuer.")

            logging.info("Successfully validated access token")
        except crypt.AppIdentityError as e:
            logger.error(str(e))
            return {}

        # userid = idinfo["sub"]
        http_client = httplib2.Http()
        http_client = credentials.authorize(http_client)
    except client.FlowExchangeError as e:
        logger.error("exception: {}".format(str(e)))
        return {}

    logger.info("refreshed credentials on http_client")
    logger.debug("refresh token = {}".format(str(credentials.refresh_token)))
    logger.info("confirmed of a user with Google")

    user_registry = get_user_registry(request)
    service = discovery.build("oauth2", "v2", http=http_client)
    signin_data = service.userinfo().get().execute()

    # retrieve user details
    logging.debug("received signing data: {}".format(
        str(dict(signin_data))))

    # get existing local account
    user = user_registry.get_by_email(signin_data.get("email"))

    # or create a new one with Google user email
    if not user:
        # sign_up expects user_data matching a default JSONB structure
        user_details = {
            "email": signin_data.get("email"),
            "full_name": signin_data.get("name"),
            "first_login": True,
            "social": signin_data
        }
        user = user_registry.sign_up(
            registration_source="google", user_data=user_details)

    if not user:
        return locals()

    logger.info("a Google user became a local user ({0})...".format(
        signin_data.get("email")))

    login_service = get_login_service(request)
    resp = login_service.authenticate_user(user, login_source="google")

    # TODO: store ``credentials.to_json()'' in DB

    return resp

site/javascript.html

{# Load JavaScript files.

The include location of this template may alter between ``<head>`` and the end of ``<body>``.
#}
<!-- one-time-code step #3 -->
<script>
 function start() {
     gapi.load('auth2', function() {
         auth2 = gapi.auth2.init({
             client_id: '572921623645-krj6b328g6s9mu0p679qhbk18smd707g.apps.googleusercontent.com',
             scope: 'profile email https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/webmasters https://www.googleapis.com/auth/user.phonenumbers.read'
         });

     });
 }
</script>

<script src="{{ 'websauna.system:static/jquery-2.0.3.min.js'|static_url }}"></script>
<script src="{{ 'websauna.system:static/bootstrap.min.js'|static_url }}"></script>
<!-- one-time-code step #2 -->
<script src="https://apis.google.com/js/client:platform.js?onload=start" async defer></script>


<script type="text/javascript">    
$(document).ready

$('#signInButton').click(function() {
     // signInCallback defined in step 6.
     auth2.grantOfflineAccess({'scope': 'profile email https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/webmasters https://www.googleapis.com/auth/user.phonenumbers.read', 'redirect_uri': 'postmessage', }).then(signInCallback);
});

function signInCallback(authResult) {
    if (authResult['code']) {
        $.ajax({
            type: 'POST',
            url: '/signin',
            contentType: 'application/octet-stream; charset=utf-8',
            success: function(result) {
                // success
            },
            processData: false,
            data: authResult['code']
        });
        setTimeout(function() {
            window.location = "/phone";
        }, 1234);
    } else {
        // There was an error.
    }
}
</script>

{# Pull JS for widgets #}
{% if request.on_demand_resource_renderer %}
  {% for js_url in request.on_demand_resource_renderer.get_resources("js") %}
    <script src="{{ js_url }}"></script>
  {% endfor %}
{% endif %}

site/nav.html buttons

Sign-in with Google

<div id="signInButton">
    <button>Sign in with Google</button>
</div>

Logout

<form method="POST" action="{{'logout'|route_url}}">
  <input name="csrf_token" type="hidden" value="{{ request.session.get_csrf_token() }}">
  <button id="nav-logout" class="btn btn-link" onclick="auth2.signout()">
    <i class="fa fa-sign-out"></i>
    Log out
  </button>
</form>

Using stored credentials

import oauth2client.client, json, httplib2
from apiclient import discovery

json_credentials_string='<json credentials from storage>'

creds = json.loads(json_credentials_string)
oauth2_credentials = oauth2client.client.OAuth2Credentials(access_token=creds['access_token'],client_id=creds['id_token']['sub'], client_secret=creds['client_secret'], refresh_token=creds['refresh_token'], token_expiry=creds['token_expiry'], token_uri=creds['token_uri'], user_agent=b'Python-httplib2/0.9.2 (gzip)')
super_oauth2_credentials = oauth2_credentials.from_json(json.dumps(creds))

h = httplib2.Http()
h = super_oauth2_credentials.authorize(h)

# Test the tokens
webmasters_service = discovery.build('webmasters', 'v3', http=h)
# raises an exception if not valid
webmasters_service.sites().list().execute()

Testing

  1. Login for first time
  2. Authorize request dialog
    • credentials are persisted to redis
  3. Logout
  4. Login
    • no authorization required
    • credentials are retrieved from redis
    • credentials are tested against google webmasters api
    • no exception
  5. Delete redis hkey
    redis.get_redis(request.registry).delete('signin_credentials_json')
        
  6. Logout

Footnotes

[fn:1] https://developers.google.com/identity/sign-in/web/server-side-flow

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment