Add chromium-committers appengine app.

This app hosts the lists of all Chromium committers and will become the
golden source of truth for that list. It receives updates from an internal
google service. It displays the list only to logged-in users whose login email
is found in the list itself, or other apps that successfully authenticate with
it via hmac (such as the commit queue).

Review URL: https://codereview.chromium.org/25515004

git-svn-id: svn://svn.chromium.org/chrome/trunk/tools/chromium-committers@227839 0039d316-1c4b-4281-b951-d872f2087c98
diff --git a/app.py b/app.py
new file mode 100644
index 0000000..ec4871c
--- /dev/null
+++ b/app.py
@@ -0,0 +1,81 @@
+# Copyright 2013 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""This file handles serving the list of committers to users."""
+
+__author__ = 'agable@google.com (Aaron Gable)'
+
+
+import base64
+import json
+import os
+
+import jinja2
+import webapp2
+
+from google.appengine.api import users
+from google.appengine.ext import ndb
+
+import auth_util
+import constants
+import hmac_util
+import model
+
+
+TEMPLATES_PATH = os.path.join(os.path.dirname(__file__), 'templates')
+JINJA2_ENVIRONMENT = jinja2.Environment(
+    loader=jinja2.FileSystemLoader(TEMPLATES_PATH),
+    autoescape=True,
+    extensions=['jinja2.ext.autoescape'])
+
+
+class MainPageHandler(webapp2.RequestHandler):
+
+  def get(self):
+    """Displays the homepage, with a login url."""
+    template = JINJA2_ENVIRONMENT.get_template('index.html')
+    template_values = {'login_url': users.create_login_url(dest_url='/')}
+    page = template.render(template_values)
+    self.response.write(page)
+
+
+class ChromiumHandler(webapp2.RequestHandler):
+
+  @auth_util.CheckUserAuth
+  @hmac_util.CheckHmacAuth
+  @auth_util.RequireAuth
+  def get(self):
+    """Displays the list of chromium committers in plain text."""
+    committer_list = ndb.Key(model.EmailList, constants.LIST).get()
+    emails = committer_list.emails if committer_list else []
+    self.response.headers['Content-Type'] = 'text/plain'
+    self.response.write('\n'.join(sorted(emails)))
+
+
+class MappingHandler(webapp2.RequestHandler):
+
+  def get(self):
+    """Displays the mapping of chromium to googler email addresses."""
+    self.response.headers['Content-Type'] = 'text/plain'
+    self.response.out.write('Not yet implemented. Sorry!')
+
+
+class UpdateHandler(webapp2.RequestHandler):
+
+  @hmac_util.CheckHmacAuth
+  @auth_util.RequireAuth
+  def post(self):
+    """Updates the list of committers from the POST data recieved."""
+    emails = base64.b64decode(self.request.get('committers'))
+    email_list = json.loads(emails)
+    committer_list = model.EmailList(id=constants.LIST, emails=email_list)
+    committer_list.put()
+
+
+app = webapp2.WSGIApplication([
+    ('/', MainPageHandler),
+    ('/chromium', ChromiumHandler),
+    ('/mapping', MappingHandler),
+    ('/update', UpdateHandler),
+  ], debug=True)
diff --git a/app.yaml b/app.yaml
new file mode 100644
index 0000000..9d0e8f3
--- /dev/null
+++ b/app.yaml
@@ -0,0 +1,36 @@
+application: chromium-committers
+version: 1
+runtime: python27
+api_version: 1
+threadsafe: true
+
+libraries:
+- name: webapp2
+  version: latest
+- name: jinja2
+  version: latest
+
+handlers:
+
+- url: /favicon.ico
+  static_files: static/favicon.ico
+  upload: static/favicon.ico
+  secure: always
+
+- url: /.*
+  script: app.app
+  secure: always
+
+
+skip_files:
+- ^(.*/)?app\.yaml
+- ^(.*/)?app\.yml
+- ^(.*/)?index\.yaml
+- ^(.*/)?index\.yml
+- ^(.*/)?#.*#
+- ^(.*/)?.*~
+- ^(.*/)?.*\.py[co]
+- ^(.*/)?.*/RCS/.*
+- ^(.*/)?\..*
+- ^(.*/)?.*\.bak$
+- tests/(.*/)?.*
diff --git a/auth_util.py b/auth_util.py
new file mode 100644
index 0000000..6878f33
--- /dev/null
+++ b/auth_util.py
@@ -0,0 +1,95 @@
+# Copyright 2013 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Utilities for generating and verifying user authentication."""
+
+__author__ = 'agable@google.com (Aaron Gable)'
+
+
+import functools
+import logging
+
+from google.appengine.api import users
+from google.appengine.ext import ndb
+
+import constants
+import model
+
+
+def CheckUserAuth(handler):
+  """Decorator for webapp2 request handler methods.
+
+  Only use on webapp2.RequestHandler methods (e.g. get, post, put).
+
+  Checks to see if the user is logged in, and if they are
+  * If they are an administrator of the app, or
+  * If their email appears in the list of allowed addresses
+
+  Sets request.authenticated to 'user' if successful. Otherwise, None.
+  """
+  @functools.wraps(handler)
+  def wrapper(self, *args, **kwargs):
+    """Does the real legwork and calls the wrapped handler."""
+    def abort_auth(log_msg):
+      """Helper method to be an exit hatch when authentication fails."""
+      logging.warning(log_msg)
+      self.request.authenticated = None
+      handler(self, *args, **kwargs)
+
+    def finish_auth(log_msg):
+      """Helper method to be an exit hatch when authentication succeeds."""
+      logging.info(log_msg)
+      self.request.authenticated = 'user'
+      handler(self, *args, **kwargs)
+
+    if getattr(self.request, 'authenticated', None):
+      finish_auth('Already authenticated.')
+      return
+
+    user = users.get_current_user()
+    if not user:
+      abort_auth('No logged in user.')
+      return
+
+    if users.is_current_user_admin():
+      finish_auth('User is admin.')
+      return
+
+    email = user.email()
+    email_list = ndb.Key(model.EmailList, constants.LIST).get()
+    allowed_emails = email_list.emails if email_list else []
+
+    if email in allowed_emails:
+      finish_auth('User in allowed email list.')
+      return
+
+    if (email.endswith('@google.com') and
+        email.replace('@google.com', '@chromium.org') in allowed_emails):
+      finish_auth('User in allowed email list via google -> chromium map.')
+      return
+
+    abort_auth('User not in allowed email list.')
+
+  return wrapper
+
+
+def RequireAuth(handler):
+  """Decorator for webapp2 request handler methods.
+
+  Only use on webapp2.RequestHandler methods (e.g. get, post, put),
+  and only after using a 'Check____Auth' decorator.
+
+  Expects the handler's self.request.authenticated to be not False-ish.
+  If it doesn't exist or evaluates to False, 403s. Otherwise, passes
+  control to the wrapped handler.
+  """
+  @functools.wraps(handler)
+  def wrapper(self, *args, **kwargs):
+    """Does the real legwork and calls the wrapped handler."""
+    if not getattr(self.request, 'authenticated', None):
+      self.abort(403)
+    else:
+      handler(self, *args, **kwargs)
+
+  return wrapper
diff --git a/constants.py b/constants.py
new file mode 100644
index 0000000..9c51f82
--- /dev/null
+++ b/constants.py
@@ -0,0 +1,11 @@
+# Copyright 2013 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""This file contains constants for use throughout the app."""
+
+__author__ = 'agable@google.com (Aaron Gable)'
+
+
+# The email list (google group) containing the list of chromium committers.
+LIST = 'committers@chromium.org'
diff --git a/hmac_util.py b/hmac_util.py
new file mode 100644
index 0000000..e602808
--- /dev/null
+++ b/hmac_util.py
@@ -0,0 +1,168 @@
+# Copyright 2013 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Utilities for generating and verifying hmac authentication."""
+
+__author__ = 'agable@google.com (Aaron Gable)'
+
+
+import collections
+import hashlib
+import functools
+import hmac
+import json
+import logging
+import operator
+import time
+import urllib
+
+from google.appengine.ext import ndb
+
+
+class AuthToken(ndb.Model):
+  """Represents an id/key pair for authentication.
+
+  Attributes:
+    client_name: The human-readable name of the client.
+    client_id: The unique identity for the client that uses this token.
+    secret: The corresponding authentication key.
+  """
+  client_name = ndb.StringProperty()
+  client_id = ndb.StringProperty()
+  secret = ndb.StringProperty()
+
+
+def GenerateHmac(authtoken, t=None, **params):
+  """Generates an HMAC cryptographic hash of the given parameters.
+
+  Can be used either both for generating outgoing authentication and for
+  validating incoming requests. Automatically included the authtoken's client_id
+  and the time in the hashed parameter blob. If t (timestamp) is None, uses now.
+  """
+  if t is None:
+    t = str(int(time.time()))
+  hmac_params = params.copy()
+  hmac_params.update({'id': authtoken.client_id, 't': t})
+  assert all(isinstance(obj, collections.Hashable)
+             for obj in hmac.params.iteritems())
+  blob = urllib.urlencode(sorted(hmac_params.items()))
+  logging.debug('Generating HMAC from blob: %s' % blob)
+  return hmac.new(authtoken.secret, blob, hashlib.sha256).hexdigest()
+
+
+def CheckHmacAuth(handler):
+  """Decorator for webapp2 request handler methods.
+
+  Only use on webapp2.RequestHandler methods (e.g. get, post, put).
+
+  Expects the handler's self.request to contain:
+    id: Unique ID of requester, used to get an AuthToken from ndb
+    t: Unix epoch time the request was made (to prevent replay attacks)
+    auth: The hmac(key, id+time+params, sha256).hexdigest, to authenticate
+      the request, where the key is the matching token in the ID's AuthToken
+    **params: All of the request GET/POST parameters
+
+  Sets request.authenticated to 'hmac' if successful. Otherwise, None.
+  """
+  @functools.wraps(handler)
+  def wrapper(self, *args, **kwargs):
+    """Does the real legwork and calls the wrapped handler."""
+    def abort_auth(log_msg):
+      """Helper method to be an exit hatch when authentication fails."""
+      logging.warning(log_msg)
+      self.request.authenticated = None
+      handler(self, *args, **kwargs)
+
+    def finish_auth(log_msg):
+      """Helper method to be an exit hatch when authentication succeeds."""
+      logging.info(log_msg)
+      handler(self, *args, **kwargs)
+
+    if getattr(self.request, 'authenticated', None):
+      finish_auth('Already authenticated.')
+      return
+
+    # Get the id, time, and auth fields from the request.
+    client_id = self.request.get('id')
+    if not client_id:
+      abort_auth('No id in request.')
+      return
+    logging.debug('Request contained id: %s' % client_id)
+    authtoken = AuthToken.query(AuthToken.client_id == client_id).get()
+    if not authtoken:
+      abort_auth('No auth token stored for client.')
+      return
+    logging.debug('AuthToken is from client: %s' % authtoken.client)
+
+    then = int(self.request.get('t', '0'))
+    if not then:
+      abort_auth('No timestamp in request.')
+      return
+    logging.debug('Request generated at time: %s' % then)
+    now = int(time.time())
+    if abs(now - then) > 60:
+      abort_auth('Timestamp too far off, token expired.')
+      return
+
+    auth = self.request.get('auth')
+    if not auth:
+      abort_auth('No auth in request.')
+      return
+    logging.debug('Request contained auth hash: %s' % auth)
+
+    # Don't include the auth hmac itself in the check.
+    params = self.request.params.copy()
+    params.pop('auth')
+    check = GenerateHmac(authtoken, **params)
+    logging.debug('Expected auth hash is: %s' % check)
+
+    # Constant time comparison.
+    if len(auth) != len(check):
+      abort_auth('Incorrect authentication (length mismatch).')
+      return
+    if reduce(operator.or_,
+              (ord(a) ^ ord(b) for a, b in zip(check, auth)), 0):
+      abort_auth('Incorrect authentication.')
+      return
+
+    # Hooray, they made it!
+    self.request.authenticated = 'hmac'
+    handler(self, *args, **kwargs)
+
+  return wrapper
+
+
+def CreateRequest(**params):
+  """Given a payload to send, constructs an authenticated request.
+
+  Returns a dictionary containing:
+    id: Unique  ID of this app, from the datastore AuthToken 'self'
+    t: Current Unix epoch time
+    auth: The hmac(key, id+time+parms, sha256), to authenticate the request,
+      where the key is the corresponding secret in the app's AuthToken
+    **params: All of the GET/POST parameters
+
+  It is up to the calling code to convert this dictionary into valid GET/POST
+  parameters.
+  """
+  authtoken = ndb.Key(AuthToken, 'self').get()
+  if not authtoken:
+    raise AuthError('No AuthToken found for this app.')
+
+  now = str(int(time.time()))
+
+  ret = params.copy()
+  ret.update({'id': authtoken.client_id, 't': now,
+              'auth': GenerateHmac(authtoken, t=now, **params)})
+  return ret
+
+
+class AuthError(Exception):
+  pass
+
+
+# There needs to be one AuthToken in the datastore so it can be added or edited
+# from the admin console. Do this one-time setup when this module is imported.
+if not ndb.Key(AuthToken, 'self').get():
+  AuthToken(key=ndb.Key(AuthToken, 'self')).put()
diff --git a/model.py b/model.py
new file mode 100644
index 0000000..81fd651
--- /dev/null
+++ b/model.py
@@ -0,0 +1,24 @@
+# Copyright 2013 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""This file defines datastore models used throughout the app."""
+
+__author__ = 'agable@google.com (Aaron Gable)'
+
+
+from google.appengine.ext import ndb
+
+
+class EmailList(ndb.Model):
+  """Represents a list of email addresses.
+
+  Does not perform any validation on the email addresses.
+
+  This app uses the id/name (unique datastore key) of the object to hold the
+  google-group email address to which all these emails belong.
+
+  Attributes:
+    emails: List of strings, one per email.
+  """
+  emails = ndb.StringProperty(repeated=True)
diff --git a/static/favicon.ico b/static/favicon.ico
new file mode 100644
index 0000000..1d0cfed
--- /dev/null
+++ b/static/favicon.ico
Binary files differ
diff --git a/templates/index.html b/templates/index.html
new file mode 100644
index 0000000..ac667d6
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1,24 @@
+<!DOCTYPE html>
+<html>
+  <body>
+    <font style="font-family:Arial,Verdana,sans-serif;color:rgb(68,68,68)">
+      <h1 style="font-weight:bold">Chromium Committers</h1>
+
+      <p style="color:rgb(255,0,0);font-weight:bold">
+        CONFIDENTIAL
+      </p>
+
+      <p style="color:rgb(255,0,0);font-weight:bold">
+        Access to the Chromium Committers list is restricted to committers only.
+        <a href={{ login_url }}>Log in.</a>
+      </p>
+
+      <p>
+        Please follow the link below to continue to the list.<br>
+        <ul>
+          <li><a href="/chromium">Chromium Committers</a>
+        </ul>
+      </p>
+    </font>
+  </body>
+</html>