Skip to content

Commit 93bea72

Browse files
committed
Login via OAuth2
1 parent f631609 commit 93bea72

13 files changed

Lines changed: 297 additions & 2 deletions

File tree

mygpo/moauth/__init__.py

Whitespace-only changes.

mygpo/moauth/admin.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from django.contrib import admin
2+
3+
from . import models
4+
5+
@admin.register(models.AuthRequest)
6+
class AuthRequestAdmin(admin.ModelAdmin):
7+
list_display = ('created', 'state')

mygpo/moauth/apps.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from django.apps import AppConfig
2+
3+
4+
class AuthConfig(AppConfig):
5+
name = 'auth'

mygpo/moauth/backends.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import requests
2+
3+
from django.db import IntegrityError
4+
from django.contrib.auth import get_user_model
5+
from django.contrib.auth.backends import ModelBackend
6+
7+
import logging
8+
logger = logging.getLogger(__name__)
9+
10+
11+
class OAuth2Backend(ModelBackend):
12+
""" OAuth2 authentication backend
13+
14+
Authenticates based on token info URL; Uses Users from the ModelBackend """
15+
16+
def authenticate(self, token_info_url=None):
17+
logger.info('Authenticating user from "%s"', token_info_url)
18+
if token_info_url is None:
19+
return
20+
21+
token = self._get_token_info(token_info_url)
22+
username = token['user']['login']
23+
return self._get_user(username)
24+
25+
def _get_token_info(self, token_info_url):
26+
""" Retrieves token info and returns the username """
27+
28+
headers = {
29+
'Accept': 'application/json'
30+
}
31+
32+
r = requests.get(token_info_url, headers=headers)
33+
token = r.json()
34+
#{
35+
# 'token': '62b6a03b16a5453f810cf6d32ac975f8',
36+
# 'app': {
37+
# 'url': None,
38+
# 'name': 'gpodder.net',
39+
# 'client_id': 'Nb0QLDW2psFSXfGwmCvJ1ElhITu9P3Kg'
40+
# },
41+
# 'created_at': '2016-02-07T12:42:14.140Z',
42+
# 'user': {
43+
# 'login': 'stefan'
44+
# },
45+
# 'scopes': [
46+
# 'actions:add',
47+
# 'podcastlists'
48+
# ]
49+
#}
50+
return token
51+
52+
def _get_user(self, username):
53+
""" Get user based on username """
54+
User = get_user_model()
55+
try:
56+
return User.objects.create(username=username)
57+
except IntegrityError as ie:
58+
return User.objects.get(username__iexact=username)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# -*- coding: utf-8 -*-
2+
# Generated by Django 1.9.2 on 2016-02-07 12:13
3+
from __future__ import unicode_literals
4+
5+
import datetime
6+
import django.contrib.postgres.fields
7+
from django.db import migrations, models
8+
9+
10+
class Migration(migrations.Migration):
11+
12+
initial = True
13+
14+
dependencies = [
15+
]
16+
17+
operations = [
18+
migrations.CreateModel(
19+
name='AuthRequest',
20+
fields=[
21+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
22+
('scopes', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(blank=True, max_length=64), size=None)),
23+
('state', models.CharField(max_length=32)),
24+
('created', models.DateTimeField(default=datetime.datetime.utcnow)),
25+
],
26+
),
27+
]

mygpo/moauth/migrations/__init__.py

Whitespace-only changes.

mygpo/moauth/models.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from datetime import datetime
2+
3+
from django.db import models
4+
from django.contrib.postgres.fields import ArrayField
5+
6+
7+
class AuthRequest(models.Model):
8+
9+
scopes = ArrayField(models.CharField(max_length=64, blank=True))
10+
11+
state = models.CharField(max_length=32)
12+
13+
created = models.DateTimeField(default=datetime.utcnow)

mygpo/moauth/tests.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from django.test import TestCase
2+
3+
# Create your tests here.

mygpo/moauth/urls.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from django.conf.urls import url
2+
3+
from . import views
4+
5+
6+
urlpatterns = [
7+
8+
url(r'^oauth/login$',
9+
views.InitiateOAuthLogin.as_view(),
10+
name='login-oauth'),
11+
12+
url(r'^oauth/callback$',
13+
views.OAuthCallback.as_view(),
14+
name='oauth-callback'),
15+
16+
]

