Repository: spacetelescope/jwql

Issue:
Integrate auth.mast with the web app
[auth.mast](https://auth.mastdev.stsci.edu) is a service provided by ASB that will allow users of a web application to authenticate through MAST.  This service can help us build an authentication layer on our web app and restrict content to users who have a MAST account.


Discussion:
I have reached out to Christian Mesh, who is the lead developer of auth.mast.  Hopefully he can help us get to work on this in the near future. 

Resolving patch:
diff --git a/jwql/website/apps/jwql/oauth.py b/jwql/website/apps/jwql/oauth.py
new file mode 100644
--- /dev/null
+++ b/jwql/website/apps/jwql/oauth.py
@@ -0,0 +1,265 @@
+"""Provides an OAuth object for authentication of the ``jwql`` web app,
+as well as decorator functions to require user authentication in other
+views of the web application.
+
+
+Authors
+-------
+
+    - Matthew Bourque
+    - Christian Mesh
+
+Use
+---
+
+    This module is intended to be imported and used as such:
+    ::
+
+        from .oauth import auth_info
+        from .oauth import auth_required
+        from .oauth import JWQL_OAUTH
+
+        @auth_info
+        def some_view(request):
+            pass
+
+        @auth_required
+        def login(request):
+            pass
+
+References
+----------
+    Much of this code was taken from the ``authlib`` documentation,
+    found here: ``http://docs.authlib.org/en/latest/client/django.html``
+
+Dependencies
+------------
+    The user must have a configuration file named ``config.json``
+    placed in the ``jwql/utils/`` directory.
+"""
+
+import os
+import requests
+
+from authlib.django.client import OAuth
+from django.shortcuts import redirect
+
+from jwql.utils.utils import get_base_url, get_config
+
+
+def register_oauth():
+    """Register the ``jwql`` application with the ``auth.mast``
+    authentication service.
+
+    Returns
+    -------
+    oauth : Object
+        An object containing methods to authenticate a user, provided
+        by the ``auth.mast`` service.
+    """
+
+    # Get configuration parameters
+    client_id = get_config()['client_id']
+    client_secret = get_config()['client_secret']
+    auth_mast = get_config()['auth_mast']
+
+    # Register with auth.mast
+    oauth = OAuth()
+    client_kwargs = {'scope': 'mast:user:info'}
+    oauth.register(
+        'mast_auth',
+        client_id='{}'.format(client_id),
+        client_secret='{}'.format(client_secret),
+        access_token_url='https://{}/oauth/access_token?client_secret={}'.format(auth_mast, client_secret),
+        access_token_params=None,
+        refresh_token_url=None,
+        authorize_url='https://{}/oauth/authorize'.format(auth_mast),
+        api_base_url='https://{}/1.1/'.format(auth_mast),
+        client_kwargs=client_kwargs)
+
+    return oauth
+
+JWQL_OAUTH = register_oauth()
+
+
+def authorize(request):
+    """Spawn the authentication process for the user
+
+    The authentication process involves retreiving an access token
+    from ``auth.mast`` and porting the data to a cookie.
+
+    Parameters
+    ----------
+    request : HttpRequest object
+        Incoming request from the webpage
+
+    Returns
+    -------
+    HttpResponse object
+        Outgoing response sent to the webpage
+    """
+
+    # Get auth.mast token
+    token = JWQL_OAUTH.mast_auth.authorize_access_token(request, headers={'Accept': 'application/json'})
+
+    # Determine domain
+    base_url = get_base_url()
+    if '127' in base_url:
+        domain = '127.0.0.1'
+    else:
+        domain = base_url.split('//')[-1]
+
+    # Set secure cookie parameters
+    cookie_args = {}
+    # cookie_args['domain'] = domain  # Currently broken
+    # cookie_args['secure'] = True  # Currently broken
+    cookie_args['httponly'] = True
+
+    # Set the cookie
+    response = redirect("/")
+    response.set_cookie("ASB-AUTH", token["access_token"], **cookie_args)
+
+    return response
+
+
+def auth_required(fn):
+    """A decorator function that requires the given function to have
+    authentication through ``auth.mast`` set up.
+
+    Parameters
+    ----------
+    fn : function
+        The function to decorate
+
+    Returns
+    -------
+    check_auth : function
+        The decorated function
+    """
+
+    @auth_info
+    def check_auth(request, user):
+        """Check if the user is authenticated through ``auth.mast``.
+        If not, perform the authorization.
+
+        Parameters
+        ----------
+        request : HttpRequest object
+            Incoming request from the webpage
+        user : dict
+            A dictionary of user credentials
+
+        Returns
+        -------
+        fn : function
+            The decorated function
+        """
+
+        # If user is currently anonymous, require a login
+        if user["anon"]:
+            # Redirect to oauth login
+            redirect_uri = os.path.join(get_base_url(), 'authorize')
+            return JWQL_OAUTH.mast_auth.authorize_redirect(request, redirect_uri)
+
+        return fn(request, user)
+
+    return check_auth
+
+
+def auth_info(fn):
+    """A decorator function that will return user credentials along
+    with what is returned by the original function.
+
+    Parameters
+    ----------
+    fn : function
+        The function to decorate
+
+    Returns
+    -------
+    user_info : function
+        The decorated function
+    """
+
+    def user_info(request, **kwargs):
+        """Store authenticated user credentials in a cookie and return
+        it.  If the user is not authenticated, store no credentials in
+        the cookie.
+
+        Parameters
+        ----------
+        request : HttpRequest object
+            Incoming request from the webpage
+
+        Returns
+        -------
+        fn : function
+            The decorated function
+        """
+
+        cookie = request.COOKIES.get("ASB-AUTH")
+
+        # If user is authenticated, return user credentials
+        if cookie is not None:
+            response = requests.get(
+                'https://{}/info'.format(get_config()['auth_mast']),
+                headers={'Accept': 'application/json',
+                         'Authorization': 'token {}'.format(cookie)})
+            response = response.json()
+
+        # If user is not authenticated, return no credentials
+        else:
+            response = {'ezid' : None, "anon": True}
+
+        return fn(request, response, **kwargs)
+
+    return user_info
+
+
+@auth_required
+def login(request, user):
+    """Spawn a login process for the user
+
+    The ``auth_requred`` decorator is used to require that the user
+    authenticate through ``auth.mast``, then the user is redirected
+    back to the homepage.
+
+    Parameters
+    ----------
+    request : HttpRequest object
+        Incoming request from the webpage
+    user : dict
+        A dictionary of user credentials.
+
+    Returns
+    -------
+    HttpResponse object
+        Outgoing response sent to the webpage
+    """
+
+    return redirect("/")
+
+
+def logout(request):
+    """Spawn a logout process for the user
+
+    Upon logout, the user's ``auth.mast`` credientials are removed and
+    the user is redirected back to the homepage.
+
+    Parameters
+    ----------
+    request : HttpRequest object
+        Incoming request from the webpage
+    user : dict
+        A dictionary of user credentials.
+
+    Returns
+    -------
+    HttpResponse object
+        Outgoing response sent to the webpage
+    """
+
+    response = redirect("/")
+    response.delete_cookie("ASB-AUTH")
+
+    return response
diff --git a/jwql/website/apps/jwql/urls.py b/jwql/website/apps/jwql/urls.py
--- a/jwql/website/apps/jwql/urls.py
+++ b/jwql/website/apps/jwql/urls.py
@@ -42,6 +42,7 @@
 from django.urls import re_path
 
 from . import api_views
+from . import oauth
 from . import views
 
 app_name = 'jwql'
@@ -49,8 +50,15 @@
 
 urlpatterns = [
 
-    # Main site views
+    # Home
     path('', views.home, name='home'),
+
+    # Authentication
+    path('login/', oauth.login, name='login'),
+    path('logout/', oauth.logout, name='logout'),
+    path('authorize/', oauth.authorize, name='authorize'),
+
+    # Main site views
     path('about/', views.about, name='about'),
     path('dashboard/', views.dashboard, name='dashboard'),
     re_path(r'^(?P<inst>({}))/$'.format(instruments), views.instrument, name='instrument'),
diff --git a/jwql/website/apps/jwql/views.py b/jwql/website/apps/jwql/views.py
--- a/jwql/website/apps/jwql/views.py
+++ b/jwql/website/apps/jwql/views.py
@@ -48,14 +48,14 @@
 from .data_containers import thumbnails
 from .data_containers import thumbnails_ajax
 from .forms import FileSearchForm
+from .oauth import auth_info
 from jwql.utils.constants import JWST_INSTRUMENT_NAMES, MONITORS, JWST_INSTRUMENT_NAMES_MIXEDCASE
 from jwql.utils.utils import get_base_url, get_config
 
-
 FILESYSTEM_DIR = os.path.join(get_config()['jwql_dir'], 'filesystem')
 
-
-def about(request):
+@auth_info
+def about(request, user):
     """Generate the ``about`` page
 
     Parameters
@@ -73,12 +73,14 @@ def about(request):
     context = {'acknowledgements': acknowledgements,
                'inst': '',
                'inst_list': JWST_INSTRUMENT_NAMES,
-               'tools': MONITORS}
+               'tools': MONITORS,
+               'user': user}
 
     return render(request, template, context)
 
 
-def archived_proposals(request, inst):
+@auth_info
+def archived_proposals(request, user, inst):
     """Generate the page listing all archived proposals in the database
 
     Parameters
@@ -99,6 +101,7 @@ def archived_proposals(request, inst):
     template = 'archive.html'
     context = {'inst': inst,
                'tools': MONITORS,
+               'user': user,
                'base_url': get_base_url()}
 
     return render(request, template, context)
@@ -122,8 +125,6 @@ def archived_proposals_ajax(request, inst):
     # Ensure the instrument is correctly capitalized
     inst = JWST_INSTRUMENT_NAMES_MIXEDCASE[inst.lower()]
 
-    template = 'archive.html'
-
     # For each proposal, get the first available thumbnail and determine
     # how many files there are
     filepaths = get_filenames_by_instrument(inst)
@@ -141,7 +142,8 @@ def archived_proposals_ajax(request, inst):
     return JsonResponse(context, json_dumps_params={'indent': 2})
 
 
-def archive_thumbnails(request, inst, proposal):
+@auth_info
+def archive_thumbnails(request, user, inst, proposal):
     """Generate the page listing all archived images in the database
     for a certain proposal
 
@@ -166,6 +168,7 @@ def archive_thumbnails(request, inst, proposal):
     context = {'inst': inst,
                'prop': proposal,
                'tools': MONITORS,
+               'user': user,
                'base_url': get_base_url()}
 
     return render(request, template, context)
@@ -198,7 +201,8 @@ def archive_thumbnails_ajax(request, inst, proposal):
     return JsonResponse(data, json_dumps_params={'indent': 2})
 
 
-def dashboard(request):
+@auth_info
+def dashboard(request, user):
     """Generate the dashbaord page
 
     Parameters
@@ -218,6 +222,7 @@ def dashboard(request):
     context = {'inst': '',
                'inst_list': JWST_INSTRUMENT_NAMES,
                'tools': MONITORS,
+               'user': user,
                'outputs': output_dir,
                'filesystem_html': os.path.join(output_dir, 'monitor_filesystem',
                                                'filesystem_monitor.html'),
@@ -227,13 +232,16 @@ def dashboard(request):
     return render(request, template, context)
 
 
-def home(request):
+@auth_info
+def home(request, user):
     """Generate the home page
 
     Parameters
     ----------
     request : HttpRequest object
         Incoming request from the webpage
+    user : dict
+        A dictionary of user credentials.
 
     Returns
     -------
@@ -253,12 +261,14 @@ def home(request):
     context = {'inst': '',
                'inst_list': JWST_INSTRUMENT_NAMES,
                'tools': MONITORS,
-               'form': form}
+               'form': form,
+               'user': user}
 
     return render(request, template, context)
 
 
-def instrument(request, inst):
+@auth_info
+def instrument(request, user, inst):
     """Generate the instrument tool index page
 
     Parameters
@@ -287,12 +297,14 @@ def instrument(request, inst):
 
     context = {'inst': inst,
                'tools': MONITORS,
+               'user': user,
                'doc_url': doc_url}
 
     return render(request, template, context)
 
 
-def unlooked_images(request, inst):
+@auth_info
+def unlooked_images(request, user, inst):
     """Generate the page listing all unlooked images in the database
 
     Parameters
@@ -312,11 +324,13 @@ def unlooked_images(request, inst):
 
     template = 'thumbnails.html'
     context = thumbnails(inst)
+    context['user'] = user
 
     return render(request, template, context)
 
 
-def view_header(request, inst, file):
+@auth_info
+def view_header(request, user, inst, file):
     """Generate the header view page
 
     Parameters
@@ -344,11 +358,13 @@ def view_header(request, inst, file):
                   {'inst': inst,
                    'file': file,
                    'tools': MONITORS,
+                   'user': user,
                    'header': header,
                    'file_root': file_root})
 
 
-def view_image(request, inst, file_root, rewrite=False):
+@auth_info
+def view_image(request, user, inst, file_root, rewrite=False):
     """Generate the image view page
 
     Parameters
@@ -375,6 +391,7 @@ def view_image(request, inst, file_root, rewrite=False):
     context = {'inst': inst,
                'file_root': file_root,
                'tools': MONITORS,
+               'user': user,
                'jpg_files': image_info['all_jpegs'],
                'fits_files': image_info['all_files'],
                'suffixes': image_info['suffixes'],


Test patch: