Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
E
edx-platform
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
edx
edx-platform
Commits
2112e7f8
Commit
2112e7f8
authored
Oct 07, 2016
by
Nimisha Asthagiri
Committed by
GitHub
Oct 07, 2016
Browse files
Options
Browse Files
Download
Plain Diff
Merge pull request #13659 from edx/common_cleanup/external_auth
Moves external_auth from common to openedx/core
parents
84bdd859
fd49f88e
Hide whitespace changes
Inline
Side-by-side
Showing
28 changed files
with
208 additions
and
133 deletions
+208
-133
cms/djangoapps/contentstore/views/public.py
+5
-2
cms/envs/common.py
+1
-1
cms/urls.py
+1
-1
common/djangoapps/student/tests/test_create_account.py
+1
-1
common/djangoapps/student/tests/test_login.py
+1
-1
common/djangoapps/student/tests/test_password_policy.py
+1
-1
common/djangoapps/student/views.py
+8
-8
docs/en_us/platform_api/source/conf.py
+1
-1
lms/djangoapps/branding/views.py
+1
-1
lms/djangoapps/courseware/access.py
+1
-1
lms/djangoapps/dashboard/sysadmin.py
+2
-2
lms/djangoapps/lms_migration/management/commands/create_user.py
+1
-1
lms/djangoapps/student_account/views.py
+1
-1
lms/envs/common.py
+1
-1
lms/urls.py
+25
-9
openedx/core/djangoapps/external_auth/__init__.py
+0
-0
openedx/core/djangoapps/external_auth/admin.py
+4
-1
openedx/core/djangoapps/external_auth/djangostore.py
+16
-7
openedx/core/djangoapps/external_auth/login_and_register.py
+11
-5
openedx/core/djangoapps/external_auth/migrations/0001_initial.py
+0
-0
openedx/core/djangoapps/external_auth/migrations/__init__.py
+0
-0
openedx/core/djangoapps/external_auth/models.py
+5
-3
openedx/core/djangoapps/external_auth/tests/__init__.py
+0
-0
openedx/core/djangoapps/external_auth/tests/test_helper.py
+1
-1
openedx/core/djangoapps/external_auth/tests/test_openid_provider.py
+46
-20
openedx/core/djangoapps/external_auth/tests/test_shib.py
+12
-10
openedx/core/djangoapps/external_auth/tests/test_ssl.py
+11
-10
openedx/core/djangoapps/external_auth/views.py
+51
-44
No files found.
cms/djangoapps/contentstore/views/public.py
View file @
2112e7f8
...
...
@@ -10,8 +10,11 @@ from django.conf import settings
from
edxmako.shortcuts
import
render_to_response
from
external_auth.views
import
(
ssl_login_shortcut
,
ssl_get_cert_from_request
,
redirect_with_get
)
from
openedx.core.djangoapps.external_auth.views
import
(
ssl_login_shortcut
,
ssl_get_cert_from_request
,
redirect_with_get
,
)
from
openedx.core.djangoapps.site_configuration
import
helpers
as
configuration_helpers
__all__
=
[
'signup'
,
'login_page'
,
'howitworks'
]
...
...
cms/envs/common.py
View file @
2112e7f8
...
...
@@ -818,7 +818,7 @@ INSTALLED_APPS = (
'contentstore'
,
'contentserver'
,
'course_creators'
,
'external_auth'
,
'
openedx.core.djangoapps.
external_auth'
,
'student'
,
# misleading name due to sharing with lms
'openedx.core.djangoapps.course_groups'
,
# not used in cms (yet), but tests run
'openedx.core.djangoapps.coursetalk'
,
# not used in cms (yet), but tests run
...
...
cms/urls.py
View file @
2112e7f8
...
...
@@ -153,7 +153,7 @@ if settings.FEATURES.get('ENABLE_SERVICE_STATUS'):
if
settings
.
FEATURES
.
get
(
'AUTH_USE_CAS'
):
urlpatterns
+=
(
url
(
r'^cas-auth/login/$'
,
'external_auth.views.cas_login'
,
name
=
"cas-login"
),
url
(
r'^cas-auth/login/$'
,
'
openedx.core.djangoapps.
external_auth.views.cas_login'
,
name
=
"cas-login"
),
url
(
r'^cas-auth/logout/$'
,
'django_cas.views.logout'
,
{
'next_page'
:
'/'
},
name
=
"cas-logout"
),
)
...
...
common/djangoapps/student/tests/test_create_account.py
View file @
2112e7f8
...
...
@@ -16,7 +16,7 @@ import mock
from
openedx.core.djangoapps.user_api.preferences.api
import
get_user_preference
from
lang_pref
import
LANGUAGE_KEY
from
notification_prefs
import
NOTIFICATION_PREF_KEY
from
external_auth.models
import
ExternalAuthMap
from
openedx.core.djangoapps.
external_auth.models
import
ExternalAuthMap
import
student
from
student.models
import
UserAttribute
from
student.views
import
REGISTRATION_AFFILIATE_ID
...
...
common/djangoapps/student/tests/test_login.py
View file @
2112e7f8
...
...
@@ -16,7 +16,7 @@ import httpretty
from
mock
import
patch
from
social.apps.django_app.default.models
import
UserSocialAuth
from
external_auth.models
import
ExternalAuthMap
from
openedx.core.djangoapps.
external_auth.models
import
ExternalAuthMap
from
openedx.core.djangolib.testing.utils
import
CacheIsolationTestCase
from
student.tests.factories
import
UserFactory
,
RegistrationFactory
,
UserProfileFactory
from
student.views
import
login_oauth_token
...
...
common/djangoapps/student/tests/test_password_policy.py
View file @
2112e7f8
...
...
@@ -11,7 +11,7 @@ from importlib import import_module
from
django.test.utils
import
override_settings
from
django.conf
import
settings
from
mock
import
patch
from
external_auth.models
import
ExternalAuthMap
from
openedx.core.djangoapps.
external_auth.models
import
ExternalAuthMap
from
student.views
import
create_account
...
...
common/djangoapps/student/views.py
View file @
2112e7f8
...
...
@@ -78,9 +78,9 @@ from courseware.access import has_access
from
django_comment_common.models
import
Role
from
external_auth.models
import
ExternalAuthMap
import
external_auth.views
from
external_auth.login_and_register
import
(
from
openedx.core.djangoapps.
external_auth.models
import
ExternalAuthMap
import
openedx.core.djangoapps.
external_auth.views
from
openedx.core.djangoapps.
external_auth.login_and_register
import
(
login
as
external_auth_login
,
register
as
external_auth_register
)
...
...
@@ -470,7 +470,9 @@ def register_user(request, extra_context=None):
if
extra_context
is
not
None
:
context
.
update
(
extra_context
)
if
context
.
get
(
"extauth_domain"
,
''
)
.
startswith
(
external_auth
.
views
.
SHIBBOLETH_DOMAIN_PREFIX
):
if
context
.
get
(
"extauth_domain"
,
''
)
.
startswith
(
openedx
.
core
.
djangoapps
.
external_auth
.
views
.
SHIBBOLETH_DOMAIN_PREFIX
):
return
render_to_response
(
'register-shib.html'
,
context
)
# If third-party auth is enabled, prepopulate the form with data from the
...
...
@@ -1195,7 +1197,7 @@ def login_user(request, error=""): # pylint: disable=too-many-statements,unused
if
settings
.
FEATURES
.
get
(
'AUTH_USE_SHIB'
)
and
user
:
try
:
eamap
=
ExternalAuthMap
.
objects
.
get
(
user
=
user
)
if
eamap
.
external_domain
.
startswith
(
external_auth
.
views
.
SHIBBOLETH_DOMAIN_PREFIX
):
if
eamap
.
external_domain
.
startswith
(
openedx
.
core
.
djangoapps
.
external_auth
.
views
.
SHIBBOLETH_DOMAIN_PREFIX
):
return
JsonResponse
({
"success"
:
False
,
"redirect"
:
reverse
(
'shib-login'
),
...
...
@@ -1637,9 +1639,7 @@ def create_account_with_params(request, params):
not
settings
.
FEATURES
.
get
(
"AUTH_USE_SHIB"
)
or
not
settings
.
FEATURES
.
get
(
"SHIB_DISABLE_TOS"
)
or
not
do_external_auth
or
not
eamap
.
external_domain
.
startswith
(
external_auth
.
views
.
SHIBBOLETH_DOMAIN_PREFIX
)
not
eamap
.
external_domain
.
startswith
(
openedx
.
core
.
djangoapps
.
external_auth
.
views
.
SHIBBOLETH_DOMAIN_PREFIX
)
)
form
=
AccountCreationForm
(
...
...
docs/en_us/platform_api/source/conf.py
View file @
2112e7f8
...
...
@@ -101,7 +101,7 @@ MOCK_MODULES = [
'openid'
,
'openid.store'
,
'openid.store.interface'
,
'external_auth.views'
,
'
openedx.core.djangoapps.
external_auth.views'
,
'mail_utils'
,
'ratelimitbackend.backends'
,
'social.apps.django_app.default'
,
...
...
lms/djangoapps/branding/views.py
View file @
2112e7f8
...
...
@@ -61,7 +61,7 @@ def index(request):
return
redirect
(
reverse
(
'dashboard'
))
if
settings
.
FEATURES
.
get
(
'AUTH_USE_CERTIFICATES'
):
from
external_auth.views
import
ssl_login
from
openedx.core.djangoapps.
external_auth.views
import
ssl_login
# Set next URL to dashboard if it isn't set to avoid
# caching a redirect to / that causes a redirect loop on logout
if
not
request
.
GET
.
get
(
'next'
):
...
...
lms/djangoapps/courseware/access.py
View file @
2112e7f8
...
...
@@ -33,7 +33,7 @@ from xmodule.x_module import XModule
from
xmodule.split_test_module
import
get_split_user_partitions
from
xmodule.partitions.partitions
import
NoSuchUserPartitionError
,
NoSuchUserPartitionGroupError
from
external_auth.models
import
ExternalAuthMap
from
openedx.core.djangoapps.
external_auth.models
import
ExternalAuthMap
from
courseware.masquerade
import
get_masquerade_role
,
is_masquerading_as_student
from
openedx.core.djangoapps.content.course_overviews.models
import
CourseOverview
from
student
import
auth
...
...
lms/djangoapps/dashboard/sysadmin.py
View file @
2112e7f8
...
...
@@ -34,8 +34,8 @@ import dashboard.git_import as git_import
from
dashboard.git_import
import
GitImportError
from
student.roles
import
CourseStaffRole
,
CourseInstructorRole
from
dashboard.models
import
CourseImportLog
from
external_auth.models
import
ExternalAuthMap
from
external_auth.views
import
generate_password
from
openedx.core.djangoapps.
external_auth.models
import
ExternalAuthMap
from
openedx.core.djangoapps.
external_auth.views
import
generate_password
from
student.models
import
CourseEnrollment
,
UserProfile
,
Registration
import
track.views
from
xmodule.modulestore.django
import
modulestore
...
...
lms/djangoapps/lms_migration/management/commands/create_user.py
View file @
2112e7f8
...
...
@@ -15,7 +15,7 @@ import readline
from
django.core.management.base
import
BaseCommand
from
student.models
import
UserProfile
,
Registration
from
external_auth.models
import
ExternalAuthMap
from
openedx.core.djangoapps.
external_auth.models
import
ExternalAuthMap
from
django.contrib.auth.models
import
User
,
Group
from
pytz
import
UTC
...
...
lms/djangoapps/student_account/views.py
View file @
2112e7f8
...
...
@@ -22,7 +22,7 @@ from edxmako.shortcuts import render_to_response
import
pytz
from
commerce.models
import
CommerceConfiguration
from
external_auth.login_and_register
import
(
from
openedx.core.djangoapps.
external_auth.login_and_register
import
(
login
as
external_auth_login
,
register
as
external_auth_register
)
...
...
lms/envs/common.py
View file @
2112e7f8
...
...
@@ -1917,7 +1917,7 @@ INSTALLED_APPS = (
'support'
,
# External auth (OpenID, shib)
'external_auth'
,
'
openedx.core.djangoapps.
external_auth'
,
'django_openid_auth'
,
# django-oauth2-provider (deprecated)
...
...
lms/urls.py
View file @
2112e7f8
...
...
@@ -803,27 +803,31 @@ if settings.DEBUG or settings.FEATURES.get('ENABLE_DJANGO_ADMIN_SITE'):
if
settings
.
FEATURES
.
get
(
'AUTH_USE_OPENID'
):
urlpatterns
+=
(
url
(
r'^openid/login/$'
,
'django_openid_auth.views.login_begin'
,
name
=
'openid-login'
),
url
(
r'^openid/complete/$'
,
'external_auth.views.openid_login_complete'
,
name
=
'openid-complete'
),
url
(
r'^openid/complete/$'
,
'openedx.core.djangoapps.external_auth.views.openid_login_complete'
,
name
=
'openid-complete'
,
),
url
(
r'^openid/logo.gif$'
,
'django_openid_auth.views.logo'
,
name
=
'openid-logo'
),
)
if
settings
.
FEATURES
.
get
(
'AUTH_USE_SHIB'
):
urlpatterns
+=
(
url
(
r'^shib-login/$'
,
'external_auth.views.shib_login'
,
name
=
'shib-login'
),
url
(
r'^shib-login/$'
,
'
openedx.core.djangoapps.
external_auth.views.shib_login'
,
name
=
'shib-login'
),
)
if
settings
.
FEATURES
.
get
(
'AUTH_USE_CAS'
):
urlpatterns
+=
(
url
(
r'^cas-auth/login/$'
,
'external_auth.views.cas_login'
,
name
=
"cas-login"
),
url
(
r'^cas-auth/login/$'
,
'
openedx.core.djangoapps.
external_auth.views.cas_login'
,
name
=
"cas-login"
),
url
(
r'^cas-auth/logout/$'
,
'django_cas.views.logout'
,
{
'next_page'
:
'/'
},
name
=
"cas-logout"
),
)
if
settings
.
FEATURES
.
get
(
'RESTRICT_ENROLL_BY_REG_METHOD'
):
urlpatterns
+=
(
url
(
r'^course_specific_login/{}/$'
.
format
(
settings
.
COURSE_ID_PATTERN
),
'external_auth.views.course_specific_login'
,
name
=
'course-specific-login'
),
'
openedx.core.djangoapps.
external_auth.views.course_specific_login'
,
name
=
'course-specific-login'
),
url
(
r'^course_specific_register/{}/$'
.
format
(
settings
.
COURSE_ID_PATTERN
),
'external_auth.views.course_specific_register'
,
name
=
'course-specific-register'
),
'
openedx.core.djangoapps.
external_auth.views.course_specific_register'
,
name
=
'course-specific-register'
),
)
...
...
@@ -846,14 +850,26 @@ urlpatterns += (
if
settings
.
FEATURES
.
get
(
'AUTH_USE_OPENID_PROVIDER'
):
urlpatterns
+=
(
url
(
r'^openid/provider/login/$'
,
'external_auth.views.provider_login'
,
name
=
'openid-provider-login'
),
url
(
r'^openid/provider/login/$'
,
'openedx.core.djangoapps.external_auth.views.provider_login'
,
name
=
'openid-provider-login'
,
),
url
(
r'^openid/provider/login/(?:.+)$'
,
'external_auth.views.provider_identity'
,
'
openedx.core.djangoapps.
external_auth.views.provider_identity'
,
name
=
'openid-provider-login-identity'
),
url
(
r'^openid/provider/identity/$'
,
'external_auth.views.provider_identity'
,
name
=
'openid-provider-identity'
),
url
(
r'^openid/provider/xrds/$'
,
'external_auth.views.provider_xrds'
,
name
=
'openid-provider-xrds'
)
url
(
r'^openid/provider/identity/$'
,
'openedx.core.djangoapps.external_auth.views.provider_identity'
,
name
=
'openid-provider-identity'
,
),
url
(
r'^openid/provider/xrds/$'
,
'openedx.core.djangoapps.external_auth.views.provider_xrds'
,
name
=
'openid-provider-xrds'
,
),
)
if
settings
.
FEATURES
.
get
(
'ENABLE_OAUTH2_PROVIDER'
):
...
...
common
/djangoapps/external_auth/__init__.py
→
openedx/core
/djangoapps/external_auth/__init__.py
View file @
2112e7f8
File moved
common
/djangoapps/external_auth/admin.py
→
openedx/core
/djangoapps/external_auth/admin.py
View file @
2112e7f8
...
...
@@ -2,11 +2,14 @@
django admin pages for courseware model
'''
from
external_auth.models
import
*
from
openedx.core.djangoapps.external_auth.models
import
ExternalAuthMap
from
ratelimitbackend
import
admin
class
ExternalAuthMapAdmin
(
admin
.
ModelAdmin
):
"""
Admin model for ExternalAuthMap
"""
search_fields
=
[
'external_id'
,
'user__username'
]
date_hierarchy
=
'dtcreated'
...
...
common
/djangoapps/external_auth/djangostore.py
→
openedx/core
/djangoapps/external_auth/djangostore.py
View file @
2112e7f8
...
...
@@ -18,19 +18,28 @@ log = logging.getLogger('DjangoOpenIDStore')
def
get_url_key
(
server_url
):
key
=
ASSOCIATIONS_KEY_PREFIX
+
server_url
return
key
"""
Returns the URL key for the given server_url.
"""
return
ASSOCIATIONS_KEY_PREFIX
+
server_url
def
get_nonce_key
(
server_url
,
timestamp
,
salt
):
key
=
'{prefix}{url}.{ts}.{salt}'
.
format
(
prefix
=
NONCE_KEY_PREFIX
,
url
=
server_url
,
ts
=
timestamp
,
salt
=
salt
)
return
key
"""
Returns the nonce for the given parameters.
"""
return
'{prefix}{url}.{ts}.{salt}'
.
format
(
prefix
=
NONCE_KEY_PREFIX
,
url
=
server_url
,
ts
=
timestamp
,
salt
=
salt
,
)
class
DjangoOpenIDStore
(
OpenIDStore
):
"""
django implementation of OpenIDStore.
"""
def
__init__
(
self
):
log
.
info
(
'DjangoStore cache:'
+
str
(
cache
.
__class__
))
...
...
common
/djangoapps/external_auth/login_and_register.py
→
openedx/core
/djangoapps/external_auth/login_and_register.py
View file @
2112e7f8
...
...
@@ -7,7 +7,7 @@ import re
from
django.conf
import
settings
from
django.shortcuts
import
redirect
from
django.core.urlresolvers
import
reverse
import
external_auth.views
import
openedx.core.djangoapps.
external_auth.views
from
xmodule.modulestore.django
import
modulestore
from
opaque_keys.edx.keys
import
CourseKey
...
...
@@ -56,11 +56,14 @@ def login(request):
# is not handling the request.
response
=
None
if
settings
.
FEATURES
[
'AUTH_USE_CERTIFICATES'
]
and
external_auth
.
views
.
ssl_get_cert_from_request
(
request
):
if
(
settings
.
FEATURES
[
'AUTH_USE_CERTIFICATES'
]
and
openedx
.
core
.
djangoapps
.
external_auth
.
views
.
ssl_get_cert_from_request
(
request
)
):
# SSL login doesn't require a view, so redirect
# branding and allow that to process the login if it
# is enabled and the header is in the request.
response
=
external_auth
.
views
.
redirect_with_get
(
'root'
,
request
.
GET
)
response
=
openedx
.
core
.
djangoapps
.
external_auth
.
views
.
redirect_with_get
(
'root'
,
request
.
GET
)
elif
settings
.
FEATURES
.
get
(
'AUTH_USE_CAS'
):
# If CAS is enabled, redirect auth handling to there
response
=
redirect
(
reverse
(
'cas-login'
))
...
...
@@ -69,7 +72,10 @@ def login(request):
if
redirect_to
:
course_id
=
_parse_course_id_from_string
(
redirect_to
)
if
course_id
and
_get_course_enrollment_domain
(
course_id
):
response
=
external_auth
.
views
.
course_specific_login
(
request
,
course_id
.
to_deprecated_string
())
response
=
openedx
.
core
.
djangoapps
.
external_auth
.
views
.
course_specific_login
(
request
,
course_id
.
to_deprecated_string
(),
)
return
response
...
...
@@ -88,5 +94,5 @@ def register(request):
if
settings
.
FEATURES
.
get
(
'AUTH_USE_CERTIFICATES_IMMEDIATE_SIGNUP'
):
# Redirect to branding to process their certificate if SSL is enabled
# and registration is disabled.
response
=
external_auth
.
views
.
redirect_with_get
(
'root'
,
request
.
GET
)
response
=
openedx
.
core
.
djangoapps
.
external_auth
.
views
.
redirect_with_get
(
'root'
,
request
.
GET
)
return
response
common
/djangoapps/external_auth/migrations/0001_initial.py
→
openedx/core
/djangoapps/external_auth/migrations/0001_initial.py
View file @
2112e7f8
File moved
common
/djangoapps/external_auth/migrations/__init__.py
→
openedx/core
/djangoapps/external_auth/migrations/__init__.py
View file @
2112e7f8
File moved
common
/djangoapps/external_auth/models.py
→
openedx/core
/djangoapps/external_auth/models.py
View file @
2112e7f8
...
...
@@ -6,7 +6,7 @@ file and check it in at the same time as your model changes. To do that,
1. Go to the edx-platform dir
2. ./manage.py lms schemamigration student --auto description_of_your_change
3. Add the migration file created in edx-platform/
common
/djangoapps/external_auth/migrations/
3. Add the migration file created in edx-platform/
openedx/core
/djangoapps/external_auth/migrations/
"""
from
django.db
import
models
...
...
@@ -14,6 +14,9 @@ from django.contrib.auth.models import User
class
ExternalAuthMap
(
models
.
Model
):
"""
Model class for external auth.
"""
class
Meta
(
object
):
app_label
=
"external_auth"
unique_together
=
((
'external_id'
,
'external_domain'
),
)
...
...
@@ -29,5 +32,4 @@ class ExternalAuthMap(models.Model):
dtsignup
=
models
.
DateTimeField
(
'signup date'
,
null
=
True
)
# set after signup
def
__unicode__
(
self
):
s
=
"[
%
s] = (
%
s /
%
s)"
%
(
self
.
external_id
,
self
.
external_name
,
self
.
external_email
)
return
s
return
"[
%
s] = (
%
s /
%
s)"
%
(
self
.
external_id
,
self
.
external_name
,
self
.
external_email
)
common
/djangoapps/external_auth/tests/__init__.py
→
openedx/core
/djangoapps/external_auth/tests/__init__.py
View file @
2112e7f8
File moved
common
/djangoapps/external_auth/tests/test_helper.py
→
openedx/core
/djangoapps/external_auth/tests/test_helper.py
View file @
2112e7f8
...
...
@@ -2,7 +2,7 @@
Tests for utility functions in external_auth module
"""
from
django.test
import
TestCase
from
external_auth.views
import
_safe_postlogin_redirect
from
openedx.core.djangoapps.
external_auth.views
import
_safe_postlogin_redirect
class
ExternalAuthHelperFnTest
(
TestCase
):
...
...
common
/djangoapps/external_auth/tests/test_openid_provider.py
→
openedx/core
/djangoapps/external_auth/tests/test_openid_provider.py
View file @
2112e7f8
...
...
@@ -17,7 +17,7 @@ from django.test.client import RequestFactory
from
unittest
import
skipUnless
from
student.tests.factories
import
UserFactory
from
external_auth.views
import
provider_login
from
openedx.core.djangoapps.
external_auth.views
import
provider_login
class
MyFetcher
(
HTTPFetcher
):
...
...
@@ -130,27 +130,53 @@ class OpenIdProviderTest(TestCase):
self
.
assertEqual
(
resp
.
status_code
,
code
,
"got code {0} for url '{1}'. Expected code {2}"
.
format
(
resp
.
status_code
,
url
,
code
))
self
.
assertContains
(
resp
,
'<input name="openid.mode" type="hidden" value="checkid_setup" />'
,
html
=
True
)
self
.
assertContains
(
resp
,
'<input name="openid.ns" type="hidden" value="http://specs.openid.net/auth/2.0" />'
,
html
=
True
)
self
.
assertContains
(
resp
,
'<input name="openid.identity" type="hidden" value="http://specs.openid.net/auth/2.0/identifier_select" />'
,
html
=
True
)
self
.
assertContains
(
resp
,
'<input name="openid.claimed_id" type="hidden" value="http://specs.openid.net/auth/2.0/identifier_select" />'
,
html
=
True
)
self
.
assertContains
(
resp
,
'<input name="openid.ns.ax" type="hidden" value="http://openid.net/srv/ax/1.0" />'
,
html
=
True
)
self
.
assertContains
(
resp
,
'<input name="openid.ax.mode" type="hidden" value="fetch_request" />'
,
html
=
True
)
self
.
assertContains
(
resp
,
'<input name="openid.ax.required" type="hidden" value="email,fullname,old_email,firstname,old_nickname,lastname,old_fullname,nickname" />'
,
html
=
True
)
self
.
assertContains
(
resp
,
'<input name="openid.ax.type.fullname" type="hidden" value="http://axschema.org/namePerson" />'
,
html
=
True
)
self
.
assertContains
(
resp
,
'<input name="openid.ax.type.lastname" type="hidden" value="http://axschema.org/namePerson/last" />'
,
html
=
True
)
self
.
assertContains
(
resp
,
'<input name="openid.ax.type.firstname" type="hidden" value="http://axschema.org/namePerson/first" />'
,
html
=
True
)
self
.
assertContains
(
resp
,
'<input name="openid.ax.type.nickname" type="hidden" value="http://axschema.org/namePerson/friendly" />'
,
html
=
True
)
self
.
assertContains
(
resp
,
'<input name="openid.ax.type.email" type="hidden" value="http://axschema.org/contact/email" />'
,
html
=
True
)
self
.
assertContains
(
resp
,
'<input name="openid.ax.type.old_email" type="hidden" value="http://schema.openid.net/contact/email" />'
,
html
=
True
)
self
.
assertContains
(
resp
,
'<input name="openid.ax.type.old_nickname" type="hidden" value="http://schema.openid.net/namePerson/friendly" />'
,
html
=
True
)
self
.
assertContains
(
resp
,
'<input name="openid.ax.type.old_fullname" type="hidden" value="http://schema.openid.net/namePerson" />'
,
html
=
True
)
self
.
assertContains
(
resp
,
'<input type="submit" value="Continue" />'
,
html
=
True
)
# this should work on the server:
self
.
assertContains
(
resp
,
'<input name="openid.realm" type="hidden" value="http://testserver/" />'
,
html
=
True
)
for
expected_input
in
(
'<input name="openid.ns" type="hidden" value="http://specs.openid.net/auth/2.0" />'
,
'<input name="openid.ns.ax" type="hidden" value="http://openid.net/srv/ax/1.0" />'
,
'<input name="openid.ax.type.fullname" type="hidden" value="http://axschema.org/namePerson" />'
,
'<input type="submit" value="Continue" />'
,
'<input name="openid.ax.type.email" type="hidden" value="http://axschema.org/contact/email" />'
,
'<input name="openid.ax.type.lastname" '
'type="hidden" value="http://axschema.org/namePerson/last" />'
,
'<input name="openid.ax.type.firstname" '
'type="hidden" value="http://axschema.org/namePerson/first" />'
,
'<input name="openid.ax.required" type="hidden" '
'value="email,fullname,old_email,firstname,old_nickname,lastname,old_fullname,nickname" />'
,
'<input name="openid.ax.type.nickname" '
'type="hidden" value="http://axschema.org/namePerson/friendly" />'
,
'<input name="openid.ax.type.old_email" '
'type="hidden" value="http://schema.openid.net/contact/email" />'
,
'<input name="openid.ax.type.old_nickname" '
'type="hidden" value="http://schema.openid.net/namePerson/friendly" />'
,
'<input name="openid.ax.type.old_fullname" '
'type="hidden" value="http://schema.openid.net/namePerson" />'
,
'<input name="openid.identity" '
'type="hidden" value="http://specs.openid.net/auth/2.0/identifier_select" />'
,
'<input name="openid.claimed_id" '
'type="hidden" value="http://specs.openid.net/auth/2.0/identifier_select" />'
,
# should work on the test server as well
'<input name="openid.realm" '
'type="hidden" value="http://testserver/" />'
,
):
self
.
assertContains
(
resp
,
expected_input
,
html
=
True
)
# not included here are elements that will vary from run to run:
# <input name="openid.return_to" type="hidden" value="http://testserver/openid/complete/?janrain_nonce=2013-01-23T06%3A20%3A17ZaN7j6H" />
# <input name="openid.return_to" type="hidden"
# value="http://testserver/openid/complete/?janrain_nonce=2013-01-23T06%3A20%3A17ZaN7j6H" />
# <input name="openid.assoc_handle" type="hidden" value="{HMAC-SHA1}{50ff8120}{rh87+Q==}" />
def
attempt_login
(
self
,
expected_code
,
login_method
=
'POST'
,
**
kwargs
):
...
...
common
/djangoapps/external_auth/tests/test_shib.py
→
openedx/core
/djangoapps/external_auth/tests/test_shib.py
View file @
2112e7f8
# -*- coding: utf-8 -*-
#pylint: disable=no-member
"""
Tests for Shibboleth Authentication
@jbau
...
...
@@ -14,8 +15,8 @@ from django.test.utils import override_settings
from
django.core.urlresolvers
import
reverse
from
django.contrib.auth.models
import
AnonymousUser
,
User
from
importlib
import
import_module
from
external_auth.models
import
ExternalAuthMap
from
external_auth.views
import
(
from
openedx.core.djangoapps.
external_auth.models
import
ExternalAuthMap
from
openedx.core.djangoapps.
external_auth.views
import
(
shib_login
,
course_specific_login
,
course_specific_register
,
_flatten_to_ascii
)
from
mock
import
patch
...
...
@@ -125,6 +126,7 @@ class ShibSPTest(CacheIsolationTestCase):
of an existing user that already has an ExternalAuthMap causes an error (403)
* shib credentials that do not match an existing ExternalAuthMap causes the registration form to appear
"""
# pylint: disable=too-many-statements
user_w_map
=
UserFactory
.
create
(
email
=
'withmap@stanford.edu'
)
extauth
=
ExternalAuthMap
(
external_id
=
'withmap@stanford.edu'
,
...
...
@@ -155,7 +157,7 @@ class ShibSPTest(CacheIsolationTestCase):
for
remote_user
in
remote_users
:
self
.
client
.
logout
()
with
patch
(
'external_auth.views.AUDIT_LOG'
)
as
mock_audit_log
:
with
patch
(
'
openedx.core.djangoapps.
external_auth.views.AUDIT_LOG'
)
as
mock_audit_log
:
response
=
self
.
client
.
get
(
reverse
(
'shib-login'
),
**
{
...
...
@@ -214,7 +216,7 @@ class ShibSPTest(CacheIsolationTestCase):
# no audit logging calls
self
.
assertEquals
(
len
(
audit_log_calls
),
0
)
def
_
base_test_extauth
_auto_activate_user_with_flag
(
self
,
log_user_string
=
"inactive@stanford.edu"
):
def
_
test
_auto_activate_user_with_flag
(
self
,
log_user_string
=
"inactive@stanford.edu"
):
"""
Tests that FEATURES['BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'] means extauth automatically
linked users, activates them, and logs them in
...
...
@@ -231,7 +233,7 @@ class ShibSPTest(CacheIsolationTestCase):
})
request
.
user
=
AnonymousUser
()
with
patch
(
'external_auth.views.AUDIT_LOG'
)
as
mock_audit_log
:
with
patch
(
'
openedx.core.djangoapps.
external_auth.views.AUDIT_LOG'
)
as
mock_audit_log
:
response
=
shib_login
(
request
)
audit_log_calls
=
mock_audit_log
.
method_calls
# reload user from db, since the view function works via db side-effects
...
...
@@ -256,7 +258,7 @@ class ShibSPTest(CacheIsolationTestCase):
"""
Wrapper to run base_test_extauth_auto_activate_user_with_flag with {'SQUELCH_PII_IN_LOGS': False}
"""
self
.
_
base_test_extauth
_auto_activate_user_with_flag
(
log_user_string
=
"inactive@stanford.edu"
)
self
.
_
test
_auto_activate_user_with_flag
(
log_user_string
=
"inactive@stanford.edu"
)
@unittest.skipUnless
(
settings
.
FEATURES
.
get
(
'AUTH_USE_SHIB'
),
"AUTH_USE_SHIB not set"
)
@patch.dict
(
settings
.
FEATURES
,
{
'BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'
:
True
,
'SQUELCH_PII_IN_LOGS'
:
True
})
...
...
@@ -264,7 +266,7 @@ class ShibSPTest(CacheIsolationTestCase):
"""
Wrapper to run base_test_extauth_auto_activate_user_with_flag with {'SQUELCH_PII_IN_LOGS': True}
"""
self
.
_
base_test_extauth
_auto_activate_user_with_flag
(
log_user_string
=
"user.id: 1"
)
self
.
_
test
_auto_activate_user_with_flag
(
log_user_string
=
"user.id: 1"
)
@unittest.skipUnless
(
settings
.
FEATURES
.
get
(
'AUTH_USE_SHIB'
),
"AUTH_USE_SHIB not set"
)
@data
(
*
gen_all_identities
())
...
...
@@ -279,11 +281,11 @@ class ShibSPTest(CacheIsolationTestCase):
response
=
client
.
get
(
path
=
'/shib-login/'
,
data
=
{},
follow
=
False
,
**
identity
)
self
.
assertEquals
(
response
.
status_code
,
200
)
mail_input_
HTML
=
'<input class="" id="email" type="email" name="email"'
mail_input_
html
=
'<input class="" id="email" type="email" name="email"'
if
not
identity
.
get
(
'mail'
):
self
.
assertContains
(
response
,
mail_input_
HTML
)
self
.
assertContains
(
response
,
mail_input_
html
)
else
:
self
.
assertNotContains
(
response
,
mail_input_
HTML
)
self
.
assertNotContains
(
response
,
mail_input_
html
)
sn_empty
=
not
identity
.
get
(
'sn'
)
given_name_empty
=
not
identity
.
get
(
'givenName'
)
displayname_empty
=
not
identity
.
get
(
'displayName'
)
...
...
common
/djangoapps/external_auth/tests/test_ssl.py
→
openedx/core
/djangoapps/external_auth/tests/test_ssl.py
View file @
2112e7f8
...
...
@@ -2,6 +2,7 @@
Provides unit tests for SSL based authentication portions
of the external_auth app.
"""
# pylint: disable=no-member
import
copy
import
unittest
...
...
@@ -14,10 +15,10 @@ from django.core.urlresolvers import reverse
from
django.test.client
import
Client
from
django.test.client
import
RequestFactory
from
django.test.utils
import
override_settings
from
external_auth.models
import
ExternalAuthMap
import
external_auth.views
from
mock
import
Mock
,
patch
from
openedx.core.djangoapps.external_auth.models
import
ExternalAuthMap
import
openedx.core.djangoapps.external_auth.views
as
external_auth_views
from
student.models
import
CourseEnrollment
from
student.roles
import
CourseStaffRole
from
student.tests.factories
import
UserFactory
...
...
@@ -87,7 +88,7 @@ class SSLClientTest(ModuleStoreTestCase):
redirects them to the signup page.
"""
with
self
.
_create_ssl_request
(
'/'
)
as
request
:
response
=
external_auth
.
views
.
ssl_login
(
request
)
response
=
external_auth
_
views
.
ssl_login
(
request
)
# Response should contain template for signup form, eamap should have user, and internal
# auth should not have a user
...
...
@@ -127,7 +128,7 @@ class SSLClientTest(ModuleStoreTestCase):
and the user is redirected to slash.
"""
with
self
.
_create_ssl_request
(
'/'
)
as
request
:
external_auth
.
views
.
ssl_login
(
request
)
external_auth
_
views
.
ssl_login
(
request
)
# Assert our user exists in both eamap and Users, and that we are logged in
try
:
...
...
@@ -250,7 +251,7 @@ class SSLClientTest(ModuleStoreTestCase):
# Create account, break internal password, and activate account
with
self
.
_create_ssl_request
(
'/'
)
as
request
:
external_auth
.
views
.
ssl_login
(
request
)
external_auth
_
views
.
ssl_login
(
request
)
user
=
User
.
objects
.
get
(
email
=
self
.
USER_EMAIL
)
user
.
set_password
(
'not autogenerated'
)
user
.
is_active
=
True
...
...
@@ -267,7 +268,7 @@ class SSLClientTest(ModuleStoreTestCase):
def
test_ssl_decorator_no_certs
(
self
):
"""Make sure no external auth happens without SSL enabled"""
dec_mock
=
external_auth
.
views
.
ssl_login_shortcut
(
self
.
mock
)
dec_mock
=
external_auth
_
views
.
ssl_login_shortcut
(
self
.
mock
)
with
self
.
_create_normal_request
(
self
.
MOCK_URL
)
as
request
:
request
.
user
=
AnonymousUser
()
...
...
@@ -282,7 +283,7 @@ class SSLClientTest(ModuleStoreTestCase):
def
test_ssl_login_decorator
(
self
):
"""Create mock function to test ssl login decorator"""
dec_mock
=
external_auth
.
views
.
ssl_login_shortcut
(
self
.
mock
)
dec_mock
=
external_auth
_
views
.
ssl_login_shortcut
(
self
.
mock
)
# Test that anonymous without cert doesn't create authmap
with
self
.
_create_normal_request
(
self
.
MOCK_URL
)
as
request
:
...
...
@@ -312,7 +313,7 @@ class SSLClientTest(ModuleStoreTestCase):
will bypass registration and call retfun.
"""
dec_mock
=
external_auth
.
views
.
ssl_login_shortcut
(
self
.
mock
)
dec_mock
=
external_auth
_
views
.
ssl_login_shortcut
(
self
.
mock
)
with
self
.
_create_ssl_request
(
self
.
MOCK_URL
)
as
request
:
dec_mock
(
request
)
...
...
@@ -343,7 +344,7 @@ class SSLClientTest(ModuleStoreTestCase):
)
with
self
.
_create_ssl_request
(
'/'
)
as
request
:
external_auth
.
views
.
ssl_login
(
request
)
external_auth
_
views
.
ssl_login
(
request
)
user
=
User
.
objects
.
get
(
email
=
self
.
USER_EMAIL
)
CourseEnrollment
.
enroll
(
user
,
course
.
id
)
course_private_url
=
'/courses/MITx/999/Robot_Super_Course/courseware'
...
...
@@ -374,7 +375,7 @@ class SSLClientTest(ModuleStoreTestCase):
)
with
self
.
_create_ssl_request
(
'/'
)
as
request
:
external_auth
.
views
.
ssl_login
(
request
)
external_auth
_
views
.
ssl_login
(
request
)
user
=
User
.
objects
.
get
(
email
=
self
.
USER_EMAIL
)
CourseEnrollment
.
enroll
(
user
,
course
.
id
)
...
...
common
/djangoapps/external_auth/views.py
→
openedx/core
/djangoapps/external_auth/views.py
View file @
2112e7f8
"""
External Auth Views
"""
import
functools
import
json
import
logging
...
...
@@ -9,8 +12,8 @@ import unicodedata
import
urllib
from
textwrap
import
dedent
from
external_auth.models
import
ExternalAuthMap
from
external_auth.djangostore
import
DjangoOpenIDStore
from
openedx.core.djangoapps.
external_auth.models
import
ExternalAuthMap
from
openedx.core.djangoapps.
external_auth.djangostore
import
DjangoOpenIDStore
from
django.conf
import
settings
from
django.contrib.auth
import
REDIRECT_FIELD_NAME
,
authenticate
,
login
...
...
@@ -31,11 +34,7 @@ from django.shortcuts import redirect
from
django.utils.translation
import
ugettext
as
_
from
edxmako.shortcuts
import
render_to_response
,
render_to_string
try
:
from
django.views.decorators.csrf
import
csrf_exempt
except
ImportError
:
from
django.contrib.csrf.middleware
import
csrf_exempt
from
django.views.decorators.csrf
import
ensure_csrf_cookie
from
django.views.decorators.csrf
import
csrf_exempt
,
ensure_csrf_cookie
import
django_openid_auth.views
as
openid_views
from
django_openid_auth
import
auth
as
openid_auth
...
...
@@ -62,7 +61,7 @@ OPENID_DOMAIN_PREFIX = settings.OPENID_DOMAIN_PREFIX
@csrf_exempt
def
default_render_failure
(
request
,
def
default_render_failure
(
request
,
# pylint: disable=unused-argument
message
,
status
=
403
,
template_name
=
'extauth_failure.html'
,
...
...
@@ -90,7 +89,7 @@ def generate_password(length=12, chars=string.letters + string.digits):
@csrf_exempt
def
openid_login_complete
(
request
,
redirect_field_name
=
REDIRECT_FIELD_NAME
,
redirect_field_name
=
REDIRECT_FIELD_NAME
,
# pylint: disable=unused-argument
render_failure
=
None
):
"""Complete the openid login process"""
...
...
@@ -104,7 +103,7 @@ def openid_login_complete(request,
if
openid_response
.
status
==
SUCCESS
:
external_id
=
openid_response
.
identity_url
oid_backend
=
openid_auth
.
OpenIDBackend
()
details
=
oid_backend
.
_extract_user_details
(
openid_response
)
details
=
oid_backend
.
_extract_user_details
(
openid_response
)
# pylint: disable=protected-access
log
.
debug
(
'openid success, details=
%
s'
,
details
)
...
...
@@ -134,6 +133,7 @@ def _external_login_or_signup(request,
fullname
,
retfun
=
None
):
"""Generic external auth login or signup"""
# pylint: disable=too-many-statements
# see if we have a map from this external_id to an edX username
try
:
eamap
=
ExternalAuthMap
.
objects
.
get
(
external_id
=
external_id
,
...
...
@@ -300,15 +300,16 @@ def _signup(request, eamap, retfun=None):
# but this only affects username, not fullname
username
=
re
.
sub
(
r'\s'
,
''
,
_flatten_to_ascii
(
eamap
.
external_name
),
flags
=
re
.
UNICODE
)
context
=
{
'has_extauth_info'
:
True
,
'show_signup_immediately'
:
True
,
'extauth_domain'
:
eamap
.
external_domain
,
'extauth_id'
:
eamap
.
external_id
,
'extauth_email'
:
eamap
.
external_email
,
'extauth_username'
:
username
,
'extauth_name'
:
eamap
.
external_name
,
'ask_for_tos'
:
True
,
}
context
=
{
'has_extauth_info'
:
True
,
'show_signup_immediately'
:
True
,
'extauth_domain'
:
eamap
.
external_domain
,
'extauth_id'
:
eamap
.
external_id
,
'extauth_email'
:
eamap
.
external_email
,
'extauth_username'
:
username
,
'extauth_name'
:
eamap
.
external_name
,
'ask_for_tos'
:
True
,
}
# Some openEdX instances can't have terms of service for shib users, like
# according to Stanford's Office of General Counsel
...
...
@@ -343,17 +344,17 @@ def _ssl_dn_extract_info(dn_string):
full name from the SSL DN string. Return (user,email,fullname) if
successful, and None otherwise.
"""
s
s
=
re
.
search
(
'/emailAddress=(.*)@([^/]+)'
,
dn_string
)
if
s
s
:
user
=
s
s
.
group
(
1
)
email
=
"
%
s@
%
s"
%
(
user
,
s
s
.
group
(
2
))
s
earch_string
=
re
.
search
(
'/emailAddress=(.*)@([^/]+)'
,
dn_string
)
if
s
earch_string
:
user
=
s
earch_string
.
group
(
1
)
email
=
"
%
s@
%
s"
%
(
user
,
s
earch_string
.
group
(
2
))
else
:
r
eturn
None
s
s
=
re
.
search
(
'/CN=([^/]+)/'
,
dn_string
)
if
s
s
:
fullname
=
s
s
.
group
(
1
)
r
aise
ValueError
s
earch_string
=
re
.
search
(
'/CN=([^/]+)/'
,
dn_string
)
if
s
earch_string
:
fullname
=
s
earch_string
.
group
(
1
)
else
:
r
eturn
None
r
aise
ValueError
return
(
user
,
email
,
fullname
)
...
...
@@ -370,14 +371,14 @@ def ssl_get_cert_from_request(request):
if
not
cert
:
try
:
# try the direct apache2 SSL key
cert
=
request
.
_req
.
subprocess_env
.
get
(
certkey
,
''
)
except
Exception
:
cert
=
request
.
_req
.
subprocess_env
.
get
(
certkey
,
''
)
# pylint: disable=protected-access
except
Exception
:
# pylint: disable=broad-except
return
''
return
cert
def
ssl_login_shortcut
(
f
n
):
def
ssl_login_shortcut
(
f
unc
):
"""
Python function decorator for login procedures, to allow direct login
based on existing ExternalAuth record and MIT ssl certificate.
...
...
@@ -390,19 +391,19 @@ def ssl_login_shortcut(fn):
"""
if
not
settings
.
FEATURES
[
'AUTH_USE_CERTIFICATES'
]:
return
f
n
(
*
args
,
**
kwargs
)
return
f
unc
(
*
args
,
**
kwargs
)
request
=
args
[
0
]
if
request
.
user
and
request
.
user
.
is_authenticated
():
# don't re-authenticate
return
f
n
(
*
args
,
**
kwargs
)
return
f
unc
(
*
args
,
**
kwargs
)
cert
=
ssl_get_cert_from_request
(
request
)
if
not
cert
:
# no certificate information - show normal login window
return
f
n
(
*
args
,
**
kwargs
)
return
f
unc
(
*
args
,
**
kwargs
)
def
retfun
():
"""Wrap function again for call by _external_login_or_signup"""
return
f
n
(
*
args
,
**
kwargs
)
return
f
unc
(
*
args
,
**
kwargs
)
(
_user
,
email
,
fullname
)
=
_ssl_dn_extract_info
(
cert
)
return
_external_login_or_signup
(
...
...
@@ -565,9 +566,9 @@ def course_specific_login(request, course_id):
# now the dispatching conditionals. Only shib for now
if
(
settings
.
FEATURES
.
get
(
'AUTH_USE_SHIB'
)
and
course
.
enrollment_domain
and
course
.
enrollment_domain
.
startswith
(
SHIBBOLETH_DOMAIN_PREFIX
)
settings
.
FEATURES
.
get
(
'AUTH_USE_SHIB'
)
and
course
.
enrollment_domain
and
course
.
enrollment_domain
.
startswith
(
SHIBBOLETH_DOMAIN_PREFIX
)
):
return
redirect_with_get
(
'shib-login'
,
request
.
GET
)
...
...
@@ -589,9 +590,9 @@ def course_specific_register(request, course_id):
# now the dispatching conditionals. Only shib for now
if
(
settings
.
FEATURES
.
get
(
'AUTH_USE_SHIB'
)
and
course
.
enrollment_domain
and
course
.
enrollment_domain
.
startswith
(
SHIBBOLETH_DOMAIN_PREFIX
)
settings
.
FEATURES
.
get
(
'AUTH_USE_SHIB'
)
and
course
.
enrollment_domain
and
course
.
enrollment_domain
.
startswith
(
SHIBBOLETH_DOMAIN_PREFIX
)
):
# shib-login takes care of both registration and login flows
return
redirect_with_get
(
'shib-login'
,
request
.
GET
)
...
...
@@ -634,6 +635,9 @@ def get_xrds_url(resource, request):
def
add_openid_simple_registration
(
request
,
response
,
data
):
"""
Add simple registration fields to the response if requested.
"""
sreg_data
=
{}
sreg_request
=
sreg
.
SRegRequest
.
fromOpenIDRequest
(
request
)
sreg_fields
=
sreg_request
.
allRequestedFields
()
...
...
@@ -655,6 +659,9 @@ def add_openid_simple_registration(request, response, data):
def
add_openid_attribute_exchange
(
request
,
response
,
data
):
"""
Add attribute exchange fields to the response if requested.
"""
try
:
ax_request
=
ax
.
FetchRequest
.
fromOpenIDRequest
(
request
)
except
ax
.
AXError
:
...
...
@@ -691,8 +698,8 @@ def provider_respond(server, request, response, data):
http_response
.
status_code
=
webresponse
.
code
# add OpenID headers to response
for
k
,
v
in
webresponse
.
headers
.
iteritems
():
http_response
[
k
]
=
v
for
k
ey
,
val
in
webresponse
.
headers
.
iteritems
():
http_response
[
k
ey
]
=
val
return
http_response
...
...
@@ -744,7 +751,7 @@ def provider_login(request):
"""
OpenID login endpoint
"""
# pylint: disable=too-many-statements
# make and validate endpoint
endpoint
=
get_xrds_url
(
'login'
,
request
)
if
not
endpoint
:
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment