Skip to content
This repository has been archived by the owner on Mar 4, 2020. It is now read-only.

Commit

Permalink
Merge pull request #11 from toolness/oauth2
Browse files Browse the repository at this point in the history
[WIP] Implement id.webmaker.org OAuth2 integration
  • Loading branch information
toolness committed Apr 7, 2015
2 parents cc20490 + 4114dd2 commit 138a74e
Show file tree
Hide file tree
Showing 13 changed files with 260 additions and 9 deletions.
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ variables are given default values: `SECRET_KEY`, `PORT`, `ORIGIN`.
(this should always be false in production).
* `BROWSERID_AUTOLOGIN_EMAIL` specifies an email address to auto-login
as when Persona login buttons are clicked. It is useful for offline
development and is only valid if `DEBUG` is true.
development and is only valid if `DEBUG` is true. Make sure an
existing Django user account exists for the email associated with
this address.
* `PORT` is the port that the server binds to.
* `ORIGIN` is the origin of the server, as it appears
to users. If `DEBUG` is enabled, this defaults to
Expand Down Expand Up @@ -79,11 +81,21 @@ variables are given default values: `SECRET_KEY`, `PORT`, `ORIGIN`.
Defaults to `https://login.webmaker.org`.
* `LOGINAPI_AUTH` is the *username:password* pair that will be
used to authenticate with the Webmaker login server, e.g.
`john:1234`.
`john:1234`. This is needed for Persona-based authentication only.
* `IDAPI_URL` is the URL of the Webmaker ID (OAuth2) server. Defaults
to `https://id.webmaker.org`. If it is set to a value of the
form `fake:username:email`, e.g. `fake:foo:[email protected]`, and if
`DEBUG` is true, then the given username/email will always be
logged in when the OAuth2 authorize endpoint is contacted, which
is useful for offline development.
* `IDAPI_CLIENT_ID` is the server's OAuth2 client ID.
* `IDAPI_CLIENT_SECRET` is the server's OAuth2 client secret.
* `CORS_API_PERSONA_ORIGINS` is a comma-separated list of origins that
can submit Persona assertions to the API server in exchange for API
tokens. This list should not contain any whitespace. If `DEBUG` is
enabled, any origin can submit Persona assertions.
tokens. It's also a list of origins that can delegate login to
the API server and obtain API tokens. This list should not
contain any whitespace. If `DEBUG` is enabled, any origin can
submit Persona assertions or delegate login to the API server.

## Deployment

Expand Down
Empty file added fake_oauth2/__init__.py
Empty file.
3 changes: 3 additions & 0 deletions fake_oauth2/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from django.contrib import admin

# Register your models here.
Empty file.
3 changes: 3 additions & 0 deletions fake_oauth2/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from django.db import models

# Create your models here.
3 changes: 3 additions & 0 deletions fake_oauth2/tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from django.test import TestCase

# Create your tests here.
10 changes: 10 additions & 0 deletions fake_oauth2/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from django.conf.urls import url

from . import views

urlpatterns = [
url(r'^login/oauth/authorize$', views.authorize),
url(r'^login/oauth/access_token$', views.access_token),
url(r'^user$', views.user),
url(r'^logout$', views.logout),
]
60 changes: 60 additions & 0 deletions fake_oauth2/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import urllib
import json
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST, require_GET
from django.conf import settings
from django.http import HttpResponse, HttpResponseRedirect
from django.core.urlresolvers import reverse

def expect(a, b):
if a != b:
print "Warning: Expected %s to equal %s." % (a, b)

@require_GET
def authorize(request):
expect(request.GET.get('client_id'), settings.IDAPI_CLIENT_ID)
expect(request.GET.get('response_type'), 'code')
expect(request.GET.get('scopes'), 'user email')

url = reverse('teach.views.oauth2_callback')
qs = urllib.urlencode({
'state': request.GET['state'],
'code': 'fake_oauth2_code',
})
return HttpResponseRedirect('%s?%s' % (url, qs))

@csrf_exempt
@require_POST
def access_token(request):
expect(request.POST.get('code'), 'fake_oauth2_code')
expect(request.POST.get('client_id'), settings.IDAPI_CLIENT_ID)
expect(request.POST.get('client_secret'), settings.IDAPI_CLIENT_SECRET)
expect(request.POST.get('grant_type'), 'authorization_code')

res = HttpResponse()
res['content-type'] = 'application/json'
res.content = json.dumps({
'access_token': 'fake_oauth2_access_token'
})

