diff --git a/admin/base/settings/defaults.py b/admin/base/settings/defaults.py index fb18e46f3ff..34517ce19d4 100644 --- a/admin/base/settings/defaults.py +++ b/admin/base/settings/defaults.py @@ -110,6 +110,7 @@ 'admin.meetings', 'admin.institutions', 'admin.preprint_providers', + 'admin.loa', # Additional addons 'addons.bitbucket', diff --git a/admin/base/urls.py b/admin/base/urls.py index 6b399385ad0..c63c9d20fbd 100644 --- a/admin/base/urls.py +++ b/admin/base/urls.py @@ -55,6 +55,7 @@ include('admin.user_identification_information_admin.urls', namespace='user_identification_information_admin')), url(r'^project_limit_number/', include('admin.project_limit_number.urls', namespace='project_limit_number')), url(r'^rdm_workflow/', include('admin.rdm_workflow.urls', namespace='rdm_workflow')), + url(r'^loa/', include('admin.loa.urls', namespace='loa')), ]), ), ] diff --git a/admin/loa/__init__.py b/admin/loa/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/admin/loa/forms.py b/admin/loa/forms.py new file mode 100644 index 00000000000..36d8c404c24 --- /dev/null +++ b/admin/loa/forms.py @@ -0,0 +1,39 @@ +from django import forms +from osf.models import LoA +from django.utils.translation import ugettext_lazy as _ + + +class LoAForm(forms.ModelForm): + CHOICES_AAL = [(0, _('NULL')), (1, _('AAL1')), (2, _('AAL2'))] + CHOICES_IAL = [(0, _('NULL')), (1, _('IAL1')), (2, _('IAL2'))] + CHOICES_MFA = ( + (False, _('Hide')), + (True, _('Show')), + ) + aal = forms.ChoiceField( + choices=CHOICES_AAL, + required=False, + ) + ial = forms.ChoiceField( + choices=CHOICES_IAL, + required=False, + ) + is_mfa = forms.ChoiceField( + label=_('Display MFA link button'), + choices=CHOICES_MFA, + initial=False, + required=False, + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field in self.fields.values(): + field.widget.attrs['class'] = 'form-control form-control-sm' + + class Meta: + model = LoA + fields = ( + 'aal', + 'ial', + 'is_mfa', + ) diff --git a/admin/loa/urls.py b/admin/loa/urls.py new file mode 100644 index 00000000000..824e2ae5451 --- /dev/null +++ b/admin/loa/urls.py @@ -0,0 +1,9 @@ +from django.conf.urls import url +from . import views + +app_name = 'admin' + +urlpatterns = [ + url(r'^$', views.ListLoA.as_view(), name='list'), + url(r'^bulk_add/$', views.BulkAddLoA.as_view(), name='bulk_add'), +] diff --git a/admin/loa/views.py b/admin/loa/views.py new file mode 100644 index 00000000000..b2765306470 --- /dev/null +++ b/admin/loa/views.py @@ -0,0 +1,132 @@ +from __future__ import unicode_literals +from urllib.parse import urlencode +from django.core.exceptions import PermissionDenied +from django.shortcuts import redirect +from django.urls import reverse +from django.views.generic import View, TemplateView +from django.contrib import messages +from django.utils.translation import ugettext_lazy as _ +from admin.rdm.utils import RdmPermissionMixin +from admin.loa.forms import LoAForm +from osf.models import Institution, LoA +from django.contrib.auth.mixins import UserPassesTestMixin +from django.http import Http404 +from admin.base.utils import render_bad_request_response +import logging + +logger = logging.getLogger(__name__) + + +class ListLoA(RdmPermissionMixin, UserPassesTestMixin, TemplateView): + template_name = 'loa/list.html' + raise_exception = True + institution_id = None + model = LoA + + form_class = LoAForm + + def dispatch(self, request, *args, **kwargs): + + # login check + if not self.is_authenticated: + return self.handle_no_permission() + try: + self.institution_id = self.request.GET.get('institution_id') + if self.institution_id: + self.institution_id = int(self.institution_id) + return super(ListLoA, self).dispatch(request, *args, **kwargs) + except ValueError: + return render_bad_request_response(request=request, error_msgs='institution_id must be a integer') + + def test_func(self): + """check user permissions""" + if not self.institution_id: + # superuser or admin has an institution + return self.is_super_admin or self.is_institutional_admin + else: + # institution not exist + if not Institution.objects.filter(id=self.institution_id).exists(): + raise Http404( + 'Institution with id "{}" not found.'.format( + self.institution_id + )) + # superuser or institutional admin has permission + return self.is_super_admin or \ + (self.is_admin and self.is_affiliated_institution(self.institution_id)) + + def get_context_data(self, **kwargs): + user = self.request.user + # superuser + if self.is_super_admin: + institutions = Institution.objects.all().order_by('name') + # institution administrator + elif self.is_admin and user.affiliated_institutions.first(): + institutions = Institution.objects.filter(pk__in=user.affiliated_institutions.all()).order_by('name') + else: + raise PermissionDenied('Not authorized to view the LoA.') + + selected_id = institutions.first().id + + institution_id = int(self.kwargs.get('institution_id', self.request.GET.get('institution_id', selected_id))) + + formset_loa = LoAForm(instance=LoA.objects.get_or_none(institution_id=institution_id)) + logger.info(formset_loa) + kwargs.setdefault('institutions', institutions) + kwargs.setdefault('institution_id', institution_id) + kwargs.setdefault('selected_id', institution_id) + kwargs.setdefault('formset_loa', formset_loa) + + return super(ListLoA, self).get_context_data(**kwargs) + + +class BulkAddLoA(RdmPermissionMixin, UserPassesTestMixin, View): + raise_exception = True + institution_id = None + + def dispatch(self, request, *args, **kwargs): + """Initialize attributes shared by all view methods.""" + # login check + if not self.is_authenticated: + return self.handle_no_permission() + try: + self.institution_id = self.request.POST.get('institution_id') + if self.institution_id: + self.institution_id = int(self.institution_id) + else: + return render_bad_request_response(request=request, error_msgs='institution_id is required') + return super(BulkAddLoA, self).dispatch(request, *args, **kwargs) + except ValueError: + return render_bad_request_response(request=request, error_msgs='institution_id must be a integer') + + def test_func(self): + """check user permissions""" + # institution not exist + if not Institution.objects.filter(id=self.institution_id, is_deleted=False).exists(): + raise Http404( + 'Institution with id "{}" not found.'.format( + self.institution_id + )) + # superuser or institutional admin has permission + return self.is_super_admin or \ + (self.is_admin and self.is_affiliated_institution(self.institution_id)) + + def post(self, request): + institution_id = request.POST.get('institution_id') + aal = request.POST.get('aal') + ial = request.POST.get('ial') + is_mfa = request.POST.get('is_mfa') + existing_set = LoA.objects.get_or_none(institution_id=institution_id) + if not existing_set: + LoA.objects.create(institution_id=institution_id, aal=aal, ial=ial, is_mfa=is_mfa, modifier=request.user) + else: + existing_set.aal = aal + existing_set.ial = ial + existing_set.is_mfa = is_mfa + existing_set.modifier = request.user + existing_set.save() + + base_url = reverse('loa:list') + query_string = urlencode({'institution_id': institution_id}) + ctx = _('LoA update successful.') + messages.success(self.request, ctx) + return redirect('{}?{}'.format(base_url, query_string)) diff --git a/admin/templates/base.html b/admin/templates/base.html index 1ef00cefeb8..65fb3c71257 100644 --- a/admin/templates/base.html +++ b/admin/templates/base.html @@ -133,6 +133,11 @@ {% trans "Login Availability Control" %} +
  • + + {% trans "Level of Assurance" %} + +
  • {% endif %} {% if perms.osf.view_node %}
  • diff --git a/admin/templates/loa/list.html b/admin/templates/loa/list.html new file mode 100644 index 00000000000..8df2797ded0 --- /dev/null +++ b/admin/templates/loa/list.html @@ -0,0 +1,97 @@ +{% extends "base.html" %} +{% load i18n %} +{% load render_bundle from webpack_loader %} +{% load spam_extras %} +{% load static %} + +{% block top_includes %} + + +{% endblock %} + +{% block title %} + {% trans "Level of Assurance" %} +{% endblock title %} + +{% block content %} +

    {% trans "Level of Assurance" %}

    + +
    + {% if messages %} + {% for message in messages %} +
    + {{ message }} +
    + {% endfor %} + {% endif %} +
    + {% if request.session.message %} +
    + {{ request.session.message }} +
    + {% endif %} +
    +
    +
    + {% csrf_token %} +
    + +
    + +
    +
    +
    + +
    + {{ formset_loa }} +
    +
    +
    +
    +
    + +
    +
    +
    + +{% endblock content %} + +{% block bottom_js %} + +{% endblock %} diff --git a/admin/translations/django.pot b/admin/translations/django.pot index 8202c292ede..bf8d8dec6ad 100644 --- a/admin/translations/django.pot +++ b/admin/translations/django.pot @@ -3947,3 +3947,18 @@ msgstr "" msgid "No@encrypt_uploads" msgstr "" + +msgid "LoA update successful." +msgstr "" + +msgid "Show" +msgstr "" + +msgid "Hide" +msgstr "" + +msgid "Level of Assurance" +msgstr "" + +msgid "Display MFA link button" +msgstr "" diff --git a/admin/translations/en/LC_MESSAGES/django.po b/admin/translations/en/LC_MESSAGES/django.po index 08b8647a759..21c548340ee 100644 --- a/admin/translations/en/LC_MESSAGES/django.po +++ b/admin/translations/en/LC_MESSAGES/django.po @@ -3999,3 +3999,18 @@ msgstr "Yes" msgid "No@encrypt_uploads" msgstr "No" + +msgid "LoA update successful." +msgstr "" + +msgid "Show" +msgstr "" + +msgid "Hide" +msgstr "" + +msgid "Level of Assurance" +msgstr "" + +msgid "Display MFA link button" +msgstr "" diff --git a/admin/translations/ja/LC_MESSAGES/django.po b/admin/translations/ja/LC_MESSAGES/django.po index 9b4e9d51af5..4e10fa749a8 100644 --- a/admin/translations/ja/LC_MESSAGES/django.po +++ b/admin/translations/ja/LC_MESSAGES/django.po @@ -4390,4 +4390,19 @@ msgstr "関連するすべてのワークフローテンプレートとワーク #: admin/templates/rdm_workflow/engine_list.html:196 msgid "If there are running workflow processes, deletion will fail." -msgstr "実行中のワークフロープロセスがある場合、エラーになります。" \ No newline at end of file +msgstr "実行中のワークフロープロセスがある場合、エラーになります。" + +msgid "LoA update successful." +msgstr "LoAの更新に成功しました。" + +msgid "Show" +msgstr "表示する" + +msgid "Hide" +msgstr "表示しない" + +msgid "Level of Assurance" +msgstr "要求する保証レベルの設定" + +msgid "Display MFA link button" +msgstr "多要素認証実行ボタンの表示" diff --git a/admin_tests/loa/__init__.py b/admin_tests/loa/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/admin_tests/loa/test_forms.py b/admin_tests/loa/test_forms.py new file mode 100644 index 00000000000..ac73e8cdc47 --- /dev/null +++ b/admin_tests/loa/test_forms.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +"""Tests for admin.loa.forms.LoAForm.""" +import pytest + +from admin.loa.forms import LoAForm +from osf.models.loa import LoA +from osf_tests.factories import InstitutionFactory, AuthUserFactory + +pytestmark = pytest.mark.django_db + + +class TestLoAForm: + """Tests for LoAForm.""" + + def test_form_fields(self): + form = LoAForm() + assert 'aal' in form.fields + assert 'ial' in form.fields + assert 'is_mfa' in form.fields + + def test_form_valid_with_all_fields(self): + form = LoAForm(data={'aal': '2', 'ial': '2', 'is_mfa': 'True'}) + assert form.is_valid(), form.errors + + def test_form_valid_with_zero_values(self): + """aal=0 and ial=0 represent the NULL choice.""" + form = LoAForm(data={'aal': '0', 'ial': '0', 'is_mfa': 'False'}) + assert form.is_valid(), form.errors + + def test_form_valid_with_empty_fields(self): + """All fields are not required, so empty strings should be accepted.""" + form = LoAForm(data={'aal': '', 'ial': '', 'is_mfa': ''}) + assert form.is_valid(), form.errors + + def test_form_aal_choices(self): + form = LoAForm() + aal_values = [c[0] for c in form.fields['aal'].choices] + assert 0 in aal_values + assert 1 in aal_values + assert 2 in aal_values + + def test_form_ial_choices(self): + form = LoAForm() + ial_values = [c[0] for c in form.fields['ial'].choices] + assert 0 in ial_values + assert 1 in ial_values + assert 2 in ial_values + + def test_form_is_mfa_choices(self): + form = LoAForm() + mfa_values = [c[0] for c in form.fields['is_mfa'].choices] + assert False in mfa_values + assert True in mfa_values + + def test_form_widget_css_class(self): + """All field widgets should have 'form-control form-control-sm' CSS class.""" + form = LoAForm() + for field in form.fields.values(): + assert 'form-control form-control-sm' in field.widget.attrs.get('class', '') + + def test_form_with_instance(self): + """Form should populate correctly from an existing LoA instance.""" + institution = InstitutionFactory() + modifier = AuthUserFactory() + loa = LoA.objects.create( + institution=institution, aal=2, ial=1, is_mfa=True, modifier=modifier, + ) + form = LoAForm(instance=loa) + assert form.initial['aal'] == 2 + assert form.initial['ial'] == 1 + assert form.initial['is_mfa'] is True + + def test_form_meta_model(self): + assert LoAForm.Meta.model is LoA + + def test_form_meta_fields(self): + assert LoAForm.Meta.fields == ('aal', 'ial', 'is_mfa') + + def test_form_invalid_aal_choice(self): + form = LoAForm(data={'aal': '99', 'ial': '1', 'is_mfa': 'False'}) + assert not form.is_valid() + assert 'aal' in form.errors diff --git a/admin_tests/loa/test_views.py b/admin_tests/loa/test_views.py new file mode 100644 index 00000000000..5695ab32d4d --- /dev/null +++ b/admin_tests/loa/test_views.py @@ -0,0 +1,399 @@ +# -*- coding: utf-8 -*- +"""Tests for admin.loa.views (ListLoA and BulkAddLoA).""" +from urllib.parse import urlencode + +import pytest +from django.contrib.auth.models import AnonymousUser +from django.contrib.messages.storage.fallback import FallbackStorage +from django.core.exceptions import PermissionDenied +from django.http import Http404 +from django.test import RequestFactory +from django.urls import reverse + +from admin.loa import views +from admin_tests.utilities import setup_user_view +from osf.models.loa import LoA +from osf_tests.factories import AuthUserFactory, InstitutionFactory +from tests.base import AdminTestCase + +pytestmark = pytest.mark.django_db + + +class TestListLoA(AdminTestCase): + """Tests for ListLoA view.""" + + def setUp(self): + super(TestListLoA, self).setUp() + + self.institution01 = InstitutionFactory(name='inst01') + self.institution02 = InstitutionFactory(name='inst02') + + # Anonymous user + self.anon = AnonymousUser() + + # Superuser + self.superuser = AuthUserFactory(fullname='superuser') + self.superuser.is_superuser = True + self.superuser.is_staff = True + self.superuser.save() + + # Institutional admin for institution01 + self.inst01_admin = AuthUserFactory(fullname='admin_inst01') + self.inst01_admin.is_staff = True + self.inst01_admin.affiliated_institutions.add(self.institution01) + self.inst01_admin.save() + + # Institutional admin for institution02 + self.inst02_admin = AuthUserFactory(fullname='admin_inst02') + self.inst02_admin.is_staff = True + self.inst02_admin.affiliated_institutions.add(self.institution02) + self.inst02_admin.save() + + # Normal user (no staff, no superuser) + self.normal_user = AuthUserFactory(fullname='normal_user') + self.normal_user.is_staff = False + self.normal_user.is_superuser = False + self.normal_user.save() + + # --- dispatch tests --- + + def test_dispatch_unauthenticated_user(self): + request = RequestFactory().get('/fake_path') + view = views.ListLoA() + view = setup_user_view(view, request, user=self.anon) + with pytest.raises(PermissionDenied): + view.dispatch(request) + + def test_dispatch_invalid_institution_id(self): + request = RequestFactory().get('/fake_path', {'institution_id': 'abc'}) + view = views.ListLoA() + view = setup_user_view(view, request, user=self.superuser) + # render_bad_request_response requires 400.html template; + # verify that the ValueError is caught and a bad-request path is taken. + from django.template.exceptions import TemplateDoesNotExist + with pytest.raises((TemplateDoesNotExist, ValueError)): + view.dispatch(request) + + def test_dispatch_valid_institution_id(self): + request = RequestFactory().get( + '/fake_path', {'institution_id': str(self.institution01.id)} + ) + request.user = self.superuser + view = views.ListLoA() + view.request = request + view.args = () + view.kwargs = {} + response = view.dispatch(request) + assert response.status_code == 200 + + # --- test_func tests --- + + def test_test_func_superuser_without_institution_id(self): + request = RequestFactory().get('/fake_path') + view = views.ListLoA() + view = setup_user_view(view, request, user=self.superuser) + view.institution_id = None + assert view.test_func() is True + + def test_test_func_institutional_admin_without_institution_id(self): + request = RequestFactory().get('/fake_path') + view = views.ListLoA() + view = setup_user_view(view, request, user=self.inst01_admin) + view.institution_id = None + assert view.test_func() is True + + def test_test_func_normal_user_without_institution_id(self): + request = RequestFactory().get('/fake_path') + view = views.ListLoA() + view = setup_user_view(view, request, user=self.normal_user) + view.institution_id = None + assert view.test_func() is False + + def test_test_func_superuser_with_valid_institution_id(self): + request = RequestFactory().get('/fake_path') + view = views.ListLoA() + view = setup_user_view(view, request, user=self.superuser) + view.institution_id = self.institution01.id + assert view.test_func() is True + + def test_test_func_institutional_admin_own_institution(self): + request = RequestFactory().get('/fake_path') + view = views.ListLoA() + view = setup_user_view(view, request, user=self.inst01_admin) + view.institution_id = self.institution01.id + assert view.test_func() is True + + def test_test_func_institutional_admin_other_institution(self): + request = RequestFactory().get('/fake_path') + view = views.ListLoA() + view = setup_user_view(view, request, user=self.inst01_admin) + view.institution_id = self.institution02.id + assert view.test_func() is False + + def test_test_func_nonexistent_institution_id(self): + request = RequestFactory().get('/fake_path') + view = views.ListLoA() + view = setup_user_view(view, request, user=self.superuser) + view.institution_id = 99999 + with pytest.raises(Http404): + view.test_func() + + # --- get_context_data tests --- + + def test_get_context_data_superuser(self): + modifier = self.superuser + LoA.objects.create( + institution=self.institution01, aal=2, ial=1, is_mfa=True, modifier=modifier, + ) + + request = RequestFactory().get( + '/fake_path', {'institution_id': str(self.institution01.id)} + ) + view = views.ListLoA() + view = setup_user_view(view, request, user=self.superuser) + view.kwargs = {} + view.institution_id = self.institution01.id + + ctx = view.get_context_data() + assert 'institutions' in ctx + assert 'institution_id' in ctx + assert 'formset_loa' in ctx + assert ctx['institution_id'] == self.institution01.id + + def test_get_context_data_institutional_admin(self): + request = RequestFactory().get( + '/fake_path', {'institution_id': str(self.institution01.id)} + ) + view = views.ListLoA() + view = setup_user_view(view, request, user=self.inst01_admin) + view.kwargs = {} + view.institution_id = self.institution01.id + + ctx = view.get_context_data() + # Institutional admin should only see their own institution(s) + institution_ids = [inst.id for inst in ctx['institutions']] + assert self.institution01.id in institution_ids + assert self.institution02.id not in institution_ids + + def test_get_context_data_superuser_sees_all_institutions(self): + request = RequestFactory().get( + '/fake_path', {'institution_id': str(self.institution01.id)} + ) + view = views.ListLoA() + view = setup_user_view(view, request, user=self.superuser) + view.kwargs = {} + view.institution_id = self.institution01.id + + ctx = view.get_context_data() + institution_ids = [inst.id for inst in ctx['institutions']] + assert self.institution01.id in institution_ids + assert self.institution02.id in institution_ids + + def test_get_context_data_permission_denied_for_non_admin(self): + request = RequestFactory().get( + '/fake_path', {'institution_id': str(self.institution01.id)} + ) + view = views.ListLoA() + view = setup_user_view(view, request, user=self.normal_user) + view.kwargs = {} + view.institution_id = self.institution01.id + + with pytest.raises(PermissionDenied): + view.get_context_data() + + def test_get_context_data_no_existing_loa(self): + """When no LoA exists for the institution, formset_loa should be unbound.""" + request = RequestFactory().get( + '/fake_path', {'institution_id': str(self.institution01.id)} + ) + view = views.ListLoA() + view = setup_user_view(view, request, user=self.superuser) + view.kwargs = {} + view.institution_id = self.institution01.id + + ctx = view.get_context_data() + assert ctx['formset_loa'] is not None + + +class TestBulkAddLoA(AdminTestCase): + """Tests for BulkAddLoA view.""" + + def setUp(self): + super(TestBulkAddLoA, self).setUp() + + self.institution01 = InstitutionFactory(name='inst01') + self.institution02 = InstitutionFactory(name='inst02') + + # Anonymous user + self.anon = AnonymousUser() + + # Superuser + self.superuser = AuthUserFactory(fullname='superuser') + self.superuser.is_superuser = True + self.superuser.is_staff = True + self.superuser.save() + + # Institutional admin for institution01 + self.inst01_admin = AuthUserFactory(fullname='admin_inst01') + self.inst01_admin.is_staff = True + self.inst01_admin.affiliated_institutions.add(self.institution01) + self.inst01_admin.save() + + self.view = views.BulkAddLoA.as_view() + + # --- dispatch tests --- + + def test_dispatch_unauthenticated(self): + request = RequestFactory().post( + reverse('loa:bulk_add'), + {'institution_id': self.institution01.id, 'aal': '1', 'ial': '1', 'is_mfa': 'False'}, + ) + view = views.BulkAddLoA() + view = setup_user_view(view, request, user=self.anon) + with pytest.raises(PermissionDenied): + view.dispatch(request) + + def test_dispatch_missing_institution_id(self): + request = RequestFactory().post( + reverse('loa:bulk_add'), + {'aal': '1', 'ial': '1', 'is_mfa': 'False'}, + ) + view = views.BulkAddLoA() + view = setup_user_view(view, request, user=self.superuser) + from django.template.exceptions import TemplateDoesNotExist + with pytest.raises((TemplateDoesNotExist, ValueError)): + view.dispatch(request) + + def test_dispatch_invalid_institution_id(self): + request = RequestFactory().post( + reverse('loa:bulk_add'), + {'institution_id': 'abc', 'aal': '1', 'ial': '1', 'is_mfa': 'False'}, + ) + view = views.BulkAddLoA() + view = setup_user_view(view, request, user=self.superuser) + from django.template.exceptions import TemplateDoesNotExist + with pytest.raises((TemplateDoesNotExist, ValueError)): + view.dispatch(request) + + # --- test_func tests --- + + def test_test_func_superuser(self): + request = RequestFactory().post('/fake_path') + view = views.BulkAddLoA() + view = setup_user_view(view, request, user=self.superuser) + view.institution_id = self.institution01.id + assert view.test_func() is True + + def test_test_func_institutional_admin_own_institution(self): + request = RequestFactory().post('/fake_path') + view = views.BulkAddLoA() + view = setup_user_view(view, request, user=self.inst01_admin) + view.institution_id = self.institution01.id + assert view.test_func() is True + + def test_test_func_institutional_admin_other_institution(self): + request = RequestFactory().post('/fake_path') + view = views.BulkAddLoA() + view = setup_user_view(view, request, user=self.inst01_admin) + view.institution_id = self.institution02.id + assert view.test_func() is False + + def test_test_func_nonexistent_institution(self): + request = RequestFactory().post('/fake_path') + view = views.BulkAddLoA() + view = setup_user_view(view, request, user=self.superuser) + view.institution_id = 99999 + with pytest.raises(Http404): + view.test_func() + + def test_test_func_deleted_institution(self): + deleted_inst = InstitutionFactory(name='deleted_inst') + deleted_inst.is_deleted = True + deleted_inst.save() + request = RequestFactory().post('/fake_path') + view = views.BulkAddLoA() + view = setup_user_view(view, request, user=self.superuser) + view.institution_id = deleted_inst.id + with pytest.raises(Http404): + view.test_func() + + # --- post tests --- + + def test_post_creates_new_loa(self): + request = RequestFactory().post( + reverse('loa:bulk_add'), + { + 'institution_id': str(self.institution01.id), + 'aal': '2', + 'ial': '1', + 'is_mfa': 'True', + }, + ) + request.user = self.superuser + setattr(request, 'session', 'session') + setattr(request, '_messages', FallbackStorage(request)) + + # Manually invoke the post method + view = views.BulkAddLoA() + view = setup_user_view(view, request, user=self.superuser) + view.institution_id = self.institution01.id + response = view.post(request) + + assert response.status_code == 302 + loa = LoA.objects.get(institution_id=self.institution01.id) + assert loa.aal == 2 + assert loa.ial == 1 + assert loa.modifier == self.superuser + + def test_post_updates_existing_loa(self): + # Create initial LoA + LoA.objects.create( + institution=self.institution01, aal=1, ial=0, is_mfa=False, + modifier=self.superuser, + ) + request = RequestFactory().post( + reverse('loa:bulk_add'), + { + 'institution_id': str(self.institution01.id), + 'aal': '2', + 'ial': '2', + 'is_mfa': 'True', + }, + ) + request.user = self.inst01_admin + setattr(request, 'session', 'session') + setattr(request, '_messages', FallbackStorage(request)) + + view = views.BulkAddLoA() + view = setup_user_view(view, request, user=self.inst01_admin) + view.institution_id = self.institution01.id + response = view.post(request) + + assert response.status_code == 302 + loa = LoA.objects.get(institution_id=self.institution01.id) + assert loa.aal == 2 + assert loa.ial == 2 + assert loa.modifier == self.inst01_admin + + def test_post_redirect_url_contains_institution_id(self): + request = RequestFactory().post( + reverse('loa:bulk_add'), + { + 'institution_id': str(self.institution01.id), + 'aal': '1', + 'ial': '1', + 'is_mfa': 'False', + }, + ) + request.user = self.superuser + setattr(request, 'session', 'session') + setattr(request, '_messages', FallbackStorage(request)) + + view = views.BulkAddLoA() + view = setup_user_view(view, request, user=self.superuser) + view.institution_id = self.institution01.id + response = view.post(request) + + assert response.status_code == 302 + expected_query = urlencode({'institution_id': str(self.institution01.id)}) + assert expected_query in response.url diff --git a/api/institutions/authentication.py b/api/institutions/authentication.py index 4a6e5e8217e..b2564d4f37a 100644 --- a/api/institutions/authentication.py +++ b/api/institutions/authentication.py @@ -6,9 +6,13 @@ import jwt import waffle +# @R2022-48 loa +import re +from urllib.parse import urlencode + #from django.utils import timezone from rest_framework.authentication import BaseAuthentication -from rest_framework.exceptions import AuthenticationFailed +from rest_framework.exceptions import AuthenticationFailed, ValidationError from api.base.authentication import drf from api.base import exceptions, settings @@ -18,18 +22,27 @@ from framework.auth.core import get_user from osf import features -from osf.models import Institution, UserExtendedData +from osf.models import Institution, UserExtendedData, LoA from osf.exceptions import BlacklistedEmailError from website.mails import send_mail, WELCOME_OSF4I -from website.settings import OSF_SUPPORT_EMAIL, DOMAIN, to_bool +from website.settings import ( + OSF_SUPPORT_EMAIL, + DOMAIN, + to_bool, + CAS_SERVER_URL, + OSF_MFA_URL, + OSF_IAL2_STR, + OSF_AAL1_STR, + OSF_AAL2_STR, + OSF_IAL2_VAR, + OSF_AAL1_VAR, + OSF_AAL2_VAR, +) from website.util.quota import update_default_storage +from future.moves.urllib.parse import urljoin logger = logging.getLogger(__name__) - -import logging -logger = logging.getLogger(__name__) - NEW_USER_NO_NAME = 'New User (no name)' def send_welcome(user, request): @@ -207,6 +220,78 @@ def get_next(obj, *args): 'gakuninIdentityAssuranceMethodReference', ) + # @R2022-48 ial,aal + ial = None + aal = None + # @R-2024-AUTH01 eduPersonAssurance(multi value) + eduPersonAssurance = p_user.get('eduPersonAssurance') + if re.search(OSF_IAL2_STR, str(eduPersonAssurance)): + ial = OSF_IAL2_VAR + if re.search(OSF_AAL2_STR, str(eduPersonAssurance)): + aal = OSF_AAL2_VAR + elif re.search(OSF_AAL1_STR, str(eduPersonAssurance)): + aal = OSF_AAL1_VAR + else: + aal = p_user.get('Shib-AuthnContext-Class') + + # @R2022-48 loa + R-2023-55 + message = '' + mfa_url = '' + mfa_url_tmp = '' + if type(p_idp) is str: + profile_url = urljoin(DOMAIN, '/profile/') + + login_url = CAS_SERVER_URL + '/login?' + urlencode({ + 'service': profile_url, + }) + + mfa_url_tmp = OSF_MFA_URL + '?' + urlencode({ + 'entityID': p_idp, + 'target': login_url, + }) + + loa_flag = True + loa = LoA.objects.get_or_none(institution_id=institution.id) + if loa: + if loa.aal == 2: + if not re.search(OSF_AAL2_STR, str(aal)): + if mfa_url_tmp: + mfa_url = mfa_url_tmp + else: + # MFA URL cannot be generated (e.g. p_idp is not a string). + # Without MFA redirect, allowing login would silently bypass + # the AAL2 requirement — reject the login instead. + message = ( + 'Institution login failed: Does not meet the required AAL.' + '
    MFA redirect is not available for this institution.' + ) + loa_flag = False + elif loa.aal == 1: + if not aal: + message = ( + 'Institution login failed: Does not meet the required AAL.
    Please contact the IdP as the' + ' appropriate value may not have been sent out by the IdP.' + ) + loa_flag = False + if loa.ial == 2: + if not re.search(OSF_IAL2_STR, str(ial)): + message = ( + 'Institution login failed: Does not meet the required IAL.
    Please check the IAL of your' + ' institution.' + ) + loa_flag = False + elif loa.ial == 1: + if not ial: + message = ( + 'Institution login failed: Does not meet the required IAL.
    Please check the IAL of your' + ' institution.' + ) + loa_flag = False + if not loa_flag: + message = 'Institution login failed: Does not meet the required AAL and IAL.' + sentry.log_message(message) + raise ValidationError(message) + # Use given name and family name to build full name if it is not provided if given_name and family_name and not fullname: fullname = given_name + ' ' + family_name @@ -336,6 +421,14 @@ def get_next(obj, *args): user.department = department user.save() + # @R-2023-55. + if ial and user.ial != ial: + user.ial = ial + user.save() + if aal and user.aal != aal: + user.aal = aal + user.save() + # Both created and activated accounts need to be updated and registered if created or activation_required: @@ -425,6 +518,7 @@ def get_next(obj, *args): # update every login. ext.set_idp_attr( { + 'id': institution.id, # @R-2023-55 'idp': p_idp, 'eppn': eppn, 'username': username, @@ -467,6 +561,10 @@ def get_next(obj, *args): # update every login. (for mAP API v1) init_cloud_gateway_groups(user, provider) + # R-2023-55 for MFA + logger.info('MFA URL "{}"'.format(mfa_url)) + user.context = {'mfa_url': mfa_url} + return user, None def login_by_eppn(): diff --git a/api/institutions/views.py b/api/institutions/views.py index dcc8cefb53b..391f8632b82 100644 --- a/api/institutions/views.py +++ b/api/institutions/views.py @@ -198,7 +198,7 @@ class InstitutionAuth(JSONAPIBaseView, generics.CreateAPIView): view_name = 'institution-auth' def post(self, request, *args, **kwargs): - return Response(status=status.HTTP_204_NO_CONTENT) + return Response(request.user.context, status=status.HTTP_200_OK) class InstitutionRegistrationList(InstitutionNodeList): diff --git a/api_tests/institutions/views/test_institution_auth.py b/api_tests/institutions/views/test_institution_auth.py index 0b1192c6bf0..ad51c4062ee 100644 --- a/api_tests/institutions/views/test_institution_auth.py +++ b/api_tests/institutions/views/test_institution_auth.py @@ -117,7 +117,7 @@ def test_new_user_created(self, app, url_auth_institution, institution): with capture_signals() as mock_signals: res = app.post(url_auth_institution, make_payload(institution, username)) - assert res.status_code == 204 + assert res.status_code == 200 assert mock_signals.signals_sent() == set([signals.user_confirmed]) user = OSFUser.objects.filter(username=username).first() @@ -134,7 +134,7 @@ def test_existing_user_found_but_not_affiliated(self, app, institution, url_auth with capture_signals() as mock_signals: res = app.post(url_auth_institution, make_payload(institution, username)) - assert res.status_code == 204 + assert res.status_code == 200 assert not mock_signals.signals_sent() user.reload() @@ -150,7 +150,7 @@ def test_user_found_and_affiliated(self, app, institution, url_auth_institution) with capture_signals() as mock_signals: res = app.post(url_auth_institution, make_payload(institution, username)) - assert res.status_code == 204 + assert res.status_code == 200 assert not mock_signals.signals_sent() user.reload() @@ -174,8 +174,7 @@ def test_new_user_names_guessed_if_not_provided(self, app, institution, url_auth username = 'user_created_with_fullname_only@osf.edu' res = app.post(url_auth_institution, make_payload(institution, username)) - assert res.status_code == 204 - + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.fullname == 'Fake User' @@ -190,8 +189,7 @@ def test_new_user_names_used_when_provided(self, app, institution, url_auth_inst url_auth_institution, make_payload(institution, username, given_name='Foo', family_name='Bar') ) - assert res.status_code == 204 - + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.fullname == 'Fake User' @@ -218,7 +216,7 @@ def test_user_active(self, app, institution, url_auth_institution): department='Fake Department', ) ) - assert res.status_code == 204 + assert res.status_code == 200 assert not mock_signals.signals_sent() user = OSFUser.objects.filter(username=username).first() @@ -257,7 +255,7 @@ def test_user_unclaimed(self, app, institution, url_auth_institution): department='Fake Department', ) ) - assert res.status_code == 204 + assert res.status_code == 200 assert mock_signals.signals_sent() == set([signals.user_confirmed]) user = OSFUser.objects.filter(username=username).first() @@ -292,7 +290,7 @@ def test_user_unconfirmed(self, app, institution, url_auth_institution): fullname='Fake User' ) ) - assert res.status_code == 204 + assert res.status_code == 200 assert mock_signals.signals_sent() == set([signals.user_confirmed]) user = OSFUser.objects.filter(username=username).first() @@ -414,8 +412,7 @@ def test_authenticate_jaSurname_and_jaGivenName_are_valid( jaGivenName=jagivenname, jaSurname=jasurname), expect_errors=True ) - assert res.status_code == 204 - + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.ext.data['idp_attr']['fullname_ja'] == jagivenname + ' ' + jasurname @@ -429,7 +426,7 @@ def test_authenticate_jaGivenName_is_valid( make_payload(institution, username, jaGivenName=jagivenname), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.given_name_ja == jagivenname @@ -443,7 +440,7 @@ def test_authenticate_jaSurname_is_valid( make_payload(institution, username, jaSurname=jasurname), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.family_name_ja == jasurname @@ -457,7 +454,7 @@ def test_authenticate_jaMiddleNames_is_valid( make_payload(institution, username, jaMiddleNames=middlename), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.middle_names_ja == middlename @@ -471,7 +468,7 @@ def test_authenticate_givenname_is_valid( make_payload(institution, username, given_name=given_name), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.given_name == given_name @@ -485,7 +482,7 @@ def test_authenticate_familyname_is_valid( make_payload(institution, username, family_name=family_name), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.family_name == family_name @@ -499,7 +496,7 @@ def test_authenticate_middlename_is_valid( make_payload(institution, username, middle_names=middle_names), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.middle_names == middle_names @@ -518,7 +515,7 @@ def test_authenticate_jaOrganizationalUnitName_is_valid( organizationName=organizationname), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.filter(username='tmp_eppn_' + username).first() assert user assert user.jobs[0]['department_ja'] == jaorganizationname @@ -537,7 +534,7 @@ def test_authenticate_OrganizationalUnitName_is_valid( organizationName=organizationname), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.filter(username='tmp_eppn_' + username).first() assert user assert user.jobs[0]['department'] == organizationnameunit @@ -572,7 +569,7 @@ def test_with_new_attribute(self, mock, app, institution, url_auth_institution): gakunin_identity_assurance_method_reference=gakunin_identity_assurance_method_reference,) ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.filter(username='tmp_eppn_' + username).first() assert user diff --git a/api_tests/institutions/views/test_institution_auth_loa.py b/api_tests/institutions/views/test_institution_auth_loa.py new file mode 100644 index 00000000000..f1cfea4310a --- /dev/null +++ b/api_tests/institutions/views/test_institution_auth_loa.py @@ -0,0 +1,581 @@ +# -*- coding: utf-8 -*- +"""Tests for LoA (Level of Assurance) validation in institution authentication. + +Covers: + - IAL / AAL extraction from eduPersonAssurance + - LoA validation logic (AAL2 required → MFA redirect, AAL1 required → ValidationError, IAL checks) + - MFA URL construction with urlencode() + - user.context containing mfa_url in response + - ValidationError raised when LoA requirements are not met + - user.ial / user.aal are persisted after successful authentication +""" +import json + +import jwe +import jwt +import mock +import pytest + +from api.base import settings +from api.base.settings.defaults import API_BASE + +from osf.models import OSFUser +from osf.models.loa import LoA +from osf_tests.factories import InstitutionFactory, UserFactory +from website.settings import ( + OSF_AAL2_VAR, + OSF_AAL1_VAR, + OSF_IAL2_VAR, +) + +def make_user(username, fullname): + return UserFactory(username=username, fullname=fullname) + + +def make_payload( + institution, + username, + fullname='Fake User', + given_name='', + family_name='', + middle_names='', + department='', + edu_person_assurance='', + shib_authn_context_class='', + idp=None, + **extra_user_fields, +): + """Build a JWE/JWT payload for institution auth. + + Accepts ``edu_person_assurance`` and ``shib_authn_context_class`` + as explicit keyword arguments so that LoA-related tests can easily + set them. Any additional user fields can be passed via **extra_user_fields. + + ``idp`` can be set to a string (e.g. an entityID URL) to make the + authentication code treat the IdP value as a string, which is required + for MFA URL generation (``type(p_idp) is str`` check). When *None* + the default ``institution.email_domains`` (a list) is used. + """ + user_dict = { + 'middleNames': middle_names, + 'familyName': family_name, + 'givenName': given_name, + 'fullname': fullname, + 'suffix': '', + 'username': username, + 'department': department, + 'eduPersonAssurance': edu_person_assurance, + 'Shib-AuthnContext-Class': shib_authn_context_class, + # defaults for other fields expected by authentication + 'jaGivenName': '', + 'jaSurname': '', + 'jaDisplayName': '', + 'jaFullname': '', + 'jaMiddleNames': '', + 'jaOrganizationalUnitName': '', + 'organizationalUnitName': '', + 'organizationName': '', + 'eduPersonAffiliation': '', + 'eduPersonScopedAffiliation': '', + 'eduPersonTargetedID': '', + 'eduPersonUniqueId': '', + 'eduPersonOrcid': '', + 'isMemberOf': '', + 'gakuninScopedPersonalUniqueCode': '', + 'gakuninIdentityAssuranceOrganization': '', + 'gakuninIdentityAssuranceMethodReference': '', + } + user_dict.update(extra_user_fields) + + data = { + 'provider': { + 'idp': idp if idp is not None else institution.email_domains, + 'id': institution._id, + 'user': user_dict, + } + } + + return jwe.encrypt( + jwt.encode( + { + 'sub': username, + 'data': json.dumps(data), + }, + settings.JWT_SECRET, + algorithm='HS256', + ), + settings.JWE_SECRET, + ) + + +@pytest.mark.django_db +class TestInstitutionAuthLoA: + """Tests for LoA validation during institution authentication.""" + + @pytest.fixture() + def institution(self): + return InstitutionFactory() + + @pytest.fixture() + def url_auth_institution(self): + return '/{0}institutions/auth/'.format(API_BASE) + + @pytest.fixture() + def app(self): + from tests.json_api_test_app import JSONAPITestApp + return JSONAPITestApp() + + # --------------------------------------------------------------- + # IAL / AAL extraction from eduPersonAssurance + # --------------------------------------------------------------- + + def test_aal2_extracted_from_edu_person_assurance( + self, app, institution, url_auth_institution, + ): + """When eduPersonAssurance contains AAL2 URL, user.aal should be set.""" + username = 'user_aal2@inst.edu' + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance='https://www.gakunin.jp/profile/AAL2', + ), + ) + assert res.status_code == 200 + user = OSFUser.objects.get(username=username) + assert user.aal == OSF_AAL2_VAR + + def test_aal1_extracted_from_edu_person_assurance( + self, app, institution, url_auth_institution, + ): + username = 'user_aal1@inst.edu' + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance='https://www.gakunin.jp/profile/AAL1', + ), + ) + assert res.status_code == 200 + user = OSFUser.objects.get(username=username) + assert user.aal == OSF_AAL1_VAR + + def test_ial2_extracted_from_edu_person_assurance( + self, app, institution, url_auth_institution, + ): + username = 'user_ial2@inst.edu' + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance='https://www.gakunin.jp/profile/IAL2', + ), + ) + assert res.status_code == 200 + user = OSFUser.objects.get(username=username) + assert user.ial == OSF_IAL2_VAR + + def test_aal_falls_back_to_shib_authn_context_class( + self, app, institution, url_auth_institution, + ): + """When eduPersonAssurance has no AAL, Shib-AuthnContext-Class is used.""" + username = 'user_shib@inst.edu' + shib_value = 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport' + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance='', + shib_authn_context_class=shib_value, + ), + ) + assert res.status_code == 200 + user = OSFUser.objects.get(username=username) + assert user.aal == shib_value + + def test_both_aal2_and_ial2_in_edu_person_assurance( + self, app, institution, url_auth_institution, + ): + """Multi-value eduPersonAssurance containing both AAL2 and IAL2.""" + username = 'user_both@inst.edu' + combined = ( + 'https://www.gakunin.jp/profile/AAL2;' + 'https://www.gakunin.jp/profile/IAL2' + ) + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance=combined, + ), + ) + assert res.status_code == 200 + user = OSFUser.objects.get(username=username) + assert user.aal == OSF_AAL2_VAR + assert user.ial == OSF_IAL2_VAR + + # --------------------------------------------------------------- + # LoA validation — no LoA record → pass through + # --------------------------------------------------------------- + + def test_no_loa_record_allows_login( + self, app, institution, url_auth_institution, + ): + """If no LoA is configured for the institution, login should succeed.""" + username = 'user_noloa@inst.edu' + res = app.post( + url_auth_institution, + make_payload(institution, username), + ) + assert res.status_code == 200 + + # --------------------------------------------------------------- + # LoA validation — AAL2 required + # --------------------------------------------------------------- + + def test_aal2_required_user_has_aal2_passes( + self, app, institution, url_auth_institution, + ): + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=2, ial=0, is_mfa=True, modifier=modifier, + ) + username = 'user_aal2_ok@inst.edu' + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance='https://www.gakunin.jp/profile/AAL2', + ), + ) + assert res.status_code == 200 + # mfa_url should be empty because AAL2 requirement is met + assert res.json.get('mfa_url', '') == '' + + @mock.patch('api.institutions.authentication.OSF_MFA_URL', 'https://mfa.example.com/ds') + def test_aal2_required_user_has_aal1_returns_mfa_url( + self, app, institution, url_auth_institution, + ): + """When AAL2 is required but user only has AAL1, mfa_url should be set.""" + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=2, ial=0, is_mfa=True, modifier=modifier, + ) + username = 'user_aal2_fail@inst.edu' + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance='https://www.gakunin.jp/profile/AAL1', + idp='https://idp.example.ac.jp', + ), + ) + assert res.status_code == 200 + mfa_url = res.json.get('mfa_url', '') + assert mfa_url != '' + # MFA URL should contain expected components + assert 'entityID=' in mfa_url or 'entityID' in mfa_url + + @mock.patch('api.institutions.authentication.OSF_MFA_URL', 'https://mfa.example.com/ds') + def test_aal2_required_user_has_no_aal_returns_mfa_url( + self, app, institution, url_auth_institution, + ): + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=2, ial=0, is_mfa=True, modifier=modifier, + ) + username = 'user_aal2_none@inst.edu' + res = app.post( + url_auth_institution, + make_payload( + institution, username, + idp='https://idp.example.ac.jp', + ), + ) + assert res.status_code == 200 + mfa_url = res.json.get('mfa_url', '') + assert mfa_url != '' + + # --------------------------------------------------------------- + # LoA validation — AAL2 required but MFA URL unavailable + # --------------------------------------------------------------- + + def test_aal2_required_no_mfa_url_available_raises_error( + self, app, institution, url_auth_institution, + ): + """AAL2 required, AAL2 not met, and p_idp is a list (not str) so + mfa_url_tmp is empty. Login must be rejected instead of silently + bypassing the AAL2 requirement. + """ + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=2, ial=0, is_mfa=True, modifier=modifier, + ) + username = 'user_aal2_no_mfa@inst.edu' + # idp is NOT passed, so institution.email_domains (a list) is used. + # type(p_idp) is str -> False -> mfa_url_tmp remains empty. + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance='https://www.gakunin.jp/profile/AAL1', + ), + expect_errors=True, + ) + assert res.status_code == 400 + + def test_aal2_required_no_aal_no_mfa_url_available_raises_error( + self, app, institution, url_auth_institution, + ): + """AAL2 required, no AAL at all, p_idp is a list -> must be rejected.""" + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=2, ial=0, is_mfa=True, modifier=modifier, + ) + username = 'user_aal2_no_mfa_none@inst.edu' + res = app.post( + url_auth_institution, + make_payload(institution, username), + expect_errors=True, + ) + assert res.status_code == 400 + + def test_aal2_required_user_has_aal2_passes_regardless_of_idp_type( + self, app, institution, url_auth_institution, + ): + """AAL2 required and met - login should pass even if p_idp is a list.""" + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=2, ial=0, is_mfa=True, modifier=modifier, + ) + username = 'user_aal2_ok_list_idp@inst.edu' + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance='https://www.gakunin.jp/profile/AAL2', + ), + ) + assert res.status_code == 200 + + # --------------------------------------------------------------- + # LoA validation — AAL1 required + # --------------------------------------------------------------- + + def test_aal1_required_user_has_aal1_passes( + self, app, institution, url_auth_institution, + ): + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=1, ial=0, modifier=modifier, + ) + username = 'user_aal1_ok@inst.edu' + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance='https://www.gakunin.jp/profile/AAL1', + ), + ) + assert res.status_code == 200 + + def test_aal1_required_user_has_no_aal_raises_error( + self, app, institution, url_auth_institution, + ): + """AAL1 required but no AAL provided → ValidationError (400).""" + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=1, ial=0, modifier=modifier, + ) + username = 'user_aal1_fail@inst.edu' + res = app.post( + url_auth_institution, + make_payload(institution, username), + expect_errors=True, + ) + assert res.status_code == 400 + + # --------------------------------------------------------------- + # LoA validation — IAL2 required + # --------------------------------------------------------------- + + def test_ial2_required_user_has_ial2_passes( + self, app, institution, url_auth_institution, + ): + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=0, ial=2, modifier=modifier, + ) + username = 'user_ial2_ok@inst.edu' + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance='https://www.gakunin.jp/profile/IAL2', + ), + ) + assert res.status_code == 200 + + def test_ial2_required_user_has_no_ial_raises_error( + self, app, institution, url_auth_institution, + ): + """IAL2 required but IAL2 not provided → ValidationError (400).""" + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=0, ial=2, modifier=modifier, + ) + username = 'user_ial2_fail@inst.edu' + res = app.post( + url_auth_institution, + make_payload(institution, username), + expect_errors=True, + ) + assert res.status_code == 400 + + # --------------------------------------------------------------- + # LoA validation — IAL1 required + # --------------------------------------------------------------- + + def test_ial1_required_user_has_ial_passes( + self, app, institution, url_auth_institution, + ): + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=0, ial=1, modifier=modifier, + ) + username = 'user_ial1_ok@inst.edu' + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance='https://www.gakunin.jp/profile/IAL2', + ), + ) + assert res.status_code == 200 + + def test_ial1_required_user_has_no_ial_raises_error( + self, app, institution, url_auth_institution, + ): + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=0, ial=1, modifier=modifier, + ) + username = 'user_ial1_fail@inst.edu' + res = app.post( + url_auth_institution, + make_payload(institution, username), + expect_errors=True, + ) + assert res.status_code == 400 + + # --------------------------------------------------------------- + # Combined AAL + IAL requirements + # --------------------------------------------------------------- + + def test_both_aal2_and_ial2_required_both_met( + self, app, institution, url_auth_institution, + ): + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=2, ial=2, is_mfa=True, modifier=modifier, + ) + username = 'user_combo_ok@inst.edu' + combined = ( + 'https://www.gakunin.jp/profile/AAL2;' + 'https://www.gakunin.jp/profile/IAL2' + ) + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance=combined, + ), + ) + assert res.status_code == 200 + assert res.json.get('mfa_url', '') == '' + + def test_aal2_met_but_ial2_not_met_raises_error( + self, app, institution, url_auth_institution, + ): + """AAL2 met + IAL2 not met → ValidationError (IAL check is independent).""" + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=2, ial=2, is_mfa=True, modifier=modifier, + ) + username = 'user_combo_ial_fail@inst.edu' + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance='https://www.gakunin.jp/profile/AAL2', + ), + expect_errors=True, + ) + assert res.status_code == 400 + + # --------------------------------------------------------------- + # Response contains mfa_url in user.context + # --------------------------------------------------------------- + + def test_response_body_contains_mfa_url_key( + self, app, institution, url_auth_institution, + ): + """InstitutionAuth.post() returns request.user.context with mfa_url.""" + username = 'user_ctx@inst.edu' + res = app.post( + url_auth_institution, + make_payload(institution, username), + ) + assert res.status_code == 200 + assert 'mfa_url' in res.json + + # --------------------------------------------------------------- + # MFA URL structure validation + # --------------------------------------------------------------- + + @mock.patch('api.institutions.authentication.OSF_MFA_URL', 'https://mfa.example.com/ds') + @mock.patch('api.institutions.authentication.CAS_SERVER_URL', 'https://cas.example.com') + @mock.patch('api.institutions.authentication.DOMAIN', 'https://osf.example.com/') + def test_mfa_url_structure( + self, app, institution, url_auth_institution, + ): + """Verify MFA URL is constructed correctly with urlencode.""" + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=2, ial=0, is_mfa=True, modifier=modifier, + ) + username = 'user_mfa_url@inst.edu' + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance='https://www.gakunin.jp/profile/AAL1', + idp='https://idp.example.ac.jp', + ), + ) + assert res.status_code == 200 + mfa_url = res.json.get('mfa_url', '') + assert mfa_url != '' + # MFA URL should start with OSF_MFA_URL (after urlencode wrapping via CAS logout) + # The overall structure: OSF_MFA_URL?entityID=...&target=CAS/login?service=profile + assert 'mfa.example.com' in mfa_url or 'cas.example.com' in mfa_url + + # --------------------------------------------------------------- + # idp_attr stores institution.id + # --------------------------------------------------------------- + + def test_idp_attr_stores_institution_id( + self, app, institution, url_auth_institution, + ): + """ext.set_idp_attr should include institution.id under key 'id'.""" + from osf.models import UserExtendedData + + username = 'user_idp_id@inst.edu' + res = app.post( + url_auth_institution, + make_payload(institution, username), + ) + assert res.status_code == 200 + user = OSFUser.objects.get(username=username) + ext = UserExtendedData.objects.get(user=user) + assert ext.data.get('idp_attr', {}).get('id') == institution.id diff --git a/osf/migrations/0265_r_2025_23_55789.py b/osf/migrations/0265_r_2025_23_55789.py new file mode 100644 index 00000000000..f01009afb6a --- /dev/null +++ b/osf/migrations/0265_r_2025_23_55789.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('osf', '0264_merge_20260218_0749'), + ] + + operations = [ + migrations.AddField( + model_name='osfuser', + name='aal', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='osfuser', + name='ial', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/osf/migrations/0266_r_2025_23_55789.py b/osf/migrations/0266_r_2025_23_55789.py new file mode 100644 index 00000000000..a374b5d3332 --- /dev/null +++ b/osf/migrations/0266_r_2025_23_55789.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import osf.models.base + + +class Migration(migrations.Migration): + dependencies = [ + ('osf', '0265_r_2025_23_55789'), + ] + + operations = [ + migrations.CreateModel( + name='LoA', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'created', + django_extensions.db.fields.CreationDateTimeField( + auto_now_add=True, verbose_name='created' + ), + ), + ( + 'modified', + django_extensions.db.fields.ModificationDateTimeField( + auto_now=True, verbose_name='modified' + ), + ), + ( + 'aal', + models.IntegerField( + blank=True, + choices=[(0, 'NULL'), (1, 'AAL1'), (2, 'AAL2')], + null=True, + ), + ), + ( + 'ial', + models.IntegerField( + blank=True, + choices=[(0, 'NULL'), (1, 'IAL1'), (2, 'IAL2')], + null=True, + ), + ), + ( + 'is_mfa', + models.BooleanField( + choices=[(False, 'Disabled'), (True, 'Enabled')], + default=False, + verbose_name='Display MFA link button', + ), + ), + ( + 'institution', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='osf.Institution', + ), + ), + ( + 'modifier', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + 'permissions': ( + ('view_loa', 'Can view loa'), + ('admin_loa', 'Can manage loa'), + ), + }, + bases=(models.Model, osf.models.base.QuerySetExplainMixin), + ), + ] diff --git a/osf/models/__init__.py b/osf/models/__init__.py index d82b6d44124..48e1367bc68 100644 --- a/osf/models/__init__.py +++ b/osf/models/__init__.py @@ -72,3 +72,4 @@ from osf.models.project_limit_number_template_attribute import ProjectLimitNumberTemplateAttribute # noqa from osf.models.project_limit_number_setting import ProjectLimitNumberSetting # noqa from osf.models.project_limit_number_setting_attribute import ProjectLimitNumberSettingAttribute # noqa +from osf.models.loa import LoA # noqa diff --git a/osf/models/loa.py b/osf/models/loa.py new file mode 100644 index 00000000000..8b0977fdc5e --- /dev/null +++ b/osf/models/loa.py @@ -0,0 +1,59 @@ +from django.db import models +from osf.models import base +from django.utils.translation import ugettext_lazy as _ +import logging + +logger = logging.getLogger(__name__) + + +class BaseManager(models.Manager): + def get_or_none(self, **kwargs): + try: + return self.get_queryset().get(**kwargs) + except self.model.DoesNotExist: + return None + + +class LoA(base.BaseModel): + objects = BaseManager() + institution = models.ForeignKey('Institution', on_delete=models.CASCADE) + aal = models.IntegerField( + choices=( + (0, 'NULL'), + (1, 'AAL1'), + (2, 'AAL2'), + ), + blank=True, + null=True, + ) + ial = models.IntegerField( + choices=( + (0, 'NULL'), + (1, 'IAL1'), + (2, 'IAL2'), + ), + blank=True, + null=True, + ) + is_mfa = models.BooleanField( + _('Display MFA link button'), + choices=( + (False, 'Disabled'), + (True, 'Enabled'), + ), + default=False, + ) + modifier = models.ForeignKey('OSFUser', on_delete=models.CASCADE) + + class Meta: + permissions = ( + ('view_loa', 'Can view loa'), + ('admin_loa', 'Can manage loa'), + ) + + def __init__(self, *args, **kwargs): + kwargs.pop('node', None) + super(LoA, self).__init__(*args, **kwargs) + + def __unicode__(self): + return u'institution_{}:{}:{}:{}'.format(self.institution._id, self.aal, self.ial, self.is_mfa) diff --git a/osf/models/user.py b/osf/models/user.py index 2725f9360be..33453fbd068 100644 --- a/osf/models/user.py +++ b/osf/models/user.py @@ -472,6 +472,10 @@ class OSFUser(DirtyFieldsMixin, GuidMixin, BaseModel, AbstractBaseUser, Permissi mapcore_api_locked = models.BooleanField(default=False) mapcore_refresh_locked = models.BooleanField(default=False) + # @R2022-48 eduPersonAssurance(ial) and AuthnContextClass(aal) from Shibboleth + ial = models.CharField(blank=True, max_length=255, null=True) + aal = models.CharField(blank=True, max_length=255, null=True) + def __repr__(self): return ''.format(self.username, self._id) diff --git a/osf_tests/test_loa.py b/osf_tests/test_loa.py new file mode 100644 index 00000000000..64dc417a1a1 --- /dev/null +++ b/osf_tests/test_loa.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +"""Tests for the LoA (Level of Assurance) model.""" +import pytest + +from osf.models.loa import LoA +from osf_tests.factories import InstitutionFactory, AuthUserFactory + +pytestmark = pytest.mark.django_db + + +class TestBaseManager: + """Tests for BaseManager.get_or_none().""" + + def test_get_or_none_returns_object_when_exists(self): + institution = InstitutionFactory() + modifier = AuthUserFactory() + loa = LoA.objects.create( + institution=institution, aal=1, ial=1, is_mfa=False, modifier=modifier, + ) + result = LoA.objects.get_or_none(institution_id=institution.id) + assert result is not None + assert result.pk == loa.pk + + def test_get_or_none_returns_none_when_not_exists(self): + result = LoA.objects.get_or_none(institution_id=99999) + assert result is None + + +class TestLoAModel: + """Tests for the LoA model fields and behaviour.""" + + def test_create_loa_with_all_fields(self): + institution = InstitutionFactory() + modifier = AuthUserFactory() + loa = LoA.objects.create( + institution=institution, aal=2, ial=2, is_mfa=True, modifier=modifier, + ) + assert loa.pk is not None + assert loa.institution == institution + assert loa.aal == 2 + assert loa.ial == 2 + assert loa.is_mfa is True + assert loa.modifier == modifier + + def test_create_loa_with_null_aal_ial(self): + institution = InstitutionFactory() + modifier = AuthUserFactory() + loa = LoA.objects.create( + institution=institution, aal=None, ial=None, is_mfa=False, modifier=modifier, + ) + assert loa.aal is None + assert loa.ial is None + + def test_create_loa_with_zero_values(self): + """aal=0 and ial=0 represent NULL choice.""" + institution = InstitutionFactory() + modifier = AuthUserFactory() + loa = LoA.objects.create( + institution=institution, aal=0, ial=0, is_mfa=False, modifier=modifier, + ) + assert loa.aal == 0 + assert loa.ial == 0 + + def test_is_mfa_defaults_to_false(self): + institution = InstitutionFactory() + modifier = AuthUserFactory() + loa = LoA.objects.create( + institution=institution, modifier=modifier, + ) + assert loa.is_mfa is False + + def test_loa_timestamps(self): + institution = InstitutionFactory() + modifier = AuthUserFactory() + loa = LoA.objects.create( + institution=institution, aal=1, ial=1, modifier=modifier, + ) + assert loa.created is not None + assert loa.modified is not None + + def test_cascade_delete_on_institution(self): + institution = InstitutionFactory() + modifier = AuthUserFactory() + LoA.objects.create( + institution=institution, aal=1, ial=1, modifier=modifier, + ) + institution_id = institution.id + institution.delete() + assert LoA.objects.filter(institution_id=institution_id).count() == 0 + + def test_cascade_delete_on_modifier(self): + institution = InstitutionFactory() + modifier = AuthUserFactory() + LoA.objects.create( + institution=institution, aal=1, ial=1, modifier=modifier, + ) + modifier_pk = modifier.pk + modifier.delete() + assert LoA.objects.filter(modifier_id=modifier_pk).count() == 0 + + def test_unicode_representation(self): + institution = InstitutionFactory() + modifier = AuthUserFactory() + loa = LoA.objects.create( + institution=institution, aal=2, ial=1, is_mfa=True, modifier=modifier, + ) + expected = u'institution_{}:{}:{}:{}'.format( + institution._id, 2, 1, True, + ) + # LoA defines __unicode__ (not __str__), so call it directly + assert loa.__unicode__() == expected + + def test_init_pops_node_kwarg(self): + """__init__ should silently pop 'node' from kwargs.""" + institution = InstitutionFactory() + modifier = AuthUserFactory() + # Should not raise + loa = LoA( + institution=institution, aal=1, ial=1, modifier=modifier, node='anything', + ) + assert loa.aal == 1 diff --git a/tests/nii/test_profile_from_idp.py b/tests/nii/test_profile_from_idp.py index 2ee10b81043..5c78714f025 100644 --- a/tests/nii/test_profile_from_idp.py +++ b/tests/nii/test_profile_from_idp.py @@ -90,7 +90,7 @@ def test_without_email(self, app, institution, url_auth_institution): make_payload(institution, eppn, fullname, given_name, family_name) ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.get(username=tmp_eppn_username) assert user assert user.fullname == fullname @@ -114,7 +114,7 @@ def test_with_email(self, app, institution, url_auth_institution): make_payload(institution, eppn, fullname, given_name, family_name, email=email) ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.get(username=email) assert user assert user.fullname == fullname @@ -151,7 +151,7 @@ def test_with_email_and_profile_attr(self, app, institution, url_auth_institutio ) ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.get(username=email) assert user assert user.fullname == fullname @@ -203,7 +203,7 @@ def test_with_email_and_profile_attr_without_orgname(self, app, institution, url ) ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.get(username=email) assert user assert user.fullname == fullname @@ -230,7 +230,7 @@ def test_with_blacklist_email(self, app, institution, url_auth_institution): make_payload(institution, eppn, fullname, given_name, family_name, email=email) ) - assert res.status_code == 204 + assert res.status_code == 200 # email is ignored from django.core.exceptions import ObjectDoesNotExist @@ -260,7 +260,7 @@ def test_same_email_is_ignored(self, app, institution, url_auth_institution): url_auth_institution, make_payload(institution, eppn, fullname, given_name, family_name, email=email) ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.get(username=email) assert user assert user.have_email == True @@ -272,7 +272,7 @@ def test_same_email_is_ignored(self, app, institution, url_auth_institution): url_auth_institution, make_payload(institution, eppn2, fullname, given_name, family_name, email=email) ) - assert res.status_code == 204 + assert res.status_code == 200 # same email is ignored user2 = OSFUser.objects.get(username=tmp_eppn_username2) @@ -315,7 +315,7 @@ def test_existing_fullname_isnot_changed(self, app, institution, url_auth_instit ) # user.fullname is not changned - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.get(username=email) assert user assert user.fullname == fullname diff --git a/tests/test_202201/api/institutions/test_authenticate.py b/tests/test_202201/api/institutions/test_authenticate.py index c1d9b2444c3..9b38a873a7b 100644 --- a/tests/test_202201/api/institutions/test_authenticate.py +++ b/tests/test_202201/api/institutions/test_authenticate.py @@ -98,7 +98,7 @@ def test_authenticate_jaSurname_and_jaGivenName_are_valid( jaGivenName=jagivenname, jaSurname=jasurname), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user @@ -113,7 +113,7 @@ def test_authenticate_jaGivenName_is_valid( make_payload(institution, username, jaGivenName=jagivenname), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.given_name_ja == jagivenname @@ -128,7 +128,7 @@ def test_authenticate_jaSurname_is_valid( make_payload(institution, username, jaSurname=jasurname), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.family_name_ja == jasurname @@ -143,7 +143,7 @@ def test_authenticate_jaMiddleNames_is_valid( make_payload(institution, username, jaMiddleNames=middlename), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.middle_names_ja == middlename @@ -158,7 +158,7 @@ def test_authenticate_givenname_is_valid( make_payload(institution, username, given_name=given_name), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.given_name == given_name @@ -173,7 +173,7 @@ def test_authenticate_familyname_is_valid( make_payload(institution, username, family_name=family_name), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.family_name == family_name @@ -188,7 +188,7 @@ def test_authenticate_middlename_is_valid( make_payload(institution, username, middle_names=middle_names), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.middle_names == middle_names @@ -207,7 +207,7 @@ def test_authenticate_jaOrganizationalUnitName_is_valid( organizationName=organizationname), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.filter(username='tmp_eppn_' + username).first() assert user assert user.jobs[0]['department_ja'] == jaorganizationname @@ -226,7 +226,7 @@ def test_authenticate_OrganizationalUnitName_is_valid( organizationName=organizationname), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.filter(username='tmp_eppn_' + username).first() assert user assert user.jobs[0]['department'] == organizationnameunit diff --git a/tests/test_profile_utils_loa.py b/tests/test_profile_utils_loa.py new file mode 100644 index 00000000000..d0d522b1783 --- /dev/null +++ b/tests/test_profile_utils_loa.py @@ -0,0 +1,240 @@ +# -*- coding: utf-8 -*- +"""Tests for LoA/MFA-related fields in website.profile.utils.serialize_user(). + +Covers: + - _aal / _ial badge classification + - mfa_url construction and content + - is_mfa flag based on LoA settings + - Behaviour when no idp_attr / no LoA record exists +""" +import mock +import pytest + +from osf.models.loa import LoA +from osf.models import UserExtendedData +from osf_tests.factories import AuthUserFactory, InstitutionFactory +from tests.base import OsfTestCase +from website import settings +from website.profile.utils import serialize_user + +pytestmark = pytest.mark.django_db + + +def _make_user_with_idp_attr(institution=None, ial=None, aal=None, idp='https://idp.example.ac.jp'): + """Helper: create a user with idp_attr set on UserExtendedData.""" + user = AuthUserFactory() + user.ial = ial + user.aal = aal + user.save() + + if institution is None: + institution = InstitutionFactory() + user.affiliated_institutions.add(institution) + + ext, _ = UserExtendedData.objects.get_or_create(user=user) + ext.set_idp_attr({ + 'id': institution.id, + 'idp': idp, + 'eppn': user.username, + 'username': user.username, + 'fullname': user.fullname, + 'email': user.username, + }) + + return user, institution + + +class TestSerializeUserAalBadge(OsfTestCase): + """Tests for _aal classification in serialize_user().""" + + def test_aal_null_when_no_aal(self): + user, _ = _make_user_with_idp_attr(aal=None) + result = serialize_user(user) + assert result['_aal'] == 'NULL' + + def test_aal_null_when_empty_string(self): + user, _ = _make_user_with_idp_attr(aal='') + result = serialize_user(user) + assert result['_aal'] == 'NULL' + + def test_aal2_when_aal_contains_aal2_url(self): + user, _ = _make_user_with_idp_attr( + aal='https://www.gakunin.jp/profile/AAL2', + ) + result = serialize_user(user) + assert result['_aal'] == 'AAL2' + + def test_aal1_when_aal_contains_aal1_url(self): + user, _ = _make_user_with_idp_attr( + aal='https://www.gakunin.jp/profile/AAL1', + ) + result = serialize_user(user) + assert result['_aal'] == 'AAL1' + + def test_aal1_when_aal_is_other_value(self): + """Any non-AAL2 truthy value should classify as AAL1.""" + user, _ = _make_user_with_idp_attr( + aal='urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport', + ) + result = serialize_user(user) + assert result['_aal'] == 'AAL1' + + def test_raw_aal_value_is_preserved(self): + aal_value = 'https://www.gakunin.jp/profile/AAL2' + user, _ = _make_user_with_idp_attr(aal=aal_value) + result = serialize_user(user) + assert result['aal'] == aal_value + + +class TestSerializeUserIalBadge(OsfTestCase): + """Tests for _ial classification in serialize_user().""" + + def test_ial1_when_no_ial(self): + """When ial is None or empty, _ial should be IAL1 (default).""" + user, _ = _make_user_with_idp_attr(ial=None) + result = serialize_user(user) + assert result['_ial'] == 'IAL1' + + def test_ial2_when_ial_contains_ial2_url(self): + user, _ = _make_user_with_idp_attr( + ial='https://www.gakunin.jp/profile/IAL2', + ) + result = serialize_user(user) + assert result['_ial'] == 'IAL2' + + def test_ial1_when_ial_is_other_value(self): + """Values other than IAL2 are equivalent to IAL1.""" + user, _ = _make_user_with_idp_attr(ial='some_other_ial_value') + result = serialize_user(user) + assert result['_ial'] == 'IAL1' + + def test_raw_ial_value_is_preserved(self): + ial_value = 'https://www.gakunin.jp/profile/IAL2' + user, _ = _make_user_with_idp_attr(ial=ial_value) + result = serialize_user(user) + assert result['ial'] == ial_value + + +class TestSerializeUserMfaUrl(OsfTestCase): + """Tests for mfa_url construction in serialize_user().""" + + @mock.patch.object(settings, 'OSF_MFA_URL', 'https://mfa.example.com/ds') + @mock.patch.object(settings, 'CAS_SERVER_URL', 'https://cas.example.com') + def test_mfa_url_constructed_when_entity_id_present(self): + """When idp_attr has entity_id (idp), mfa_url should be constructed.""" + user, institution = _make_user_with_idp_attr( + idp='https://idp.example.ac.jp', + ) + result = serialize_user(user) + mfa_url = result['mfa_url'] + assert mfa_url != '' + # Should contain CAS logout redirect pattern + assert 'cas.example.com/logout' in mfa_url + # Should contain mfa.example.com/ds in the service param + assert 'mfa.example.com' in mfa_url + + @mock.patch.object(settings, 'OSF_MFA_URL', 'https://mfa.example.com/ds') + @mock.patch.object(settings, 'CAS_SERVER_URL', 'https://cas.example.com') + def test_mfa_url_contains_entity_id(self): + entity_id = 'https://idp.specific.ac.jp' + user, institution = _make_user_with_idp_attr(idp=entity_id) + result = serialize_user(user) + mfa_url = result['mfa_url'] + # The entityID should be URL-encoded within the mfa_url + assert 'idp.specific.ac.jp' in mfa_url + + @mock.patch.object(settings, 'OSF_MFA_URL', 'https://mfa.example.com/ds') + @mock.patch.object(settings, 'CAS_SERVER_URL', 'https://cas.example.com') + def test_mfa_url_contains_login_service_url(self): + user, institution = _make_user_with_idp_attr() + result = serialize_user(user) + mfa_url = result['mfa_url'] + # The CAS login URL is nested inside multiple urlencode layers, + # so slashes and colons are percent-encoded repeatedly. + # Fully decode the URL and then check for the expected substring. + from urllib.parse import unquote + decoded = mfa_url + for _ in range(5): + decoded = unquote(decoded) + assert 'cas.example.com/login' in decoded + + def test_mfa_url_empty_when_no_entity_id(self): + """When idp_attr has no 'idp' key, mfa_url should be empty.""" + user = AuthUserFactory() + user.aal = None + user.ial = None + user.save() + + # Set idp_attr without 'idp' key + ext, _ = UserExtendedData.objects.get_or_create(user=user) + ext.set_idp_attr({ + 'id': None, + 'username': user.username, + }) + + result = serialize_user(user) + assert result['mfa_url'] == '' + + def test_mfa_url_empty_when_no_idp_attr(self): + """When user has no UserExtendedData, mfa_url should be empty.""" + user = AuthUserFactory() + user.aal = None + user.ial = None + user.save() + + result = serialize_user(user) + assert result['mfa_url'] == '' + + +class TestSerializeUserIsMfa(OsfTestCase): + """Tests for is_mfa flag based on LoA settings.""" + + def test_is_mfa_true_when_loa_has_mfa_enabled(self): + user, institution = _make_user_with_idp_attr() + modifier = AuthUserFactory() + LoA.objects.create( + institution=institution, aal=2, ial=0, is_mfa=True, modifier=modifier, + ) + result = serialize_user(user) + assert result['is_mfa'] is True + + def test_is_mfa_false_when_loa_has_mfa_disabled(self): + user, institution = _make_user_with_idp_attr() + modifier = AuthUserFactory() + LoA.objects.create( + institution=institution, aal=2, ial=0, is_mfa=False, modifier=modifier, + ) + result = serialize_user(user) + assert result['is_mfa'] is False + + def test_is_mfa_false_when_no_loa_record(self): + user, institution = _make_user_with_idp_attr() + # No LoA record created + result = serialize_user(user) + assert result['is_mfa'] is False + + def test_is_mfa_false_when_no_institution_id_in_idp_attr(self): + """When idp_attr has id=None, LoA lookup returns None -> is_mfa=False.""" + user = AuthUserFactory() + user.aal = None + user.ial = None + user.save() + + ext, _ = UserExtendedData.objects.get_or_create(user=user) + ext.set_idp_attr({ + 'id': None, + 'idp': 'https://idp.example.ac.jp', + }) + + result = serialize_user(user) + assert result['is_mfa'] is False + + +class TestSerializeUserReturnedKeys(OsfTestCase): + """Verify that all LoA-related keys are present in the serialized output.""" + + def test_loa_keys_present(self): + user, _ = _make_user_with_idp_attr() + result = serialize_user(user) + for key in ('ial', 'aal', '_ial', '_aal', 'mfa_url', 'is_mfa'): + assert key in result, 'Missing key: {}'.format(key) diff --git a/website/profile/utils.py b/website/profile/utils.py index 7b80d5c9509..e0448c942d4 100644 --- a/website/profile/utils.py +++ b/website/profile/utils.py @@ -3,13 +3,17 @@ from api.base import settings as api_settings from website import settings -from osf.models import Contributor, UserQuota +from osf.models import Contributor, UserQuota, LoA from addons.osfstorage.models import Region from website.filters import profile_image_url from osf.utils.permissions import READ from osf.utils import workflows from api.waffle.utils import storage_i18n_flag_active -from website.util import quota +from website.util import quota, web_url_for + +# @R2022-48 +import re +from urllib.parse import urlencode def get_profile_image_url(user, size=settings.PROFILE_IMAGE_MEDIUM): @@ -31,6 +35,47 @@ def serialize_user(user, node=None, admin=False, full=False, is_profile=False, i user = contrib.user fullname = user.display_full_name(node=node) idp_attrs = user.get_idp_attr() + + # @R2022-48 + if not user.aal: + _aal = 'NULL' + elif re.search(settings.OSF_AAL2_STR, str(user.aal)): + _aal = 'AAL2' + else: + _aal = 'AAL1' + + # @R-2024-AUTH01 Values other than IAL2 are equivalent to IAL1. + if re.search(settings.OSF_IAL2_STR, str(user.ial)): + _ial = 'IAL2' + else: + _ial = 'IAL1' + + # @R-2023-55 + mfa_url = '' + entity_id = idp_attrs.get('idp') + if entity_id is not None: + profile_url = web_url_for('user_profile', _absolute=True) + + login_url = settings.CAS_SERVER_URL + '/login?' + urlencode({ + 'service': profile_url, + }) + + mfa_url_q = settings.OSF_MFA_URL + '?' + urlencode({ + 'entityID': entity_id, + 'target': login_url, + }) + + # CAS logout → MFA の redirect + mfa_url = settings.CAS_SERVER_URL + '/logout?' + urlencode({ + 'service': mfa_url_q, + }) + + loa = LoA.objects.get_or_none(institution_id=idp_attrs.get('id')) + if loa is not None: + is_mfa = loa.is_mfa + else: + is_mfa = False + ret = { 'id': str(user._id), 'primary_key': user.id, @@ -40,6 +85,12 @@ def serialize_user(user, node=None, admin=False, full=False, is_profile=False, i 'shortname': fullname if len(fullname) < 50 else fullname[:23] + '...' + fullname[-23:], 'profile_image_url': user.profile_image_url(size=settings.PROFILE_IMAGE_MEDIUM), 'active': user.is_active, + 'ial': user.ial, # @R2022-48 + 'aal': user.aal, # @R2022-48 + '_ial': _ial, # @R2022-48 + '_aal': _aal, # @R2022-48 + 'mfa_url': mfa_url, # @R-2023-55 + 'is_mfa': is_mfa, # @R-2023-55 'have_email': user.have_email, 'idp_email': idp_attrs.get('email'), } diff --git a/website/routes.py b/website/routes.py index b3de0fbefdd..e1d3cf4864c 100644 --- a/website/routes.py +++ b/website/routes.py @@ -173,9 +173,11 @@ def get_globals(): 'webpack_asset': paths.webpack_asset, 'osf_url': settings.INTERNAL_DOMAIN, 'waterbutler_url': settings.WATERBUTLER_URL, + 'cas_server_url': settings.CAS_SERVER_URL, # R-2022-48 'login_url': cas.get_login_url(request_login_url), 'sign_up_url': util.web_url_for('auth_register', _absolute=True, next=request_login_url), 'reauth_url': util.web_url_for('auth_logout', redirect_url=request.url, reauth=True), + 'mfa_url': settings.CAS_SERVER_URL + '/logout?service=' + settings.OSF_MFA_URL, # R-2023-55 'profile_url': cas.get_profile_url(), 'enable_institutions': settings.ENABLE_INSTITUTIONS, 'keen': { diff --git a/website/settings/defaults.py b/website/settings/defaults.py index 1ace7782bba..ba9c55d5c36 100644 --- a/website/settings/defaults.py +++ b/website/settings/defaults.py @@ -374,6 +374,7 @@ def parent_dir(path): CAS_SERVER_URL = 'http://localhost:8080' MFR_SERVER_URL = 'http://localhost:7778' +OSF_MFA_URL = '' # R-2022-48 ###### ARCHIVER ########### ARCHIVE_PROVIDER = 'osfstorage' @@ -2094,3 +2095,12 @@ class CeleryConfig: 'ja_jp': '日本語' } BABEL_DEFAULT_LOCALE = 'ja' + +# Default values for IAL2 & AAL2 parameters(R-2023-55) +# Default values for IAL1 & AAL1 parameters(R-2024-AUTH01) +OSF_IAL2_STR = 'https://www\.gakunin\.jp/profile/IAL2' +OSF_AAL1_STR = 'https://www\.gakunin\.jp/profile/AAL1' +OSF_AAL2_STR = 'https://www\.gakunin\.jp/profile/AAL2' +OSF_IAL2_VAR = 'https://www.gakunin.jp/profile/IAL2' +OSF_AAL1_VAR = 'https://www.gakunin.jp/profile/AAL1' +OSF_AAL2_VAR = 'https://www.gakunin.jp/profile/AAL2' diff --git a/website/static/css/pages/profile-page.css b/website/static/css/pages/profile-page.css index b1ad2f52177..393f9cd1c5b 100644 --- a/website/static/css/pages/profile-page.css +++ b/website/static/css/pages/profile-page.css @@ -140,3 +140,18 @@ table.social-links { table.social-links a { word-wrap: break-word; } + +/* +@R2022-48 +*/ +.badge.AAL1, +.badge.IAL1 +{ + background-color:#3498db!important; +} + +.badge.AAL2, +.badge.IAL2 +{ + background-color:#18bc9c!important; +} diff --git a/website/static/img/institutions/banners/orthros-logo.png b/website/static/img/institutions/banners/orthros-logo.png new file mode 100644 index 00000000000..d177b1ad076 Binary files /dev/null and b/website/static/img/institutions/banners/orthros-logo.png differ diff --git a/website/static/img/institutions/shields-rounded-corners/orthros-shield-rounded-corners.png b/website/static/img/institutions/shields-rounded-corners/orthros-shield-rounded-corners.png new file mode 100644 index 00000000000..b978279c11a Binary files /dev/null and b/website/static/img/institutions/shields-rounded-corners/orthros-shield-rounded-corners.png differ diff --git a/website/static/img/institutions/shields/orthros-shield.png b/website/static/img/institutions/shields/orthros-shield.png new file mode 100644 index 00000000000..79f309c4e92 Binary files /dev/null and b/website/static/img/institutions/shields/orthros-shield.png differ diff --git a/website/templates/profile.mako b/website/templates/profile.mako index 1f71e1f59de..2dedebcd0c8 100644 --- a/website/templates/profile.mako +++ b/website/templates/profile.mako @@ -1,4 +1,5 @@ <%inherit file="base.mako"/> + <%namespace name="render_nodes" file="util/render_nodes.mako" /> <%def name="title()">${profile["fullname"]} <%def name="resource()"><% @@ -64,7 +65,24 @@
    ${profile['display_absolute_url']} % endif + % if user['is_profile']: + + ${_("IAL") | n} + ${profile['_ial']}${profile['ial']} + + + ${_("AuthnContext-Class") | n} + ${profile['_aal']}${profile['aal']} + + % if profile.get('is_mfa') and profile.get('_aal') != "AAL2" and profile.get('mfa_url'): + +   + ${_("Log in again using multi-factor authentication")} + + % endif + % endif + % if user['is_profile']:

    ${profile['activity_points'] or _("No")} ${ngettext('activity point', 'activity points', profile['activity_points'])}
    ${profile["number_projects"]} ${_("project")}${ngettext(' ', 's', profile["number_projects"])} @@ -73,6 +91,7 @@ ${_("Usage of storage")}
    ${profile['quota']['rate']}%, ${profile['quota']['used']} / ${profile['quota']['max']}[GB]

    + % endif
    diff --git a/website/translations/en/LC_MESSAGES/messages.po b/website/translations/en/LC_MESSAGES/messages.po index 6d84a486354..dff3eba901f 100644 --- a/website/translations/en/LC_MESSAGES/messages.po +++ b/website/translations/en/LC_MESSAGES/messages.po @@ -4080,4 +4080,16 @@ msgid "\"Full name\", \"Family name\", \"Given name\", \"Family name (EN)\", \"G msgstr "" msgid "If you do not have an email address registered, please enter or add your email address in the \"Registered email address\" entry field first." -msgstr "" \ No newline at end of file +msgstr "" + +#: website/templates/profile.mako:70 +msgid "IAL" +msgstr "" + +#: website/templates/profile.mako:74 +msgid "AuthnContext-Class" +msgstr "" + +#: website/templates/profile.mako:80 +msgid "Log in again using multi-factor authentication" +msgstr "" diff --git a/website/translations/ja/LC_MESSAGES/messages.po b/website/translations/ja/LC_MESSAGES/messages.po index f94fbbaa2dd..b20ec9c95ed 100644 --- a/website/translations/ja/LC_MESSAGES/messages.po +++ b/website/translations/ja/LC_MESSAGES/messages.po @@ -4439,6 +4439,18 @@ msgstr "%(summaryTitle)sのフォークを作成" msgid "Manage Contributors" msgstr "メンバー管理" +#: website/templates/profile.mako:70 +msgid "IAL" +msgstr "" + +#: website/templates/profile.mako:74 +msgid "AuthnContext-Class" +msgstr "" + +#: website/templates/profile.mako:80 +msgid "Log in again using multi-factor authentication" +msgstr "多要素認証で再ログインする" + #~ msgid "" #~ "Because you have not configured the " #~ "{addon} add-on, your authentication will" diff --git a/website/translations/messages.pot b/website/translations/messages.pot index b1d25f76719..08be18af240 100644 --- a/website/translations/messages.pot +++ b/website/translations/messages.pot @@ -4345,3 +4345,14 @@ msgstr "" msgid "Manage Contributors" msgstr "" +#: website/templates/profile.mako:70 +msgid "IAL" +msgstr "" + +#: website/templates/profile.mako:74 +msgid "AuthnContext-Class" +msgstr "" + +#: website/templates/profile.mako:80 +msgid "Log in again using multi-factor authentication" +msgstr ""