Przeglądaj źródła

Fixes #23112: Initiate SSO logins via a script-driven navigation (#23177)

Rendering the SSO buttons as POST forms (#23042) made every SSO login a form
submission which NetBox answers with a redirect to the identity provider.
Chromium-based browsers evaluate the CSP form-action directive against every hop
in a form submission's redirect chain, so a deployment which serves NetBox with
`form-action 'self'` blocks that redirect and the button silently does nothing.

Add SocialAuthBeginView, which wraps python-social-auth's begin view and returns
the identity provider's URL as JSON to clients which request it. The login page
now submits the form via fetch() and assigns window.location, which form-action
does not govern. The upstream view is reused as-is, so CSRF protection, the
callback URL, and the session state recorded for the identity provider are
unchanged; clients which do not request JSON (a browser without JavaScript, or a
backend which renders an HTML form rather than redirecting) receive the
unmodified response as before.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jeremy Stretch 7 godzin temu
rodzic
commit
a4ade2e7ae

+ 6 - 0
docs/administration/authentication/overview.md

@@ -41,6 +41,12 @@ NetBox supports single sign-on authentication via the [python-social-auth](https
 
 
 Most remote authentication backends require some additional configuration through settings prefixed with `SOCIAL_AUTH_`. These will be automatically imported from NetBox's `configuration.py` file. Additionally, the [authentication pipeline](https://python-social-auth.readthedocs.io/en/latest/pipeline.html) can be customized via the `SOCIAL_AUTH_PIPELINE` parameter. (NetBox's default pipeline is defined in `netbox/settings.py` for your reference.)
 Most remote authentication backends require some additional configuration through settings prefixed with `SOCIAL_AUTH_`. These will be automatically imported from NetBox's `configuration.py` file. Additionally, the [authentication pipeline](https://python-social-auth.readthedocs.io/en/latest/pipeline.html) can be customized via the `SOCIAL_AUTH_PIPELINE` parameter. (NetBox's default pipeline is defined in `netbox/settings.py` for your reference.)
 
 
+!!! note "Content Security Policy"
+    Beginning an SSO login requires the browser to make a request back to NetBox before it is sent
+    on to the identity provider. If you serve NetBox with a Content Security Policy which does not
+    permit same-origin connections, SSO logins will fail: add `connect-src 'self'` (or a
+    `default-src` which covers it) to your policy.
+
 #### Configuring the SSO module's appearance
 #### Configuring the SSO module's appearance
 
 
 The way a remote authentication backend is displayed to the user on the login
 The way a remote authentication backend is displayed to the user on the login

+ 48 - 2
netbox/account/views.py

@@ -9,14 +9,16 @@ from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm
 from django.contrib.auth.mixins import LoginRequiredMixin
 from django.contrib.auth.mixins import LoginRequiredMixin
 from django.contrib.auth.models import update_last_login
 from django.contrib.auth.models import update_last_login
 from django.contrib.auth.signals import user_logged_in
 from django.contrib.auth.signals import user_logged_in
-from django.http import HttpResponseRedirect
+from django.http import HttpResponseRedirect, JsonResponse
 from django.shortcuts import get_object_or_404, redirect, render, resolve_url
 from django.shortcuts import get_object_or_404, redirect, render, resolve_url
 from django.urls import reverse, reverse_lazy
 from django.urls import reverse, reverse_lazy
 from django.utils.decorators import method_decorator
 from django.utils.decorators import method_decorator
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
+from django.views.decorators.cache import never_cache
 from django.views.decorators.debug import sensitive_post_parameters
 from django.views.decorators.debug import sensitive_post_parameters
 from django.views.generic import View
 from django.views.generic import View
 from social_core.backends.utils import load_backends
 from social_core.backends.utils import load_backends
+from social_django.views import auth as social_auth_begin
 
 
 from account.models import UserToken
 from account.models import UserToken
 from core.models import ObjectChange
 from core.models import ObjectChange
@@ -78,7 +80,7 @@ class LoginView(View):
         request_data = request.POST if request.method == 'POST' else request.GET
         request_data = request.POST if request.method == 'POST' else request.GET
 
 
         for name in load_backends(settings.AUTHENTICATION_BACKENDS).keys():
         for name in load_backends(settings.AUTHENTICATION_BACKENDS).keys():