return res

@require_GET
def user(request):
expect(request.META.get('HTTP_AUTHORIZATION'),
'token fake_oauth2_access_token')

res = HttpResponse()
res['content-type'] = 'application/json'
res.content = json.dumps({
'username': settings.IDAPI_FAKE_OAUTH2_USERNAME,
'email': settings.IDAPI_FAKE_OAUTH2_EMAIL
})

return res

@require_GET
def logout(request):
url = reverse('teach.views.oauth2_callback')
qs = 'logout=true'
return HttpResponseRedirect('%s?%s' % (url, qs))
46 changes: 46 additions & 0 deletions teach/new_webmaker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import urllib
import requests
from django.contrib.auth.models import User
from django.conf import settings

def get_idapi_url(path, query=None):
if query is not None:
qs = urllib.urlencode(query)
path = '%s?%s' % (path, qs)
if settings.IDAPI_ENABLE_FAKE_OAUTH2:
return '%s/fake_oauth2%s' % (settings.ORIGIN, path)
else:
return '%s%s' % (settings.IDAPI_URL, path)

class WebmakerOAuth2Backend(object):
def authenticate(self, webmaker_oauth2_code=None, **kwargs):
if webmaker_oauth2_code is None:
return None

payload = {
'client_id': settings.IDAPI_CLIENT_ID,
'client_secret': settings.IDAPI_CLIENT_SECRET,
'grant_type': 'authorization_code',
'code': webmaker_oauth2_code
}
token_req = requests.post(get_idapi_url('/login/oauth/access_token'),
data=payload)
access_token = token_req.json()['access_token']
user_req = requests.get(get_idapi_url('/user'), headers={
'authorization': 'token %s' % access_token
})
user_info = user_req.json()

users = User.objects.filter(username=user_info['username'])
if len(users) == 0:
user = User.objects.create_user(user_info['username'],
user_info['email'])
return user
else:
return users[0]

def get_user(self, user_id):
try:
return User.objects.get(pk=user_id)
except User.DoesNotExist:
return None
17 changes: 17 additions & 0 deletions teach/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@
CORS_API_PERSONA_ORIGINS='*'
)

IDAPI_URL = os.environ.get('IDAPI_URL', 'https://id.webmaker.org')
IDAPI_CLIENT_ID = os.environ.get('IDAPI_CLIENT_ID')
IDAPI_CLIENT_SECRET = os.environ.get('IDAPI_CLIENT_SECRET')

LOGINAPI_URL = os.environ.get('LOGINAPI_URL', 'https://login.webmaker.org')
LOGINAPI_AUTH = os.environ.get('LOGINAPI_AUTH')

Expand All @@ -56,6 +60,13 @@

if DEBUG: set_default_env(ORIGIN='http://localhost:%d' % PORT)

if DEBUG and IDAPI_URL.startswith('fake:'):
IDAPI_ENABLE_FAKE_OAUTH2 = True
IDAPI_FAKE_OAUTH2_USERNAME = IDAPI_URL.split(':')[1]
IDAPI_FAKE_OAUTH2_EMAIL = IDAPI_URL.split(':')[2]
else:
IDAPI_ENABLE_FAKE_OAUTH2 = False

set_default_db('sqlite:///%s' % path('db.sqlite3'))

globals().update(parse_email_backend_url(os.environ['EMAIL_BACKEND_URL']))
Expand Down Expand Up @@ -91,6 +102,11 @@
'clubs',
)

if IDAPI_ENABLE_FAKE_OAUTH2:
INSTALLED_APPS += (
'fake_oauth2',
)

MIDDLEWARE_CLASSES = ()

if not DEBUG:
Expand Down Expand Up @@ -119,6 +135,7 @@

AUTHENTICATION_BACKENDS += (
'django.contrib.auth.backends.ModelBackend',
'teach.new_webmaker.WebmakerOAuth2Backend',
'teach.webmaker.WebmakerBrowserIDBackend',
)

Expand Down
5 changes: 5 additions & 0 deletions teach/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import doctest
from django.test import TestCase, RequestFactory, Client
from django.test.utils import override_settings
from django.contrib.auth.models import User, AnonymousUser
Expand Down Expand Up @@ -199,3 +200,7 @@ def test_200_when_assertion_valid_and_account_exists(self):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json['username'], 'foo')
self.assertRegexpMatches(response.json['token'], r'^[0-9a-f]+$')

