Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 71 additions & 49 deletions admin/base/urls.py
Original file line number Diff line number Diff line change
@@ -1,62 +1,84 @@
from django.conf.urls import include, url
from django.contrib import admin
from admin.base.settings import ADMIN_BASE, DEBUG
from django.http import Http404
from admin.base.settings import ADMIN_BASE, DEBUG, ENABLE_LOGIN_FORM
from admin.base import views

base_pattern = '^{}'.format(ADMIN_BASE)


def admin_login_disabled(request):
# admin.site.urls provides its own password-based login (AdminSite.login),
# which must not exist while password-based login is disabled.
raise Http404

admin_urls = [
url(r'^$', views.home, name='home'),
]

if not ENABLE_LOGIN_FORM:
# must be registered before admin.site.urls, otherwise ^admin/ matches first
admin_urls.append(url(r'^admin/login/$', admin_login_disabled, name='admin_login_disabled'))

admin_urls += [
url(r'^admin/', admin.site.urls),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ENABLE_LOGIN_FORM=False でも、ここで公開している /admin/login/ からパスワード認証が可能なように見えます。

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yacchin1205

ご指摘ありがとうございます。2件とも、ご指摘のとおりでした。コメントごとに、対応・経緯・同種の漏れがないことの確認を記載します。修正完了後に、改めてレビュー依頼させていただきます。

対応

ENABLE_LOGIN_FORM が無効の場合、/admin/login/ を 404 とします(admin/base/urls.py で admin.site.urls より前に登録します)。学認でログイン済みのユーザは、引き続き Django 管理サイトを利用できます。UT・結合試験・総合テストに、404 となることの確認を追加します。

経緯

  • 本対応は、保守チケットで示された URL パスを対象に始めており、/admin/ 配下は対象に含まれていませんでした。
  • その後、パスワード関連の URL への対処として、パスワードによるログインを止めて学認ログインに統一する方針(ENABLE_LOGIN_FORM 連動)を採りました。この時点でパスワード認証の入口をすべて洗い出すべきところ、本リポジトリ内のログイン処理(LoginView)のみを対象としていました。Django 管理サイトのログインは Django 本体側で認証を呼び出すため、リポジトリ内の確認では対象から漏れていました。

同種の漏れがないことの確認

  • パスワード認証の入口:管理画面でパスワード認証を行うのは、LoginView と Django 管理サイトのログインの2か所のみです(管理画面の DRF の認証は SessionAuthentication のみ)。今回の修正で、ENABLE_LOGIN_FORM が無効の場合は両方とも使えなくなります。
  • Django 管理サイト配下のほかの URL:ログイン以外の URL は、Django の AdminSite.admin_view により、is_active かつ is_staff のユーザ以外はログイン画面へ転送されます。未ログインで利用できる URL は、ログイン以外にありません。

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yacchin1205
対応が完了しましたので、改めて、レビュー依頼を投げさせていただきました。

url(r'^asset_files/', include('admin.asset_files.urls', namespace='asset_files')),
url(r'^banners/', include('admin.banners.urls', namespace='banners')),
url(r'^brands/', include('admin.brands.urls', namespace='brands')),
url(r'^spam/', include('admin.spam.urls', namespace='spam')),
url(r'^institutions/', include('admin.institutions.urls', namespace='institutions')),
url(r'^entitlements/', include('admin.entitlements.urls', namespace='entitlements')),
url(r'^quota_recalc/', include('admin.quota_recalc.urls', namespace='quota_recalc')),
url(r'^preprint_providers/', include('admin.preprint_providers.urls', namespace='preprint_providers')),
url(r'^collection_providers/', include('admin.collection_providers.urls', namespace='collection_providers')),
url(r'^registration_providers/', include('admin.registration_providers.urls', namespace='registration_providers')),
url(r'^account/', include('admin.common_auth.urls', namespace='auth')),
]

if ENABLE_LOGIN_FORM:
# password recovery/reset only makes sense while password-based login itself is enabled
admin_urls.append(url(r'^password/', include('password_reset.urls')))

admin_urls += [
url(r'^nodes/', include('admin.nodes.urls', namespace='nodes')),
url(r'^preprints/', include('admin.preprints.urls', namespace='preprints')),
url(r'^subjects/', include('admin.subjects.urls', namespace='subjects')),
url(r'^users/', include('admin.users.urls', namespace='users')),
url(r'^user-emails/', include('admin.user_emails.urls', namespace='user-emails')),
url(r'^maintenance/', include('admin.maintenance.urls', namespace='maintenance')),
url(r'^meetings/', include('admin.meetings.urls',
namespace='meetings')),
url(r'^metrics/', include('admin.metrics.urls',
namespace='metrics')),
url(r'^desk/', include('admin.desk.urls',
namespace='desk')),
url(r'^osf_groups/', include('admin.osf_groups.urls', namespace='osf_groups')),
url(r'^management/', include('admin.management.urls', namespace='management')),
url(r'^announcement/', include('admin.rdm_announcement.urls', namespace='announcement')),
url(r'^addons/', include('admin.rdm_addons.urls', namespace='addons')),
url(r'^oauth/', include('admin.rdm_addons.oauth.urls', namespace='oauth')),
url(r'^statistics/', include('admin.rdm_statistics.urls', namespace='statistics')),
url(r'^timestampadd/', include('admin.rdm_timestampadd.urls', namespace='timestampadd')),
url(r'^keymanagement/', include('admin.rdm_keymanagement.urls', namespace='keymanagement')),
url(r'^timestampsettings/', include('admin.rdm_timestampsettings.urls', namespace='timestampsettings')),
url(r'^custom_storage_location/', include('admin.rdm_custom_storage_location.urls', namespace='custom_storage_location')),
url(r'^institutional_storage_quota_control/', include('admin.institutional_storage_quota_control.urls',
namespace='institutional_storage_quota_control')),
url(r'^metadata/', include('admin.rdm_metadata.urls', namespace='metadata')),
url(r'^user_identification_information/',
include('admin.user_identification_information.urls', namespace='user_identification_information')),
url(r'^user_identification_information_admin/',
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')),
]

urlpatterns = [
### ADMIN ###
url(
base_pattern,
include([
url(r'^$', views.home, name='home'),
url(r'^admin/', admin.site.urls),
url(r'^asset_files/', include('admin.asset_files.urls', namespace='asset_files')),
url(r'^banners/', include('admin.banners.urls', namespace='banners')),
url(r'^brands/', include('admin.brands.urls', namespace='brands')),
url(r'^spam/', include('admin.spam.urls', namespace='spam')),
url(r'^institutions/', include('admin.institutions.urls', namespace='institutions')),
url(r'^entitlements/', include('admin.entitlements.urls', namespace='entitlements')),
url(r'^quota_recalc/', include('admin.quota_recalc.urls', namespace='quota_recalc')),
url(r'^preprint_providers/', include('admin.preprint_providers.urls', namespace='preprint_providers')),
url(r'^collection_providers/', include('admin.collection_providers.urls', namespace='collection_providers')),
url(r'^registration_providers/', include('admin.registration_providers.urls', namespace='registration_providers')),
url(r'^account/', include('admin.common_auth.urls', namespace='auth')),
url(r'^password/', include('password_reset.urls')),
url(r'^nodes/', include('admin.nodes.urls', namespace='nodes')),
url(r'^preprints/', include('admin.preprints.urls', namespace='preprints')),
url(r'^subjects/', include('admin.subjects.urls', namespace='subjects')),
url(r'^users/', include('admin.users.urls', namespace='users')),
url(r'^user-emails/', include('admin.user_emails.urls', namespace='user-emails')),
url(r'^maintenance/', include('admin.maintenance.urls', namespace='maintenance')),
url(r'^meetings/', include('admin.meetings.urls',
namespace='meetings')),
url(r'^metrics/', include('admin.metrics.urls',
namespace='metrics')),
url(r'^desk/', include('admin.desk.urls',
namespace='desk')),
url(r'^osf_groups/', include('admin.osf_groups.urls', namespace='osf_groups')),
url(r'^management/', include('admin.management.urls', namespace='management')),
url(r'^announcement/', include('admin.rdm_announcement.urls', namespace='announcement')),
url(r'^addons/', include('admin.rdm_addons.urls', namespace='addons')),
url(r'^oauth/', include('admin.rdm_addons.oauth.urls', namespace='oauth')),
url(r'^statistics/', include('admin.rdm_statistics.urls', namespace='statistics')),
url(r'^timestampadd/', include('admin.rdm_timestampadd.urls', namespace='timestampadd')),
url(r'^keymanagement/', include('admin.rdm_keymanagement.urls', namespace='keymanagement')),
url(r'^timestampsettings/', include('admin.rdm_timestampsettings.urls', namespace='timestampsettings')),
url(r'^custom_storage_location/', include('admin.rdm_custom_storage_location.urls', namespace='custom_storage_location')),
url(r'^institutional_storage_quota_control/', include('admin.institutional_storage_quota_control.urls',
namespace='institutional_storage_quota_control')),
url(r'^metadata/', include('admin.rdm_metadata.urls', namespace='metadata')),
url(r'^user_identification_information/',
include('admin.user_identification_information.urls', namespace='user_identification_information')),
url(r'^user_identification_information_admin/',
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')),
]),
include(admin_urls),
),
]

Expand Down
25 changes: 25 additions & 0 deletions admin/base/utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""
Utility functions and classes
"""
from functools import wraps

from osf.models import Subject, NodeLicense, Brand

from django.core.exceptions import ValidationError, PermissionDenied
Expand Down Expand Up @@ -35,6 +37,29 @@ def osf_staff_check(user):
return user.is_authenticated and user.is_staff


def osf_superuser_check(user):
"""An inactive or unregistered superuser is not an Integrated Admin."""
return bool(
user.is_authenticated and
user.is_active and
user.is_registered and
user.is_superuser
)


def superuser_required(view_func):
"""Function-view decorator that returns 403 (PermissionDenied) instead of
redirecting to login, unlike django.contrib.auth.decorators.user_passes_test."""

@wraps(view_func)
def wrapped_view(request, *args, **kwargs):
if not osf_superuser_check(request.user):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

無効化されたsuperuserを許可してしまうコードに見えます。他のコードと合わせるという点で、

def is_super_admin(self):
"""determine whether the user is super or not"""
user = self.request.user
if not (user.is_active and user.is_registered):
# invalid user
return False
if user.is_superuser:
return True
return False
のように、is_active と is_registered の確認をしておくのが手堅いかと思いました。

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yacchin1205

こちらも同様に修正完了後に、改めてレビュー依頼させていただきます。

対応

osf_superuser_check を、RdmPermissionMixin.is_super_admin と同じ条件(is_active と is_registered を確認)に揃えます。@superuser_required を使用する3つの view について、無効化・未登録の superuser が 403 となる確認を UT と結合試験に追加します。

同じファイルの osf_staff_check も is_active・is_registered を確認していませんが、使用箇所はトップ画面(admin/base/views.py の home)のみで、ログインユーザ本人の名前と各画面へのメニューを表示するだけのため、今回は変更しません。

経緯

  • superuser_required は、関数で書かれた view 向けに今回新設したデコレータです。判定(osf_superuser_check)は、同じファイルの既存の osf_staff_check と同じ形(ログイン済みかと権限フラグのみ)で作成しており、既存の RdmPermissionMixin.is_super_admin と条件を揃えていませんでした。
  • 試験も権限の種類ごとの許可・拒否のみを確認しており、無効化・未登録の状態を条件に含めていませんでした。

同種の漏れがないことの確認

  • 本 PR のほかのユーザ権限の判定は、既存の RdmPermissionMixin、または同じアプリの既存の画面と同じ PermissionRequiredMixin を使用しており、いずれも無効化されたユーザを拒否します。既存の判定と条件が異なる新設の判定は、superuser_required のみでした。

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yacchin1205
対応が完了しましたので、改めて、レビュー依頼を投げさせていただきました。

raise PermissionDenied
return view_func(request, *args, **kwargs)

return wrapped_view


def get_subject_rules(subjects_selected):
"""
Take a list of subjects, and parse them into rules consistent with preprpint provider
Expand Down
8 changes: 5 additions & 3 deletions admin/collection_providers/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from django.core import serializers
from django.core.exceptions import ValidationError
from django.urls import reverse_lazy
from django.shortcuts import redirect
from django.shortcuts import redirect, get_object_or_404
from django.views.generic import View, CreateView, ListView, DetailView, UpdateView, DeleteView, TemplateView
from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
Expand Down Expand Up @@ -228,12 +228,14 @@ def get_object(self, queryset=None):
return CollectionProvider.objects.get(id=self.kwargs['collection_provider_id'])


class CannotDeleteProvider(TemplateView):
class CannotDeleteProvider(PermissionRequiredMixin, TemplateView):
permission_required = 'osf.delete_collectionprovider'
raise_exception = True
template_name = 'collection_providers/cannot_delete.html'

def get_context_data(self, **kwargs):
context = super(CannotDeleteProvider, self).get_context_data(**kwargs)
context['provider'] = CollectionProvider.objects.get(id=self.kwargs['collection_provider_id'])
context['provider'] = get_object_or_404(CollectionProvider, id=self.kwargs['collection_provider_id'])
return context


Expand Down
10 changes: 6 additions & 4 deletions admin/common_auth/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,12 @@ def dispatch(self, request, *args, **kwargs):
return super(LoginView, self).dispatch(request, *args, **kwargs)

def form_valid(self, form):
user = authenticate(
username=form.cleaned_data.get('email').strip(),
password=form.cleaned_data.get('password').strip()
)
user = None
if ENABLE_LOGIN_FORM:
user = authenticate(
username=form.cleaned_data.get('email').strip(),
password=form.cleaned_data.get('password').strip()
)
if user is not None:
login(self.request, user)
else:
Expand Down
18 changes: 16 additions & 2 deletions admin/management/views.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,29 @@
from django.contrib.auth.mixins import UserPassesTestMixin
from django.views.generic import TemplateView, View
from osf.management.commands.manage_switch_flags import manage_waffle
from django.core.urlresolvers import reverse
from django.shortcuts import redirect

class ManagementCommands(TemplateView):
from admin.rdm.utils import RdmPermissionMixin

class ManagementCommands(RdmPermissionMixin, UserPassesTestMixin, TemplateView):
""" Basic form to trigger various management commands
"""
template_name = 'management/commands.html'
object_type = 'management'
raise_exception = True

def test_func(self):
"""check user permissions"""
return self.is_super_admin


class WaffleFlag(RdmPermissionMixin, UserPassesTestMixin, View):
raise_exception = True

class WaffleFlag(View):
def test_func(self):
"""check user permissions"""
return self.is_super_admin

def post(self, request, *args, **kwargs):
manage_waffle()
Expand Down
8 changes: 5 additions & 3 deletions admin/preprint_providers/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.forms.models import model_to_dict
from django.shortcuts import redirect, render
from django.shortcuts import redirect, render, get_object_or_404

from admin.base import settings
from admin.base.forms import ImportFileForm
Expand Down Expand Up @@ -319,12 +319,14 @@ def get_object(self, queryset=None):
return PreprintProvider.objects.get(id=self.kwargs['preprint_provider_id'])


class CannotDeleteProvider(TemplateView):
class CannotDeleteProvider(PermissionRequiredMixin, TemplateView):
permission_required = 'osf.delete_preprintprovider'
raise_exception = True
template_name = 'preprint_providers/cannot_delete.html'

def get_context_data(self, **kwargs):
context = super(CannotDeleteProvider, self).get_context_data(**kwargs)
context['provider'] = PreprintProvider.objects.get(id=self.kwargs['preprint_provider_id'])
context['provider'] = get_object_or_404(PreprintProvider, id=self.kwargs['preprint_provider_id'])
return context


Expand Down
3 changes: 3 additions & 0 deletions admin/quota_recalc/views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.http import JsonResponse
from django.db import transaction, IntegrityError

from admin.base.utils import superuser_required
from addons.osfstorage.models import Region
from api.base import settings as api_settings
from osf.models import OSFUser, UserQuota, Node
Expand Down Expand Up @@ -60,6 +61,7 @@ def calculate_quota(user):
user_quota.used = used
user_quota.save()

@superuser_required
def all_users(request, **kwargs):
c = 0
for osf_user in OSFUser.objects.exclude(deleted__isnull=False):
Expand All @@ -70,6 +72,7 @@ def all_users(request, **kwargs):
'message': str(c) + ' users\' quota successfully recalculated!'
})

@superuser_required
def user(request, guid, **kwargs):
user = OSFUser.load(guid)
if user is None:
Expand Down
2 changes: 1 addition & 1 deletion admin/rdm_custom_storage_location/cron.sh
Original file line number Diff line number Diff line change
@@ -1 +1 @@
*/30 * * * * curl http://localhost:8001/custom_storage_location/external_acc_update/d610ef95f0b0f5868f13919b8ed64070b9acb9c19b8da9f2c514ed938203ec3e236c9cad4f4146bdf22b4e79cf0d92f6d4f4c996d236c6b0ee79a1336b26afb7/ | jq -c .
*/30 * * * * TOKEN=$(cd /code && python3 manage.py generate_signed_cron_token | tail -n1) && [ -n "$TOKEN" ] && curl "http://localhost:8001/custom_storage_location/external_acc_update/$TOKEN/" | jq -c .
2 changes: 1 addition & 1 deletion admin/rdm_custom_storage_location/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@


urlpatterns = [
url(r'^external_acc_update/(?P<access_token>-?\w+)/$', views.external_acc_update, name='external_acc_update'),
url(r'^external_acc_update/(?P<ts>\d+)/(?P<signature>[0-9a-fA-F]+)/$', views.external_acc_update, name='external_acc_update'),
url(r'^institutional_storage/$', views.InstitutionalStorageView.as_view(), name='institutional_storage'),
url(r'^institutional_storage/institutions/$', views.InstitutionalStorageListView.as_view(), name='institutional_storage_institutions'),
url(r'^institutional_storage/institutions/(?P<institution_id>[0-9]+)/$', views.InstitutionalStorageView.as_view(), name='institutional_storage_list'),
Expand Down
7 changes: 3 additions & 4 deletions admin/rdm_custom_storage_location/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from django.views.generic import TemplateView, View, ListView
import codecs
import json
import hashlib
from rest_framework import status as http_status
from mimetypes import MimeTypes
import os
Expand All @@ -19,13 +18,13 @@
from osf.models import Institution, OSFUser
from osf.models.external import ExternalAccountTemporary
from scripts import refresh_addon_tokens
from framework.auth import cron_signed_url
from website import settings as osf_settings
from distutils.util import strtobool
from admin.base import settings

logger = logging.getLogger(__name__)

SITE_KEY = 'rdm_custom_storage_location'
INSTITUTION_NOT_FOUND_MESSAGE = 'Institution does not exist'

class InstitutionalStorageBaseView(RdmPermissionMixin, UserPassesTestMixin):
Expand Down Expand Up @@ -522,8 +521,8 @@ def post(self, request, *args, **kwargs):
'message': 'Garbage data removed!!'
}, status=http_status.HTTP_200_OK)

def external_acc_update(request, access_token):
if hashlib.sha512(SITE_KEY.encode('utf-8')).hexdigest() != access_token.lower():
def external_acc_update(request, ts, signature):
if not cron_signed_url.verify_signed_params(ts, signature):
return HttpResponse(
json.dumps({'state': 'fail', 'error': 'access forbidden'}),
content_type='application/json',
Expand Down
2 changes: 1 addition & 1 deletion admin/rdm_statistics/cron.sh
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0 2 * * 1 curl http://localhost:8001/statistics/gather/2A85563B2B0F7D3168199F475365F57DA1D56E4BB2CE2B7044EB058AE5E287637E7C636A772682D92C8D6B1830B9A97C5A5DC3DE7016C60BDE4BAA7CC3B38AEB/ | jq -c .
0 2 * * 1 TOKEN=$(cd /code && python3 manage.py generate_signed_cron_token | tail -n1) && [ -n "$TOKEN" ] && curl "http://localhost:8001/statistics/gather/$TOKEN/" | jq -c .
2 changes: 1 addition & 1 deletion admin/rdm_statistics/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
url(r'^index$', views.IndexView.as_view(), name='index'),
url(r'^(?P<institution_id>-?[0-9]+)/graph/(?P<graph_type>\w+)_(?P<provider>\w+)\.(\w+)$',
views.ImageView.as_view(), name='graph'),
url(r'^gather/(?P<access_token>-?\w+)/$', views.GatherView.as_view(), name='gather'),
url(r'^gather/(?P<ts>\d+)/(?P<signature>[0-9a-fA-F]+)/$', views.GatherView.as_view(), name='gather'),
url(r'^report/(?P<institution_id>-?[0-9]+)/$', views.create_pdf, name='report'),
url(r'^csv/(?P<institution_id>-?[0-9]+)/$', views.create_csv, name='csv'),
url(r'^mail/(?P<institution_id>-?[0-9]+)/$', views.SendView.as_view(), name='mail'),
Expand Down
Loading
Loading