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..9e91561 --- /dev/null +++ b/musician/api.py @@ -0,0 +1,83 @@ +import requests +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/', + 'my-account': 'accounts/', + + # services + 'domain-list': 'domains/', + # ... TODO (@slamora) complete list of backend URLs +} + + +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) + + 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, raise_exception=True): + 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) + + if raise_exception: + 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 + + 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 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 new file mode 100644 index 0000000..5fcafbe --- /dev/null +++ b/musician/forms.py @@ -0,0 +1,21 @@ + +from django.contrib.auth.forms import AuthenticationForm + +from . import api + +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: + orchestra = api.Orchestra(username=username, password=password) + + if orchestra.auth_token is None: + raise self.get_invalid_login_error() + else: + self.username = username + self.token = orchestra.auth_token + + return self.cleaned_data 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/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 @@
Little description of what to be expected...
-{% for i in "123"|make_list %} +{% for domain in domains %}