def load_tests(loader, tests, ignore):
tests.addTests(doctest.DocTestSuite(views))
return tests
19 changes: 16 additions & 3 deletions teach/urls.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from django.conf.urls import patterns, include, url
from django.conf import settings
from django.conf.urls import include, url
from django.contrib import admin
from django.views.generic import RedirectView

Expand All @@ -11,7 +12,7 @@
router = TeachRouter()
router.register(r'clubs', ClubViewSet)

urlpatterns = patterns('',
urlpatterns = [
# Examples:
# url(r'^$', 'teach.views.home', name='home'),
# url(r'^blog/', include('blog.urls')),
Expand All @@ -25,10 +26,22 @@
url(r'^auth/logout$',
'teach.views.logout'),

url(r'^auth/oauth2/authorize$',
'teach.views.oauth2_authorize'),
url(r'^auth/oauth2/callback$',
'teach.views.oauth2_callback'),
url(r'^auth/oauth2/logout$',
'teach.views.oauth2_logout'),

url(r'^api-introduction/', 'teach.views.api_introduction',
name='api-introduction'),
url(r'^api/', include(router.urls)),
url(r'^$', RedirectView.as_view(url='/api/', permanent=False)),
url(r'', include('django_browserid.urls')),
url(r'^admin/', include(teach_admin.urls)),
)
]

if settings.IDAPI_ENABLE_FAKE_OAUTH2:
urlpatterns += [
url(r'^fake_oauth2/', include('fake_oauth2.urls')),
]
83 changes: 81 additions & 2 deletions teach/views.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,98 @@
import json
import urlparse
import django.contrib.auth
from django.shortcuts import render
from django.conf import settings
from django.http import HttpResponse
from django.http import HttpResponse, HttpResponseRedirect
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST, require_GET
from django.utils.crypto import get_random_string
import django_browserid.base
import requests
from rest_framework import routers
from rest_framework.authtoken.models import Token

from . import webmaker
from . import webmaker, new_webmaker
from .new_webmaker import get_idapi_url

def get_verifier():
return django_browserid.base.RemoteVerifier()

def get_origin(url):
"""
Returns the origin (http://www.w3.org/Security/wiki/Same_Origin_Policy)
of the given URL.
Examples:
>>> get_origin('http://foo/blarg')
'http://foo'
>>> get_origin('https://foo')
'https://foo'
>>> get_origin('http://foo:123/blarg')
'http://foo:123'
If the URL isn't http or https, it returns None:
>>> get_origin('')
>>> get_origin('weirdprotocol://lol.com')
"""

info = urlparse.urlparse(url)
if info.scheme not in ['http', 'https']:
return None
return '%s://%s' % (info.scheme, info.netloc)

def validate_callback(callback):
origin = get_origin(callback)
valid_origins = settings.CORS_API_PERSONA_ORIGINS
if origin and origin in valid_origins:
return callback
if settings.DEBUG and valid_origins == ['*']:
return callback
return None

def set_callback(request):
callback = validate_callback(request.GET.get('callback', ''))
if callback:
request.session['oauth2_callback'] = callback

def oauth2_authorize(request):
set_callback(request)
request.session['oauth2_state'] = get_random_string(length=32)

return HttpResponseRedirect(get_idapi_url("/login/oauth/authorize", {
'client_id': settings.IDAPI_CLIENT_ID,
'response_type': 'code',
'scopes': 'user email',
'state': request.session['oauth2_state']
}))

def oauth2_callback(request):
callback = request.session.get('oauth2_callback', '/')
expected_state = request.session.get('oauth2_state')
state = request.GET.get('state')
code = request.GET.get('code')
if request.GET.get('logout') == 'true':
django.contrib.auth.logout(request)
return HttpResponseRedirect(callback)
if state is None or expected_state is None or state != expected_state:
return HttpResponse('invalid state')
if code is None:
return HttpResponse('invalid code')
del request.session['oauth2_state']
user = django.contrib.auth.authenticate(webmaker_oauth2_code=code)
django.contrib.auth.login(request, user)

return HttpResponseRedirect(callback)

def oauth2_logout(request):
set_callback(request)
return HttpResponseRedirect(get_idapi_url("/logout", {
'client_id': settings.IDAPI_CLIENT_ID
}))

def check_origin(request):
origin = request.META.get('HTTP_ORIGIN')
valid_origins = settings.CORS_API_PERSONA_ORIGINS
Expand Down

0 comments on commit 138a74e

Please sign in to comment.