diff --git a/admin/base/settings/defaults.py b/admin/base/settings/defaults.py index 1f08a47c5b6..7fd97e5c9f5 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 d5904970197..6106901baa0 100644 --- a/api/institutions/authentication.py +++ b/api/institutions/authentication.py @@ -9,9 +9,13 @@ from osf.models.mapcore_user_group import MapCoreUserGroup 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 @@ -21,20 +25,29 @@ 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 from django_bulk_update.helper import bulk_update from django.utils import timezone logger = logging.getLogger(__name__) - -import logging -logger = logging.getLogger(__name__) - NEW_USER_NO_NAME = 'New User (no name)' def send_welcome(user, request): @@ -212,6 +225,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 @@ -341,6 +426,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: @@ -430,6 +523,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, @@ -473,6 +567,10 @@ def get_next(obj, *args): init_cloud_gateway_groups(user, provider) update_mapcore_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 c86fc3973ad..36b22471194 100644 --- a/osf/models/__init__.py +++ b/osf/models/__init__.py @@ -72,4 +72,5 @@ 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 from osf.models.mapcore_group import MapCoreGroup # 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 abfb708a247..ebf83761fff 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 2a2ebe2d464..57ca9ea09b2 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 798ca41a892..2ccb72782c7 100644 --- a/website/routes.py +++ b/website/routes.py @@ -175,9 +175,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 30887f5cd77..5c58c4951c6 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' @@ -2098,5 +2099,13 @@ class CeleryConfig: } 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' # Prefix of isMemberOf attribute for groups. MAP_GATEWAY_ISMEMBEROF_PREFIX = 'https://sptest.cg.gakunin.jp/gr/' 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 7f2084820e1..bd5a26a1afc 100644 --- a/website/translations/en/LC_MESSAGES/messages.po +++ b/website/translations/en/LC_MESSAGES/messages.po @@ -4082,6 +4082,18 @@ 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 "" +#: 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 "" + #: website/templates/util/render_addon_widget.mako msgid "Available workflows" msgstr "" diff --git a/website/translations/ja/LC_MESSAGES/messages.po b/website/translations/ja/LC_MESSAGES/messages.po index fe3c9a10b93..9bdfd8cc8f4 100644 --- a/website/translations/ja/LC_MESSAGES/messages.po +++ b/website/translations/ja/LC_MESSAGES/messages.po @@ -4443,6 +4443,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 dd4b7bcd503..fea0d185005 100644 --- a/website/translations/messages.pot +++ b/website/translations/messages.pot @@ -4347,6 +4347,18 @@ 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 "" + #: website/templates/util/render_addon_widget.mako msgid "Available workflows" msgstr ""