mygpo/moauth/views.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import requests
2+
from requests.auth import HTTPBasicAuth
3+
4+
import urllib.parse
5+
6+
from django.db import IntegrityError
7+
from django.core.urlresolvers import reverse
8+
from django.shortcuts import render
9+
from django.views.generic.base import RedirectView
10+
from django.views.generic.base import View
11+
from django.http import HttpResponseRedirect
12+
from django.contrib.sites.requests import RequestSite
13+
from django.contrib.auth import login, get_user_model, authenticate
14+
from django.conf import settings
15+
16+
from mygpo.utils import random_token
17+
from . import models
18+
19+
import logging
20+
logger = logging.getLogger(__name__)
21+
22+
23+
AVAILABLE_SCOPES = [
24+
'subscriptions',
25+
'suggestions',
26+
'account',
27+
'favorites',
28+
'podcastlists',
29+
'apps:get',
30+
'apps:sync',
31+
'actions:get',
32+
'actions:add',
33+
]
34+
35+
class InitiateOAuthLogin(RedirectView):
36+
37+
def get_redirect_url(self):
38+
39+
client_id = settings.MYGPO_AUTH_CLIENT_ID
40+
redir_uri = self._get_callback_url()
41+
state = random_token()
42+
response_type = 'code'
43+
44+
models.AuthRequest.objects.create(
45+
scopes = AVAILABLE_SCOPES,
46+
state = state,
47+
)
48+
logger.info('Initiated new new auth request "%s"', state)
49+
50+
scopes = AVAILABLE_SCOPES
51+
qs = self._get_qs(client_id, redir_uri, scopes, state, response_type)
52+
return _get_authorize_url('/authorize', qs)
53+
54+
def _get_qs(self, client_id, redirect_uri, scopes, state, response_type):
55+
return urllib.parse.urlencode([
56+
('client_id', client_id),
57+
('redirect_uri', redirect_uri),
58+
('scope', ' '.join(scopes)),
59+
('state', state),
60+
('response_type', response_type),
61+
])
62+
63+
def _get_callback_url(self):
64+
protocol = 'https' if self.request.is_secure() else 'http'
65+
site = RequestSite(self.request)
66+
domain = site.domain
67+
view = reverse('oauth-callback')
68+
return '{0}://{1}{2}'.format(protocol, domain, view)
69+
70+
71+
class OAuthCallback(View):
72+
""" OAuth 2 callback handler
73+
74+
Gets and verifies token, logs in user """
75+
76+
def get(self, request):
77+
78+
if 'error' in self.request.GET:
79+
# handle error
80+
# error=server_error&error_description=An+unknown+error+occured
81+
return
82+
83+
code = self.request.GET.get('code', None)
84+
state = self.request.GET.get('state', None)
85+
86+
try:
87+
authreq = models.AuthRequest.objects.get(state=state)
88+
except models.AuthRequest.DoesNotExist:
89+
# handle
90+
return
91+
92+
access_token, token_info_url = self._get_access_token(code)
93+
94+
user = authenticate(token_info_url=token_info_url)
95+
login(self.request, user)
96+
97+
return HttpResponseRedirect(reverse('home'))
98+
99+
def _get_access_token(self, code):
100+
payload = {
101+
'grant_type': 'authorization_code',
102+
'code': code,
103+
'client_id': settings.MYGPO_AUTH_CLIENT_ID,
104+
}
105+
auth = HTTPBasicAuth(settings.MYGPO_AUTH_CLIENT_ID,
106+
settings.MYGPO_AUTH_CLIENT_SECRET)
107+
108+
qs = self._get_qs(AVAILABLE_SCOPES)
109+
token_url = _get_authorize_url('/token', qs)
110+
r = requests.post(token_url, data=payload, auth=auth)
111+
if r.status_code != 200:
112+
return # handle error
113+
114+
resp = r.json()
115+
access_token = resp['access_token']
116+
expires_in = resp['expires_in']
117+
token_type = resp['token_type']
118+
scopes = resp['scope'].split(' ')
119+
#{
120+
# 'expires_in': 3599.995724,
121+
# 'access_token': 'a46de116972b46e88481e7a082db60ca',
122+
# 'token_type': 'Bearer',
123+
# 'scope': 'podcastlists subscriptions suggestions apps:get actions:get account actions:add apps:sync favorites'
124+
#}
125+
logger.info(
126+
'Received %s token "%s" for scopes "%s", expires in %f',
127+
token_type, access_token, ' '.join(scopes), expires_in
128+
)
129+
130+
token_info = r.links['https://gpodder.net/relation/token-info']['url']
131+
132+
# Reference Resolution
133+
# https://tools.ietf.org/html/rfc3986#section-5
134+
token_info_url = urllib.parse.urljoin(settings.MYGPO_AUTH_URL,
135+
token_info)
136+
137+
return access_token, token_info_url
138+
139+
login(self.request, user)
140+
141+
142+
def _get_qs(self, scopes):
143+
return urllib.parse.urlencode([
144+
('scope', ' '.join(scopes)),
145+
])
146+
147+
148+
def _get_authorize_url(endpoint, qs):
149+
r = urllib.parse.urlsplit(settings.MYGPO_AUTH_URL)
150+
path = r.path
151+
if path.endswith('/'):
152+
path = path[:-1]
153+
154+
path = path + endpoint
155+
parts = (r.scheme, r.netloc, path, qs, r.fragment)
156+
return urllib.parse.urlunsplit(parts)

0 commit comments

Comments
 (0)