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: if not settings.DIRECT_EDITING:
self.fields['package'].widget = HiddenInput() self.fields['package'].widget = HiddenInput()
self.fields['package'].disabled = True self.fields['package'].disabled = True
else: elif not settings.DIRECT_EDITING:
unlocked_packages = get_unlocked_packages(request) unlocked_packages = get_unlocked_packages(request)
if len(unlocked_packages) == 1: if len(unlocked_packages) == 1:
self.fields['package'].widget = HiddenInput() 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 from c3nav.mapdata.models import Package
class Hoster: class Hoster(ABC):
def __init__(self, name, base_url): def __init__(self, name, base_url):
self.name = name self.name = name
self.base_url = base_url self.base_url = base_url
def get_packages(self): 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) 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): def _get_session_data(self, request):
request.session.modified = True
return request.session.setdefault('hosters', {}).setdefault(self.name, {}) return request.session.setdefault('hosters', {}).setdefault(self.name, {})
def is_access_granted(self, request): def get_error(self, request):
return self._get_session_data(request).get('access_granted', False) """
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 from c3nav.editor.hosters.base import Hoster
@ -8,3 +15,53 @@ class GithubHoster(Hoster):
super().__init__(**kwargs) super().__init__(**kwargs)
self._app_id = app_id self._app_id = app_id
self._app_secret = app_secret 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 from c3nav.editor.hosters.base import Hoster
@ -8,3 +15,57 @@ class GitlabHoster(Hoster):
super().__init__(**kwargs) super().__init__(**kwargs)
self._app_id = app_id self._app_id = app_id
self._app_secret = app_secret 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() name = serializers.CharField()
base_url = serializers.CharField() base_url = serializers.CharField()
packages = serializers.SerializerMethodField() packages = serializers.SerializerMethodField()
signed_in = serializers.SerializerMethodField()
def get_packages(self, obj): def get_packages(self, obj):
return tuple(obj.get_packages().values_list('name', flat=True)) 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 { #btn_editing_cancel {
margin-right:8px; 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) { if ($('#mapeditlist').length) {
editor.init(); 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"> <form action="{% url 'editor.finalize' %}" method="POST" name="redirect">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="data" value="{{ data }}"> <input type="hidden" name="data" value="{{ data }}">
<input type="hidden" name="check" value="1">
<button type="submit" class="btn btn-default disabled">Redirecting…</button> <button type="submit" class="btn btn-default disabled">Redirecting…</button>
</form> </form>
{% endif %} {% endif %}

View file

@ -1,15 +1,36 @@
{% extends 'editor/base.html' %} {% extends 'editor/base.html' %}
{% block content %} {% block content %}
{% if hoster %} {% if hoster %}
{% 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> <h2>Sign in with {{ hoster.title }}</h2>
<p>Please sign in to continue and propose your edit.</p> <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> <p>
<a class="btn btn-lg btn-primary">Sign in with {{ hoster.title }}</a><br> <button type="submit" class="btn btn-lg btn-primary">Sign in with {{ hoster.title }}</button><br>
<small><em> <small><em>
{{ hoster.name }} {{ hoster.base_url }} {{ hoster.name }} {{ hoster.base_url }}
</small></em> </small></em>
</p> </p>
<p></p> </form>
{% endif %}
<p>Alternatively, you can copy your edit below and send it to the maps maintainer.</p> <p>Alternatively, you can copy your edit below and send it to the maps maintainer.</p>
{% else %} {% else %}
<h2>Copy your edit</h2> <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.conf.urls import url
from django.views.generic import TemplateView 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 = [ urlpatterns = [
url(r'^$', TemplateView.as_view(template_name='editor/map.html'), name='editor.index'), 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/(?P<feature_type>[^/]+)/add/$', add_feature, name='editor.feature.add'),
url(r'^features/edit/(?P<name>[^/]+)/$', edit_feature, name='editor.feature.edit'), 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 import signing
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.http.response import Http404 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.utils.translation import ugettext_lazy as _
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from c3nav.editor.forms import FeatureForm 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.feature import FEATURE_TYPES, Feature
from c3nav.mapdata.models.package import Package from c3nav.mapdata.models.package import Package
from c3nav.mapdata.packageio.write import json_encode from c3nav.mapdata.packageio.write import json_encode
@ -106,17 +106,62 @@ def finalize(request):
'title': _('Missing data.'), 'title': _('Missing data.'),
'description': _('Edit data is missing.') 'description': _('Edit data is missing.')
}, status=400) }, 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() package = Package.objects.filter(name=package_name).first()
hoster = None hoster = None
if package is not None: if package is not None:
hoster = get_hoster_for_package(package) 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', { return render(request, 'editor/finalize.html', {
'data': data,
'package_name': package_name, 'package_name': package_name,
'hoster': hoster, 'hoster': hoster,
'hoster_state': hoster_state,
'hoster_error': hoster_error,
'file_path': file_path, 'file_path': file_path,
'file_contents': file_contents '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 django-filter>=0.14,<0.15
shapely>=1.5,<1.6 shapely>=1.5,<1.6
celery>=3.1,<3.2 celery>=3.1,<3.2
requests>=2.11,<2.12