-            url = reverse('social:begin', args=[name])
+            url = reverse('social_auth_begin', args=[name])
             params = {}
             params = {}
             if next := request_data.get('next'):
             if next := request_data.get('next'):
                 params['next'] = next
                 params['next'] = next
@@ -188,6 +190,50 @@ class LogoutView(View):
         return response
         return response
 
 
 
 
+class SocialAuthBeginView(View):
+    """
+    Initiate authentication against a social auth (SSO) backend.
+
+    This wraps python-social-auth's "begin" view, which responds with an HTTP redirect to the
+    identity provider. Chromium-based browsers evaluate the CSP `form-action` directive against
+    every hop in a form submission's redirect chain, so a deployment which serves NetBox with
+    `form-action 'self'` (a common reverse proxy default) blocks that redirect and the SSO button
+    appears to do nothing. A client which asks for JSON is given the identity provider's URL in the
+    response body instead, and navigates to it itself: `form-action` does not govern a navigation
+    initiated by a script. Any other client (e.g. a browser with JavaScript disabled) receives the
+    unmodified response from python-social-auth.
+
+    A backend which does not redirect (`uses_redirect()` is False, as for OpenID 2.0) renders its
+    own HTML instead, which is returned in the response body for the client to render in place so
+    that it need not repeat the request. This is not a way around `form-action`: that document
+    carries a form which submits itself to the identity provider, and such a submission is governed
+    by the policy wherever the document is rendered. Deployments using one of these backends still
+    require a `form-action` which admits the identity provider.
+
+    The underlying view is reused as-is so that CSRF protection, the callback URL, and the session
+    state recorded for the identity provider all remain identical to a direct form submission.
+    """
+    @method_decorator(never_cache)
+    def dispatch(self, *args, **kwargs):
+        return super().dispatch(*args, **kwargs)
+
+    def post(self, request, backend):
+        response = social_auth_begin(request, backend)
+
+        if 'application/json' in request.headers.get('Accept', ''):
+            if url := response.headers.get('Location'):
+                return JsonResponse({'url': url})
+            if response.status_code == 200:
+                # Some backends render an HTML form (which submits itself to the identity provider)
+                # rather than redirecting. Hand that document to the client to render, so that it
+                # need not repeat the request and initiate the login a second time.
+                return JsonResponse({'html': response.content.decode(response.charset)})
+
+        # Anything else (including the response to a client which has not asked for JSON) is passed
+        # through unchanged.
+        return response
+
+
 #
 #
 # User profiles
 # User profiles
 #
 #

+ 155 - 2
netbox/netbox/tests/test_authentication.py

@@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch
 
 
 from django.conf import settings
 from django.conf import settings
 from django.contrib.messages.storage.fallback import FallbackStorage
 from django.contrib.messages.storage.fallback import FallbackStorage
+from django.http import HttpResponse
 from django.test import Client, RequestFactory, SimpleTestCase
 from django.test import Client, RequestFactory, SimpleTestCase
 from django.test import TestCase as DjangoTestCase
 from django.test import TestCase as DjangoTestCase
 from django.test.utils import override_settings
 from django.test.utils import override_settings
@@ -861,7 +862,7 @@ class SSOLoginButtonTestCase(DjangoTestCase):
         Return the body of the rendered SSO form. The password login form renders its own hidden
         Return the body of the rendered SSO form. The password login form renders its own hidden
         `next` field, so assertions about the SSO parameters must be scoped to this form.
         `next` field, so assertions about the SSO parameters must be scoped to this form.
         """
         """
-        begin_url = reverse('social:begin', args=['google-oauth2'])
+        begin_url = reverse('social_auth_begin', args=['google-oauth2'])
         match = re.search(
         match = re.search(
             rf'<form[^>]*action="{re.escape(begin_url)}"[^>]*>(.*?)</form>',
             rf'<form[^>]*action="{re.escape(begin_url)}"[^>]*>(.*?)</form>',
             response.content.decode(),
             response.content.decode(),
@@ -876,7 +877,7 @@ class SSOLoginButtonTestCase(DjangoTestCase):
         """
         """
         Each SSO button must be rendered as a POST form (including a CSRF token) rather than a link.
         Each SSO button must be rendered as a POST form (including a CSRF token) rather than a link.
         """
         """
-        begin_url = reverse('social:begin', args=['google-oauth2'])
+        begin_url = reverse('social_auth_begin', args=['google-oauth2'])
         response = self.client.get(reverse('login'))
         response = self.client.get(reverse('login'))
 
 
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
@@ -951,6 +952,158 @@ class SSOLoginButtonTestCase(DjangoTestCase):
             self.assertEqual(auth_backend['params'].get('next'), '/dcim/sites/')
             self.assertEqual(auth_backend['params'].get('next'), '/dcim/sites/')
 
 
 
 
+class SocialAuthBeginViewTestCase(DjangoTestCase):
+    """
+    Verify the view which initiates an SSO login. Chromium-based browsers evaluate the CSP
+    `form-action` directive against every hop in a form submission's redirect chain, so redirecting
+    the submission to the identity provider is blocked wherever `form-action 'self'` is enforced.
+    Clients which ask for JSON are handed the identity provider's URL to navigate to instead
+    (see #23112).
+    """
+    SSO_BACKENDS = [
+        'social_core.backends.google.GoogleOAuth2',
+        'netbox.authentication.ObjectPermissionBackend',
+    ]
+    AUTHORIZATION_URL = 'https://accounts.google.com/o/oauth2/auth'
+    # Stands in for the document rendered by a backend which does not redirect (see BaseAuth.start())
+    AUTH_HTML = '<html><body><form id="openid_message" action="https://idp.example.com/"></form></body></html>'
+
+    def setUp(self):
+        # load_backends() caches the discovered backends in a module-level dict, so isolate the
+        # backends overridden below from the remainder of the test suite.
+        cache_patcher = patch.dict('social_core.backends.utils.BACKENDSCACHE', {}, clear=True)
+        cache_patcher.start()
+        self.addCleanup(cache_patcher.stop)
+
+        self.url = reverse('social_auth_begin', args=['google-oauth2'])
+
+    @override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
+    def test_json_request_returns_authorization_url(self):
+        """
+        A client which requests JSON receives the identity provider's URL in the response body
+        rather than an HTTP redirect, so that it can navigate there itself.
+        """
+        response = self.client.post(self.url, headers={'accept': 'application/json'})
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.headers['Content-Type'], 'application/json')
+        self.assertNotIn('Location', response.headers)
+        self.assertTrue(response.json()['url'].startswith(self.AUTHORIZATION_URL))
+
+    @override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
+    def test_form_submission_returns_redirect(self):
+        """
+        A client which has not asked for JSON (e.g. a browser with JavaScript disabled) receives the
+        unmodified redirect from python-social-auth.
+        """
+        response = self.client.post(self.url, headers={'accept': 'text/html'})
+
+        self.assertEqual(response.status_code, 302)
+        self.assertTrue(response.headers['Location'].startswith(self.AUTHORIZATION_URL))
+
+    @override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
+    def test_session_state_recorded(self):
+        """
+        The anti-forgery state conveyed to the identity provider must be recorded in the session, as
+        the completion view compares the two. This is what makes the JSON response safe to follow:
+        the session established here is the one the callback is validated against.
+        """
+        response = self.client.post(self.url, headers={'accept': 'application/json'})
+
+        state = self.client.session['google-oauth2_state']
+        self.assertIn(f'state={state}', response.json()['url'])
+
+    @override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
+    def test_next_recorded_in_session(self):
+        """
+        The post-login URL is read from the POST data by do_auth() and stashed in the session; the
+        wrapper must not interfere with the form fields rendered on the login page.
+        """
+        self.client.post(self.url, {'next': '/dcim/sites/'}, headers={'accept': 'application/json'})
+
+        self.assertEqual(self.client.session['next'], '/dcim/sites/')
+
+    @override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
+    def test_get_request_not_allowed(self):
+        """
+        Authentication must be initiated by POST: a GET request is trivially forgeable, which is why
+        social-auth-app-django restricts its own begin view to POST.
+        """
+        response = self.client.get(self.url)
+
+        self.assertEqual(response.status_code, 405)
+
+    @override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
+    def test_csrf_token_required(self):
+        """
+        CSRF protection must be retained, so that a third party cannot silently initiate an SSO
+        login on the user's behalf.
+        """
+        client = Client(enforce_csrf_checks=True)
+        response = client.post(self.url, headers={'accept': 'application/json'})
+
+        self.assertEqual(response.status_code, 403)
+
+    @override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
+    def test_response_is_not_cached(self):
+        """
+        The authorization URL embeds a single-use state parameter and must never be cached.
+        """
+        response = self.client.post(self.url, headers={'accept': 'application/json'})
+
+        self.assertIn('no-store', response.headers['Cache-Control'])
+
+    @override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
+    def test_unknown_backend(self):
+        """
+        An unconfigured backend yields an HTTP 404, as it does via python-social-auth directly.
+        """
+        response = self.client.post(reverse('social_auth_begin', args=['nosuchbackend']))
+
+        self.assertEqual(response.status_code, 404)
+
+    @override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
+    @patch('social_core.backends.google.GoogleOAuth2.uses_redirect', return_value=False)
+    @patch('social_core.backends.google.GoogleOAuth2.auth_html', return_value=AUTH_HTML)
+    def test_json_request_returns_html_for_non_redirecting_backend(self, _auth_html, _uses_redirect):
+        """
+        A backend which renders its own HTML rather than redirecting has that document returned in
+        the response body. The client renders it in place: were it made to submit the form to fetch
+        the document again, the login would be initiated a second time.
+        """
+        response = self.client.post(self.url, headers={'accept': 'application/json'})
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.headers['Content-Type'], 'application/json')
+        self.assertNotIn('Location', response.headers)
+        self.assertEqual(response.json()['html'], self.AUTH_HTML)
+
+    @override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
+    @patch('social_core.backends.google.GoogleOAuth2.uses_redirect', return_value=False)
+    @patch('social_core.backends.google.GoogleOAuth2.auth_html', return_value=AUTH_HTML)
+    def test_html_passed_through_for_non_redirecting_backend(self, _auth_html, _uses_redirect):
+        """
+        A client which has not asked for JSON receives that same document unmodified.
+        """
+        response = self.client.post(self.url, headers={'accept': 'text/html'})
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.content.decode(), self.AUTH_HTML)
+
+    @override_settings(AUTHENTICATION_BACKENDS=SSO_BACKENDS)
+    @patch('account.views.social_auth_begin', side_effect=lambda *args, **kwargs: HttpResponse(status=502))
+    def test_json_request_passes_through_error_response(self, _begin):
+        """
+        Only a redirect or a rendered document is translated to JSON. An unsuccessful response is
+        passed through as-is, so that the client reports the failure rather than mistaking the
+        response for a login it can act on.
+        """
+        response = self.client.post(self.url, headers={'accept': 'application/json'})
+
+        self.assertEqual(response.status_code, 502)
+        self.assertNotEqual(response.headers.get('Content-Type'), 'application/json')
+
+
 class SocialAuthExceptionMiddlewareTestCase(SimpleTestCase):
 class SocialAuthExceptionMiddlewareTestCase(SimpleTestCase):
     """
     """
     Verify that SSO/SAML authentication failures are surfaced as a login-page message rather than
     Verify that SSO/SAML authentication failures are surfaced as a login-page message rather than

+ 2 - 1
netbox/netbox/urls.py

@@ -4,7 +4,7 @@ from django.urls import path
 from django.views.decorators.cache import cache_page
 from django.views.decorators.cache import cache_page
 from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
 from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
 
 
-from account.views import LoginView, LogoutView
+from account.views import LoginView, LogoutView, SocialAuthBeginView
 from netbox.api.views import APIRootView, AuthenticationCheckView, StatusView
 from netbox.api.views import APIRootView, AuthenticationCheckView, StatusView
 from netbox.graphql.schema import schema
 from netbox.graphql.schema import schema
 from netbox.graphql.views import NetBoxGraphQLView
 from netbox.graphql.views import NetBoxGraphQLView
@@ -20,6 +20,7 @@ _patterns = [
     # Login/logout
     # Login/logout
     path('login/', LoginView.as_view(), name='login'),
     path('login/', LoginView.as_view(), name='login'),
     path('logout/', LogoutView.as_view(), name='logout'),
     path('logout/', LogoutView.as_view(), name='logout'),
+    path('oauth/begin/<str:backend>/', SocialAuthBeginView.as_view(), name='social_auth_begin'),
     path('oauth/', include('social_django.urls', namespace='social')),
     path('oauth/', include('social_django.urls', namespace='social')),
 
 
     # Apps
     # Apps

Plik diff jest za duży
+ 0 - 0
netbox/project-static/dist/netbox.js


Plik diff jest za duży
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 2 - 0
netbox/project-static/src/netbox.ts

@@ -14,6 +14,7 @@ import { initRackElevation } from './racks';
 import { initHtmx } from './htmx';
 import { initHtmx } from './htmx';
 import { initSavedFilterSelect } from './forms/savedFiltersSelect';
 import { initSavedFilterSelect } from './forms/savedFiltersSelect';
 import { initHotkeys } from './hotkeys';
 import { initHotkeys } from './hotkeys';
+import { initSSOForms } from './sso';
 
 
 function initDocument(): void {
 function initDocument(): void {
   for (const init of [
   for (const init of [
@@ -33,6 +34,7 @@ function initDocument(): void {
     initHtmx,
     initHtmx,
     initSavedFilterSelect,
     initSavedFilterSelect,
     initHotkeys,
     initHotkeys,
+    initSSOForms,
   ]) {
   ]) {
     init();
     init();
   }
   }

+ 124 - 0
netbox/project-static/src/sso.ts

@@ -0,0 +1,124 @@
+import { getElement, getElements } from './util';
+
+// Abandon a login which has gone unanswered for this many milliseconds, so that a request which
+// hangs (a backend retrieving identity provider metadata of its own, for instance) surfaces an
+// error rather than leaving the SSO buttons disabled indefinitely.
+const REQUEST_TIMEOUT = 15000;
+
+// Whether a login is already being initiated. Each login is issued a single-use state parameter,
+// which the completion view compares against the value recorded in the session, so a second login
+// would invalidate the state of the one being navigated to. This is scoped to the document rather
+// than to the form: SAML renders one form per identity provider, and all of them write the session
+// keys of the same backend.
+let loginPending = false;
+
+/**
+ * Enable or disable every SSO button on the page. All of them are disabled while a login is being
+ * initiated, as only one login can be in flight at a time.
+ */
+function setButtonsDisabled(disabled: boolean): void {
+  for (const form of getElements<HTMLFormElement>('form.sso-login-form')) {
+    for (const button of form.querySelectorAll<HTMLButtonElement>('button[type="submit"]')) {
+      button.disabled = disabled;
+    }
+  }
+}
+
+function setErrorVisible(visible: boolean): void {
+  const error = getElement('sso-error');
+  if (error === null) return;
+
+  error.classList.toggle('d-none', !visible);
+  if (visible) {
+    // Revealing an alert whose text has not itself changed is not reliably announced by a screen
+    // reader, so move focus to it (the login the user asked for did not begin, and the reason for
+    // that is the only thing worth their attention).
+    error.focus();
+  }
+}
+
+/**
+ * Begin an SSO login by navigating to the identity provider, rather than by submitting the form.
+ *
+ * The social auth "begin" endpoint responds with a redirect to the identity provider. Chromium-based
+ * browsers evaluate the CSP `form-action` directive against every hop in a form submission's
+ * redirect chain, so a deployment which serves NetBox with `form-action 'self'` blocks that redirect
+ * and the SSO button appears to do nothing. Requesting the identity provider's URL and navigating to
+ * it here sidesteps the directive, which does not govern a navigation initiated by a script.
+ */
+async function beginLogin(form: HTMLFormElement): Promise<void> {
+  const body = new URLSearchParams();
+  for (const [name, value] of new FormData(form).entries()) {
+    if (typeof value === 'string') {
+      body.append(name, value);
+    }
+  }
+
+  // Browsers predating AbortSignal.timeout() issue the request without a deadline, which is no
+  // worse than the behavior they had before it was imposed.
+  const signal =
+    typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(REQUEST_TIMEOUT) : null;
+
+  const res = await fetch(form.action, {
+    method: 'POST',
+    headers: { Accept: 'application/json' },
+    body,
+    credentials: 'same-origin',
+    signal,
+  });
+  if (!res.ok || !(res.headers.get('Content-Type') ?? '').includes('application/json')) {
+    throw new Error(`The login request returned an unexpected response (HTTP ${res.status})`);
+  }
+
+  const { url, html } = (await res.json()) as { url?: string; html?: string };
+  if (typeof url === 'string') {
+    window.location.assign(url);
+  } else if (typeof html === 'string') {
+    // The backend renders its own HTML (an auto-submitting form, for instance) instead of
+    // redirecting. That document has already been generated by the request above, so render it in
+    // place; submitting the form to fetch it again would initiate the login a second time. Note
+    // that this does not evade `form-action`: the document is written into NetBox's own, so the
+    // form it carries is submitted under NetBox's policy exactly as it would have been otherwise.
+    document.open();
+    document.write(html);
+    document.close();
+  } else {
+    throw new Error('The login response contained neither a URL nor a document');
+  }
+}
+
+export function initSSOForms(): void {
+  for (const form of getElements<HTMLFormElement>('form.sso-login-form')) {
+    form.addEventListener('submit', event => {
+      event.preventDefault();
+
+      if (loginPending) return;
+      loginPending = true;
+      setErrorVisible(false);
+      setButtonsDisabled(true);
+
+      beginLogin(form).catch(error => {
+        // Report the failure rather than falling back to submitting the form: a deployment which
+        // serves NetBox with `form-action 'self'` — the very condition this indirection exists to
+        // work around — blocks that submission silently, leaving a dead button and no explanation.
+        // The alert shown to the user cannot say why the login failed, so log the reason.
+        console.error(error);
+        loginPending = false;
+        setButtonsDisabled(false);
+        setErrorVisible(true);
+      });
+    });
+  }
+
+  // A browser which restores the login page from its back/forward cache (having navigated to the
+  // identity provider and returned) preserves both the DOM and the state above, which would
+  // otherwise leave every SSO button permanently disabled. Only a restore may clear the guard:
+  // pageshow also fires on an ordinary load, after `load` and so after a login begun in the
+  // meantime, where resetting it would readmit the very double-submission it exists to prevent.
+  window.addEventListener('pageshow', event => {
+    if (!event.persisted) return;
+
+    loginPending = false;
+    setButtonsDisabled(false);
+  });
+}

+ 6 - 2
netbox/templates/login.html

@@ -83,11 +83,15 @@
             {% if login_form_hidden %}
             {% if login_form_hidden %}
               <h2 class="text-center mb-4">{% trans "Log In" %}</h2>
               <h2 class="text-center mb-4">{% trans "Log In" %}</h2>
             {% endif %}
             {% endif %}
+            {# Revealed and focused by initSSOForms() when a login cannot be initiated #}
+            <div id="sso-error" class="alert alert-danger d-none" role="alert" tabindex="-1">
+              {% trans "Unable to begin single sign-on. Please try again." %}
+            </div>
             <div class="row">
             <div class="row">
               {% for backend in auth_backends %}
               {% for backend in auth_backends %}
                 <div class="col">
                 <div class="col">
-                  {# The social auth begin view accepts only POST requests #}
-                  <form action="{{ backend.url }}" method="post">
+                  {# SSO logins are initiated by POST; see SocialAuthBeginView #}
+                  <form action="{{ backend.url }}" method="post" class="sso-login-form">
                     {% csrf_token %}
                     {% csrf_token %}
                     {% for param, value in backend.params.items %}
                     {% for param, value in backend.params.items %}
                       <input type="hidden" name="{{ param }}" value="{{ value }}" />
                       <input type="hidden" name="{{ param }}" value="{{ value }}" />

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików