add oauth flow for github and gitlab

This commit is contained in:
Laura Klünder 2016-09-29 16:33:45 +02:00
parent 7deb001f08
commit 3707469eed
14 changed files with 359 additions and 22 deletions

View file

@ -24,7 +24,7 @@ class FeatureForm(ModelForm):
if not settings.DIRECT_EDITING:
self.fields['package'].widget = HiddenInput()
self.fields['package'].disabled = True
else:
elif not settings.DIRECT_EDITING:
unlocked_packages = get_unlocked_packages(request)
if len(unlocked_packages) == 1:
self.fields['package'].widget = HiddenInput()

View file

@ -1,16 +1,135 @@
from abc import ABC, abstractmethod
from django.urls.base import reverse
from c3nav.editor.tasks import check_access_token, request_access_token
from c3nav.mapdata.models import Package
class Hoster:
class Hoster(ABC):
def __init__(self, name, base_url):
self.name = name
self.base_url = base_url
def get_packages(self):
"""
Get a Queryset of all packages that can be handled by this hoster
"""
return Package.objects.filter(home_repo__startswith=self.base_url)
def _get_callback_uri(self, request):
return request.build_absolute_uri(reverse('editor.finalize.oauth.callback', kwargs={'hoster': self.name}))
def _get_session_data(self, request):
request.session.modified = True
return request.session.setdefault('hosters', {}).setdefault(self.name, {})
def is_access_granted(self, request):
return self._get_session_data(request).get('access_granted', False)
def get_error(self, request):
"""
If an error occured lately, return and forget it.
"""
session_data = self._get_session_data(request)
if 'error' in session_data:
return session_data.pop('error')
def set_tmp_data(self, request, data):
"""
Save data before redirecting to the OAuth Provider.
"""
self._get_session_data(request)['tmp_data'] = data
def get_tmp_data(self, request):
"""
Get and forget data that was saved before redirecting to the OAuth Provider.
"""
data = self._get_session_data(request)
if 'tmp_data' not in data:
return None
return data.pop('tmp_data')
def get_state(self, request):
"""
Get current hoster state for this user.
:return: 'logged_in', 'logged_out', 'missing_permissions' or 'checking' if a check is currently running.
"""
session_data = self._get_session_data(request)
state = session_data.setdefault('state', 'logged_out')
if state == 'checking':
task = request_access_token.AsyncResult(id=session_data.get('checking_progress_id'))
self._handle_checking_task(request, task, session_data)
state = session_data['state']
return state
def check_state(self, request):
"""
Sets the state for this user to 'checking' immediately and starts a task that checks if the currently known
is still valid and sets the state afterwards.
Does nothing if the current state is not 'logged_in'.
"""
session_data = self._get_session_data(request)
state = session_data.get('state')
if state == 'logged_in':
session_data['state'] = 'checking'
task = check_access_token.delay(hoster=self.name, access_token=session_data['access_token'])
session_data['checking_progress_id'] = task.id
self._handle_checking_task(request, task, session_data)
def _handle_checking_task(self, request, task, session_data):
"""
Checks if the checking task is finished and if so handles its results.
"""
if task.ready():
task.maybe_reraise()
state, content = task.result
if content:
if state == 'logged_out':
session_data['error'] = content
else:
session_data['access_token'] = content
session_data['state'] = state
session_data.pop('checking_progress_id')
def request_access_token(self, request, *args, **kwargs):
"""
Starts a task that calls do_request_access_token.
"""
args = (self.name, )+args
session_data = self._get_session_data(request)
session_data['state'] = 'checking'
task = request_access_token.apply_async(args=args, kwargs=kwargs)
session_data['checking_progress_id'] = task.id
self._handle_checking_task(request, task, session_data)
@abstractmethod
def get_auth_uri(self, request):
"""
Get the a URL the user should be redirected to to authenticate and invalidates any previous URLs.
"""
pass
@abstractmethod
def handle_callback_request(self, request):
"""
Validates and handles the callback request and calls request_access_token.
"""
pass
@abstractmethod
def do_request_access_token(self, code, state):
"""
Task method for requesting the access token asynchroniously.
Return a tuple with a new state and the access_token, or an optional error string if the state is 'logged_out'.
"""
pass
@abstractmethod
def do_check_access_token(self, access_token):
"""
Task method for checking the access token asynchroniously.
Return a tuple with a new state and None, or an optional error string if the state is 'logged_out'.
"""
pass

View file

@ -1,3 +1,10 @@
import string
from urllib.parse import urlencode
import requests
from django.core.exceptions import SuspiciousOperation
from django.utils.crypto import get_random_string
from c3nav.editor.hosters.base import Hoster
@ -8,3 +15,53 @@ class GithubHoster(Hoster):
super().__init__(**kwargs)
self._app_id = app_id
self._app_secret = app_secret
def get_auth_uri(self, request):
oauth_csrf_token = get_random_string(42, string.ascii_letters+string.digits)
self._get_session_data(request)['oauth_csrf_token'] = oauth_csrf_token
return 'https://github.com/login/oauth/authorize?%s' % urlencode((
('client_id', self._app_id),
('scope', 'public_repo'),
('state', oauth_csrf_token),
))
def handle_callback_request(self, request):
code = request.GET.get('code')
state = request.GET.get('state')
if code is None or state is None:
raise SuspiciousOperation('Missing parameters.')
session_data = self._get_session_data(request)
if session_data.get('oauth_csrf_token') != state:
raise SuspiciousOperation('OAuth CSRF token mismatch')
session_data.pop('oauth_csrf_token')
self.request_access_token(request, code, state)
def do_request_access_token(self, code, state):
response = requests.post('https://github.com/login/oauth/access_token', data={
'client_id': self._app_id,
'client_secret': self._app_secret,
'code': code,
'state': state
}, headers={'Accept': 'application/json'}).json()
if 'error' in response:
return ('logged_out',
'%s: %s %s' % (response['error'], response['error_description'], response['error_uri']))
if 'public_repo' not in response['scope'].split(','):
return ('missing_permissions', response['access_token'])
return ('logged_in', response['access_token'])
def do_check_access_token(self, access_token):
response = requests.get('https://api.github.com/rate_limit', headers={'Authorization': 'token '+access_token})
if response.status_code != 200:
return ('logged_out', '')
if 'public_repo' not in (s.strip() for s in response.headers.get('X-OAuth-Scopes').split(',')):
return ('missing_permissions', None)
return ('logged_in', None)

View file

@ -1,3 +1,10 @@
import string
from urllib.parse import urlencode, urljoin
import requests
from django.core.exceptions import SuspiciousOperation
from django.utils.crypto import get_random_string
from c3nav.editor.hosters.base import Hoster
@ -8,3 +15,57 @@ class GitlabHoster(Hoster):
super().__init__(**kwargs)
self._app_id = app_id
self._app_secret = app_secret
def get_endpoint(self, path):
return urljoin(self.base_url, path)
def get_auth_uri(self, request):
oauth_csrf_token = get_random_string(42, string.ascii_letters+string.digits)
self._get_session_data(request)['oauth_csrf_token'] = oauth_csrf_token
callback_uri = self._get_callback_uri(request)
self._get_session_data(request)['callback_uri'] = callback_uri
return self.get_endpoint('/oauth/authorize?%s' % urlencode((
('client_id', self._app_id),
('redirect_uri', callback_uri),
('response_type', 'code'),
('state', oauth_csrf_token),
)))
def handle_callback_request(self, request):
code = request.GET.get('code')
state = request.GET.get('state')
if code is None or state is None:
raise SuspiciousOperation('Missing parameters.')
session_data = self._get_session_data(request)
if session_data.get('oauth_csrf_token') != state:
raise SuspiciousOperation('OAuth CSRF token mismatch')
session_data.pop('oauth_csrf_token')
callback_uri = session_data.pop('callback_uri')
self.request_access_token(request, code, state, callback_uri)
def do_request_access_token(self, code, state, callback_uri):
response = requests.post(self.get_endpoint('/oauth/token'), data={
'client_id': self._app_id,
'client_secret': self._app_secret,
'code': code,
'grant_type': 'authorization_code',
'redirect_uri': callback_uri,
'state': state,
}).json()
if 'error' in response:
return ('logged_out', '%s: %s' % (response['error'], response['error_description']))
return ('logged_in', response['access_token'])
def do_check_access_token(self, access_token):
response = requests.get(self.get_endpoint('/user'), headers={'Authorization': 'Bearer '+access_token})
if response.status_code != 200:
return ('logged_out', '')
return ('logged_in', None)

View file

@ -5,10 +5,6 @@ class HosterSerializer(serializers.Serializer):
name = serializers.CharField()
base_url = serializers.CharField()
packages = serializers.SerializerMethodField()
signed_in = serializers.SerializerMethodField()
def get_packages(self, obj):
return tuple(obj.get_packages().values_list('name', flat=True))
def get_signed_in(self, obj):
return obj.is_access_granted(self.context['request']) if 'request' in self.context else None

View file

@ -127,3 +127,13 @@ legend {
#btn_editing_cancel {
margin-right:8px;
}
form[name=redirect] button {
background-image: url('/static/img/loader.gif');
background-repeat: no-repeat;
background-color: transparent !important;
border: 0 !important;
margin: 0 !important;
padding: 50px 0 0 0;
cursor: wait !important;
}

View file

@ -395,3 +395,4 @@ editor = {
if ($('#mapeditlist').length) {
editor.init();
}
$('form[name=redirect]').submit();

13
src/c3nav/editor/tasks.py Normal file
View file

@ -0,0 +1,13 @@
from c3nav.celery import app
@app.task()
def request_access_token(hoster, *args, **kwargs):
from c3nav.editor.hosters import hosters
return hosters[hoster].do_request_access_token(*args, **kwargs)
@app.task()
def check_access_token(hoster, access_token):
from c3nav.editor.hosters import hosters
return hosters[hoster].do_check_access_token(access_token)

View file

@ -2,6 +2,7 @@
<form action="{% url 'editor.finalize' %}" method="POST" name="redirect">
{% csrf_token %}
<input type="hidden" name="data" value="{{ data }}">
<input type="hidden" name="check" value="1">
<button type="submit" class="btn btn-default disabled">Redirecting…</button>
</form>
{% endif %}

View file

@ -1,15 +1,36 @@
{% extends 'editor/base.html' %}
{% block content %}
{% if hoster %}
<h2>Sign in with {{ hoster.title }}</h2>
<p>Please sign in to continue and propose your edit.</p>
<p>
<a class="btn btn-lg btn-primary">Sign in with {{ hoster.title }}</a><br>
<small><em>
{{ hoster.name }} {{ hoster.base_url }}
</small></em>
</p>
<p></p>
{% if hoster_error %}
<div class="alert alert-danger" role="alert">
<p>{{ hoster_error }}</p>
</div>
{% endif %}
{% if hoster_state == 'logged_in' %}
<h2>Submit a pull request</h2>
<p>blablabla #todo</p>
{% elif hoster_state == 'checking' %}
{% else %}
{% if hoster_state == 'misssing_permissions' %}
<h2>Missing {{ hoster.title }} Permissions</h2>
<p>c3nav is missing permissions that it needs to propose your edit.</p>
<p>Please click the button below to grant the missing permissions.</p>
{% else %}
<h2>Sign in with {{ hoster.title }}</h2>
<p>Please sign in to continue and propose your edit.</p>
{% endif %}
<form action="{% url 'editor.finalize.oauth' %}" method="POST">
{% csrf_token %}
<input type="hidden" name="data" value="{{ data }}">
<p>
<button type="submit" class="btn btn-lg btn-primary">Sign in with {{ hoster.title }}</button><br>
<small><em>
{{ hoster.name }} {{ hoster.base_url }}
</small></em>
</p>
</form>
{% endif %}
<p>Alternatively, you can copy your edit below and send it to the maps maintainer.</p>
{% else %}
<h2>Copy your edit</h2>

View file

@ -0,0 +1,8 @@
{% extends 'editor/base.html' %}
{% block content %}
<form action="{% url 'editor.finalize' %}" method="POST" name="redirect">
{% csrf_token %}
<input type="hidden" name="data" value="{{ data }}">
<button type="submit" class="btn btn-default disabled">Redirecting…</button>
</form>
{% endblock %}

View file

@ -1,11 +1,15 @@
from django.conf.urls import url
from django.views.generic import TemplateView
from c3nav.editor.views import add_feature, edit_feature, finalize
from c3nav.editor.views import (add_feature, edit_feature, finalize, finalize_oauth_callback, finalize_oauth_progress,
finalize_oauth_redirect)
urlpatterns = [
url(r'^$', TemplateView.as_view(template_name='editor/map.html'), name='editor.index'),
url(r'^features/(?P<feature_type>[^/]+)/add/$', add_feature, name='editor.feature.add'),
url(r'^features/edit/(?P<name>[^/]+)/$', edit_feature, name='editor.feature.edit'),
url(r'^finalize/$', finalize, name='editor.finalize')
url(r'^finalize/$', finalize, name='editor.finalize'),
url(r'^finalize/oauth/$', finalize_oauth_redirect, name='editor.finalize.oauth'),
url(r'^finalize/oauth/progress$', finalize_oauth_progress, name='editor.finalize.oauth.progress'),
url(r'^finalize/oauth/(?P<hoster>[^/]+)/callback$', finalize_oauth_callback, name='editor.finalize.oauth.callback')
]

View file

@ -2,12 +2,12 @@ from django.conf import settings
from django.core import signing
from django.core.exceptions import PermissionDenied
from django.http.response import Http404
from django.shortcuts import get_object_or_404, render
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.translation import ugettext_lazy as _
from django.views.decorators.http import require_POST
from c3nav.editor.forms import FeatureForm
from c3nav.editor.hosters import get_hoster_for_package
from c3nav.editor.hosters import get_hoster_for_package, hosters
from c3nav.mapdata.models.feature import FEATURE_TYPES, Feature
from c3nav.mapdata.models.package import Package
from c3nav.mapdata.packageio.write import json_encode
@ -106,17 +106,62 @@ def finalize(request):
'title': _('Missing data.'),
'description': _('Edit data is missing.')
}, status=400)
data = request.POST['data']
package_name, file_path, file_contents = signing.loads(request.POST['data'])
package_name, file_path, file_contents = signing.loads(data)
package = Package.objects.filter(name=package_name).first()
hoster = None
if package is not None:
hoster = get_hoster_for_package(package)
if request.POST.get('check'):
hoster.check_state(request)
hoster_state = hoster.get_state(request)
hoster_error = hoster.get_error(request) if hoster_state == 'logged_out' else None
return render(request, 'editor/finalize.html', {
'data': data,
'package_name': package_name,
'hoster': hoster,
'hoster_state': hoster_state,
'hoster_error': hoster_error,
'file_path': file_path,
'file_contents': file_contents
})
@require_POST
def finalize_oauth_progress(request):
pass
@require_POST
def finalize_oauth_redirect(request):
if 'data' not in request.POST:
return render(request, 'editor/error.html', {
'title': _('Missing data.'),
'description': _('Edit data is missing.')
}, status=400)
data = request.POST['data']
package_name, file_path, file_contents = signing.loads(data)
package = Package.objects.filter(name=package_name).first()
hoster = None
if package is not None:
hoster = get_hoster_for_package(package)
hoster.set_tmp_data(request, data)
return redirect(hoster.get_auth_uri(request))
def finalize_oauth_callback(request, hoster):
hoster = hosters.get(hoster)
if hoster is None:
raise Http404
data = hoster.get_tmp_data(request)
hoster.handle_callback_request(request)
return render(request, 'editor/finalize_oauth_callback.html', {'data': data})

View file

@ -6,3 +6,4 @@ djangorestframework>=3.4,<3.5
django-filter>=0.14,<0.15
shapely>=1.5,<1.6
celery>=3.1,<3.2
requests>=2.11,<2.12