From ae6b138ea51a9fce783771712bb5efd43258c273 Mon Sep 17 00:00:00 2001 From: Andreas Kloeckner Date: Tue, 5 Sep 2017 11:19:23 -0500 Subject: [PATCH 1/4] Some mini steps towards an API --- course/api.py | 29 +++++++++++++++++++++++++++++ relate/urls.py | 6 ++++++ 2 files changed, 35 insertions(+) create mode 100644 course/api.py diff --git a/course/api.py b/course/api.py new file mode 100644 index 00000000..af66ac05 --- /dev/null +++ b/course/api.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- + +from __future__ import division + +__copyright__ = "Copyright (C) 2017 Andreas Kloeckner" + +__license__ = """ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + + +def get_submission_data(request, course_identifier): + pass diff --git a/relate/urls.py b/relate/urls.py index a9cc437f..6e431c61 100644 --- a/relate/urls.py +++ b/relate/urls.py @@ -532,6 +532,12 @@ urlpatterns = [ #}}} + url(r"^course" + "/" + COURSE_ID_REGEX + + "/api/get-submissions$", + course.api.get_submission_data, + name="relate-course_get_submissions"), + url(r'^admin/', admin.site.urls), ] -- GitLab From 0297607c8ce7117e0c88d0d8c7f6ad1a55fc7f20 Mon Sep 17 00:00:00 2001 From: Andreas Kloeckner Date: Mon, 11 Sep 2017 16:11:46 -0500 Subject: [PATCH 2/4] Towards auth token management --- accounts/models.py | 6 - course/api.py | 1 + course/auth.py | 181 +++++++++++++----- course/models.py | 35 ++++ course/templates/course/course-base.html | 2 +- .../templates/course/generic-course-form.html | 4 + .../templates/course/manage-auth-tokens.html | 52 +++++ relate/settings.py | 3 +- relate/templates/generic-form.html | 4 + relate/urls.py | 2 +- 10 files changed, 234 insertions(+), 56 deletions(-) create mode 100644 course/templates/course/manage-auth-tokens.html diff --git a/accounts/models.py b/accounts/models.py index e0d8b670..ff27880d 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -122,12 +122,6 @@ class User(AbstractBaseUser, PermissionsMixin): # Translators: the text editor used by participants verbose_name=_("Editor mode")) - git_auth_token_hash = models.CharField(max_length=200, - help_text=_("A hash of the authentication token to be " - "used for direct git access."), - null=True, blank=True, - verbose_name=_('Hash of git authentication token')) - USERNAME_FIELD = 'username' REQUIRED_FIELDS = ['email'] diff --git a/course/api.py b/course/api.py index af66ac05..be760490 100644 --- a/course/api.py +++ b/course/api.py @@ -24,6 +24,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +import re def get_submission_data(request, course_identifier): pass diff --git a/course/auth.py b/course/auth.py index fadb73c3..f7db9046 100644 --- a/course/auth.py +++ b/course/auth.py @@ -24,6 +24,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +import re from typing import cast from django.utils.translation import ugettext_lazy as _ from django.shortcuts import ( # noqa @@ -58,7 +59,7 @@ from course.constants import ( participation_status, participation_permission as pperm, ) -from course.models import Participation # noqa +from course.models import Participation, AuthenticationToken # noqa from accounts.models import User from relate.utils import StyledForm, StyledModelForm, string_concat @@ -320,7 +321,7 @@ def check_sign_in_key(user_id, token): return True -class TokenBackend(object): +class EmailedTokenBackend(object): def authenticate(self, user_id=None, token=None): users = get_user_model().objects.filter( id=user_id, sign_in_key=token) @@ -1012,51 +1013,6 @@ def user_profile(request): # }}} -# {{{ manage auth token - -class AuthenticationTokenForm(StyledForm): - def __init__(self, *args, **kwargs): - # type: (*Any, **Any) -> None - super(AuthenticationTokenForm, self).__init__(*args, **kwargs) - - self.helper.add_input(Submit("reset", _("Reset"))) - - -def manage_authentication_token(request): - # type: (http.HttpRequest) -> http.HttpResponse - if not request.user.is_authenticated: - raise PermissionDenied() - - if request.method == 'POST': - form = AuthenticationTokenForm(request.POST) - if form.is_valid(): - token = make_sign_in_key(request.user) - from django.contrib.auth.hashers import make_password - request.user.git_auth_token_hash = make_password(token) - request.user.save() - - messages.add_message(request, messages.SUCCESS, - _("A new authentication token has been set: %s.") - % token) - - else: - if request.user.git_auth_token_hash is not None: - messages.add_message(request, messages.INFO, - _("An authentication token has previously been set.")) - else: - messages.add_message(request, messages.INFO, - _("No authentication token has previously been set.")) - - form = AuthenticationTokenForm() - - return render(request, "generic-form.html", { - "form_description": _("Manage Git Authentication Token"), - "form": form - }) - -# }}} - - # {{{ SAML auth backend # This ticks the 'verified' boxes once we've receive attribute assertions @@ -1124,4 +1080,135 @@ def sign_out(request, redirect_field_name=REDIRECT_FIELD_NAME): # }}} + +# {{{ API auth + +def find_matching_token(token_id=None, token_hash_str=None, now_datetime=None): + try: + token = AuthenticationToken().objects.get(id=token_id) + except AuthenticationToken.DoesNotExist: + return None + + from django.contrib.auth.hashers import check_password + if not check_password(token, token_hash_str): + return None + + if token.revocation_time is not None: + return None + if now_datetime > token.valid_until: + return None + + return token + + +class APIBearerTokenBackend(object): + def authenticate(self, token_id=None, token_hash_str=None, now_datetime=None): + token = find_matching_token(token_id, token_hash_str, now_datetime) + + token.last_use_time = now_datetime + token.save() + + return token.user + + def get_user(self, user_id): + try: + return get_user_model().objects.get(pk=user_id) + except get_user_model().DoesNotExist: + return None + + +AUTH_HEADER_RE = re.compile("^Token ([0-9]+)_([a-z0-9]+)$") + + +def with_course_api_auth(f): + def wrapper(request, course_identifier, *args, **kwargs): + from django.utils.timezone import now + now_datetime = now() + + auth_header = request.META.get("AUTHORIZATION", None) + + match = AUTH_HEADER_RE.match(auth_header) + if match is None: + raise PermissionDenied("ill-formed Authorization header") + + auth_data = dict( + token_id=int(AUTH_HEADER_RE.group(1)), + token_hash_str=AUTH_HEADER_RE.group(2), + now_datetime=now_datetime) + + # FIXME: Redundant db roundtrip + token = find_matching_token(**auth_data) + + from django.contrib.auth import authenticate, login + user = authenticate(**auth_data) + + assert user is not None + + login(request, user) + + response = f( + token.participation, + token.restrict_to_participation_role, + *args, **kwargs) + + return response + + from functools import update_wrapper + update_wrapper(wrapper, f) + + return wrapper + +# }}} + + +# {{{ manage API auth tokens + +class AuthenticationTokenForm(StyledModelForm): + class Meta: + model = AuthenticationToken + fields = ( + "restrict_to_participation_role", + "description", + "valid_until", + ) + + def __init__(self, *args, **kwargs): + # type: (*Any, **Any) -> None + super(AuthenticationTokenForm, self).__init__(*args, **kwargs) + + self.helper.add_input(Submit("create", _("Create"))) + + +def manage_authentication_tokens(request): + # type: (http.HttpRequest) -> http.HttpResponse + if not request.user.is_authenticated: + raise PermissionDenied() + + if request.method == 'POST': + form = AuthenticationTokenForm(request.POST) + if form.is_valid(): + token = make_sign_in_key(request.user) + from django.contrib.auth.hashers import make_password + request.user.git_auth_token_hash = make_password(token) + request.user.save() + + messages.add_message(request, messages.SUCCESS, + _("A new authentication token has been set: %s.") + % token) + + else: + form = AuthenticationTokenForm() + + tokens = AuthenticationToken.objects.filter( + user=request.user, + revocation_time=None) + + return render(request, "course/manage-auth-tokens.html", { + "form": form, + "new_token_message": "", + "tokens": tokens, + }) + +# }}} + # vim: foldmethod=marker diff --git a/course/models.py b/course/models.py index 6a0f84c3..9f18e70e 100644 --- a/course/models.py +++ b/course/models.py @@ -676,6 +676,41 @@ def _set_up_course_permissions(sender, instance, created, raw, using, update_fie # }}} +# {{{ auth token + +class AuthenticationToken(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, + verbose_name=_('User ID'), on_delete=models.CASCADE, + related_name="participations") + + participation = models.ForeignKey(Participation, + verbose_name=_('Participation'), on_delete=models.CASCADE) + + restrict_to_participation_role = models.ForeignKey(ParticipationRole, + verbose_name=_('Restrict to role'), on_delete=models.CASCADE, + blank=True, null=True) + + description = models.CharField(max_length=200, + verbose_name=_('Description')) + + creation_time = models.DateTimeField( + default=now, verbose_name=_('Creation time')) + last_use_time = models.DateTimeField( + default=now, verbose_name=_('Last use time')) + valid_until = models.DateTimeField( + default=None, verbose_name=_('Valid until')) + revocation_time = models.DateTimeField( + default=None, verbose_name=_('Revocation time')) + + token_hash = models.CharField(max_length=200, + help_text=_("A hash of the authentication token to be " + "used for direct git access."), + null=True, blank=True, unique=True, + verbose_name=_('Hash of git authentication token')) + +# }}} + + # {{{ instant flow request class InstantFlowRequest(models.Model): diff --git a/course/templates/course/course-base.html b/course/templates/course/course-base.html index 429ae881..d8fa4415 100644 --- a/course/templates/course/course-base.html +++ b/course/templates/course/course-base.html @@ -101,7 +101,7 @@ {% if pperm.preview_content or pperm.update_content %}
  • {% trans "Retrieve/preview new course revisions" %}
  • -
  • {% trans "Manage Git Authentication Token" %}
  • +
  • {% trans "Manage Git Authentication Token" %}
  • {% endif %} {% if pperm.edit_course %} diff --git a/course/templates/course/generic-course-form.html b/course/templates/course/generic-course-form.html index 18900bea..74ac9641 100644 --- a/course/templates/course/generic-course-form.html +++ b/course/templates/course/generic-course-form.html @@ -3,6 +3,10 @@ {% load crispy_forms_tags %} +{% block title %} + {{ form_description }} - {% trans "RELATE" %} +{% endblock %} + {% block content %} {% if form_description %}

    {{ form_description }}

    diff --git a/course/templates/course/manage-auth-tokens.html b/course/templates/course/manage-auth-tokens.html new file mode 100644 index 00000000..217e25af --- /dev/null +++ b/course/templates/course/manage-auth-tokens.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} +{% load i18n %} + +{% load crispy_forms_tags %} + +{% block title %} + {% trans "Manage Authentication Tokens" %}: - {% trans "RELATE" %} +{% endblock %} + +{% block content %} +

    {% trans "Manage Authentication Tokens" %}

    + + {{ new_token_message }} + + {% if not tokens %} + {% trans "(No tokens have been created yet.)" %} + {% else %} +
    + + + + + + + + + + + + {% for token in tokens %} + + + + + + + + + + {% endfor %} + +
    {% trans "Token ID" %}{% trans "Role Restriction" %}{% trans "Description" %}{% trans "Created" %}{% trans "Valid until" %}{% trans "Last used" %}{% trans "Actions" %}
    {{ token.id }}{{ token.restrict_to_participation_role }}}{{ token.description}}{{ token.creation_time }}{{ token.valid_until }}{{ token.last_used }} + +
    + {% endif %} + +
    + {% crispy form %} +
    +{% endblock %} diff --git a/relate/settings.py b/relate/settings.py index 46694888..58878adb 100644 --- a/relate/settings.py +++ b/relate/settings.py @@ -84,7 +84,8 @@ MIDDLEWARE = ( # {{{ django: auth AUTHENTICATION_BACKENDS = ( - "course.auth.TokenBackend", + "course.auth.EmailedTokenBackend", + "course.auth.APIBearerTokenBackend", "course.exam.ExamTicketBackend", "django.contrib.auth.backends.ModelBackend", ) diff --git a/relate/templates/generic-form.html b/relate/templates/generic-form.html index 00a2aa80..07a6f933 100644 --- a/relate/templates/generic-form.html +++ b/relate/templates/generic-form.html @@ -3,6 +3,10 @@ {% load crispy_forms_tags %} +{% block title %} + {{ form_description }} - {% trans "RELATE" %} +{% endblock %} + {% block content %} {% if form_description %}

    {{ form_description }}

    diff --git a/relate/urls.py b/relate/urls.py index 6e431c61..f706e15f 100644 --- a/relate/urls.py +++ b/relate/urls.py @@ -82,7 +82,7 @@ urlpatterns = [ name="relate-user_profile"), url(r"^profile/auth-token/$", course.auth.manage_authentication_token, - name="relate-manage_authentication_token"), + name="relate-manage_authentication_tokens"), url(r"^generate-ssh-key/$", course.views.generate_ssh_keypair, -- GitLab From 9383b8691ca5be06017c6ab7bc541c6d4a917a52 Mon Sep 17 00:00:00 2001 From: Andreas Kloeckner Date: Thu, 21 Sep 2017 15:36:02 -0500 Subject: [PATCH 3/4] Finish auth token management, implement flow session getter API call --- .../0011_remove_user_git_auth_token_hash.py | 19 ++ course/api.py | 58 +++++- course/auth.py | 196 ++++++++++++++---- course/constants.py | 5 + ...ring_manual_grading_permission_to_roles.py | 4 +- course/migrations/0105_authenticationtoken.py | 34 +++ .../0106_add_auth_tokens_permission.py | 34 +++ course/models.py | 39 +++- course/templates/course/course-base.html | 8 +- .../templates/course/manage-auth-tokens.html | 43 +++- relate/templates/user-profile-form.html | 1 + relate/urls.py | 14 +- 12 files changed, 390 insertions(+), 65 deletions(-) create mode 100644 accounts/migrations/0011_remove_user_git_auth_token_hash.py create mode 100644 course/migrations/0105_authenticationtoken.py create mode 100644 course/migrations/0106_add_auth_tokens_permission.py diff --git a/accounts/migrations/0011_remove_user_git_auth_token_hash.py b/accounts/migrations/0011_remove_user_git_auth_token_hash.py new file mode 100644 index 00000000..9b8b8747 --- /dev/null +++ b/accounts/migrations/0011_remove_user_git_auth_token_hash.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.5 on 2017-09-21 03:50 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0010_change_username_validator'), + ] + + operations = [ + migrations.RemoveField( + model_name='user', + name='git_auth_token_hash', + ), + ] diff --git a/course/api.py b/course/api.py index be760490..b325263c 100644 --- a/course/api.py +++ b/course/api.py @@ -24,7 +24,59 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -import re +from django import http +from django.core.exceptions import PermissionDenied, SuspiciousOperation -def get_submission_data(request, course_identifier): - pass +from course.auth import with_course_api_auth, APIError +from course.constants import ( + participation_permission as pperm, + ) +import json + +from course.models import FlowSession + + +@with_course_api_auth +def get_flow_sessions(api_ctx, course_identifier): + if not api_ctx.has_permission(pperm.view_gradebook): + raise PermissionDenied("token role does not have required permissions") + + try: + flow_id = api_ctx.request.GET["flow_id"] + except KeyError: + raise APIError("must specify flow_id GET parameter") + + sessions = FlowSession.objects.filter( + course=api_ctx.course, + flow_id=flow_id) + + result = [ + dict( + participation_username=( + sess.participation.user.username + if sess.participation is not None + else None), + participation_institutional_id=( + sess.participation.user.institutional_id + if sess.participation is not None + else None), + active_git_commit_sha=sess.active_git_commit_sha, + flow_id=sess.flow_id, + + start_time=sess.start_time, + completion_time=sess.completion_time, + last_activity_time=sess.last_activity(), + page_count=sess.page_count, + + in_progress=sess.in_progress, + access_rules_tag=sess.access_rules_tag, + expiration_mode=sess.expiration_mode, + points=sess.points, + max_points=sess.max_points, + result_comment=sess.result_comment, + ) + for sess in sessions] + + return http.JsonResponse(result, safe=False) + +# vim: foldmethod=marker diff --git a/course/auth.py b/course/auth.py index f7db9046..d1707d09 100644 --- a/course/auth.py +++ b/course/auth.py @@ -52,6 +52,8 @@ from django.views.decorators.cache import never_cache from django.views.decorators.csrf import csrf_protect from django import http # noqa +from bootstrap3_datetime.widgets import DateTimePicker + from djangosaml2.backends import Saml2Backend as Saml2BackendBase from course.constants import ( @@ -59,8 +61,9 @@ from course.constants import ( participation_status, participation_permission as pperm, ) -from course.models import Participation, AuthenticationToken # noqa +from course.models import Participation, ParticipationRole, AuthenticationToken # noqa from accounts.models import User +from course.utils import render_course_page, course_view from relate.utils import StyledForm, StyledModelForm, string_concat from django_select2.forms import ModelSelect2Widget @@ -1083,27 +1086,39 @@ def sign_out(request, redirect_field_name=REDIRECT_FIELD_NAME): # {{{ API auth -def find_matching_token(token_id=None, token_hash_str=None, now_datetime=None): +class APIError(Exception): + pass + + +def find_matching_token( + course_identifier=None, token_id=None, token_hash_str=None, + now_datetime=None): try: - token = AuthenticationToken().objects.get(id=token_id) + token = AuthenticationToken.objects.get( + id=token_id, + participation__course__identifier=course_identifier) except AuthenticationToken.DoesNotExist: return None from django.contrib.auth.hashers import check_password - if not check_password(token, token_hash_str): + if not check_password(token_hash_str, token.token_hash): return None if token.revocation_time is not None: return None - if now_datetime > token.valid_until: + if token.valid_until is not None and now_datetime > token.valid_until: return None return token class APIBearerTokenBackend(object): - def authenticate(self, token_id=None, token_hash_str=None, now_datetime=None): - token = find_matching_token(token_id, token_hash_str, now_datetime) + def authenticate(self, course_identifier=None, token_id=None, + token_hash_str=None, now_datetime=None): + token = find_matching_token(course_identifier, token_id, token_hash_str, + now_datetime) + if token is None: + return None token.last_use_time = now_datetime token.save() @@ -1120,36 +1135,79 @@ class APIBearerTokenBackend(object): AUTH_HEADER_RE = re.compile("^Token ([0-9]+)_([a-z0-9]+)$") +class APIContext(object): + def __init__(self, request, token): + self.request = request + + self.token = token + self.participation = token.participation + self.course = self.participation.course + + restrict_to_role = token.restrict_to_participation_role + if restrict_to_role is not None: + role_restriction_ok = False + + if restrict_to_role in token.participation.roles.all(): + role_restriction_ok = True + + if not role_restriction_ok and self.participation.has_permission( + pperm.impersonate_role, restrict_to_role.identifier): + role_restriction_ok = True + + if not role_restriction_ok: + raise PermissionDenied( + "API token specifies invalid role restriction") + + self.restrict_to_role = restrict_to_role + + def has_permission(self, perm, argument=None): + if self.restrict_to_role is None: + return self.participation.has_permission(perm, argument) + else: + return self.restrict_to_role.has_permission(perm, argument) + + def with_course_api_auth(f): def wrapper(request, course_identifier, *args, **kwargs): from django.utils.timezone import now now_datetime = now() - auth_header = request.META.get("AUTHORIZATION", None) + try: + auth_header = request.META.get("HTTP_AUTHORIZATION", None) + if auth_header is None: + raise PermissionDenied("No Authorization header provided") - match = AUTH_HEADER_RE.match(auth_header) - if match is None: - raise PermissionDenied("ill-formed Authorization header") + match = AUTH_HEADER_RE.match(auth_header) + if match is None: + raise PermissionDenied("ill-formed Authorization header") - auth_data = dict( - token_id=int(AUTH_HEADER_RE.group(1)), - token_hash_str=AUTH_HEADER_RE.group(2), - now_datetime=now_datetime) + auth_data = dict( + course_identifier=course_identifier, + token_id=int(match.group(1)), + token_hash_str=match.group(2), + now_datetime=now_datetime) - # FIXME: Redundant db roundtrip - token = find_matching_token(**auth_data) + # FIXME: Redundant db roundtrip + token = find_matching_token(**auth_data) + if token is None: + raise PermissionDenied("invalid authentication token") - from django.contrib.auth import authenticate, login - user = authenticate(**auth_data) + from django.contrib.auth import authenticate, login + user = authenticate(**auth_data) - assert user is not None + assert user is not None - login(request, user) + login(request, user) - response = f( - token.participation, - token.restrict_to_participation_role, - *args, **kwargs) + response = f( + APIContext(request, token), + course_identifier, *args, **kwargs) + except PermissionDenied as e: + return http.HttpResponseForbidden( + "403 Forbidden: " + str(e)) + except APIError as e: + return http.HttpResponseBadRequest( + "400 Bad Request: " + str(e)) return response @@ -1172,38 +1230,98 @@ class AuthenticationTokenForm(StyledModelForm): "valid_until", ) - def __init__(self, *args, **kwargs): + widgets = { + "valid_until": DateTimePicker(options={"format": "YYYY-MM-DD"}) + } + + def __init__(self, participation, *args, **kwargs): # type: (*Any, **Any) -> None super(AuthenticationTokenForm, self).__init__(*args, **kwargs) + self.participation = participation + + self.fields["restrict_to_participation_role"].queryset = ( + participation.roles.all() + | ParticipationRole.objects.filter( + id__in=[ + prole.id + for prole in ParticipationRole.objects.filter( + course=participation.course) + if participation.has_permission( + pperm.impersonate_role, prole.identifier) + ])) self.helper.add_input(Submit("create", _("Create"))) -def manage_authentication_tokens(request): +@course_view +def manage_authentication_tokens(pctx): # type: (http.HttpRequest) -> http.HttpResponse + + request = pctx.request + if not request.user.is_authenticated: raise PermissionDenied() + from course.views import get_now_or_fake_time + now_datetime = get_now_or_fake_time(request) + if request.method == 'POST': - form = AuthenticationTokenForm(request.POST) - if form.is_valid(): - token = make_sign_in_key(request.user) - from django.contrib.auth.hashers import make_password - request.user.git_auth_token_hash = make_password(token) - request.user.save() + form = AuthenticationTokenForm(pctx.participation, request.POST) + + revoke_prefix = "revoke_" + revoke_post_args = [key for key in request.POST if key.startswith("revoke_")] + + if revoke_post_args: + token_id = int(revoke_post_args[0][len(revoke_prefix):]) - messages.add_message(request, messages.SUCCESS, - _("A new authentication token has been set: %s.") - % token) + auth_token = get_object_or_404(AuthenticationToken, + id=token_id, + user=request.user) + + auth_token.revocation_time = now_datetime + auth_token.save() + + form = AuthenticationTokenForm(pctx.participation) + + elif "create" in request.POST: + if form.is_valid(): + token = make_sign_in_key(request.user) + + from django.contrib.auth.hashers import make_password + auth_token = AuthenticationToken( + user=pctx.request.user, + participation=pctx.participation, + restrict_to_participation_role=form.cleaned_data[ + "restrict_to_participation_role"], + description=form.cleaned_data["description"], + valid_until=form.cleaned_data["valid_until"], + token_hash=make_password(token)) + auth_token.save() + + user_token = "%d_%s" % (auth_token.id, token) + + messages.add_message(request, messages.SUCCESS, + _("A new authentication token has been created: %s. " + "Please save this token, as you will not be able " + "to retrieve it later.") + % user_token) + else: + messages.add_message(request, messages.ERROR, + _("Could not find which button was pressed.")) else: - form = AuthenticationTokenForm() + form = AuthenticationTokenForm(pctx.participation) + + from django.db.models import Q + from datetime import timedelta tokens = AuthenticationToken.objects.filter( user=request.user, - revocation_time=None) + ).filter( + Q(revocation_time=None) + | Q(revocation_time__gt=now_datetime - timedelta(weeks=1))) - return render(request, "course/manage-auth-tokens.html", { + return render_course_page(pctx, "course/manage-auth-tokens.html", { "form": form, "new_token_message": "", "tokens": tokens, diff --git a/course/constants.py b/course/constants.py index f418746b..e4207e42 100644 --- a/course/constants.py +++ b/course/constants.py @@ -78,6 +78,7 @@ PARTICIPATION_STATUS_CHOICES = ( class participation_permission: # noqa edit_course = "edit_course" use_admin_interface = "use_admin_interface" + manage_authentication_tokens = "manage_authentication_tokens" impersonate_role = "impersonate_role" set_fake_time = "set_fake_time" @@ -138,6 +139,10 @@ PARTICIPATION_PERMISSION_CHOICES = ( pgettext_lazy("Participation permission", "Edit course")), (participation_permission.use_admin_interface, pgettext_lazy("Participation permission", "Use admin interface")), + (participation_permission.manage_authentication_tokens, + pgettext_lazy("Participation permission", + "Manage authentication tokens")), + (participation_permission.impersonate_role, pgettext_lazy("Participation permission", "Impersonate role")), (participation_permission.set_fake_time, diff --git a/course/migrations/0104_add_skip_during_manual_grading_permission_to_roles.py b/course/migrations/0104_add_skip_during_manual_grading_permission_to_roles.py index 79009ad1..b46f83c7 100644 --- a/course/migrations/0104_add_skip_during_manual_grading_permission_to_roles.py +++ b/course/migrations/0104_add_skip_during_manual_grading_permission_to_roles.py @@ -21,7 +21,7 @@ def remove_mistakenly_added_individual_pperm(apps, schema_editor): permission=pperm.skip_during_manual_grading).delete() -def add_skip_during_manul_grading_permission_to_roles(apps, schema_editor): +def add_skip_during_manual_grading_permission_to_roles(apps, schema_editor): from course.constants import participation_permission as pperm ParticipationRolePermission = apps.get_model("course", "ParticipationRolePermission") # noqa @@ -48,5 +48,5 @@ class Migration(migrations.Migration): operations = [ migrations.RunPython(remove_mistakenly_added_individual_pperm), - migrations.RunPython(add_skip_during_manul_grading_permission_to_roles) + migrations.RunPython(add_skip_during_manual_grading_permission_to_roles) ] diff --git a/course/migrations/0105_authenticationtoken.py b/course/migrations/0105_authenticationtoken.py new file mode 100644 index 00000000..e1dfe8c5 --- /dev/null +++ b/course/migrations/0105_authenticationtoken.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.5 on 2017-09-21 03:50 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('course', '0104_add_skip_during_manual_grading_permission_to_roles'), + ] + + operations = [ + migrations.CreateModel( + name='AuthenticationToken', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', models.CharField(max_length=200, verbose_name='Description')), + ('creation_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Creation time')), + ('last_use_time', models.DateTimeField(blank=True, null=True, verbose_name='Last use time')), + ('valid_until', models.DateTimeField(blank=True, default=None, null=True, verbose_name='Valid until')), + ('revocation_time', models.DateTimeField(blank=True, default=None, null=True, verbose_name='Revocation time')), + ('token_hash', models.CharField(blank=True, help_text='A hash of the authentication token to be used for direct git access.', max_length=200, null=True, unique=True, verbose_name='Hash of git authentication token')), + ('participation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course.Participation', verbose_name='Participation')), + ('restrict_to_participation_role', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='course.ParticipationRole', verbose_name='Restrict to role')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User ID')), + ], + ), + ] diff --git a/course/migrations/0106_add_auth_tokens_permission.py b/course/migrations/0106_add_auth_tokens_permission.py new file mode 100644 index 00000000..6fec9f79 --- /dev/null +++ b/course/migrations/0106_add_auth_tokens_permission.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals + +from django.db import migrations + +def add_manage_auth_tokens_permission(apps, schema_editor): + from course.constants import participation_permission as pperm + + ParticipationRolePermission = apps.get_model("course", "ParticipationRolePermission") # noqa + + roles_pks = ( + ParticipationRolePermission.objects.filter( + permission=pperm.edit_course) + .values_list("role", flat=True) + ) + + if roles_pks.count(): + for pk in roles_pks: + ParticipationRolePermission.objects.get_or_create( + role_id=pk, + permission=pperm.manage_authentication_tokens + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('course', '0105_authenticationtoken'), + ] + + operations = [ + migrations.RunPython(add_manage_auth_tokens_permission) + ] diff --git a/course/models.py b/course/models.py index cad279c0..d170d3a7 100644 --- a/course/models.py +++ b/course/models.py @@ -369,6 +369,32 @@ class ParticipationRole(models.Model): "identifier": self.identifier, "course": self.course} + # {{{ permissions handling + + _permissions_cache = None # type: FrozenSet[Tuple[Text, Optional[Text]]] + + def permission_tuples(self): + # type: () -> FrozenSet[Tuple[Text, Optional[Text]]] + + if self._permissions_cache is not None: + return self._permissions_cache + + perm = list( + ParticipationRolePermission.objects.filter(role=self) + .values_list("permission", "argument")) + + fset_perm = frozenset( + (permission, argument) if argument else (permission, None) + for permission, argument in perm) + + self._permissions_cache = fset_perm + return fset_perm + + def has_permission(self, perm, argument=None): + # type: (Text, Optional[Text]) -> bool + return (perm, argument) in self.permission_tuples() + + # }}} if six.PY3: __str__ = __unicode__ @@ -619,6 +645,7 @@ def add_default_roles_and_permissions(course, argument="ta").save() rpm(role=role, permission=pp.edit_course_permissions).save() rpm(role=role, permission=pp.edit_course).save() + rpm(role=role, permission=pp.manage_authentication_tokens).save() rpm(role=role, permission=pp.access_files_for, argument="instructor").save() @@ -684,8 +711,7 @@ def _set_up_course_permissions(sender, instance, created, raw, using, update_fie class AuthenticationToken(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, - verbose_name=_('User ID'), on_delete=models.CASCADE, - related_name="participations") + verbose_name=_('User ID'), on_delete=models.CASCADE) participation = models.ForeignKey(Participation, verbose_name=_('Participation'), on_delete=models.CASCADE) @@ -700,11 +726,14 @@ class AuthenticationToken(models.Model): creation_time = models.DateTimeField( default=now, verbose_name=_('Creation time')) last_use_time = models.DateTimeField( - default=now, verbose_name=_('Last use time')) + verbose_name=_('Last use time'), + blank=True, null=True) valid_until = models.DateTimeField( - default=None, verbose_name=_('Valid until')) + default=None, verbose_name=_('Valid until'), + blank=True, null=True) revocation_time = models.DateTimeField( - default=None, verbose_name=_('Revocation time')) + default=None, verbose_name=_('Revocation time'), + blank=True, null=True) token_hash = models.CharField(max_length=200, help_text=_("A hash of the authentication token to be " diff --git a/course/templates/course/course-base.html b/course/templates/course/course-base.html index d8fa4415..67b27101 100644 --- a/course/templates/course/course-base.html +++ b/course/templates/course/course-base.html @@ -28,7 +28,7 @@ {% if not relate_exam_lockdown and pperm.view_calendar %} {% endif %} @@ -101,7 +105,6 @@ {% if pperm.preview_content or pperm.update_content %}
  • {% trans "Retrieve/preview new course revisions" %}
  • -
  • {% trans "Manage Git Authentication Token" %}
  • {% endif %} {% if pperm.edit_course %} @@ -154,6 +157,7 @@
  • {% trans "Manage instant flow requests" %}
  • {% endif %} + {% endif %} diff --git a/course/templates/course/manage-auth-tokens.html b/course/templates/course/manage-auth-tokens.html index 217e25af..3f0e5433 100644 --- a/course/templates/course/manage-auth-tokens.html +++ b/course/templates/course/manage-auth-tokens.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "course/course-base.html" %} {% load i18n %} {% load crispy_forms_tags %} @@ -13,7 +13,9 @@ {{ new_token_message }} {% if not tokens %} - {% trans "(No tokens have been created yet.)" %} +
    + {% trans "(No tokens have been created yet.)" %} +
    {% else %} @@ -29,16 +31,39 @@ {% for token in tokens %} - - - + + + - + {% if token.revocation_time != None %} + + {% elif token.valid_until == None %} + + {% else %} + + {% endif %} {% endfor %} diff --git a/relate/templates/user-profile-form.html b/relate/templates/user-profile-form.html index f553aec7..61f777a0 100644 --- a/relate/templates/user-profile-form.html +++ b/relate/templates/user-profile-form.html @@ -10,6 +10,7 @@ diff --git a/relate/urls.py b/relate/urls.py index f706e15f..6d77a890 100644 --- a/relate/urls.py +++ b/relate/urls.py @@ -40,6 +40,7 @@ import course.versioning import course.flow import course.analytics import course.exam +import course.api urlpatterns = [ url(r"^login/$", @@ -80,8 +81,11 @@ urlpatterns = [ url(r"^profile/$", course.auth.user_profile, name="relate-user_profile"), - url(r"^profile/auth-token/$", - course.auth.manage_authentication_token, + url( + r"^course" + "/" + COURSE_ID_REGEX + + "/auth-tokens/$", + course.auth.manage_authentication_tokens, name="relate-manage_authentication_tokens"), url(r"^generate-ssh-key/$", @@ -534,9 +538,9 @@ urlpatterns = [ url(r"^course" "/" + COURSE_ID_REGEX + - "/api/get-submissions$", - course.api.get_submission_data, - name="relate-course_get_submissions"), + "/api/v1/get-flow-sessions$", + course.api.get_flow_sessions, + name="relate-course_get_flow_session"), url(r'^admin/', admin.site.urls), ] -- GitLab From d4265a5504ba00707fab2b2e918bafa02a431558 Mon Sep 17 00:00:00 2001 From: Andreas Kloeckner Date: Thu, 21 Sep 2017 15:39:42 -0500 Subject: [PATCH 4/4] Address mypy and flake8 complaints --- course/api.py | 3 +-- course/auth.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/course/api.py b/course/api.py index b325263c..5ba27e71 100644 --- a/course/api.py +++ b/course/api.py @@ -25,13 +25,12 @@ THE SOFTWARE. """ from django import http -from django.core.exceptions import PermissionDenied, SuspiciousOperation +from django.core.exceptions import PermissionDenied from course.auth import with_course_api_auth, APIError from course.constants import ( participation_permission as pperm, ) -import json from course.models import FlowSession diff --git a/course/auth.py b/course/auth.py index d1707d09..1508d094 100644 --- a/course/auth.py +++ b/course/auth.py @@ -1235,7 +1235,7 @@ class AuthenticationTokenForm(StyledModelForm): } def __init__(self, participation, *args, **kwargs): - # type: (*Any, **Any) -> None + # type: (Participation, *Any, **Any) -> None super(AuthenticationTokenForm, self).__init__(*args, **kwargs) self.participation = participation -- GitLab
    {{ token.id }}{{ token.restrict_to_participation_role }}}{{ token.description}} + {% if token.revocation_time %} + {{ token.id }} + {% else %} + {{ token.id }} + {% endif %} + {{ token.restrict_to_participation_role }} + {% if token.revocation_time %} + {{ token.description }} + {% else %} + {{ token.description }} + {% endif %} + {{ token.creation_time }}{{ token.valid_until }}{% trans "Revoked" %} {{ token.revocation_time }}{% trans "Indefinitely" %}{{ token.valid_until }}{{ token.last_used }} - + {% if token.revocation_time == None %} + + {% csrf_token %} + + + {% endif %}