From df0ef879bb3464195cc1293d3d77efe56011d133 Mon Sep 17 00:00:00 2001 From: Yusaku Kitabatake Date: Tue, 25 Nov 2025 14:08:17 +0900 Subject: [PATCH 1/8] =?UTF-8?q?redmine55789/=E5=A4=9A=E8=A6=81=E7=B4=A0?= =?UTF-8?q?=E8=AA=8D=E8=A8=BC=E6=A9=9F=E8=83=BD=E3=81=AE=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/base/settings/defaults.py | 1 + admin/loa/__init__.py | 0 admin/loa/forms.py | 39 ++++++ admin/loa/urls.py | 9 ++ admin/loa/views.py | 132 ++++++++++++++++++ admin/templates/base.html | 5 + admin/templates/loa/list.html | 109 +++++++++++++++ api/institutions/authentication.py | 100 ++++++++++++- api/institutions/views.py | 2 +- osf/migrations/0257_r_2025_23_55789.py | 22 +++ osf/migrations/0258_r_2025_23_55789.py | 88 ++++++++++++ osf/models/loa.py | 59 ++++++++ osf/models/user.py | 4 + tests/nii/test_profile_from_idp.py | 16 +-- .../api/institutions/test_authenticate.py | 18 +-- website/profile/utils.py | 53 ++++++- website/routes.py | 3 + website/settings/defaults.py | 11 ++ website/static/css/pages/profile-page.css | 15 ++ .../img/institutions/banners/orthros-logo.png | Bin 0 -> 17408 bytes .../orthros-shield-rounded-corners.png | Bin 0 -> 24883 bytes .../institutions/shields/orthros-shield.png | Bin 0 -> 19214 bytes website/templates/profile.mako | 19 +++ 23 files changed, 683 insertions(+), 22 deletions(-) create mode 100644 admin/loa/__init__.py create mode 100644 admin/loa/forms.py create mode 100644 admin/loa/urls.py create mode 100644 admin/loa/views.py create mode 100644 admin/templates/loa/list.html create mode 100644 osf/migrations/0257_r_2025_23_55789.py create mode 100644 osf/migrations/0258_r_2025_23_55789.py create mode 100644 osf/models/loa.py create mode 100644 website/static/img/institutions/banners/orthros-logo.png create mode 100644 website/static/img/institutions/shields-rounded-corners/orthros-shield-rounded-corners.png create mode 100644 website/static/img/institutions/shields/orthros-shield.png 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/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..d1aa314e63a --- /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, _('表示しない')), + (True, _('表示する')), + ) + 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..b35f2da8961 --- /dev/null +++ b/admin/templates/loa/list.html @@ -0,0 +1,109 @@ +{% 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/api/institutions/authentication.py b/api/institutions/authentication.py index 4a6e5e8217e..b5e48648367 100644 --- a/api/institutions/authentication.py +++ b/api/institutions/authentication.py @@ -6,9 +6,13 @@ import jwt import waffle +# @R2022-48 loa +import re +import urllib.parse + #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,10 +22,23 @@ 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, + OSF_SERVICE_URL, + 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 logger = logging.getLogger(__name__) @@ -57,6 +74,7 @@ class InstitutionAuthentication(BaseAuthentication): """ media_type = 'text/plain' + context = {'mfa_url': ''} def authenticate(self, request): """ @@ -207,6 +225,72 @@ 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 = '' + self.context['mfa_url'] = '' + mfa_url = '' + if type(p_idp) is str: + mfa_url_q = ( + OSF_MFA_URL + + '?entityID=' + + p_idp + + '&target=' + + CAS_SERVER_URL + + '/login?service=' + + OSF_SERVICE_URL + + '/profile/' + ) + mfa_url = ( + CAS_SERVER_URL + + '/logout?service=' + + urllib.parse.quote(mfa_url_q, safe='') + ) + 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)): + self.context['mfa_url'] = mfa_url + 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 +420,15 @@ 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() + logger.info('MFA URL "{}"'.format(self.context['mfa_url'])) + # 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, diff --git a/api/institutions/views.py b/api/institutions/views.py index dcc8cefb53b..1936edfdf27 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(self.authentication_classes[0].context, status=status.HTTP_200_OK) class InstitutionRegistrationList(InstitutionNodeList): diff --git a/osf/migrations/0257_r_2025_23_55789.py b/osf/migrations/0257_r_2025_23_55789.py new file mode 100644 index 00000000000..2b373546685 --- /dev/null +++ b/osf/migrations/0257_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', '0257_merge_20251023_1304'), + ] + + 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/0258_r_2025_23_55789.py b/osf/migrations/0258_r_2025_23_55789.py new file mode 100644 index 00000000000..2bd25b57260 --- /dev/null +++ b/osf/migrations/0258_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', '0257_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/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/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/website/profile/utils.py b/website/profile/utils.py index 7b80d5c9509..da8685dbf75 100644 --- a/website/profile/utils.py +++ b/website/profile/utils.py @@ -3,7 +3,7 @@ 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 @@ -11,6 +11,10 @@ from api.waffle.utils import storage_i18n_flag_active from website.util import quota +# @R2022-48 +import re +import urllib.parse + def get_profile_image_url(user, size=settings.PROFILE_IMAGE_MEDIUM): return profile_image_url(settings.PROFILE_IMAGE_PROVIDER, @@ -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: + mfa_url_q = ( + settings.OSF_MFA_URL + + '?entityID=' + + entity_id + + '&target=' + + settings.CAS_SERVER_URL + + '/login?service=' + + settings.OSF_SERVICE_URL + + '/profile/' + ) + mfa_url = ( + settings.CAS_SERVER_URL + + '/logout?service=' + + urllib.parse.quote(mfa_url_q, safe='') + ) + + 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..cefca3f8512 100644 --- a/website/routes.py +++ b/website/routes.py @@ -172,10 +172,13 @@ def get_globals(): 'sjson': lambda s: sanitize.safe_json(s), 'webpack_asset': paths.webpack_asset, 'osf_url': settings.INTERNAL_DOMAIN, + 'osf_service_url': settings.OSF_SERVICE_URL, # R-2022-48 '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..41f318515f0 100644 --- a/website/settings/defaults.py +++ b/website/settings/defaults.py @@ -374,6 +374,8 @@ def parent_dir(path): CAS_SERVER_URL = 'http://localhost:8080' MFR_SERVER_URL = 'http://localhost:7778' +OSF_SERVICE_URL = '' # R-2022-48 +OSF_MFA_URL = '' # R-2022-48 ###### ARCHIVER ########### ARCHIVE_PROVIDER = 'osfstorage' @@ -2094,3 +2096,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 0000000000000000000000000000000000000000..d177b1ad076e14e98dd9c3b227b4631bf650d38b GIT binary patch literal 17408 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%0D$%H0FI<>qJ+Pyt{ym@?&s zmTZI5t@X=Q!zZme1_zw=FjwZjfhh41sF0HIoWu`?~GSW{*9$4Aceurek-?^kA7;Pa|m`)s~uD};- zMh6Tc#_Bsa-(lA_0cZ!~bax(j8Z`AH5HYy!HBA)HFvty=Ik>&J@CIXvV2yyk>$j^v(bF0U4luW)kqZ;e))p4hdy*Fv6qv5bPlt7N!|t0| zS^^aJ01)llw{P#D-(?EA!L$@5o#WE@=nq>|Pj2%>Qo@}aPDwtx6SI5=SiB4_kpPA% zsK3^tr{mv^@w%P(!19029a7}GPoPnpP+jv6Mb9qB$b$BFdq7R_AHU7u{?TIg)t^pO z%~a^vAI!{Xl9N+8OuB&%3c>bYk$Ee=_VscMEKE2J3X}?TA0?PmyaX;6J$9|L>rNNM zA#UeQsoKv(1Y*e&@H!ky<5!wo2>%(Zzun@s1z%`rX#8ir1JWonE32j#S0y)t(fQH$ zyw1|nQt?Ra@N9QCH~3pq&{}LJlA#pO$jQy%T*g0a@ zpkyYbzu)xDAJ%uX7r9b@iI^uYCc(c8@zXD1uWC~=akow-0aA?ApS)IA$j)TD$t&t*@y#T_p_V@+vRSA<=@TZp@sr`}Z-|y%!!)oB5&o3?x);jz^vOb$ZuzK4+0(z4pOZ=c%w+?&vA#S30@qRrYM$*>S{`%{^ z#|f$4KHqYcw!LCk&2|a{eN0PB%djPs+}%+szooI5n15lR<(5%|x{e|74>i1>1_@86 z8N8V?A6j%aSgvH{<`A1*l|g52_>@c;s=y{KGL{0 zFjoi*{FlY#sAB>q$3{mX=mEtIH8tv~^6;vvs!%rHfK^pTC^nArJOHX0%JY@-y^^A$ ze;O+-R3P0dZ_(25h(U^Oa`Ux`Ti{bW0s_xJZMBx}ksqX_q+*7q(T*TP1aS^cOjypK zE8pm@tSp;bKN<1WWCdw~%k-{6rwnWXZqNPh9RV9TF78aWB;uG?Q&GW_xrMH5h^No8 zhesTb1yOmVs5iIhY-cAW50A!Fg{bpp@5Rmtwot6?$|?N>6o+z=C)tw_NQsabiC=Qy zUfU&XO|p;NwZKtv&0tc|rxgCMa4Xe~a;IP|EM{+i{9lPXLL0agirTlEe^C~{n}Ko8 zVLXG;I8pJvJ2Odqum$s>#Fg9)we?h7LWfeGPng=d?fjYbtVKtC`hCyef~lrBgq->D zibe!n{`u32A)ETggBiNhtu1b!8@t|dT++viFIjzr7Xg`X`hP1ke`x$FnrLPT=BA6@ zGv!=oD#?@Q|1*p(ix_$ILlw(cE$*PrZ2EPZLG>n_T;NMyO$|43e_46?_}EyR%Pxj| zCTHnN*y6FwOZ?Xt-;Iv@NKH06#s42EcRn3|PcH0bWooUQ-^LwB1!J*;{GC*!BZj$X z!)fGzzmFG8YWZrys=9o0_X2WlhnAb5REZhq_DStXA$K43mLlEE++31OE||ZSqql5H z^~l)dnViVxGory#{a)X*OiJ&Q+PD~h90kUv#{wa=d9sX+&w`KJ3d-^Pyd0VGpQrZ_ zxgE%c8^f=Gig|86fP5)*=ZD+2Ym)6r@*ZoPA&GmOiqF&2$Y`yzQ;dK0{(Rc`S5OK3 zwZyy-9Ba`o=yttLVHa6HBiV6KwFWVi`WtazZIyZFh0tU?hpUyvaG>sluXp@!*m%j7 zi(*T?&8?Twa60oU#=t zC7Us>OAcGUnqv-%L$>UEX;mMJyQ0Uveoc)tQO69$nDNoMwN){n9Hf&5d=vsU5r~Ujd@_&y5fFp1f zL5PNpSJg=Ps6n2r`@&U2*EAT`-K>svqyA(PLBrnY%D(NE{emZZ^ zzyaL2EF~qSs){pZ1YY+Uv17e+O&4`` zhbR-!vAv^nxalXeN7GMF*!u4pl*kW8dHMOQU=j}0P%G9tmLJ> literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b978279c11af40bd1ab80ae43fb37f62a0ce2735 GIT binary patch literal 24883 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 z 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 literal 0 HcmV?d00001 diff --git a/website/templates/profile.mako b/website/templates/profile.mako index 1f71e1f59de..21146c044d0 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'): + +   + ${_("Configure multi-factor authentication (MFA)")} + + % 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
    From 3341d4fa6fa059c8e6820fd4d6d8f1132287f68d Mon Sep 17 00:00:00 2001 From: Yusaku Kitabatake Date: Tue, 25 Nov 2025 23:31:56 +0900 Subject: [PATCH 2/8] =?UTF-8?q?redmine56420/test=5Finstitution=5Fauth.py?= =?UTF-8?q?=E3=81=AE=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/test_institution_auth.py | 39 +++++++++---------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/api_tests/institutions/views/test_institution_auth.py b/api_tests/institutions/views/test_institution_auth.py index 0b1192c6bf0..16be75b83ff 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 == 204 or 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 == 204 or 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 == 204 or 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 == 204 or 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 == 204 or 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 == 204 or 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 == 204 or 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 == 204 or 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 == 204 or 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 == 204 or 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 == 204 or 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 == 204 or 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 == 204 or 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 == 204 or 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 == 204 or 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 == 204 or 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 == 204 or 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 == 204 or res.status_code == 200 user = OSFUser.objects.filter(username='tmp_eppn_' + username).first() assert user From 44b4988363d98cf5f7fd871275f67421c4879ff1 Mon Sep 17 00:00:00 2001 From: Yusaku Kitabatake Date: Fri, 28 Nov 2025 13:09:04 +0900 Subject: [PATCH 3/8] =?UTF-8?q?redmine56420/CAS=E3=81=AB=E6=B8=A1=E3=81=99?= =?UTF-8?q?MFA=20URL=E3=81=AE=E3=82=A4=E3=83=B3=E3=82=B9=E3=82=BF=E3=83=B3?= =?UTF-8?q?=E3=82=B9=E5=8C=96=EF=BC=8B=E5=A4=9A=E8=A8=80=E8=AA=9E=E3=83=AA?= =?UTF-8?q?=E3=82=BD=E3=83=BC=E3=82=B9=E3=81=AE=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/translations/django.pot | 3 +++ admin/translations/en/LC_MESSAGES/django.po | 3 +++ admin/translations/ja/LC_MESSAGES/django.po | 2 +- api/institutions/authentication.py | 21 ++++++++----------- api/institutions/views.py | 2 +- website/profile/utils.py | 5 ++--- .../translations/en/LC_MESSAGES/messages.po | 14 ++++++++++++- .../translations/ja/LC_MESSAGES/messages.po | 12 +++++++++++ website/translations/messages.pot | 11 ++++++++++ 9 files changed, 55 insertions(+), 18 deletions(-) diff --git a/admin/translations/django.pot b/admin/translations/django.pot index 8202c292ede..8bda4924c3b 100644 --- a/admin/translations/django.pot +++ b/admin/translations/django.pot @@ -3947,3 +3947,6 @@ msgstr "" msgid "No@encrypt_uploads" msgstr "" + +msgid "LoA update successful." +msgstr "" diff --git a/admin/translations/en/LC_MESSAGES/django.po b/admin/translations/en/LC_MESSAGES/django.po index 08b8647a759..8de0a4d62e9 100644 --- a/admin/translations/en/LC_MESSAGES/django.po +++ b/admin/translations/en/LC_MESSAGES/django.po @@ -3999,3 +3999,6 @@ msgstr "Yes" msgid "No@encrypt_uploads" msgstr "No" + +msgid "LoA update successful." +msgstr "" diff --git a/admin/translations/ja/LC_MESSAGES/django.po b/admin/translations/ja/LC_MESSAGES/django.po index 9b4e9d51af5..3d72923c846 100644 --- a/admin/translations/ja/LC_MESSAGES/django.po +++ b/admin/translations/ja/LC_MESSAGES/django.po @@ -4390,4 +4390,4 @@ 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 "実行中のワークフロープロセスがある場合、エラーになります。" diff --git a/api/institutions/authentication.py b/api/institutions/authentication.py index b5e48648367..46280cf0110 100644 --- a/api/institutions/authentication.py +++ b/api/institutions/authentication.py @@ -29,7 +29,6 @@ OSF_SUPPORT_EMAIL, DOMAIN, to_bool, - OSF_SERVICE_URL, CAS_SERVER_URL, OSF_MFA_URL, OSF_IAL2_STR, @@ -40,13 +39,10 @@ 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): @@ -74,7 +70,6 @@ class InstitutionAuthentication(BaseAuthentication): """ media_type = 'text/plain' - context = {'mfa_url': ''} def authenticate(self, request): """ @@ -241,8 +236,8 @@ def get_next(obj, *args): # @R2022-48 loa + R-2023-55 message = '' - self.context['mfa_url'] = '' mfa_url = '' + mfa_url_tmp = '' if type(p_idp) is str: mfa_url_q = ( OSF_MFA_URL @@ -251,10 +246,9 @@ def get_next(obj, *args): + '&target=' + CAS_SERVER_URL + '/login?service=' - + OSF_SERVICE_URL - + '/profile/' + + urljoin(DOMAIN, "/profile/") ) - mfa_url = ( + mfa_url_tmp = ( CAS_SERVER_URL + '/logout?service=' + urllib.parse.quote(mfa_url_q, safe='') @@ -264,7 +258,7 @@ def get_next(obj, *args): if loa: if loa.aal == 2: if not re.search(OSF_AAL2_STR, str(aal)): - self.context['mfa_url'] = mfa_url + mfa_url = mfa_url_tmp elif loa.aal == 1: if not aal: message = ( @@ -427,7 +421,6 @@ def get_next(obj, *args): if aal and user.aal != aal: user.aal = aal user.save() - logger.info('MFA URL "{}"'.format(self.context['mfa_url'])) # Both created and activated accounts need to be updated and registered if created or activation_required: @@ -561,6 +554,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 1936edfdf27..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(self.authentication_classes[0].context, status=status.HTTP_200_OK) + return Response(request.user.context, status=status.HTTP_200_OK) class InstitutionRegistrationList(InstitutionNodeList): diff --git a/website/profile/utils.py b/website/profile/utils.py index da8685dbf75..ee050371bf5 100644 --- a/website/profile/utils.py +++ b/website/profile/utils.py @@ -9,7 +9,7 @@ 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 @@ -61,8 +61,7 @@ def serialize_user(user, node=None, admin=False, full=False, is_profile=False, i + '&target=' + settings.CAS_SERVER_URL + '/login?service=' - + settings.OSF_SERVICE_URL - + '/profile/' + + web_url_for('user_profile', _absolute=True) ) mfa_url = ( settings.CAS_SERVER_URL diff --git a/website/translations/en/LC_MESSAGES/messages.po b/website/translations/en/LC_MESSAGES/messages.po index 6d84a486354..194a6b92213 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 "Configure multi-factor authentication (MFA)" +msgstr "Log in again using multi-factor authentication" diff --git a/website/translations/ja/LC_MESSAGES/messages.po b/website/translations/ja/LC_MESSAGES/messages.po index f94fbbaa2dd..137351a07d3 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 "Configure multi-factor authentication (MFA)" +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..1da2787b976 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 "Configure multi-factor authentication (MFA)" +msgstr "" From 9cb3b4a08c38932054565ddf3a3b9924aab306f3 Mon Sep 17 00:00:00 2001 From: Yusaku Kitabatake Date: Fri, 28 Nov 2025 18:29:17 +0900 Subject: [PATCH 4/8] =?UTF-8?q?redmine56420/=E5=A4=9A=E8=A8=80=E8=AA=9E?= =?UTF-8?q?=E3=83=AA=E3=82=BD=E3=83=BC=E3=82=B9=E3=81=AE=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/loa/forms.py | 4 ++-- admin/translations/django.pot | 11 ++++++++++- admin/translations/en/LC_MESSAGES/django.po | 12 ++++++++++++ website/routes.py | 1 - website/settings/defaults.py | 1 - website/templates/profile.mako | 2 +- website/translations/en/LC_MESSAGES/messages.po | 4 ++-- website/translations/ja/LC_MESSAGES/messages.po | 2 +- website/translations/messages.pot | 2 +- 9 files changed, 29 insertions(+), 10 deletions(-) diff --git a/admin/loa/forms.py b/admin/loa/forms.py index d1aa314e63a..36d8c404c24 100644 --- a/admin/loa/forms.py +++ b/admin/loa/forms.py @@ -7,8 +7,8 @@ class LoAForm(forms.ModelForm): CHOICES_AAL = [(0, _('NULL')), (1, _('AAL1')), (2, _('AAL2'))] CHOICES_IAL = [(0, _('NULL')), (1, _('IAL1')), (2, _('IAL2'))] CHOICES_MFA = ( - (False, _('表示しない')), - (True, _('表示する')), + (False, _('Hide')), + (True, _('Show')), ) aal = forms.ChoiceField( choices=CHOICES_AAL, diff --git a/admin/translations/django.pot b/admin/translations/django.pot index 8bda4924c3b..c917acba686 100644 --- a/admin/translations/django.pot +++ b/admin/translations/django.pot @@ -3948,5 +3948,14 @@ msgstr "" msgid "No@encrypt_uploads" msgstr "" -msgid "LoA update successful." +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 8de0a4d62e9..21c548340ee 100644 --- a/admin/translations/en/LC_MESSAGES/django.po +++ b/admin/translations/en/LC_MESSAGES/django.po @@ -4002,3 +4002,15 @@ 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/website/routes.py b/website/routes.py index cefca3f8512..e1d3cf4864c 100644 --- a/website/routes.py +++ b/website/routes.py @@ -172,7 +172,6 @@ def get_globals(): 'sjson': lambda s: sanitize.safe_json(s), 'webpack_asset': paths.webpack_asset, 'osf_url': settings.INTERNAL_DOMAIN, - 'osf_service_url': settings.OSF_SERVICE_URL, # R-2022-48 'waterbutler_url': settings.WATERBUTLER_URL, 'cas_server_url': settings.CAS_SERVER_URL, # R-2022-48 'login_url': cas.get_login_url(request_login_url), diff --git a/website/settings/defaults.py b/website/settings/defaults.py index 41f318515f0..ba9c55d5c36 100644 --- a/website/settings/defaults.py +++ b/website/settings/defaults.py @@ -374,7 +374,6 @@ def parent_dir(path): CAS_SERVER_URL = 'http://localhost:8080' MFR_SERVER_URL = 'http://localhost:7778' -OSF_SERVICE_URL = '' # R-2022-48 OSF_MFA_URL = '' # R-2022-48 ###### ARCHIVER ########### diff --git a/website/templates/profile.mako b/website/templates/profile.mako index 21146c044d0..2dedebcd0c8 100644 --- a/website/templates/profile.mako +++ b/website/templates/profile.mako @@ -77,7 +77,7 @@ % if profile.get('is_mfa') and profile.get('_aal') != "AAL2" and profile.get('mfa_url'):   - ${_("Configure multi-factor authentication (MFA)")} + ${_("Log in again using multi-factor authentication")} % endif % endif diff --git a/website/translations/en/LC_MESSAGES/messages.po b/website/translations/en/LC_MESSAGES/messages.po index 194a6b92213..dff3eba901f 100644 --- a/website/translations/en/LC_MESSAGES/messages.po +++ b/website/translations/en/LC_MESSAGES/messages.po @@ -4091,5 +4091,5 @@ msgid "AuthnContext-Class" msgstr "" #: website/templates/profile.mako:80 -msgid "Configure multi-factor authentication (MFA)" -msgstr "Log in again using multi-factor authentication" +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 137351a07d3..b20ec9c95ed 100644 --- a/website/translations/ja/LC_MESSAGES/messages.po +++ b/website/translations/ja/LC_MESSAGES/messages.po @@ -4448,7 +4448,7 @@ msgid "AuthnContext-Class" msgstr "" #: website/templates/profile.mako:80 -msgid "Configure multi-factor authentication (MFA)" +msgid "Log in again using multi-factor authentication" msgstr "多要素認証で再ログインする" #~ msgid "" diff --git a/website/translations/messages.pot b/website/translations/messages.pot index 1da2787b976..08be18af240 100644 --- a/website/translations/messages.pot +++ b/website/translations/messages.pot @@ -4354,5 +4354,5 @@ msgid "AuthnContext-Class" msgstr "" #: website/templates/profile.mako:80 -msgid "Configure multi-factor authentication (MFA)" +msgid "Log in again using multi-factor authentication" msgstr "" From ba14a808b05e4afe0f7c55d0e77cbbbeb8f3713c Mon Sep 17 00:00:00 2001 From: Yusaku Kitabatake Date: Fri, 28 Nov 2025 15:45:26 +0900 Subject: [PATCH 5/8] =?UTF-8?q?redmine56420/MFA=20URL=E3=81=AE=E6=9C=80?= =?UTF-8?q?=E9=81=A9=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/institutions/authentication.py | 27 ++++++++++++-------------- website/profile/utils.py | 31 +++++++++++++++--------------- 2 files changed, 28 insertions(+), 30 deletions(-) diff --git a/api/institutions/authentication.py b/api/institutions/authentication.py index 46280cf0110..dcdad6946ff 100644 --- a/api/institutions/authentication.py +++ b/api/institutions/authentication.py @@ -8,7 +8,7 @@ # @R2022-48 loa import re -import urllib.parse +from urllib.parse import urlencode #from django.utils import timezone from rest_framework.authentication import BaseAuthentication @@ -239,20 +239,17 @@ def get_next(obj, *args): mfa_url = '' mfa_url_tmp = '' if type(p_idp) is str: - mfa_url_q = ( - OSF_MFA_URL - + '?entityID=' - + p_idp - + '&target=' - + CAS_SERVER_URL - + '/login?service=' - + urljoin(DOMAIN, "/profile/") - ) - mfa_url_tmp = ( - CAS_SERVER_URL - + '/logout?service=' - + urllib.parse.quote(mfa_url_q, safe='') - ) + 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: diff --git a/website/profile/utils.py b/website/profile/utils.py index ee050371bf5..e0448c942d4 100644 --- a/website/profile/utils.py +++ b/website/profile/utils.py @@ -13,7 +13,7 @@ # @R2022-48 import re -import urllib.parse +from urllib.parse import urlencode def get_profile_image_url(user, size=settings.PROFILE_IMAGE_MEDIUM): @@ -54,20 +54,21 @@ def serialize_user(user, node=None, admin=False, full=False, is_profile=False, i mfa_url = '' entity_id = idp_attrs.get('idp') if entity_id is not None: - mfa_url_q = ( - settings.OSF_MFA_URL - + '?entityID=' - + entity_id - + '&target=' - + settings.CAS_SERVER_URL - + '/login?service=' - + web_url_for('user_profile', _absolute=True) - ) - mfa_url = ( - settings.CAS_SERVER_URL - + '/logout?service=' - + urllib.parse.quote(mfa_url_q, safe='') - ) + 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: From 11b3293eda8318567195643759c2bb6fee407dea Mon Sep 17 00:00:00 2001 From: Yusaku Kitabatake Date: Fri, 3 Apr 2026 15:23:17 +0900 Subject: [PATCH 6/8] redmine56420/Resolve conflicts with the develop --- admin/base/urls.py | 1 + admin/translations/django.pot | 3 +++ admin/translations/ja/LC_MESSAGES/django.po | 15 +++++++++++++++ ...r_2025_23_55789.py => 0265_r_2025_23_55789.py} | 2 +- ...r_2025_23_55789.py => 0266_r_2025_23_55789.py} | 2 +- osf/models/__init__.py | 1 + 6 files changed, 22 insertions(+), 2 deletions(-) rename osf/migrations/{0257_r_2025_23_55789.py => 0265_r_2025_23_55789.py} (92%) rename osf/migrations/{0258_r_2025_23_55789.py => 0266_r_2025_23_55789.py} (98%) 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/translations/django.pot b/admin/translations/django.pot index c917acba686..bf8d8dec6ad 100644 --- a/admin/translations/django.pot +++ b/admin/translations/django.pot @@ -3948,6 +3948,9 @@ msgstr "" msgid "No@encrypt_uploads" msgstr "" +msgid "LoA update successful." +msgstr "" + msgid "Show" msgstr "" diff --git a/admin/translations/ja/LC_MESSAGES/django.po b/admin/translations/ja/LC_MESSAGES/django.po index 3d72923c846..4e10fa749a8 100644 --- a/admin/translations/ja/LC_MESSAGES/django.po +++ b/admin/translations/ja/LC_MESSAGES/django.po @@ -4391,3 +4391,18 @@ msgstr "関連するすべてのワークフローテンプレートとワーク #: admin/templates/rdm_workflow/engine_list.html:196 msgid "If there are running workflow processes, deletion will fail." 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/osf/migrations/0257_r_2025_23_55789.py b/osf/migrations/0265_r_2025_23_55789.py similarity index 92% rename from osf/migrations/0257_r_2025_23_55789.py rename to osf/migrations/0265_r_2025_23_55789.py index 2b373546685..f01009afb6a 100644 --- a/osf/migrations/0257_r_2025_23_55789.py +++ b/osf/migrations/0265_r_2025_23_55789.py @@ -5,7 +5,7 @@ class Migration(migrations.Migration): dependencies = [ - ('osf', '0257_merge_20251023_1304'), + ('osf', '0264_merge_20260218_0749'), ] operations = [ diff --git a/osf/migrations/0258_r_2025_23_55789.py b/osf/migrations/0266_r_2025_23_55789.py similarity index 98% rename from osf/migrations/0258_r_2025_23_55789.py rename to osf/migrations/0266_r_2025_23_55789.py index 2bd25b57260..a374b5d3332 100644 --- a/osf/migrations/0258_r_2025_23_55789.py +++ b/osf/migrations/0266_r_2025_23_55789.py @@ -10,7 +10,7 @@ class Migration(migrations.Migration): dependencies = [ - ('osf', '0257_r_2025_23_55789'), + ('osf', '0265_r_2025_23_55789'), ] operations = [ 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 From 95d564bbeccb6373f30b42ab8cbca1f9edb45b90 Mon Sep 17 00:00:00 2001 From: Yusaku Kitabatake Date: Fri, 3 Apr 2026 15:29:16 +0900 Subject: [PATCH 7/8] R-2025-23/Add test code for MFA --- admin_tests/loa/__init__.py | 0 admin_tests/loa/test_forms.py | 82 +++ admin_tests/loa/test_views.py | 399 ++++++++++++++ .../views/test_institution_auth_loa.py | 521 ++++++++++++++++++ osf_tests/test_loa.py | 121 ++++ tests/test_profile_utils_loa.py | 240 ++++++++ 6 files changed, 1363 insertions(+) create mode 100644 admin_tests/loa/__init__.py create mode 100644 admin_tests/loa/test_forms.py create mode 100644 admin_tests/loa/test_views.py create mode 100644 api_tests/institutions/views/test_institution_auth_loa.py create mode 100644 osf_tests/test_loa.py create mode 100644 tests/test_profile_utils_loa.py 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_tests/institutions/views/test_institution_auth_loa.py b/api_tests/institutions/views/test_institution_auth_loa.py new file mode 100644 index 00000000000..a8d1c285a77 --- /dev/null +++ b/api_tests/institutions/views/test_institution_auth_loa.py @@ -0,0 +1,521 @@ +# -*- 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 in (200, 204) + 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 in (200, 204) + 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 in (200, 204) + 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 in (200, 204) + 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 in (200, 204) + 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 in (200, 204) + + # --------------------------------------------------------------- + # 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 in (200, 204) + # mfa_url should be empty because AAL2 requirement is met + if res.status_code == 200: + 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 — 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 in (200, 204) + + 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 in (200, 204) + + 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 in (200, 204) + + 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 in (200, 204) + if 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 in (200, 204) + 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_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/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) From 6897f8c681af5fc3f64262016447cac7aa64a24e Mon Sep 17 00:00:00 2001 From: Yusaku Kitabatake Date: Wed, 8 Apr 2026 11:04:51 +0900 Subject: [PATCH 8/8] Fixes based on the code review (redmine-58907) --- admin/templates/loa/list.html | 12 --- api/institutions/authentication.py | 12 ++- .../views/test_institution_auth.py | 36 ++++---- .../views/test_institution_auth_loa.py | 92 +++++++++++++++---- 4 files changed, 105 insertions(+), 47 deletions(-) diff --git a/admin/templates/loa/list.html b/admin/templates/loa/list.html index b35f2da8961..8df2797ded0 100644 --- a/admin/templates/loa/list.html +++ b/admin/templates/loa/list.html @@ -89,21 +89,9 @@

    {% trans "Level of Assurance" %}

    {% endblock content %} {% block bottom_js %} - - {% endblock %} diff --git a/api/institutions/authentication.py b/api/institutions/authentication.py index dcdad6946ff..b2564d4f37a 100644 --- a/api/institutions/authentication.py +++ b/api/institutions/authentication.py @@ -255,7 +255,17 @@ def get_next(obj, *args): if loa: if loa.aal == 2: if not re.search(OSF_AAL2_STR, str(aal)): - mfa_url = mfa_url_tmp + 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 = ( diff --git a/api_tests/institutions/views/test_institution_auth.py b/api_tests/institutions/views/test_institution_auth.py index 16be75b83ff..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 or res.status_code == 200 + 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 or res.status_code == 200 + 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 or res.status_code == 200 + assert res.status_code == 200 assert not mock_signals.signals_sent() user.reload() @@ -174,7 +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 or res.status_code == 200 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.fullname == 'Fake User' @@ -189,7 +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 or res.status_code == 200 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.fullname == 'Fake User' @@ -216,7 +216,7 @@ def test_user_active(self, app, institution, url_auth_institution): department='Fake Department', ) ) - assert res.status_code == 204 or res.status_code == 200 + assert res.status_code == 200 assert not mock_signals.signals_sent() user = OSFUser.objects.filter(username=username).first() @@ -255,7 +255,7 @@ def test_user_unclaimed(self, app, institution, url_auth_institution): department='Fake Department', ) ) - assert res.status_code == 204 or res.status_code == 200 + assert res.status_code == 200 assert mock_signals.signals_sent() == set([signals.user_confirmed]) user = OSFUser.objects.filter(username=username).first() @@ -290,7 +290,7 @@ def test_user_unconfirmed(self, app, institution, url_auth_institution): fullname='Fake User' ) ) - assert res.status_code == 204 or res.status_code == 200 + assert res.status_code == 200 assert mock_signals.signals_sent() == set([signals.user_confirmed]) user = OSFUser.objects.filter(username=username).first() @@ -412,7 +412,7 @@ def test_authenticate_jaSurname_and_jaGivenName_are_valid( jaGivenName=jagivenname, jaSurname=jasurname), expect_errors=True ) - assert res.status_code == 204 or res.status_code == 200 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.ext.data['idp_attr']['fullname_ja'] == jagivenname + ' ' + jasurname @@ -426,7 +426,7 @@ def test_authenticate_jaGivenName_is_valid( make_payload(institution, username, jaGivenName=jagivenname), expect_errors=True ) - assert res.status_code == 204 or res.status_code == 200 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.given_name_ja == jagivenname @@ -440,7 +440,7 @@ def test_authenticate_jaSurname_is_valid( make_payload(institution, username, jaSurname=jasurname), expect_errors=True ) - assert res.status_code == 204 or res.status_code == 200 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.family_name_ja == jasurname @@ -454,7 +454,7 @@ def test_authenticate_jaMiddleNames_is_valid( make_payload(institution, username, jaMiddleNames=middlename), expect_errors=True ) - assert res.status_code == 204 or res.status_code == 200 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.middle_names_ja == middlename @@ -468,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 or res.status_code == 200 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.given_name == given_name @@ -482,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 or res.status_code == 200 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.family_name == family_name @@ -496,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 or res.status_code == 200 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.middle_names == middle_names @@ -515,7 +515,7 @@ def test_authenticate_jaOrganizationalUnitName_is_valid( organizationName=organizationname), expect_errors=True ) - assert res.status_code == 204 or res.status_code == 200 + assert res.status_code == 200 user = OSFUser.objects.filter(username='tmp_eppn_' + username).first() assert user assert user.jobs[0]['department_ja'] == jaorganizationname @@ -534,7 +534,7 @@ def test_authenticate_OrganizationalUnitName_is_valid( organizationName=organizationname), expect_errors=True ) - assert res.status_code == 204 or res.status_code == 200 + assert res.status_code == 200 user = OSFUser.objects.filter(username='tmp_eppn_' + username).first() assert user assert user.jobs[0]['department'] == organizationnameunit @@ -569,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 or res.status_code == 200 + 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 index a8d1c285a77..f1cfea4310a 100644 --- a/api_tests/institutions/views/test_institution_auth_loa.py +++ b/api_tests/institutions/views/test_institution_auth_loa.py @@ -141,7 +141,7 @@ def test_aal2_extracted_from_edu_person_assurance( edu_person_assurance='https://www.gakunin.jp/profile/AAL2', ), ) - assert res.status_code in (200, 204) + assert res.status_code == 200 user = OSFUser.objects.get(username=username) assert user.aal == OSF_AAL2_VAR @@ -156,7 +156,7 @@ def test_aal1_extracted_from_edu_person_assurance( edu_person_assurance='https://www.gakunin.jp/profile/AAL1', ), ) - assert res.status_code in (200, 204) + assert res.status_code == 200 user = OSFUser.objects.get(username=username) assert user.aal == OSF_AAL1_VAR @@ -171,7 +171,7 @@ def test_ial2_extracted_from_edu_person_assurance( edu_person_assurance='https://www.gakunin.jp/profile/IAL2', ), ) - assert res.status_code in (200, 204) + assert res.status_code == 200 user = OSFUser.objects.get(username=username) assert user.ial == OSF_IAL2_VAR @@ -189,7 +189,7 @@ def test_aal_falls_back_to_shib_authn_context_class( shib_authn_context_class=shib_value, ), ) - assert res.status_code in (200, 204) + assert res.status_code == 200 user = OSFUser.objects.get(username=username) assert user.aal == shib_value @@ -209,7 +209,7 @@ def test_both_aal2_and_ial2_in_edu_person_assurance( edu_person_assurance=combined, ), ) - assert res.status_code in (200, 204) + assert res.status_code == 200 user = OSFUser.objects.get(username=username) assert user.aal == OSF_AAL2_VAR assert user.ial == OSF_IAL2_VAR @@ -227,7 +227,7 @@ def test_no_loa_record_allows_login( url_auth_institution, make_payload(institution, username), ) - assert res.status_code in (200, 204) + assert res.status_code == 200 # --------------------------------------------------------------- # LoA validation — AAL2 required @@ -248,10 +248,9 @@ def test_aal2_required_user_has_aal2_passes( edu_person_assurance='https://www.gakunin.jp/profile/AAL2', ), ) - assert res.status_code in (200, 204) + assert res.status_code == 200 # mfa_url should be empty because AAL2 requirement is met - if res.status_code == 200: - assert res.json.get('mfa_url', '') == '' + 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( @@ -297,6 +296,68 @@ def test_aal2_required_user_has_no_aal_returns_mfa_url( 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 # --------------------------------------------------------------- @@ -316,7 +377,7 @@ def test_aal1_required_user_has_aal1_passes( edu_person_assurance='https://www.gakunin.jp/profile/AAL1', ), ) - assert res.status_code in (200, 204) + assert res.status_code == 200 def test_aal1_required_user_has_no_aal_raises_error( self, app, institution, url_auth_institution, @@ -353,7 +414,7 @@ def test_ial2_required_user_has_ial2_passes( edu_person_assurance='https://www.gakunin.jp/profile/IAL2', ), ) - assert res.status_code in (200, 204) + assert res.status_code == 200 def test_ial2_required_user_has_no_ial_raises_error( self, app, institution, url_auth_institution, @@ -390,7 +451,7 @@ def test_ial1_required_user_has_ial_passes( edu_person_assurance='https://www.gakunin.jp/profile/IAL2', ), ) - assert res.status_code in (200, 204) + assert res.status_code == 200 def test_ial1_required_user_has_no_ial_raises_error( self, app, institution, url_auth_institution, @@ -430,9 +491,8 @@ def test_both_aal2_and_ial2_required_both_met( edu_person_assurance=combined, ), ) - assert res.status_code in (200, 204) - if res.status_code == 200: - assert res.json.get('mfa_url', '') == '' + 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, @@ -515,7 +575,7 @@ def test_idp_attr_stores_institution_id( url_auth_institution, make_payload(institution, username), ) - assert res.status_code in (200, 204) + 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