add oauth flow for github and gitlab
This commit is contained in:
parent
7deb001f08
commit
3707469eed
14 changed files with 359 additions and 22 deletions
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -395,3 +395,4 @@ editor = {
|
|||
if ($('#mapeditlist').length) {
|
||||
editor.init();
|
||||
}
|
||||
$('form[name=redirect]').submit();
|
||||
|
|
13
src/c3nav/editor/tasks.py
Normal file
13
src/c3nav/editor/tasks.py
Normal 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)
|
|
@ -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 %}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
|
@ -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')
|
||||
]
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue