Browse Source

Use dynamic settings in Misago SSO (#1273)

* Use dynamic settings in Misago SSO

* Make SSO match users over email hash

* Another cleanup pass on SSO

* Fix build
Rafał Pitoń 5 years ago
parent
commit
8ad1cf9c7d

+ 0 - 5
devproject/settings.py

@@ -417,8 +417,3 @@ EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
 DEBUG_TOOLBAR_CONFIG = {
 DEBUG_TOOLBAR_CONFIG = {
     "SHOW_TOOLBAR_CALLBACK": "misago.conf.debugtoolbar.enable_debug_toolbar"
     "SHOW_TOOLBAR_CALLBACK": "misago.conf.debugtoolbar.enable_debug_toolbar"
 }
 }
-
-# SECURITY WARNING: keep the private key used in production secret!
-SSO_PRIVATE_KEY = None  # This key should be secret (max 64 chars)
-SSO_PUBLIC_KEY = None  # This key may be public (max 64 chars)
-SSO_SERVER = "http://www.example.com/server/"

+ 0 - 5
devproject/test_settings.py

@@ -69,8 +69,3 @@ if os.environ.get("TRAVIS"):
     }
     }
 
 
     TEST_NAME = "travis_ci_test"
     TEST_NAME = "travis_ci_test"
-
-# for testing misago.sso module (single sign on)
-SSO_PRIVATE_KEY = "priv1"
-SSO_PUBLIC_KEY = "fakeSsoPublicKey"
-SSO_SERVER = "http://example.com/server/"

+ 0 - 56
misago/sso/README.rst

@@ -1,56 +0,0 @@
-=====================
-Misago Single Sign ON
-=====================
-
-Client
-======
-
-In Misago instance set settings::
-
-    SSO_PRIVATE_KEY = 'MySecretKey'
-    SSO_PUBLIC_KEY = 'MyPublicKey'
-    SSO_SERVER = "http://www.example.com/server/"
-
-Server
-======
-
-Example django server instance config:
-
-1. Install django-simple-sso: ``pip install django-simple-sso``
-2. Run migrations: ``./manage.py migrate``
-3. Create ``Consumer`` object in shell: ``./manage.py shell``::
-
-    > from simple_sso.sso_server.models import Consumer
-    > Consumer.objects.create(public_key='MyPublicKey',
-    ... private_key='MySecretKey', name='MyAppName')
-
-4. Add ``'simple_sso.sso_server'`` to ``INSTALLED_APPS``
-5. Initialize server and add urls to ``urls.py``::
-
-    from simple_sso.sso_server.server import Server
-    my_server = Server()
-    urlpatterns += [
-        url(r'^server/', include(my_server.get_urls())),
-    ]
-
-How to test
-===========
-
-In your web browser Go to site ``http://localhost:8000/sso/client/``. You should be redirected to
-login on your ``server`` site. After logging you will back to Misago and you will be logged as a
-user from ``server`` site.
-
-External docs
-=============
-
-Soruce code
------------
-
-* https://github.com/divio/django-simple-sso
-
-Docs sites
-----------
-
-* https://micropyramid.com/blog/django-single-sign-on-sso-to-multiple-applications/
-* https://medium.com/@MicroPyramid/django-single-sign-on-sso-to-multiple-applications-64637da015f4
-

+ 1 - 1
misago/sso/__init__.py

@@ -1 +1 @@
-default_app_config = "misago.sso.apps.MisagoSsoConfig"
+default_app_config = "misago.sso.apps.MisagoSSOConfig"

+ 1 - 1
misago/sso/apps.py

@@ -1,7 +1,7 @@
 from django.apps import AppConfig
 from django.apps import AppConfig
 
 
 
 
-class MisagoSsoConfig(AppConfig):
+class MisagoSSOConfig(AppConfig):
     """
     """
     Using https://github.com/divio/django-simple-sso
     Using https://github.com/divio/django-simple-sso
     """
     """

+ 44 - 24
misago/sso/client.py

@@ -1,39 +1,59 @@
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
-from django.contrib.auth.hashers import make_password
-from misago.conf import settings
-from misago.conf.shortcuts import get_dynamic_settings
-from misago.users.authbackends import MisagoBackend
-from misago.users.setupnewuser import setup_new_user
+from django.http import Http404
+from simple_sso.sso_client.client import AuthenticateView, Client, LoginView
 
 
-from simple_sso.sso_client.client import Client
+from ..users.authbackends import MisagoBackend
+from ..users.setupnewuser import setup_new_user
 
 
 User = get_user_model()
 User = get_user_model()
 
 
 
 
+class MisagoAuthenticateView(AuthenticateView):
+    @property
+    def client(self):
+        return create_configured_client(self.request)
+
+    def get(self, request):
+        if not request.settings.enable_sso:
+            raise Http404()
+
+        return super().get(request)
+
+
+class MisagoLoginView(LoginView):
+    @property
+    def client(self):
+        return create_configured_client(self.request)
+
+    def get(self, request):
+        if not request.settings.enable_sso:
+            raise Http404()
+
+        return super().get(request)
+
+
+def create_configured_client(request):
+    settings = request.settings
+
+    return ClientMisago(
+        settings.sso_server,
+        settings.sso_public_key,
+        settings.sso_private_key,
+        request=request,
+    )
+
+
 class ClientMisago(Client):
 class ClientMisago(Client):
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
+        self.request = kwargs.pop("request")
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
         self.backend = "%s.%s" % (MisagoBackend.__module__, MisagoBackend.__name__)
         self.backend = "%s.%s" % (MisagoBackend.__module__, MisagoBackend.__name__)
 
 
     def build_user(self, user_data):
     def build_user(self, user_data):
         try:
         try:
-            user = User.objects.get(username=user_data["username"])
+            return User.objects.get_by_email(user_data["email"])
         except User.DoesNotExist:
         except User.DoesNotExist:
-
-            user = User.objects.create_user(
-                user_data["username"],
-                user_data["email"],
-                make_password(make_password("ItDoesMatter")),
-            )
-
+            user = User.objects.create_user(user_data["username"], user_data["email"])
             user.update_acl_key()
             user.update_acl_key()
-
-            user_settings = get_dynamic_settings()
-            setup_new_user(user_settings, user)
-
-        return user
-
-
-client = ClientMisago(
-    settings.SSO_SERVER, settings.SSO_PUBLIC_KEY, settings.SSO_PRIVATE_KEY
-)
+            setup_new_user(self.request.settings, user)
+            return user

+ 96 - 37
misago/sso/tests.py

@@ -1,19 +1,31 @@
+from urllib.parse import urlparse
+
 from itsdangerous.timed import TimestampSigner
 from itsdangerous.timed import TimestampSigner
 from requests import Response
 from requests import Response
 from requests.sessions import Session
 from requests.sessions import Session
-from urllib.parse import urlparse
 
 
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
 from django.shortcuts import reverse
 from django.shortcuts import reverse
 from django.test import override_settings, TestCase
 from django.test import override_settings, TestCase
 from django.utils.timezone import now
 from django.utils.timezone import now
 
 
+from ..conf.test import override_dynamic_settings
+
 User = get_user_model()
 User = get_user_model()
 
 
+TEST_SSO_SETTINGS = {
+    "enable_sso": True,
+    "sso_private_key": "priv1",
+    "sso_public_key": "fakeSsoPublicKey",
+    "sso_server": "http://example.com/server/",
+}
+
+SSO_USER_EMAIL = "jkowalski@example.com"
+
 
 
 class ConnectionMock:
 class ConnectionMock:
     def __init__(self):
     def __init__(self):
-        self.Session = Session
+        self.session = Session
 
 
     def __enter__(self):
     def __enter__(self):
         self.origin_post = Session.post
         self.origin_post = Session.post
@@ -29,19 +41,22 @@ class ConnectionMock:
                 )
                 )
             elif "/server/verify/" == urlparse(requested_url).path:
             elif "/server/verify/" == urlparse(requested_url).path:
                 mocked_response._content = (
                 mocked_response._content = (
-                    b'{"username": "jkowalski", "email": "jkowalski@example.com", "first_name": '
-                    b'"Jan", "last_name": "Kowalski", "is_staff": false, "is_superuser": false, '
-                    b'"is_active": true}.XTg4IQ._cANZR5jHvtwhNzcnNYDfE1nLHE'
-                )
+                    (
+                        '{"username": "jkowalski", "email": "%s", "first_name": '
+                        '"Jan", "last_name": "Kowalski", "is_staff": false, "is_superuser": false, '
+                        '"is_active": true}.XTg4IQ._cANZR5jHvtwhNzcnNYDfE1nLHE'
+                    )
+                    % SSO_USER_EMAIL
+                ).encode("utf-8")
 
 
             mocked_response.status_code = 200
             mocked_response.status_code = 200
             return mocked_response
             return mocked_response
 
 
-        setattr(self.Session, "post", mocked_post)
-        return self.Session
+        setattr(self.session, "post", mocked_post)
+        return self.session
 
 
     def __exit__(self, type, value, traceback):
     def __exit__(self, type, value, traceback):
-        setattr(self.Session, "post", self.origin_post)
+        setattr(self.session, "post", self.origin_post)
 
 
 
 
 class TimestampSignerMock:
 class TimestampSignerMock:
@@ -67,38 +82,82 @@ class TimestampSignerMock:
         setattr(self.TimestampSigner, "unsign", self.origin_unsign)
         setattr(self.TimestampSigner, "unsign", self.origin_unsign)
 
 
 
 
-class SsoModuleTestCase(TestCase):
-    def test_sso_client(self):
-        url_to_external_logging = reverse("simple-sso-login")
-        self.assertEqual("/sso/client/", url_to_external_logging)
+@override_dynamic_settings(enable_sso=False)
+def test_sso_login_view_returns_404_if_sso_is_disabled(db, client):
+    url_to_external_logging = reverse("simple-sso-login")
+    assert url_to_external_logging == "/sso/client/"
+
+    response = client.get(url_to_external_logging)
+    assert response.status_code == 404
+
+
+@override_dynamic_settings(**TEST_SSO_SETTINGS)
+def test_sso_login_view_initiates_auth_flow(db, client):
+    url_to_external_logging = reverse("simple-sso-login")
+    assert url_to_external_logging == "/sso/client/"
+
+    with ConnectionMock():
+        response = client.get(url_to_external_logging)
+
+    assert response.status_code == 302
+
+    url_parsed = urlparse(response.url)
+    assert url_parsed.path == "/server/authorize/"
+    assert url_parsed.query == (
+        "token=XcHtuemqcjnIT6J2WHTFswLQP0W07nI96XfxqGkm6b1zFToF0YGEoIYu37QOajkc"
+    )
+
+
+@override_dynamic_settings(enable_sso=False)
+def test_sso_auth_view_returns_404_if_sso_is_disabled(db, client):
+    url_to_authenticate = reverse("simple-sso-authenticate")
+    assert url_to_authenticate == "/sso/client/authenticate/"
+
+    response = client.get(url_to_authenticate)
+    assert response.status_code == 404
+
+
+@override_dynamic_settings(**TEST_SSO_SETTINGS)
+def test_sso_auth_view_creates_new_user(db, client):
+    url_to_authenticate = reverse("simple-sso-authenticate")
+    assert url_to_authenticate == "/sso/client/authenticate/"
+
+    query = (
+        "next=%2F&access_token=InBBMjllMlNla2ZWdDdJMnR0c3R3QWIxcjQwRzV6TmphZDRSaEprbjlMbnR0TnF"
+        "Ka3Q2d1dNR1lVYkhzVThvZU0i.XTeRVQ.3XiIMg0AFcJKDFCekse6s43uNLI"
+    )
+    url_to_authenticate += "?" + query
+
+    with ConnectionMock():
+        with TimestampSignerMock():
+            response = client.get(url_to_authenticate)
+
+    assert response.status_code == 302
+    assert response.url == "/"
+
+    user = User.objects.first()
+    assert user.username == "jkowalski"
 
 
-        with ConnectionMock():
-            response = self.client.get(url_to_external_logging)
 
 
-        self.assertEqual(302, response.status_code)
+@override_dynamic_settings(**TEST_SSO_SETTINGS)
+def test_sso_auth_view_authenticates_existing_user(user, client):
+    user.set_email(SSO_USER_EMAIL)
+    user.save()
 
 
-        url_parsed = urlparse(response.url)
-        self.assertEqual("/server/authorize/", url_parsed.path)
-        self.assertEqual(
-            "token=XcHtuemqcjnIT6J2WHTFswLQP0W07nI96XfxqGkm6b1zFToF0YGEoIYu37QOajkc",
-            url_parsed.query,
-        )
+    url_to_authenticate = reverse("simple-sso-authenticate")
+    assert url_to_authenticate == "/sso/client/authenticate/"
 
 
-    def test_sso_client_authenticate(self):
-        url_to_authenticate = reverse("simple-sso-authenticate")
-        self.assertEqual("/sso/client/authenticate/", url_to_authenticate)
-        query = (
-            "next=%2F&access_token=InBBMjllMlNla2ZWdDdJMnR0c3R3QWIxcjQwRzV6TmphZDRSaEprbjlMbnR0TnF"
-            "Ka3Q2d1dNR1lVYkhzVThvZU0i.XTeRVQ.3XiIMg0AFcJKDFCekse6s43uNLI"
-        )
-        url_to_authenticate += "?" + query
+    query = (
+        "next=%2F&access_token=InBBMjllMlNla2ZWdDdJMnR0c3R3QWIxcjQwRzV6TmphZDRSaEprbjlMbnR0TnF"
+        "Ka3Q2d1dNR1lVYkhzVThvZU0i.XTeRVQ.3XiIMg0AFcJKDFCekse6s43uNLI"
+    )
+    url_to_authenticate += "?" + query
 
 
-        with ConnectionMock():
-            with TimestampSignerMock():
-                response = self.client.get(url_to_authenticate)
+    with ConnectionMock():
+        with TimestampSignerMock():
+            response = client.get(url_to_authenticate)
 
 
-        self.assertEqual(302, response.status_code)
-        self.assertEqual("/", response.url)
+    assert response.status_code == 302
+    assert response.url == "/"
 
 
-        u = User.objects.first()
-        self.assertEqual("jkowalski", u.username)
+    assert User.objects.count() == 1

+ 11 - 3
misago/sso/urls.py

@@ -1,4 +1,12 @@
-from django.conf.urls import url, include
-from .client import client
+from django.conf.urls import url
 
 
-urlpatterns = [url(r"^client/", include(client.get_urls()))]
+from .client import MisagoAuthenticateView, MisagoLoginView
+
+urlpatterns = [
+    url(r"^client/$", MisagoLoginView.as_view(), name="simple-sso-login"),
+    url(
+        r"^client/authenticate/$",
+        MisagoAuthenticateView.as_view(),
+        name="simple-sso-authenticate",
+    ),
+]