mirror of
https://github.com/haiwen/seahub.git
synced 2025-09-17 15:53:28 +00:00
Improve adfs error msg (#5898)
* improve error msg * send error msg to sys/org admin * fix code
This commit is contained in:
@@ -19,6 +19,7 @@ const MSG_TYPE_DRAFT_REVIEWER = 'draft_reviewer';
|
|||||||
// const MSG_TYPE_GUEST_INVITATION_ACCEPTED = 'guest_invitation_accepted';
|
// const MSG_TYPE_GUEST_INVITATION_ACCEPTED = 'guest_invitation_accepted';
|
||||||
const MSG_TYPE_REPO_MONITOR = 'repo_monitor';
|
const MSG_TYPE_REPO_MONITOR = 'repo_monitor';
|
||||||
const MSG_TYPE_DELETED_FILES = 'deleted_files';
|
const MSG_TYPE_DELETED_FILES = 'deleted_files';
|
||||||
|
const MSG_TYPE_SAML_SSO_FAILED = 'saml_sso_failed';
|
||||||
|
|
||||||
class NoticeItem extends React.Component {
|
class NoticeItem extends React.Component {
|
||||||
|
|
||||||
@@ -282,6 +283,13 @@ class NoticeItem extends React.Component {
|
|||||||
return { avatar_url : null, notice };
|
return { avatar_url : null, notice };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (noticeType === MSG_TYPE_SAML_SSO_FAILED) {
|
||||||
|
const { error_msg } = detail;
|
||||||
|
let notice = gettext(error_msg);
|
||||||
|
|
||||||
|
return { avatar_url : null, notice };
|
||||||
|
}
|
||||||
|
|
||||||
// if (noticeType === MSG_TYPE_GUEST_INVITATION_ACCEPTED) {
|
// if (noticeType === MSG_TYPE_GUEST_INVITATION_ACCEPTED) {
|
||||||
|
|
||||||
// }
|
// }
|
||||||
|
@@ -49,14 +49,6 @@ class Saml2Backend(ModelBackend):
|
|||||||
logger.error('Session info or attribute mapping are None')
|
logger.error('Session info or attribute mapping are None')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if 'ava' not in session_info:
|
|
||||||
logger.error('"ava" key not found in session_info')
|
|
||||||
return None
|
|
||||||
|
|
||||||
attributes = session_info['ava']
|
|
||||||
if not attributes:
|
|
||||||
logger.warning('The attributes dictionary is empty')
|
|
||||||
|
|
||||||
name_id = session_info.get('name_id', '')
|
name_id = session_info.get('name_id', '')
|
||||||
if not name_id:
|
if not name_id:
|
||||||
logger.error('The name_id is not available. Could not determine user identifier.')
|
logger.error('The name_id is not available. Could not determine user identifier.')
|
||||||
@@ -97,6 +89,15 @@ class Saml2Backend(ModelBackend):
|
|||||||
notify_admins_on_register_complete(user.username)
|
notify_admins_on_register_complete(user.username)
|
||||||
|
|
||||||
if user:
|
if user:
|
||||||
|
if 'ava' not in session_info:
|
||||||
|
logger.warning('"ava" key not found in session_info')
|
||||||
|
return user
|
||||||
|
|
||||||
|
attributes = session_info['ava']
|
||||||
|
if not attributes:
|
||||||
|
logger.warning('The attributes dictionary is empty')
|
||||||
|
return user
|
||||||
|
|
||||||
self.make_profile(user, attributes, attribute_mapping)
|
self.make_profile(user, attributes, attribute_mapping)
|
||||||
self.sync_saml_groups(user, attributes)
|
self.sync_saml_groups(user, attributes)
|
||||||
|
|
||||||
|
4
seahub/adfs_auth/signals.py
Normal file
4
seahub/adfs_auth/signals.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from django.dispatch import Signal
|
||||||
|
|
||||||
|
saml_sso_failed = Signal()
|
@@ -35,18 +35,18 @@ def settings_check(func):
|
|||||||
error = True
|
error = True
|
||||||
else:
|
else:
|
||||||
if not XMLSEC_BINARY_PATH or not CERTS_DIR or not SAML_ATTRIBUTE_MAPPING or not SAML_PROVIDER_IDENTIFIER:
|
if not XMLSEC_BINARY_PATH or not CERTS_DIR or not SAML_ATTRIBUTE_MAPPING or not SAML_PROVIDER_IDENTIFIER:
|
||||||
logger.error('ADFS login relevant settings invalid.')
|
logger.error('ADFS/SAML login relevant settings invalid.')
|
||||||
logger.error('SAML_XMLSEC_BINARY_PATH: %s' % XMLSEC_BINARY_PATH)
|
logger.error('SAML_XMLSEC_BINARY_PATH: %s' % XMLSEC_BINARY_PATH)
|
||||||
logger.error('SAML_CERTS_DIR: %s' % CERTS_DIR)
|
logger.error('SAML_CERTS_DIR: %s' % CERTS_DIR)
|
||||||
logger.error('SAML_ATTRIBUTE_MAPPING: %s' % SAML_ATTRIBUTE_MAPPING)
|
logger.error('SAML_ATTRIBUTE_MAPPING: %s' % SAML_ATTRIBUTE_MAPPING)
|
||||||
logger.error('SAML_PROVIDER_IDENTIFIER: %s' % SAML_PROVIDER_IDENTIFIER)
|
logger.error('SAML_PROVIDER_IDENTIFIER: %s' % SAML_PROVIDER_IDENTIFIER)
|
||||||
error = True
|
error = True
|
||||||
if ENABLE_ADFS_LOGIN and not REMOTE_METADATA_URL:
|
if ENABLE_ADFS_LOGIN and not REMOTE_METADATA_URL:
|
||||||
logger.error('SAML relevant settings invalid.')
|
logger.error('ADFS/SAML login relevant settings invalid.')
|
||||||
logger.error('SAML_REMOTE_METADATA_URL: %s' % REMOTE_METADATA_URL)
|
logger.error('SAML_REMOTE_METADATA_URL: %s' % REMOTE_METADATA_URL)
|
||||||
error = True
|
error = True
|
||||||
if error:
|
if error:
|
||||||
raise Exception(_('Error, please contact administrator.'))
|
raise Exception(_('ADFS/SAML login relevant settings invalid.'))
|
||||||
return func(request)
|
return func(request)
|
||||||
return _decorated
|
return _decorated
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ def config_settings_loader(request):
|
|||||||
|
|
||||||
org_saml_config = OrgSAMLConfig.objects.get_config_by_org_id(org_id)
|
org_saml_config = OrgSAMLConfig.objects.get_config_by_org_id(org_id)
|
||||||
if not org_saml_config:
|
if not org_saml_config:
|
||||||
raise Exception('Failed to get org %s saml_config' % org_id)
|
raise Exception('Cannot find an ADFS/SAML config for the organization related to org_id %s.' % org_id)
|
||||||
|
|
||||||
# get org remote_metadata_url
|
# get org remote_metadata_url
|
||||||
remote_metadata_url = org_saml_config.metadata_url
|
remote_metadata_url = org_saml_config.metadata_url
|
||||||
@@ -131,6 +131,6 @@ def config_settings_loader(request):
|
|||||||
conf = SPConfig()
|
conf = SPConfig()
|
||||||
conf.load(copy.deepcopy(saml_config))
|
conf.load(copy.deepcopy(saml_config))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception('Failed to load saml config, error: %s' % e)
|
logger.exception('Failed to load adfs/saml config, error: %s' % e)
|
||||||
raise Exception('Failed to load saml config, error: %s' % e)
|
raise RuntimeError('Failed to load adfs/saml config, error: %s' % e)
|
||||||
return conf
|
return conf
|
||||||
|
@@ -20,6 +20,7 @@ from django.urls import reverse
|
|||||||
from django.http import HttpResponseRedirect, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, \
|
from django.http import HttpResponseRedirect, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, \
|
||||||
HttpResponsePermanentRedirect
|
HttpResponsePermanentRedirect
|
||||||
from django.utils.http import url_has_allowed_host_and_scheme
|
from django.utils.http import url_has_allowed_host_and_scheme
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from saml2 import BINDING_HTTP_POST
|
from saml2 import BINDING_HTTP_POST
|
||||||
@@ -28,7 +29,6 @@ from saml2.client import Saml2Client
|
|||||||
from saml2.metadata import entity_descriptor
|
from saml2.metadata import entity_descriptor
|
||||||
from djangosaml2.cache import IdentityCache, OutstandingQueriesCache
|
from djangosaml2.cache import IdentityCache, OutstandingQueriesCache
|
||||||
from djangosaml2.conf import get_config
|
from djangosaml2.conf import get_config
|
||||||
from djangosaml2.signals import post_authenticated
|
|
||||||
|
|
||||||
from seaserv import ccnet_api, seafile_api
|
from seaserv import ccnet_api, seafile_api
|
||||||
|
|
||||||
@@ -42,6 +42,7 @@ from seahub.profile.models import Profile, DetailedProfile
|
|||||||
from seahub.utils.licenseparse import user_number_over_limit
|
from seahub.utils.licenseparse import user_number_over_limit
|
||||||
from seahub.utils.file_size import get_quota_from_string
|
from seahub.utils.file_size import get_quota_from_string
|
||||||
from seahub.role_permissions.utils import get_enabled_role_permissions_by_role
|
from seahub.role_permissions.utils import get_enabled_role_permissions_by_role
|
||||||
|
from seahub.adfs_auth.signals import saml_sso_failed
|
||||||
# Added by khorkin
|
# Added by khorkin
|
||||||
from seahub.base.sudo_mode import update_sudo_mode_ts
|
from seahub.base.sudo_mode import update_sudo_mode_ts
|
||||||
try:
|
try:
|
||||||
@@ -60,6 +61,15 @@ def _set_subject_id(session, subject_id):
|
|||||||
session['_saml2_subject_id'] = code(subject_id)
|
session['_saml2_subject_id'] = code(subject_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_org_admins(org):
|
||||||
|
org_admins = list()
|
||||||
|
org_users = ccnet_api.get_org_emailusers(org.url_prefix, -1, -1)
|
||||||
|
for user in org_users:
|
||||||
|
if ccnet_api.is_org_staff(org.org_id, user.email):
|
||||||
|
org_admins.append(user)
|
||||||
|
return org_admins
|
||||||
|
|
||||||
|
|
||||||
def update_user_profile(user, attribute_mapping, attributes):
|
def update_user_profile(user, attribute_mapping, attributes):
|
||||||
parse_result = {}
|
parse_result = {}
|
||||||
for saml_attr, django_attrs in list(attribute_mapping.items()):
|
for saml_attr, django_attrs in list(attribute_mapping.items()):
|
||||||
@@ -111,12 +121,13 @@ def update_user_profile(user, attribute_mapping, attributes):
|
|||||||
|
|
||||||
|
|
||||||
def login(request, org_id=None):
|
def login(request, org_id=None):
|
||||||
|
org = None
|
||||||
if org_id and int(org_id) > 0:
|
if org_id and int(org_id) > 0:
|
||||||
org_id = int(org_id)
|
org_id = int(org_id)
|
||||||
org = ccnet_api.get_org_by_id(org_id)
|
org = ccnet_api.get_org_by_id(org_id)
|
||||||
if not org:
|
if not org:
|
||||||
logger.error('Cannot find an organization related to org_id %s.' % org_id)
|
logger.error('Cannot find an organization related to org_id %s.' % org_id)
|
||||||
return HttpResponseBadRequest('Cannot find an organization related to org_id %s.' % org_id)
|
return HttpResponseBadRequest(_('Internal server error. Please contact system administrator.'))
|
||||||
|
|
||||||
next_url = settings.LOGIN_REDIRECT_URL
|
next_url = settings.LOGIN_REDIRECT_URL
|
||||||
if 'next' in request.GET:
|
if 'next' in request.GET:
|
||||||
@@ -129,9 +140,23 @@ def login(request, org_id=None):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
sp_config = get_config(None, request)
|
sp_config = get_config(None, request)
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.error(e)
|
||||||
|
# send error msg to admin
|
||||||
|
error_msg = 'ADFS/SAML service error. Please check and fix the ADFS/SAML service.'
|
||||||
|
if org:
|
||||||
|
org_admins = get_org_admins(org)
|
||||||
|
for org_admin in org_admins:
|
||||||
|
saml_sso_failed.send(sender=None, to_user=org_admin.email, error_msg=error_msg)
|
||||||
|
else:
|
||||||
|
admins = User.objects.get_superusers()
|
||||||
|
for admin in admins:
|
||||||
|
saml_sso_failed.send(sender=None, to_user=admin.email, error_msg=error_msg)
|
||||||
|
return HttpResponseBadRequest(_('Login failed: ADFS/SAML service error. '
|
||||||
|
'Please report to your organization (company) administrator.'))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(e)
|
logger.error(e)
|
||||||
return HttpResponseBadRequest('Failed to get saml config, please check your ADFS/SAML service.')
|
return HttpResponseBadRequest(_('Internal server error. Please contact system administrator.'))
|
||||||
|
|
||||||
saml_client = Saml2Client(sp_config)
|
saml_client = Saml2Client(sp_config)
|
||||||
session_id, info = saml_client.prepare_for_authenticate(relay_state=next_url)
|
session_id, info = saml_client.prepare_for_authenticate(relay_state=next_url)
|
||||||
@@ -158,24 +183,50 @@ def assertion_consumer_service(request, org_id=None, attribute_mapping=None, cre
|
|||||||
settings.py. The `djangosaml2.backends.Saml2Backend` can be used for this purpose,
|
settings.py. The `djangosaml2.backends.Saml2Backend` can be used for this purpose,
|
||||||
though some implementations may instead register their own subclasses of Saml2Backend.
|
though some implementations may instead register their own subclasses of Saml2Backend.
|
||||||
"""
|
"""
|
||||||
if 'SAMLResponse' not in request.POST:
|
|
||||||
return HttpResponseBadRequest('Missing "SAMLResponse" parameter in POST data.')
|
|
||||||
|
|
||||||
org = None
|
org = None
|
||||||
if org_id and int(org_id) > 0:
|
if org_id and int(org_id) > 0:
|
||||||
org_id = int(org_id)
|
org_id = int(org_id)
|
||||||
org = ccnet_api.get_org_by_id(org_id)
|
org = ccnet_api.get_org_by_id(org_id)
|
||||||
if not org:
|
if not org:
|
||||||
logger.error('Cannot find an organization related to org_id %s.' % org_id)
|
logger.error('Cannot find an organization related to org_id %s.' % org_id)
|
||||||
return HttpResponseBadRequest('Cannot find an organization related to org_id %s.' % org_id)
|
return HttpResponseBadRequest(_('Internal server error. Please contact system administrator.'))
|
||||||
else:
|
else:
|
||||||
org_id = -1
|
org_id = -1
|
||||||
|
|
||||||
|
if 'SAMLResponse' not in request.POST:
|
||||||
|
logger.error('Missing "SAMLResponse" parameter in POST data.')
|
||||||
|
# send error msg to admin
|
||||||
|
error_msg = 'ADFS/SAML service error. Please check and fix the ADFS/SAML service.'
|
||||||
|
if org:
|
||||||
|
org_admins = get_org_admins(org)
|
||||||
|
for org_admin in org_admins:
|
||||||
|
saml_sso_failed.send(sender=None, to_user=org_admin.email, error_msg=error_msg)
|
||||||
|
else:
|
||||||
|
admins = User.objects.get_superusers()
|
||||||
|
for admin in admins:
|
||||||
|
saml_sso_failed.send(sender=None, to_user=admin.email, error_msg=error_msg)
|
||||||
|
return HttpResponseBadRequest(_('Login failed: Bad response from ADFS/SAML service. '
|
||||||
|
'Please report to your organization (company) administrator.'))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
conf = get_config(None, request)
|
conf = get_config(None, request)
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.error(e)
|
||||||
|
# send error msg to admin
|
||||||
|
error_msg = 'ADFS/SAML service error. Please check and fix the ADFS/SAML service.'
|
||||||
|
if org:
|
||||||
|
org_admins = get_org_admins(org)
|
||||||
|
for org_admin in org_admins:
|
||||||
|
saml_sso_failed.send(sender=None, to_user=org_admin.email, error_msg=error_msg)
|
||||||
|
else:
|
||||||
|
admins = User.objects.get_superusers()
|
||||||
|
for admin in admins:
|
||||||
|
saml_sso_failed.send(sender=None, to_user=admin.email, error_msg=error_msg)
|
||||||
|
return HttpResponseBadRequest(_('Login failed: ADFS/SAML service error. '
|
||||||
|
'Please report to your organization (company) administrator.'))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(e)
|
logger.error(e)
|
||||||
return HttpResponseBadRequest('Failed to get saml config, please check your ADFS/SAML service.')
|
return HttpResponseBadRequest(_('Internal server error. Please contact system administrator.'))
|
||||||
|
|
||||||
identity_cache = IdentityCache(request.saml_session)
|
identity_cache = IdentityCache(request.saml_session)
|
||||||
client = Saml2Client(conf, identity_cache=identity_cache)
|
client = Saml2Client(conf, identity_cache=identity_cache)
|
||||||
@@ -187,12 +238,33 @@ def assertion_consumer_service(request, org_id=None, attribute_mapping=None, cre
|
|||||||
try:
|
try:
|
||||||
response = client.parse_authn_request_response(xmlstr, BINDING_HTTP_POST, outstanding_queries)
|
response = client.parse_authn_request_response(xmlstr, BINDING_HTTP_POST, outstanding_queries)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(e)
|
logger.error('SAMLResponse Error: %s' % e)
|
||||||
return HttpResponseBadRequest('SAMLResponse Error')
|
# send error msg to admin
|
||||||
|
error_msg = 'ADFS/SAML service error. Please check and fix the ADFS/SAML service.'
|
||||||
|
if org:
|
||||||
|
org_admins = get_org_admins(org)
|
||||||
|
for org_admin in org_admins:
|
||||||
|
saml_sso_failed.send(sender=None, to_user=org_admin.email, error_msg=error_msg)
|
||||||
|
else:
|
||||||
|
admins = User.objects.get_superusers()
|
||||||
|
for admin in admins:
|
||||||
|
saml_sso_failed.send(sender=None, to_user=admin.email, error_msg=error_msg)
|
||||||
|
return HttpResponseBadRequest(_('Login failed: Bad response from ADFS/SAML service. '
|
||||||
|
'Please report to your organization (company) administrator.'))
|
||||||
if response is None:
|
if response is None:
|
||||||
logger.error('SAML response is None')
|
logger.error('Invalid SAML Assertion received.')
|
||||||
return HttpResponseBadRequest('SAML response has errors. Please check the logs')
|
# send error msg to admin
|
||||||
|
error_msg = 'ADFS/SAML service error. Please check and fix the ADFS/SAML service.'
|
||||||
|
if org:
|
||||||
|
org_admins = get_org_admins(org)
|
||||||
|
for org_admin in org_admins:
|
||||||
|
saml_sso_failed.send(sender=None, to_user=org_admin.email, error_msg=error_msg)
|
||||||
|
else:
|
||||||
|
admins = User.objects.get_superusers()
|
||||||
|
for admin in admins:
|
||||||
|
saml_sso_failed.send(sender=None, to_user=admin.email, error_msg=error_msg)
|
||||||
|
return HttpResponseBadRequest(_('Login failed: Bad response from ADFS/SAML service. '
|
||||||
|
'Please report to your organization (company) administrator.'))
|
||||||
|
|
||||||
session_id = response.session_id()
|
session_id = response.session_id()
|
||||||
oq_cache.delete(session_id)
|
oq_cache.delete(session_id)
|
||||||
@@ -204,18 +276,19 @@ def assertion_consumer_service(request, org_id=None, attribute_mapping=None, cre
|
|||||||
is_saml2_connect = parse_qs(urlparse(unquote(relay_state)).query).get('is_saml2_connect', [''])[0]
|
is_saml2_connect = parse_qs(urlparse(unquote(relay_state)).query).get('is_saml2_connect', [''])[0]
|
||||||
if is_saml2_connect == 'true':
|
if is_saml2_connect == 'true':
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return HttpResponseBadRequest('Failed to bind SAML, please login first.')
|
return HttpResponseBadRequest(_('Failed to bind SAML, please login first.'))
|
||||||
|
|
||||||
# get uid and other attrs from session_info
|
# get uid and other attrs from session_info
|
||||||
name_id = session_info.get('name_id', '')
|
name_id = session_info.get('name_id', '')
|
||||||
if not name_id:
|
if not name_id:
|
||||||
logger.error('The name_id is not available. Could not determine user identifier.')
|
logger.error('The name_id is not available. Could not determine user identifier.')
|
||||||
return HttpResponseBadRequest('Failed to bind SAML, please contact admin.')
|
return HttpResponseBadRequest(_('Internal server error. Please contact system administrator.'))
|
||||||
|
|
||||||
name_id = name_id.text
|
name_id = name_id.text
|
||||||
saml_user = SocialAuthUser.objects.get_by_provider_and_uid(SAML_PROVIDER_IDENTIFIER, name_id)
|
saml_user = SocialAuthUser.objects.get_by_provider_and_uid(SAML_PROVIDER_IDENTIFIER, name_id)
|
||||||
if saml_user:
|
if saml_user:
|
||||||
return HttpResponseBadRequest('The SAML user has already been bound to another account.')
|
logger.error('The SAML user has already been bound to another account.')
|
||||||
|
return HttpResponseBadRequest(_('Internal server error. Please contact system administrator.'))
|
||||||
|
|
||||||
# bind saml user
|
# bind saml user
|
||||||
username = request.user.username
|
username = request.user.username
|
||||||
@@ -236,7 +309,13 @@ def assertion_consumer_service(request, org_id=None, attribute_mapping=None, cre
|
|||||||
|
|
||||||
# check user number limit by license
|
# check user number limit by license
|
||||||
if user_number_over_limit():
|
if user_number_over_limit():
|
||||||
return HttpResponseForbidden('The number of users exceeds the license limit.')
|
logger.error('The number of users exceeds the license limit.')
|
||||||
|
# send error msg to admin
|
||||||
|
error_msg = 'The number of users exceeds the license limit.'
|
||||||
|
admins = User.objects.get_superusers()
|
||||||
|
for admin in admins:
|
||||||
|
saml_sso_failed.send(sender=None, to_user=admin.email, error_msg=error_msg)
|
||||||
|
return HttpResponseForbidden(_('Internal server error. Please contact system administrator.'))
|
||||||
|
|
||||||
# check user number limit by org member quota
|
# check user number limit by org member quota
|
||||||
if org:
|
if org:
|
||||||
@@ -245,7 +324,15 @@ def assertion_consumer_service(request, org_id=None, attribute_mapping=None, cre
|
|||||||
from seahub.organizations.models import OrgMemberQuota
|
from seahub.organizations.models import OrgMemberQuota
|
||||||
org_members_quota = OrgMemberQuota.objects.get_quota(org_id)
|
org_members_quota = OrgMemberQuota.objects.get_quota(org_id)
|
||||||
if org_members_quota is not None and org_members >= org_members_quota:
|
if org_members_quota is not None and org_members >= org_members_quota:
|
||||||
return HttpResponseForbidden('The number of users exceeds the organization quota.')
|
logger.error('The number of users exceeds the organization quota.')
|
||||||
|
# send error msg to admin
|
||||||
|
error_msg = 'Failed to create new user: the number of users exceeds the organization quota.'
|
||||||
|
org_admins = get_org_admins(org)
|
||||||
|
for org_admin in org_admins:
|
||||||
|
saml_sso_failed.send(sender=None, to_user=org_admin.email, error_msg=error_msg)
|
||||||
|
return HttpResponseForbidden(_('Failed to create new user: '
|
||||||
|
'the number of users exceeds the organization quota. '
|
||||||
|
'Please report to your organization (company) administrator.'))
|
||||||
|
|
||||||
# authenticate the remote user
|
# authenticate the remote user
|
||||||
logger.debug('Trying to authenticate the user')
|
logger.debug('Trying to authenticate the user')
|
||||||
@@ -254,17 +341,37 @@ def assertion_consumer_service(request, org_id=None, attribute_mapping=None, cre
|
|||||||
create_unknown_user=create_unknown_user,
|
create_unknown_user=create_unknown_user,
|
||||||
org_id=org_id)
|
org_id=org_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
logger.error('The user is None')
|
logger.error('ADFS/SAML single sign-on failed: failed to create user.')
|
||||||
return HttpResponseForbidden("Permission denied")
|
# send error msg to admin
|
||||||
|
error_msg = 'ADFS/SAML single sign-on failed: failed to create user.'
|
||||||
|
if org:
|
||||||
|
org_admins = get_org_admins(org)
|
||||||
|
for org_admin in org_admins:
|
||||||
|
saml_sso_failed.send(sender=None, to_user=org_admin.email, error_msg=error_msg)
|
||||||
|
else:
|
||||||
|
admins = User.objects.get_superusers()
|
||||||
|
for admin in admins:
|
||||||
|
saml_sso_failed.send(sender=None, to_user=admin.email, error_msg=error_msg)
|
||||||
|
return HttpResponseForbidden(_('Login failed: failed to create user. '
|
||||||
|
'Please report to your organization (company) administrator.'))
|
||||||
|
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
logger.error('The user is inactive')
|
logger.error('ADFS/SAML single sign-on failed: user %s is deactivated.' % user.username)
|
||||||
return HttpResponseForbidden("Permission denied")
|
# send error msg to admin
|
||||||
|
error_msg = 'ADFS/SAML single sign-on failed: user % is deactivated.' % user.username
|
||||||
|
if org:
|
||||||
|
org_admins = get_org_admins(org)
|
||||||
|
for org_admin in org_admins:
|
||||||
|
saml_sso_failed.send(sender=None, to_user=org_admin.email, error_msg=error_msg)
|
||||||
|
else:
|
||||||
|
admins = User.objects.get_superusers()
|
||||||
|
for admin in admins:
|
||||||
|
saml_sso_failed.send(sender=None, to_user=admin.email, error_msg=error_msg)
|
||||||
|
return HttpResponseForbidden(_('Login failed: user is deactivated. '
|
||||||
|
'Please report to your organization (company) administrator.'))
|
||||||
|
|
||||||
auth_login(request, user)
|
auth_login(request, user)
|
||||||
_set_subject_id(request.saml_session, session_info['name_id'])
|
_set_subject_id(request.saml_session, session_info['name_id'])
|
||||||
logger.debug('Sending the post_authenticated signal')
|
|
||||||
post_authenticated.send_robust(sender=user, session_info=session_info)
|
|
||||||
|
|
||||||
# redirect the user to the view where he came from
|
# redirect the user to the view where he came from
|
||||||
default_relay_state = settings.LOGIN_REDIRECT_URL
|
default_relay_state = settings.LOGIN_REDIRECT_URL
|
||||||
@@ -277,18 +384,34 @@ def assertion_consumer_service(request, org_id=None, attribute_mapping=None, cre
|
|||||||
|
|
||||||
|
|
||||||
def metadata(request, org_id=None):
|
def metadata(request, org_id=None):
|
||||||
|
org = None
|
||||||
if org_id and int(org_id) > 0:
|
if org_id and int(org_id) > 0:
|
||||||
org_id = int(org_id)
|
org_id = int(org_id)
|
||||||
org = ccnet_api.get_org_by_id(org_id)
|
org = ccnet_api.get_org_by_id(org_id)
|
||||||
if not org:
|
if not org:
|
||||||
logger.error('Cannot find an organization related to org_id %s.' % org_id)
|
logger.error('Cannot find an organization related to org_id %s.' % org_id)
|
||||||
return HttpResponseBadRequest('Cannot find an organization related to org_id %s.' % org_id)
|
return HttpResponseBadRequest(_('Internal server error. Please contact system administrator.'))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sp_config = get_config(None, request)
|
sp_config = get_config(None, request)
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.error(e)
|
||||||
|
# send error msg to admin
|
||||||
|
error_msg = 'ADFS/SAML service error. Please check and fix the ADFS/SAML service.'
|
||||||
|
if org:
|
||||||
|
org_admins = get_org_admins(org)
|
||||||
|
for org_admin in org_admins:
|
||||||
|
saml_sso_failed.send(sender=None, to_user=org_admin.email, error_msg=error_msg)
|
||||||
|
else:
|
||||||
|
admins = User.objects.get_superusers()
|
||||||
|
for admin in admins:
|
||||||
|
saml_sso_failed.send(sender=None, to_user=admin.email, error_msg=error_msg)
|
||||||
|
return HttpResponseBadRequest(_('Login failed: ADFS/SAML service error. '
|
||||||
|
'Please report to your organization (company) administrator.'))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(e)
|
logger.error(e)
|
||||||
return HttpResponseBadRequest('Failed to get saml config, please check your ADFS/SAML service.')
|
return HttpResponseBadRequest(_('Internal server error. Please contact system administrator.'))
|
||||||
|
|
||||||
sp_metadata = entity_descriptor(sp_config)
|
sp_metadata = entity_descriptor(sp_config)
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
content=str(sp_metadata).encode("utf-8"),
|
content=str(sp_metadata).encode("utf-8"),
|
||||||
@@ -298,16 +421,17 @@ def metadata(request, org_id=None):
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def saml2_connect(request, org_id=None):
|
def saml2_connect(request, org_id=None):
|
||||||
|
org = None
|
||||||
if org_id and int(org_id) > 0:
|
if org_id and int(org_id) > 0:
|
||||||
org_id = int(org_id)
|
org_id = int(org_id)
|
||||||
org = ccnet_api.get_org_by_id(org_id)
|
org = ccnet_api.get_org_by_id(org_id)
|
||||||
if not org:
|
if not org:
|
||||||
logger.error('Cannot find an organization related to org_id %s.' % org_id)
|
logger.error('Cannot find an organization related to org_id %s.' % org_id)
|
||||||
return HttpResponseBadRequest('Cannot find an organization related to org_id %s.' % org_id)
|
return HttpResponseBadRequest(_('Internal server error. Please contact system administrator.'))
|
||||||
|
|
||||||
if request.user.org.org_id != org_id:
|
if request.user.org.org_id != org_id:
|
||||||
logger.error('User %s does not belong to this organization: %s.' % (request.user.username, org.org_id))
|
logger.error('User %s does not belong to this organization: %s.' % (request.user.username, org.org_name))
|
||||||
return HttpResponseBadRequest('Failed to bind SAML, please contact admin.')
|
return HttpResponseBadRequest(_('Internal server error. Please contact system administrator.'))
|
||||||
|
|
||||||
next_url = settings.LOGIN_REDIRECT_URL
|
next_url = settings.LOGIN_REDIRECT_URL
|
||||||
if 'next' in request.GET:
|
if 'next' in request.GET:
|
||||||
@@ -321,9 +445,23 @@ def saml2_connect(request, org_id=None):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
sp_config = get_config(None, request)
|
sp_config = get_config(None, request)
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.error(e)
|
||||||
|
# send error msg to admin
|
||||||
|
error_msg = 'ADFS/SAML service error. Please check and fix the ADFS/SAML service.'
|
||||||
|
if org:
|
||||||
|
org_admins = get_org_admins(org)
|
||||||
|
for org_admin in org_admins:
|
||||||
|
saml_sso_failed.send(sender=None, to_user=org_admin.email, error_msg=error_msg)
|
||||||
|
else:
|
||||||
|
admins = User.objects.get_superusers()
|
||||||
|
for admin in admins:
|
||||||
|
saml_sso_failed.send(sender=None, to_user=admin.email, error_msg=error_msg)
|
||||||
|
return HttpResponseBadRequest(_('Login failed: ADFS/SAML service error. '
|
||||||
|
'Please report to your organization (company) administrator.'))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(e)
|
logger.error(e)
|
||||||
return HttpResponseBadRequest('Failed to get ADFS/SAML config, please check your ADFS/SAML service.')
|
return HttpResponseBadRequest(_('Internal server error. Please contact system administrator.'))
|
||||||
|
|
||||||
saml_client = Saml2Client(sp_config)
|
saml_client = Saml2Client(sp_config)
|
||||||
session_id, info = saml_client.prepare_for_authenticate(relay_state=next_url)
|
session_id, info = saml_client.prepare_for_authenticate(relay_state=next_url)
|
||||||
@@ -347,18 +485,20 @@ def saml2_disconnect(request, org_id=None):
|
|||||||
org_id = int(org_id)
|
org_id = int(org_id)
|
||||||
org = ccnet_api.get_org_by_id(org_id)
|
org = ccnet_api.get_org_by_id(org_id)
|
||||||
if not org:
|
if not org:
|
||||||
return HttpResponseBadRequest('Cannot find an organization related to org_id %s.' % org_id)
|
logger.error('Cannot find an organization related to org_id %s.' % org_id)
|
||||||
|
return HttpResponseBadRequest(_('Internal server error. Please contact system administrator.'))
|
||||||
|
|
||||||
if request.user.org.org_id != org_id:
|
if request.user.org.org_id != org_id:
|
||||||
logger.error('User %s does not belong to this organization: %s.' % (request.user.username, org.org_id))
|
logger.error('User %s does not belong to this organization: %s.' % (request.user.username, org.org_name))
|
||||||
return HttpResponseBadRequest('Failed to disbind SAML, please contact admin.')
|
return HttpResponseBadRequest(_('Internal server error. Please contact system administrator.'))
|
||||||
|
|
||||||
username = request.user.username
|
username = request.user.username
|
||||||
if request.user.enc_password == '!':
|
if request.user.enc_password == '!':
|
||||||
return HttpResponseBadRequest('Failed to disbind SAML, please set a password first.')
|
return HttpResponseBadRequest(_('Failed to unbind SAML, please set a password first.'))
|
||||||
|
|
||||||
profile = Profile.objects.get_profile_by_user(username)
|
profile = Profile.objects.get_profile_by_user(username)
|
||||||
if not profile or not profile.contact_email:
|
if not profile or not profile.contact_email:
|
||||||
return HttpResponseBadRequest('Failed to disbind SAML, please set a contact email first.')
|
return HttpResponseBadRequest(_('Failed to unbind SAML, please set a contact email first.'))
|
||||||
|
|
||||||
SocialAuthUser.objects.delete_by_username_and_provider(username, SAML_PROVIDER_IDENTIFIER)
|
SocialAuthUser.objects.delete_by_username_and_provider(username, SAML_PROVIDER_IDENTIFIER)
|
||||||
next_url = request.GET.get(auth.REDIRECT_FIELD_NAME, settings.LOGIN_REDIRECT_URL)
|
next_url = request.GET.get(auth.REDIRECT_FIELD_NAME, settings.LOGIN_REDIRECT_URL)
|
||||||
@@ -414,11 +554,11 @@ def adfs_compatible_view(request, url_prefix):
|
|||||||
org = ccnet_api.get_org_by_url_prefix(url_prefix)
|
org = ccnet_api.get_org_by_url_prefix(url_prefix)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(e)
|
logger.error(e)
|
||||||
return HttpResponseBadRequest('login failed, please contact admin')
|
return HttpResponseBadRequest(_('Internal server error. Please contact system administrator.'))
|
||||||
|
|
||||||
if not org:
|
if not org:
|
||||||
logger.error('Cannot find an organization related to url_prefix %s.' % url_prefix)
|
logger.error('Cannot find an organization related to url_prefix %s.' % url_prefix)
|
||||||
return HttpResponseBadRequest('Cannot find an organization related to url_prefix %s.' % url_prefix)
|
return HttpResponseBadRequest(_('Internal server error. Please contact system administrator.'))
|
||||||
|
|
||||||
org_id = str(org.org_id)
|
org_id = str(org.org_id)
|
||||||
return HttpResponsePermanentRedirect(request.path.replace(url_prefix, org_id))
|
return HttpResponsePermanentRedirect(request.path.replace(url_prefix, org_id))
|
||||||
|
@@ -501,7 +501,7 @@ def multi_adfs_sso(request):
|
|||||||
try:
|
try:
|
||||||
org_saml_config = OrgSAMLConfig.objects.get_config_by_domain(domain)
|
org_saml_config = OrgSAMLConfig.objects.get_config_by_domain(domain)
|
||||||
if not org_saml_config:
|
if not org_saml_config:
|
||||||
render_data['error_msg'] = "Cannot find a SAML config for the team related to domain %s." % domain
|
render_data['error_msg'] = "Cannot find an ADFS/SAML config for the team related to domain %s." % domain
|
||||||
return render(request, template_name, render_data)
|
return render(request, template_name, render_data)
|
||||||
if not org_saml_config.domain_verified:
|
if not org_saml_config.domain_verified:
|
||||||
render_data['error_msg'] = \
|
render_data['error_msg'] = \
|
||||||
@@ -510,7 +510,7 @@ def multi_adfs_sso(request):
|
|||||||
org_id = org_saml_config.org_id
|
org_id = org_saml_config.org_id
|
||||||
org = ccnet_api.get_org_by_id(org_id)
|
org = ccnet_api.get_org_by_id(org_id)
|
||||||
if not org:
|
if not org:
|
||||||
render_data['error_msg'] = "Cannot find a SAML config for the team related to domain %s." % domain
|
render_data['error_msg'] = "Cannot find an ADFS/SAML config for the team related to domain %s." % domain
|
||||||
return render(request, template_name, render_data)
|
return render(request, template_name, render_data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(e)
|
logger.error(e)
|
||||||
|
@@ -258,6 +258,11 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
return notice
|
return notice
|
||||||
|
|
||||||
|
def format_saml_sso_error_msg(self, notice):
|
||||||
|
d = json.loads(notice.detail)
|
||||||
|
notice.error_msg = d['error_msg']
|
||||||
|
return notice
|
||||||
|
|
||||||
def format_sdoc_msg(self, sdoc_queryset, sdoc_notice):
|
def format_sdoc_msg(self, sdoc_queryset, sdoc_notice):
|
||||||
sdoc_obj = sdoc_queryset.filter(uuid=sdoc_notice.doc_uuid).first()
|
sdoc_obj = sdoc_queryset.filter(uuid=sdoc_notice.doc_uuid).first()
|
||||||
if not sdoc_obj:
|
if not sdoc_obj:
|
||||||
@@ -421,6 +426,9 @@ class Command(BaseCommand):
|
|||||||
elif notice.is_repo_monitor_msg():
|
elif notice.is_repo_monitor_msg():
|
||||||
notice = self.format_repo_monitor_msg(notice)
|
notice = self.format_repo_monitor_msg(notice)
|
||||||
|
|
||||||
|
elif notice.is_saml_sso_error_msg():
|
||||||
|
notice = self.format_saml_sso_error_msg(notice)
|
||||||
|
|
||||||
if notice is None:
|
if notice is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
@@ -76,6 +76,7 @@ MSG_TYPE_GUEST_INVITATION_ACCEPTED = 'guest_invitation_accepted'
|
|||||||
MSG_TYPE_REPO_TRANSFER = 'repo_transfer'
|
MSG_TYPE_REPO_TRANSFER = 'repo_transfer'
|
||||||
MSG_TYPE_REPO_MINOTOR = 'repo_monitor'
|
MSG_TYPE_REPO_MINOTOR = 'repo_monitor'
|
||||||
MSG_TYPE_DELETED_FILES = 'deleted_files'
|
MSG_TYPE_DELETED_FILES = 'deleted_files'
|
||||||
|
MSG_TYPE_SAML_SSO_FAILED = 'saml_sso_failed'
|
||||||
|
|
||||||
USER_NOTIFICATION_COUNT_CACHE_PREFIX = 'USER_NOTIFICATION_COUNT_'
|
USER_NOTIFICATION_COUNT_CACHE_PREFIX = 'USER_NOTIFICATION_COUNT_'
|
||||||
|
|
||||||
@@ -133,6 +134,11 @@ def repo_transfer_msg_to_json(org_id, repo_owner, repo_id, repo_name):
|
|||||||
return json.dumps({'org_id': org_id, 'repo_owner': repo_owner,
|
return json.dumps({'org_id': org_id, 'repo_owner': repo_owner,
|
||||||
'repo_id': repo_id, 'repo_name': repo_name})
|
'repo_id': repo_id, 'repo_name': repo_name})
|
||||||
|
|
||||||
|
|
||||||
|
def saml_sso_error_msg_to_json(error_msg):
|
||||||
|
return json.dumps({'error_msg': error_msg})
|
||||||
|
|
||||||
|
|
||||||
def get_cache_key_of_unseen_notifications(username):
|
def get_cache_key_of_unseen_notifications(username):
|
||||||
return normalize_cache_key(username,
|
return normalize_cache_key(username,
|
||||||
USER_NOTIFICATION_COUNT_CACHE_PREFIX)
|
USER_NOTIFICATION_COUNT_CACHE_PREFIX)
|
||||||
@@ -309,6 +315,11 @@ class UserNotificationManager(models.Manager):
|
|||||||
return self._add_user_notification(
|
return self._add_user_notification(
|
||||||
to_user, MSG_TYPE_REPO_TRANSFER, detail)
|
to_user, MSG_TYPE_REPO_TRANSFER, detail)
|
||||||
|
|
||||||
|
def add_saml_sso_error_msg(self, to_user, detail):
|
||||||
|
"""Notify ``to_user`` that saml sso occurred an error
|
||||||
|
"""
|
||||||
|
return self._add_user_notification(to_user, MSG_TYPE_SAML_SSO_FAILED, detail)
|
||||||
|
|
||||||
|
|
||||||
class UserNotification(models.Model):
|
class UserNotification(models.Model):
|
||||||
to_user = LowerCaseCharField(db_index=True, max_length=255)
|
to_user = LowerCaseCharField(db_index=True, max_length=255)
|
||||||
@@ -411,6 +422,9 @@ class UserNotification(models.Model):
|
|||||||
def is_deleted_files_msg(self):
|
def is_deleted_files_msg(self):
|
||||||
return self.msg_type == MSG_TYPE_DELETED_FILES
|
return self.msg_type == MSG_TYPE_DELETED_FILES
|
||||||
|
|
||||||
|
def is_saml_sso_error_msg(self):
|
||||||
|
return self.msg_type == MSG_TYPE_SAML_SSO_FAILED
|
||||||
|
|
||||||
def user_message_detail_to_dict(self):
|
def user_message_detail_to_dict(self):
|
||||||
"""Parse user message detail, returns dict contains ``message`` and
|
"""Parse user message detail, returns dict contains ``message`` and
|
||||||
``msg_from``.
|
``msg_from``.
|
||||||
@@ -780,6 +794,8 @@ from seahub.share.signals import share_repo_to_user_successful, \
|
|||||||
from seahub.invitations.signals import accept_guest_invitation_successful
|
from seahub.invitations.signals import accept_guest_invitation_successful
|
||||||
from seahub.drafts.signals import comment_draft_successful, \
|
from seahub.drafts.signals import comment_draft_successful, \
|
||||||
request_reviewer_successful
|
request_reviewer_successful
|
||||||
|
from seahub.adfs_auth.signals import saml_sso_failed
|
||||||
|
|
||||||
|
|
||||||
@receiver(upload_file_successful)
|
@receiver(upload_file_successful)
|
||||||
def add_upload_file_msg_cb(sender, **kwargs):
|
def add_upload_file_msg_cb(sender, **kwargs):
|
||||||
@@ -918,3 +934,12 @@ def repo_transfer_cb(sender, **kwargs):
|
|||||||
|
|
||||||
detail = repo_transfer_msg_to_json(org_id, repo_owner, repo_id, repo_name)
|
detail = repo_transfer_msg_to_json(org_id, repo_owner, repo_id, repo_name)
|
||||||
UserNotification.objects.add_repo_transfer_msg(to_user, detail)
|
UserNotification.objects.add_repo_transfer_msg(to_user, detail)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(saml_sso_failed)
|
||||||
|
def saml_sso_failed_cb(sender, **kwargs):
|
||||||
|
to_user = kwargs['to_user']
|
||||||
|
error_msg = kwargs['error_msg']
|
||||||
|
|
||||||
|
detail = saml_sso_error_msg_to_json(error_msg)
|
||||||
|
UserNotification.objects.add_saml_sso_error_msg(to_user, detail)
|
||||||
|
@@ -65,6 +65,9 @@ You've got {{num}} new notices on {{ site_name }}:
|
|||||||
{% elif notice.is_deleted_files_msg %}
|
{% elif notice.is_deleted_files_msg %}
|
||||||
<p style="line-height:1.5; margin:.2em 10px .2em 0;">{% blocktrans with repo_url=notice.repo_url repo_name=notice.repo_name %}A large number of files in your library <a href="{{url_base}}{{ repo_url }}">{{ repo_name }}</a> has been deleted recently.{% endblocktrans %}</p>
|
<p style="line-height:1.5; margin:.2em 10px .2em 0;">{% blocktrans with repo_url=notice.repo_url repo_name=notice.repo_name %}A large number of files in your library <a href="{{url_base}}{{ repo_url }}">{{ repo_name }}</a> has been deleted recently.{% endblocktrans %}</p>
|
||||||
|
|
||||||
|
{% elif notice.is_saml_sso_error_msg %}
|
||||||
|
<p style="line-height:1.5; margin:.2em 10px .2em 0;">{% blocktrans %}{{notice.error_msg}}{% endblocktrans %}</p>
|
||||||
|
|
||||||
{% elif notice.is_repo_monitor_msg %}
|
{% elif notice.is_repo_monitor_msg %}
|
||||||
<p style="line-height:1.5; margin:.2em 10px .2em 0;">
|
<p style="line-height:1.5; margin:.2em 10px .2em 0;">
|
||||||
{% if notice.obj_type == 'file' %}
|
{% if notice.obj_type == 'file' %}
|
||||||
|
@@ -268,6 +268,13 @@ def update_notice_detail(request, notices):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(e)
|
logger.error(e)
|
||||||
|
|
||||||
|
elif notice.is_saml_sso_error_msg():
|
||||||
|
try:
|
||||||
|
d = json.loads(notice.detail)
|
||||||
|
notice.detail = d
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(e)
|
||||||
|
|
||||||
return notices
|
return notices
|
||||||
|
|
||||||
|
|
||||||
|
@@ -109,7 +109,7 @@ class OrgVerifyDomain(APIView):
|
|||||||
|
|
||||||
saml_config = OrgSAMLConfig.objects.get_config_by_org_id(org_id)
|
saml_config = OrgSAMLConfig.objects.get_config_by_org_id(org_id)
|
||||||
if not saml_config:
|
if not saml_config:
|
||||||
error_msg = 'Cannot find a SAML/ADFS config for the organization %s.' % org.org_name
|
error_msg = 'Cannot find an ADFS/SAML config for the team %s.' % org.org_name
|
||||||
return api_error(status.HTTP_404_NOT_FOUND, error_msg)
|
return api_error(status.HTTP_404_NOT_FOUND, error_msg)
|
||||||
|
|
||||||
if saml_config.domain != domain:
|
if saml_config.domain != domain:
|
||||||
|
Reference in New Issue
Block a user