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 0000000000000000000000000000000000000000..9b8b8747624d8a259382c26b11f806d723710a1c --- /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/accounts/models.py b/accounts/models.py index e0d8b6706c6f26cc8e392b91727cd7fc4c7da9f5..ff27880def59ad144946447838922c00808bd086 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 new file mode 100644 index 0000000000000000000000000000000000000000..5ba27e7160049ec2bceef746c1210e30cdb5c22f --- /dev/null +++ b/course/api.py @@ -0,0 +1,81 @@ +# -*- 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. +""" + +from django import http +from django.core.exceptions import PermissionDenied + +from course.auth import with_course_api_auth, APIError +from course.constants import ( + participation_permission as pperm, + ) + +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 fadb73c3c00caa119366c6b1353fefe27be8eb4e..1508d094adc5fbb2d517288a8d270bba1dbf30b0 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 @@ -51,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 ( @@ -58,8 +61,9 @@ from course.constants import ( participation_status, participation_permission as pperm, ) -from course.models import Participation # 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 @@ -320,7 +324,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 +1016,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 +1083,250 @@ def sign_out(request, redirect_field_name=REDIRECT_FIELD_NAME): # }}} + +# {{{ API auth + +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, + participation__course__identifier=course_identifier) + except AuthenticationToken.DoesNotExist: + return None + + from django.contrib.auth.hashers import check_password + if not check_password(token_hash_str, token.token_hash): + return None + + if token.revocation_time is not None: + return None + if token.valid_until is not None and now_datetime > token.valid_until: + return None + + return token + + +class APIBearerTokenBackend(object): + 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() + + 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]+)$") + + +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() + + 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") + + 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) + if token is None: + raise PermissionDenied("invalid authentication token") + + from django.contrib.auth import authenticate, login + user = authenticate(**auth_data) + + assert user is not None + + login(request, user) + + 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 + + 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", + ) + + widgets = { + "valid_until": DateTimePicker(options={"format": "YYYY-MM-DD"}) + } + + def __init__(self, participation, *args, **kwargs): + # type: (Participation, *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"))) + + +@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(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):]) + + 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(pctx.participation) + + from django.db.models import Q + + from datetime import timedelta + tokens = AuthenticationToken.objects.filter( + user=request.user, + ).filter( + Q(revocation_time=None) + | Q(revocation_time__gt=now_datetime - timedelta(weeks=1))) + + return render_course_page(pctx, "course/manage-auth-tokens.html", { + "form": form, + "new_token_message": "", + "tokens": tokens, + }) + +# }}} + # vim: foldmethod=marker diff --git a/course/constants.py b/course/constants.py index f418746b796327abd60311e4fbd1040f3fcd62bf..e4207e42acdc1dd8380605127bff330eb4e10194 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 79009ad18195d110627e40eb4cfc6b3235a8ca62..b46f83c7e1c6cc687e936d8c04917e4f9e83e168 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 0000000000000000000000000000000000000000..e1dfe8c55312b98243359047004d03ae289e39fd --- /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 0000000000000000000000000000000000000000..6fec9f79771a9b86f28900f6a4186a17717bf200 --- /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 84e18d2f591d692575a659d35ae6a792c2c8123f..d170d3a76debd8885393a52b829b5347f8f07339 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() @@ -680,6 +707,43 @@ 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) + + 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( + verbose_name=_('Last use time'), + blank=True, null=True) + valid_until = models.DateTimeField( + default=None, verbose_name=_('Valid until'), + blank=True, null=True) + revocation_time = models.DateTimeField( + 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 " + "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 429ae881f7fe620a9f177cdf0e9123715d205cfa..67b271016f551554e13dfdf339d539225cd0e45b 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 %}