Skip to content

Commit

Permalink
Merge pull request #10 from pennlabs/feature/ipc
Browse files Browse the repository at this point in the history
IPC on behalf of user
  • Loading branch information
ArmaanT committed Dec 9, 2019
2 parents 8dc6dbc + 3191c1a commit 361c0c6
Show file tree
Hide file tree
Showing 21 changed files with 538 additions and 23 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
@@ -1,6 +1,13 @@
Changelog
=========

0.5.0 (UNRELEASED)
------------------
* Feature: IPC receiving middleware
* Feature: IPC sending helper method
* Feature: Better documentation
* Fix: Better error checking

0.4.2 (2019-11-14)
------------------
* Fix: Register models from the default django admin
Expand Down
10 changes: 10 additions & 0 deletions README.md
Expand Up @@ -34,6 +34,16 @@ AUTHENTICATION_BACKENDS = (
)
```

Add the new accounts middleware to `MIDDLEWARE`. Note the middleware does not need to be at the top of the list, but should be placed above the default Django middleware.

```python
MIDDLEWARE = [
...
'accounts.middleware.OAuth2TokenMiddleware',
...
]
```

Add the following to `urls.py`

```python
Expand Down
20 changes: 20 additions & 0 deletions accounts/admin.py
Expand Up @@ -2,9 +2,29 @@
from django.shortcuts import redirect
from django.urls import reverse

from accounts.models import AccessToken, RefreshToken
from accounts.settings import accounts_settings


class AccessTokenAdmin(admin.ModelAdmin):
"""
Custom ModelAdmin for Access Tokens
"""
list_display = ('token', 'user', 'expires_at',)


class RefreshTokenAdmin(admin.ModelAdmin):
"""
Custom ModelAdmin for Refresh Tokens
"""
list_display = ('token', 'user',)


# Register models to admin site
admin.site.register(AccessToken, AccessTokenAdmin)
admin.site.register(RefreshToken, RefreshTokenAdmin)


class LabsAdminSite(admin.AdminSite):
"""
Custom admin site that redirects users to log in through platform
Expand Down
19 changes: 19 additions & 0 deletions accounts/backends.py
@@ -1,6 +1,10 @@
from datetime import timedelta

from django.contrib.auth import get_user_model
from django.contrib.auth.backends import RemoteUserBackend
from django.utils import timezone

from accounts.models import AccessToken, RefreshToken
from accounts.settings import accounts_settings


Expand Down Expand Up @@ -31,6 +35,21 @@ def authenticate(self, request, remote_user):
if getattr(user, field) is not remote_user[field]:
setattr(user, field, remote_user[field])

# Update Access and Refresh Token
AccessToken.objects.update_or_create(
user=user,
defaults={
'expires_at': timezone.now() + timedelta(seconds=remote_user['token']['expires_in']),
'token': remote_user['token']['access_token'],
}
)
RefreshToken.objects.update_or_create(
user=user,
defaults={
'token': remote_user['token']['refresh_token'],
}
)

# Set or remove admin permissions
if accounts_settings.ADMIN_PERMISSION in remote_user['product_permission']:
if not user.is_staff:
Expand Down
90 changes: 90 additions & 0 deletions accounts/ipc.py
@@ -0,0 +1,90 @@
from datetime import timedelta

import requests
from django.utils import timezone

from accounts.settings import accounts_settings


def authenticated_request(user, method, url,
params=None, data=None, headers=None, cookies=None, files=None,
auth=None, timeout=None, allow_redirects=True, proxies=None,
hooks=None, stream=None, verify=None, cert=None, json=None):
"""
Helper method to make an authenticated request using the user's access token
NOTE be ABSOLUTELY sure you only make a request to Penn Labs products, otherwise
you will expose user's access tokens to the URL you provide and bad things will
happen
"""

# Access token is expired. Try to refresh access token
if user.accesstoken.expires_at < timezone.now():
if not _refresh_access_token(user):
# Couldn't update the user's access token. Return a response with a 403 status code
# as if the user didn't have access to the requested resource
response = requests.models.Response
response.status_code = 403
return response

# Update Headers
headers = {} if headers is None else headers
headers['Authorization'] = f'Bearer {user.accesstoken.token}'

# Make the request
# We're only using a session to provide an easy wrapper to define the http method
# GET, POST, etc in the method call.
s = requests.Session()
return s.request(
method=method,
url=url,
params=params,
data=data,
headers=headers,
cookies=cookies,
files=files,
auth=None,
timeout=None,
allow_redirects=allow_redirects,
proxies=proxies,
hooks=hooks,
stream=stream,
verify=verify,
cert=cert,
json=json,
)


def _refresh_access_token(user):
"""
Helper method to update a user's access token. Should be used when a user's
access token has expired, but still has a valid refresh token.
Returns:
bool: true if the access token is updated, false otherwise.
"""
body = {
'grant_type': 'refresh_token',
'client_id': accounts_settings.CLIENT_ID,
'client_secret': accounts_settings.CLIENT_SECRET,
'refresh_token': user.refreshtoken.token,
}
try:
data = requests.post(
url=accounts_settings.PLATFORM_URL + '/accounts/token/',
data=body
)
if data.status_code == 200: # Access token refreshed successfully
data = data.json()
# Update Access token
user.accesstoken.token = data['access_token']
user.accesstoken.expires_at = timezone.now() + timedelta(seconds=data['expires_in'])
user.accesstoken.save()

# Update Refresh Token
user.refreshtoken.token = data['refresh_token']
user.refreshtoken.save()

return True
except requests.exceptions.RequestException: # Can't connect to platform
return False
return False
47 changes: 47 additions & 0 deletions accounts/middleware.py
@@ -0,0 +1,47 @@
import requests
from django.contrib.auth import get_user_model
from django.http import HttpResponseForbidden

from accounts.settings import accounts_settings


User = get_user_model()


class OAuth2TokenMiddleware:
"""
When a view is requested using a Bearer Authorization header,
check and set request.user to the owner of said token
"""

def __init__(self, get_response):
self.get_response = get_response

def __call__(self, request):
authorization = request.META.get('HTTP_AUTHORIZATION')
if authorization and ' ' in authorization:
auth_type, token = authorization.split()
if auth_type == 'Bearer': # Only validate if Authorization header type is Bearer
body = {'token': token}
headers = {'Authorization': 'Bearer {}'.format(token)}
try:
data = requests.post(
url=accounts_settings.PLATFORM_URL + '/accounts/introspect/',
headers=headers,
data=body
)
if data.status_code == 200: # Access token is valid
data = data.json()
user = User.objects.filter(id=int(data['user']['pennid']))
if len(user) == 1: # User has an account on this product
request.user = user.first()
else: # Access token is invalid
return HttpResponseForbidden()
except requests.exceptions.RequestException: # Can't connect to platform
# Throw a 403 because we can't verify the incoming access token so we
# treat it as invalid. Ideally platform will never go down, so this
# should never happen.
return HttpResponseForbidden()

response = self.get_response(request)
return response
34 changes: 34 additions & 0 deletions accounts/migrations/0001_initial.py
@@ -0,0 +1,34 @@
# Generated by Django 2.2.7 on 2019-11-26 19:08

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

initial = True

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='RefreshToken',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token', models.CharField(blank=True, max_length=255, null=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='AccessToken',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token', models.CharField(blank=True, max_length=255, null=True)),
('expires_at', models.DateTimeField()),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]
Empty file added accounts/migrations/__init__.py
Empty file.
19 changes: 19 additions & 0 deletions accounts/models.py
@@ -0,0 +1,19 @@
from django.conf import settings
from django.db import models


class AccessToken(models.Model):
token = models.CharField(max_length=255, blank=True, null=True)
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
expires_at = models.DateTimeField()

def __str__(self):
return str(self.token)


class RefreshToken(models.Model):
token = models.CharField(max_length=255, blank=True, null=True)
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)

def __str__(self):
return str(self.token)
39 changes: 28 additions & 11 deletions accounts/views.py
@@ -1,5 +1,5 @@
from django.contrib import auth
from django.http import HttpResponseServerError
from django.http import HttpResponseRedirect, HttpResponseServerError
from django.http.response import HttpResponseBadRequest
from django.shortcuts import redirect
from django.views import View
Expand All @@ -9,11 +9,14 @@


class LoginView(View):
"""
Log in the user and redirect to next query parameter
"""
def get(self, request):
return_to = request.GET.get('next')
if not return_to:
return HttpResponseBadRequest('Invalid next parameter')
request.session.__setitem__('next', return_to)
request.session['next'] = return_to
if not request.user.is_authenticated:
platform = OAuth2Session(
accounts_settings.CLIENT_ID,
Expand All @@ -24,33 +27,47 @@ def get(self, request):
accounts_settings.PLATFORM_URL + '/accounts/authorize/'
)
response = redirect(authorization_url)
request.session.__setitem__('state', state)
request.session['state'] = state
return response
return redirect(request.session.pop('next'))
return redirect(request.session.pop('next', '/'))


class CallbackView(View):
"""
View where the the user is redirected to from platform with the
query parameter code being that user's Authorization Code
"""
def get(self, request):
response = HttpResponseRedirect(request.session.pop('next'))
state = request.session.pop('state')
platform = OAuth2Session(accounts_settings.CLIENT_ID, redirect_uri=accounts_settings.REDIRECT_URI, state=state)

# Get the user's access and refresh tokens
token = platform.fetch_token(
accounts_settings.PLATFORM_URL + '/accounts/token/',
client_secret=accounts_settings.CLIENT_SECRET,
authorization_response=request.build_absolute_uri()
)

# Use the access token to log in the user using information from platform
platform = OAuth2Session(accounts_settings.CLIENT_ID, token=token)
access_token = token['access_token']
introspect_url = accounts_settings.PLATFORM_URL + '/accounts/introspect/'
user_props = platform.post(introspect_url, data={'token': access_token}).json()['user']
user_props['pennid'] = int(user_props['pennid'])
user = auth.authenticate(request, remote_user=user_props)
if user:
auth.login(request, user)
return redirect(request.session.pop('next'))
request = platform.post(introspect_url, data={'token': token['access_token']})
if request.status_code == 200: # Connected to platform successfully
user_props = request.json()['user']
user_props['token'] = token
user_props['pennid'] = int(user_props['pennid'])
user = auth.authenticate(request, remote_user=user_props)
if user:
auth.login(request, user)
return response
return HttpResponseServerError()


class LogoutView(View):
"""
Log out the user and redirect to next query parameter
"""
def get(self, request):
auth.logout(request)
return redirect(request.GET.get('next', '/'))

0 comments on commit 361c0c6

Please sign in to comment.