From 750d5cc465cadbe178ea81de511e3d7b455a5724 Mon Sep 17 00:00:00 2001 From: Santiago Lamora Date: Tue, 29 Oct 2019 10:47:50 +0100 Subject: [PATCH 1/7] Add basic structure to access django-orchestra API --- .env.example | 1 + musician/api.py | 24 ++++++++++++++++++++++++ requirements.txt | 1 + userpanel/settings.py | 4 ++++ 4 files changed, 30 insertions(+) create mode 100644 musician/api.py diff --git a/.env.example b/.env.example index 41acf39..e753e2b 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ SECRET_KEY='$omeR@nd0mSecr3tKeyWith4V3ryL0ng$tring!?' DEBUG=True ALLOWED_HOSTS=.localhost,127.0.0.1 +API_BASE_URL = 'https://api.examplea.org/' diff --git a/musician/api.py b/musician/api.py new file mode 100644 index 0000000..800cc1d --- /dev/null +++ b/musician/api.py @@ -0,0 +1,24 @@ +import urllib.parse + +from django.conf import settings +from django.urls.exceptions import NoReverseMatch + +DOMAINS_PATH = 'domains/' +TOKEN_PATH = '/api-token-auth/' + +API_PATHS = { + # auth + 'token-auth': '/api-token-auth/', + + # services + 'domain-list': 'domains/', + # ... TODO (@slamora) complete list of backend URLs +} + + +def build_absolute_uri(path_name): + path = API_PATHS.get(path_name, None) + if path is None: + raise NoReverseMatch("Not found API path name '{}'".format(path_name)) + + return urllib.parse.urljoin(settings.API_BASE_URL, path) diff --git a/requirements.txt b/requirements.txt index def8cfc..0691a4a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ django==2.2 python-decouple==3.1 django-extensions dj_database_url==0.5.0 +requests==2.22.0 diff --git a/userpanel/settings.py b/userpanel/settings.py index 4a0121c..dd40d8a 100644 --- a/userpanel/settings.py +++ b/userpanel/settings.py @@ -135,3 +135,7 @@ USE_TZ = True STATIC_URL = '/static/' STATIC_ROOT = config('STATIC_ROOT') + +# Backend API configuration + +API_BASE_URL = config('API_BASE_URL') From 77ee7987a28d31301abd89dc48b831641d1f9663 Mon Sep 17 00:00:00 2001 From: Santiago Lamora Date: Tue, 29 Oct 2019 10:58:54 +0100 Subject: [PATCH 2/7] Add custom login view to replace of django.auth --- musician/forms.py | 33 +++++++++++++++++++++++++++++++++ musician/urls.py | 4 +--- musician/views.py | 11 +++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 musician/forms.py diff --git a/musician/forms.py b/musician/forms.py new file mode 100644 index 0000000..e344049 --- /dev/null +++ b/musician/forms.py @@ -0,0 +1,33 @@ +import urllib.parse + +import requests +from django.contrib.auth.forms import AuthenticationForm + +from . import api + + +def authenticate(username, password): + url = api.build_absolute_uri('token-auth') + r = requests.post( + url, + data={"username": username, "password": password}, + ) + + token = r.json().get("token", None) + return token + + +class LoginForm(AuthenticationForm): + + def clean(self): + username = self.cleaned_data.get('username') + password = self.cleaned_data.get('password') + + if username is not None and password: + self.token = authenticate(username, password) + if self.token is None: + raise self.get_invalid_login_error() + else: + return self.token + + return self.cleaned_data diff --git a/musician/urls.py b/musician/urls.py index 10cbdf4..b8a6765 100644 --- a/musician/urls.py +++ b/musician/urls.py @@ -4,7 +4,6 @@ URL routes definition. Describe the paths where the views are accesible. """ -from django.contrib.auth import views as auth_views from django.urls import path from . import views @@ -13,8 +12,7 @@ from . import views app_name = 'musician' urlpatterns = [ - path('auth/login/', auth_views.LoginView.as_view(template_name='auth/login.html', - extra_context={'version': '0.1'}), name='login'), + path('auth/login/', views.LoginView.as_view(), name='login'), # path('auth/logout/', views.LogoutView.as_view(), name='logout'), path('dashboard/', views.DashboardView.as_view(), name='dashboard'), ] diff --git a/musician/views.py b/musician/views.py index 1102473..7e0568f 100644 --- a/musician/views.py +++ b/musician/views.py @@ -1,9 +1,20 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.shortcuts import render +from django.urls import reverse_lazy from django.views.generic.base import TemplateView +from django.views.generic.edit import FormView +from . import api, get_version +from .forms import LoginForm from .mixins import CustomContextMixin class DashboardView(CustomContextMixin, TemplateView): ## TODO LoginRequiredMixin template_name = "musician/dashboard.html" + + +class LoginView(FormView): + template_name = 'auth/login.html' + form_class = LoginForm + success_url = reverse_lazy('musician:dashboard') + extra_context = {'version': get_version()} From 5b2294b699c93d1de824ac7e6eabda13cf667ccc Mon Sep 17 00:00:00 2001 From: Santiago Lamora Date: Wed, 30 Oct 2019 13:05:46 +0100 Subject: [PATCH 3/7] Implement login and logout through cookie sessions --- musician/api.py | 50 ++++++++++++++++++++++++--- musician/auth.py | 38 ++++++++++++++++++++ musician/forms.py | 22 +++--------- musician/templates/musician/base.html | 2 +- musician/urls.py | 2 +- musician/views.py | 33 +++++++++++++++++- userpanel/settings.py | 8 +++++ 7 files changed, 130 insertions(+), 25 deletions(-) create mode 100644 musician/auth.py diff --git a/musician/api.py b/musician/api.py index 800cc1d..8f7bf32 100644 --- a/musician/api.py +++ b/musician/api.py @@ -1,3 +1,4 @@ +import requests import urllib.parse from django.conf import settings @@ -16,9 +17,48 @@ API_PATHS = { } -def build_absolute_uri(path_name): - path = API_PATHS.get(path_name, None) - if path is None: - raise NoReverseMatch("Not found API path name '{}'".format(path_name)) +class Orchestra(object): + def __init__(self, *args, username=None, password=None, **kwargs): + self.base_url = kwargs.pop('base_url', settings.API_BASE_URL) + self.username = username + self.session = requests.Session() + self.auth_token = kwargs.pop("auth_token", None) - return urllib.parse.urljoin(settings.API_BASE_URL, path) + if self.auth_token is None: + self.auth_token = self.authenticate(self.username, password) + + def build_absolute_uri(self, path_name): + path = API_PATHS.get(path_name, None) + if path is None: + raise NoReverseMatch( + "Not found API path name '{}'".format(path_name)) + + return urllib.parse.urljoin(self.base_url, path) + + def authenticate(self, username, password): + url = self.build_absolute_uri('token-auth') + response = self.session.post( + url, + data={"username": username, "password": password}, + ) + + return response.json().get("token", None) + + def request(self, verb, resource): + assert verb in ["HEAD", "GET", "POST", "PATCH", "PUT", "DELETE"] + url = self.build_absolute_uri(resource) + + verb = getattr(self.session, verb.lower()) + response = verb(url, headers={"Authorization": "Token {}".format( + self.auth_token)}, allow_redirects=False) + + response.raise_for_status() + + status = response.status_code + output = response.json() + + return status, output + + def retrieve_domains(self): + status, output = self.request("GET", 'domain-list') + return output diff --git a/musician/auth.py b/musician/auth.py new file mode 100644 index 0000000..6e6ff3d --- /dev/null +++ b/musician/auth.py @@ -0,0 +1,38 @@ +from django.middleware.csrf import rotate_token +from django.utils.crypto import constant_time_compare + +SESSION_KEY_TOKEN = '_auth_token' +SESSION_KEY_USERNAME = '_auth_username' + + +def login(request, username, token): + """ + Persist a user id and a backend in the request. This way a user doesn't + have to reauthenticate on every request. Note that data set during + the anonymous session is retained when the user logs in. + """ + if SESSION_KEY_TOKEN in request.session: + if request.session[SESSION_KEY_USERNAME] != username: + # To avoid reusing another user's session, create a new, empty + # session if the existing session corresponds to a different + # authenticated user. + request.session.flush() + else: + request.session.cycle_key() + + request.session[SESSION_KEY_TOKEN] = token + request.session[SESSION_KEY_USERNAME] = username + # if hasattr(request, 'user'): + # request.user = user + rotate_token(request) + + +def logout(request): + """ + Remove the authenticated user's ID from the request and flush their session + data. + """ + request.session.flush() + # if hasattr(request, 'user'): + # from django.contrib.auth.models import AnonymousUser + # request.user = AnonymousUser() diff --git a/musician/forms.py b/musician/forms.py index e344049..5fcafbe 100644 --- a/musician/forms.py +++ b/musician/forms.py @@ -1,22 +1,8 @@ -import urllib.parse -import requests from django.contrib.auth.forms import AuthenticationForm from . import api - -def authenticate(username, password): - url = api.build_absolute_uri('token-auth') - r = requests.post( - url, - data={"username": username, "password": password}, - ) - - token = r.json().get("token", None) - return token - - class LoginForm(AuthenticationForm): def clean(self): @@ -24,10 +10,12 @@ class LoginForm(AuthenticationForm): password = self.cleaned_data.get('password') if username is not None and password: - self.token = authenticate(username, password) - if self.token is None: + orchestra = api.Orchestra(username=username, password=password) + + if orchestra.auth_token is None: raise self.get_invalid_login_error() else: - return self.token + self.username = username + self.token = orchestra.auth_token return self.cleaned_data diff --git a/musician/templates/musician/base.html b/musician/templates/musician/base.html index c172d01..8e1eb35 100644 --- a/musician/templates/musician/base.html +++ b/musician/templates/musician/base.html @@ -51,7 +51,7 @@ diff --git a/musician/urls.py b/musician/urls.py index b8a6765..8e8ff38 100644 --- a/musician/urls.py +++ b/musician/urls.py @@ -13,6 +13,6 @@ app_name = 'musician' urlpatterns = [ path('auth/login/', views.LoginView.as_view(), name='login'), - # path('auth/logout/', views.LogoutView.as_view(), name='logout'), + path('auth/logout/', views.LogoutView.as_view(), name='logout'), path('dashboard/', views.DashboardView.as_view(), name='dashboard'), ] diff --git a/musician/views.py b/musician/views.py index 7e0568f..e0a8c79 100644 --- a/musician/views.py +++ b/musician/views.py @@ -1,10 +1,12 @@ from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import HttpResponseRedirect from django.shortcuts import render from django.urls import reverse_lazy -from django.views.generic.base import TemplateView +from django.views.generic.base import RedirectView, TemplateView from django.views.generic.edit import FormView from . import api, get_version +from .auth import login as auth_login, logout as auth_logout from .forms import LoginForm from .mixins import CustomContextMixin @@ -18,3 +20,32 @@ class LoginView(FormView): form_class = LoginForm success_url = reverse_lazy('musician:dashboard') extra_context = {'version': get_version()} + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['request'] = self.request + return kwargs + + def form_valid(self, form): + """Security check complete. Log the user in.""" + auth_login(self.request, form.username, form.token) + return HttpResponseRedirect(self.get_success_url()) + + +class LogoutView(RedirectView): + """ + Log out the user. + """ + permanent = False + pattern_name = 'musician:login' + + def get_redirect_url(self, *args, **kwargs): + """ + Logs out the user. + """ + auth_logout(self.request) + return super().get_redirect_url(*args, **kwargs) + + def post(self, request, *args, **kwargs): + """Logout may be done via POST.""" + return self.get(request, *args, **kwargs) diff --git a/userpanel/settings.py b/userpanel/settings.py index dd40d8a..4d45a17 100644 --- a/userpanel/settings.py +++ b/userpanel/settings.py @@ -114,6 +114,14 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] +LOGIN_URL = '/auth/login/' + +# Sessions +# https://docs.djangoproject.com/en/2.2/topics/http/sessions/#configuring-sessions + +SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies" + +# SESSION_COOKIE_SECURE = True # Internationalization # https://docs.djangoproject.com/en/2.2/topics/i18n/ From c6698ae761af6dc9860fb2f10d28bcd560ffcc7c Mon Sep 17 00:00:00 2001 From: Santiago Lamora Date: Wed, 30 Oct 2019 14:00:12 +0100 Subject: [PATCH 4/7] Add `verify_credentials` method. --- musician/api.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/musician/api.py b/musician/api.py index 8f7bf32..9e91561 100644 --- a/musician/api.py +++ b/musician/api.py @@ -10,6 +10,7 @@ TOKEN_PATH = '/api-token-auth/' API_PATHS = { # auth 'token-auth': '/api-token-auth/', + 'my-account': 'accounts/', # services 'domain-list': 'domains/', @@ -44,7 +45,7 @@ class Orchestra(object): return response.json().get("token", None) - def request(self, verb, resource): + def request(self, verb, resource, raise_exception=True): assert verb in ["HEAD", "GET", "POST", "PATCH", "PUT", "DELETE"] url = self.build_absolute_uri(resource) @@ -52,7 +53,8 @@ class Orchestra(object): response = verb(url, headers={"Authorization": "Token {}".format( self.auth_token)}, allow_redirects=False) - response.raise_for_status() + if raise_exception: + response.raise_for_status() status = response.status_code output = response.json() @@ -62,3 +64,20 @@ class Orchestra(object): def retrieve_domains(self): status, output = self.request("GET", 'domain-list') return output + + def retreve_profile(self): + _, output = self.request("GET", 'my-account') + return output + + def verify_credentials(self): + """ + Returns: + A user profile info if the + credentials are valid, None otherwise. + """ + status, output = self.request("GET", 'my-account', raise_exception=False) + + if status < 400: + return output + + return None From 3f07ca7f8a849ad44257b0b026a0953f5b6fb0f7 Mon Sep 17 00:00:00 2001 From: Santiago Lamora Date: Wed, 30 Oct 2019 14:06:55 +0100 Subject: [PATCH 5/7] Add `UserTokenRequiredMixin`. Create a subclass of `UserPassesTestMixin` that checks user has an authorized token. --- musician/mixins.py | 19 +++++++++++++++++++ musician/views.py | 9 +++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/musician/mixins.py b/musician/mixins.py index cbc7592..3b6b0bf 100644 --- a/musician/mixins.py +++ b/musician/mixins.py @@ -1,6 +1,8 @@ +from django.contrib.auth.mixins import UserPassesTestMixin from django.views.generic.base import ContextMixin from . import get_version +from .auth import SESSION_KEY_TOKEN class CustomContextMixin(ContextMixin): @@ -12,3 +14,20 @@ class CustomContextMixin(ContextMixin): }) return context + + +class UserTokenRequiredMixin(UserPassesTestMixin): + def test_func(self): + """Check that the user has an authorized token.""" + token = self.request.session.get(SESSION_KEY_TOKEN, None) + if token is None: + return False + + # initialize orchestra api orm + self.orchestra = api.Orchestra(auth_token=token) + + # verify if the token is valid + if self.orchestra.verify_credentials() is None: + return False + + return True diff --git a/musician/views.py b/musician/views.py index e0a8c79..6374c49 100644 --- a/musician/views.py +++ b/musician/views.py @@ -1,4 +1,4 @@ -from django.contrib.auth.mixins import LoginRequiredMixin + from django.http import HttpResponseRedirect from django.shortcuts import render from django.urls import reverse_lazy @@ -6,12 +6,13 @@ from django.views.generic.base import RedirectView, TemplateView from django.views.generic.edit import FormView from . import api, get_version -from .auth import login as auth_login, logout as auth_logout +from .auth import login as auth_login +from .auth import logout as auth_logout from .forms import LoginForm -from .mixins import CustomContextMixin +from .mixins import CustomContextMixin, UserTokenRequiredMixin -class DashboardView(CustomContextMixin, TemplateView): ## TODO LoginRequiredMixin +class DashboardView(CustomContextMixin, UserTokenRequiredMixin, TemplateView): template_name = "musician/dashboard.html" From 0650155e83f180721c27e7f36a0007ac606f3a55 Mon Sep 17 00:00:00 2001 From: Santiago Lamora Date: Wed, 30 Oct 2019 14:08:14 +0100 Subject: [PATCH 6/7] Retrieve and show user domains on the dashboard. --- musician/templates/musician/dashboard.html | 4 ++-- musician/views.py | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/musician/templates/musician/dashboard.html b/musician/templates/musician/dashboard.html index c5a653a..b738440 100644 --- a/musician/templates/musician/dashboard.html +++ b/musician/templates/musician/dashboard.html @@ -18,10 +18,10 @@

Domains and websites

Little description of what to be expected...

-{% for i in "123"|make_list %} +{% for domain in domains %}
-

domain.com

+

{{ domain.name }}

{% for service in "123"|make_list %}
diff --git a/musician/views.py b/musician/views.py index 6374c49..2035df6 100644 --- a/musician/views.py +++ b/musician/views.py @@ -15,6 +15,18 @@ from .mixins import CustomContextMixin, UserTokenRequiredMixin class DashboardView(CustomContextMixin, UserTokenRequiredMixin, TemplateView): template_name = "musician/dashboard.html" + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + # TODO retrieve all data needed from orchestra + raw_domains = self.orchestra.retrieve_domains() + + context.update({ + 'domains': raw_domains + }) + + return context + class LoginView(FormView): template_name = 'auth/login.html' From a564f7600089c77161b39e6dfb2879d94d5b35ae Mon Sep 17 00:00:00 2001 From: Santiago Lamora Date: Wed, 30 Oct 2019 14:23:46 +0100 Subject: [PATCH 7/7] Fix missing import. --- musician/views.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/musician/views.py b/musician/views.py index 2035df6..ce5e52c 100644 --- a/musician/views.py +++ b/musician/views.py @@ -28,6 +28,22 @@ class DashboardView(CustomContextMixin, UserTokenRequiredMixin, TemplateView): return context +class MailView(CustomContextMixin, UserTokenRequiredMixin, TemplateView): + template_name = "musician/mail.html" + + +class MailingListsView(CustomContextMixin, UserTokenRequiredMixin, TemplateView): + template_name = "musician/mailinglists.html" + + +class DatabasesView(CustomContextMixin, UserTokenRequiredMixin, TemplateView): + template_name = "musician/databases.html" + + +class SaasView(CustomContextMixin, UserTokenRequiredMixin, TemplateView): + template_name = "musician/saas.html" + + class LoginView(FormView): template_name = 'auth/login.html' form_class = LoginForm