mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-11-13 12:23:44 +01:00
Merge pull request #637 from fariskazmi/feat/oidc-disable-local-login
feat(auth): add OIDC_DISABLE_LOCAL_LOGIN to turn off password logins
This commit is contained in:
6 files changed
+213
-32
No files matched your search
@@ -41,6 +41,8 @@ TASK_WORKERS=1 # This only work if you're using the single container option. Inc
|
||||
# Trust all provider emails, including false or missing email_verified claims.
|
||||
# Set to false to require provider-verified emails before linking existing accounts.
|
||||
#OIDC_TRUST_EMAIL=true
|
||||
# Only allow signing in through OIDC: email and password logins are refused everywhere.
|
||||
#OIDC_DISABLE_LOCAL_LOGIN=false
|
||||
|
||||
# Personal access tokens. How often (seconds) a token's last_used_at is rewritten.
|
||||
#API_TOKEN_LAST_USED_UPDATE_INTERVAL=600
|
||||
|
||||
@@ -168,14 +168,15 @@ WYGIWYH supports login via OpenID Connect (OIDC) through `django-allauth`. This
|
||||
|
||||
To configure OIDC, you need to set the following environment variables:
|
||||
|
||||
| Variable | Description |
|
||||
|----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `OIDC_CLIENT_NAME` | The name of the provider. will be displayed in the login page. Defaults to `OpenID Connect` |
|
||||
| `OIDC_CLIENT_ID` | The Client ID provided by your OIDC provider. |
|
||||
| `OIDC_CLIENT_SECRET` | The Client Secret provided by your OIDC provider. |
|
||||
| `OIDC_SERVER_URL` | The base URL of your OIDC provider's discovery document or authorization server (e.g., `https://your-provider.com/auth/realms/your-realm`). `django-allauth` will use this to discover the necessary endpoints (authorization, token, userinfo, etc.). |
|
||||
| `OIDC_ALLOW_SIGNUP` | Allow the automatic creation of inexistent accounts on a successfull authentication. Defaults to `true`. |
|
||||
| `OIDC_TRUST_EMAIL` | Treat every email sent by your provider as verified, linking it to a matching existing local account even without the `email_verified` claim. Set to `false` to only link verified emails. Defaults to `true`. |
|
||||
| Variable | Description |
|
||||
|----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `OIDC_CLIENT_NAME` | The name of the provider. will be displayed in the login page. Defaults to `OpenID Connect` |
|
||||
| `OIDC_CLIENT_ID` | The Client ID provided by your OIDC provider. |
|
||||
| `OIDC_CLIENT_SECRET` | The Client Secret provided by your OIDC provider. |
|
||||
| `OIDC_SERVER_URL` | The base URL of your OIDC provider's discovery document or authorization server (e.g., `https://your-provider.com/auth/realms/your-realm`). `django-allauth` will use this to discover the necessary endpoints (authorization, token, userinfo, etc.). |
|
||||
| `OIDC_ALLOW_SIGNUP` | Allow the automatic creation of inexistent accounts on a successfull authentication. Defaults to `true`. |
|
||||
| `OIDC_TRUST_EMAIL` | Treat every email sent by your provider as verified, linking it to a matching existing local account even without the `email_verified` claim. Set to `false` to only link verified emails. Defaults to `true`. |
|
||||
| `OIDC_DISABLE_LOCAL_LOGIN` | Only allow signing in through your OIDC provider. Email and password logins are refused everywhere: the login page, the admin and the API's basic authentication. API tokens and OAuth apps keep working. Only takes effect when OIDC is configured, and changing it signs everyone out once. If `OIDC_ALLOW_SIGNUP` is `false`, make sure an admin's local account email matches their provider account first, or nobody will be able to sign in. Defaults to `false`. |
|
||||
|
||||
**Callback URL (Redirect URI):**
|
||||
|
||||
|
||||
+12
-2
@@ -359,11 +359,12 @@ AUTHENTICATION_BACKENDS = [
|
||||
|
||||
SOCIALACCOUNT_PROVIDERS = {"openid_connect": {"APPS": []}}
|
||||
|
||||
if (
|
||||
OIDC_CONFIGURED = bool(
|
||||
os.getenv("OIDC_CLIENT_ID")
|
||||
and os.getenv("OIDC_CLIENT_SECRET")
|
||||
and os.getenv("OIDC_SERVER_URL")
|
||||
):
|
||||
)
|
||||
if OIDC_CONFIGURED:
|
||||
SOCIALACCOUNT_PROVIDERS["openid_connect"]["APPS"].append(
|
||||
{
|
||||
"provider_id": slugify(os.getenv("OIDC_CLIENT_NAME", "OpenID Connect")),
|
||||
@@ -393,6 +394,15 @@ SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True
|
||||
ACCOUNT_ADAPTER = "allauth.account.adapter.DefaultAccountAdapter"
|
||||
SOCIALACCOUNT_ADAPTER = "apps.users.adapters.AutoConnectSocialAccountAdapter"
|
||||
|
||||
# Only takes effect when an OIDC provider is configured, so an instance can't
|
||||
# be left without any way to sign in.
|
||||
OIDC_DISABLE_LOCAL_LOGIN = (
|
||||
OIDC_CONFIGURED
|
||||
and os.getenv("OIDC_DISABLE_LOCAL_LOGIN", "false").lower() == "true"
|
||||
)
|
||||
if OIDC_DISABLE_LOCAL_LOGIN:
|
||||
AUTHENTICATION_BACKENDS = ["apps.users.backends.SocialOnlyAuthenticationBackend"]
|
||||
|
||||
# Personal access tokens. last_used_at is only rewritten once per interval to
|
||||
# avoid a database write on every authenticated request.
|
||||
API_TOKEN_LAST_USED_UPDATE_INTERVAL = int(
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
from allauth.account.auth_backends import AuthenticationBackend
|
||||
|
||||
|
||||
class SocialOnlyAuthenticationBackend(AuthenticationBackend):
|
||||
"""Replaces the default backends when OIDC_DISABLE_LOCAL_LOGIN is on.
|
||||
|
||||
Every e-mail and password is refused, wherever it's checked: the login
|
||||
page, the admin and the API's basic auth. OIDC logins still work, because
|
||||
allauth picks its own backend class, which this is, for those sessions.
|
||||
Permission checks keep working too, since they come from ModelBackend.
|
||||
"""
|
||||
|
||||
def authenticate(self, request, **credentials):
|
||||
return None
|
||||
+169
-20
@@ -1,4 +1,12 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
import base64
|
||||
|
||||
from django.contrib.auth import (
|
||||
BACKEND_SESSION_KEY,
|
||||
SESSION_KEY,
|
||||
authenticate,
|
||||
get_user_model,
|
||||
)
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
@@ -92,6 +100,28 @@ def _oidc_providers(trust_email=False):
|
||||
}
|
||||
|
||||
|
||||
def _complete_oidc_login(claims):
|
||||
from allauth.core import context
|
||||
from allauth.socialaccount.adapter import get_adapter
|
||||
from allauth.socialaccount.helpers import complete_social_login
|
||||
from django.contrib.messages.middleware import MessageMiddleware
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.test import RequestFactory
|
||||
|
||||
request = RequestFactory().get("/auth/oidc/test-oidc/login/callback/")
|
||||
SessionMiddleware(lambda r: None).process_request(request)
|
||||
MessageMiddleware(lambda r: None).process_request(request)
|
||||
request.user = AnonymousUser()
|
||||
|
||||
provider = get_adapter().get_provider(request, "test-oidc")
|
||||
sociallogin = provider.sociallogin_from_response(request, claims)
|
||||
sociallogin.state = {"process": "login"}
|
||||
with context.request_context(request):
|
||||
complete_social_login(request, sociallogin)
|
||||
return request
|
||||
|
||||
|
||||
class OIDCEmailAutoConnectTests(TestCase):
|
||||
"""GHSA-prgm-wrjv-fvrh: OIDC logins must only link to existing local
|
||||
accounts by email when the email is verified (or explicitly trusted)."""
|
||||
@@ -102,25 +132,7 @@ class OIDCEmailAutoConnectTests(TestCase):
|
||||
)
|
||||
|
||||
def _login(self, claims):
|
||||
from allauth.core import context
|
||||
from allauth.socialaccount.adapter import get_adapter
|
||||
from allauth.socialaccount.helpers import complete_social_login
|
||||
from django.contrib.messages.middleware import MessageMiddleware
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.test import RequestFactory
|
||||
|
||||
request = RequestFactory().get("/auth/oidc/test-oidc/login/callback/")
|
||||
SessionMiddleware(lambda r: None).process_request(request)
|
||||
MessageMiddleware(lambda r: None).process_request(request)
|
||||
request.user = AnonymousUser()
|
||||
|
||||
provider = get_adapter().get_provider(request, "test-oidc")
|
||||
sociallogin = provider.sociallogin_from_response(request, claims)
|
||||
sociallogin.state = {"process": "login"}
|
||||
with context.request_context(request):
|
||||
complete_social_login(request, sociallogin)
|
||||
return request
|
||||
return _complete_oidc_login(claims)
|
||||
|
||||
def _assert_not_linked(self, request):
|
||||
from allauth.socialaccount.models import SocialAccount
|
||||
@@ -168,3 +180,140 @@ class OIDCEmailAutoConnectTests(TestCase):
|
||||
{"sub": "oidc-uid", "email": "victim@example.com", "email_verified": False}
|
||||
)
|
||||
self._assert_linked(request)
|
||||
|
||||
|
||||
SOCIAL_ONLY_BACKEND = "apps.users.backends.SocialOnlyAuthenticationBackend"
|
||||
|
||||
# The login page loads static files, which the default manifest storage can
|
||||
# only find after collectstatic.
|
||||
PLAIN_STATIC_FILES = {
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"},
|
||||
}
|
||||
|
||||
|
||||
def _basic_auth(email, password):
|
||||
credentials = base64.b64encode(f"{email}:{password}".encode()).decode()
|
||||
return f"Basic {credentials}"
|
||||
|
||||
|
||||
@override_settings(STORAGES=PLAIN_STATIC_FILES, WHITENOISE_AUTOREFRESH=True)
|
||||
class LocalLoginEnabledByDefaultTests(TestCase):
|
||||
def setUp(self):
|
||||
# Superuser so the admin login is allowed; mirrors LocalLoginDisabledTests.
|
||||
self.user = get_user_model().objects.create_superuser(
|
||||
email="user@example.com", password="test-password"
|
||||
)
|
||||
|
||||
def test_password_authenticates(self):
|
||||
self.assertEqual(
|
||||
authenticate(username="user@example.com", password="test-password"),
|
||||
self.user,
|
||||
)
|
||||
|
||||
def test_login_page_shows_the_password_form(self):
|
||||
response = self.client.get(reverse("login"))
|
||||
|
||||
self.assertContains(response, 'name="password"')
|
||||
|
||||
def test_login_page_accepts_a_password(self):
|
||||
self.client.post(
|
||||
reverse("login"),
|
||||
{"username": "user@example.com", "password": "test-password"},
|
||||
)
|
||||
|
||||
self.assertEqual(self.client.session.get(SESSION_KEY), str(self.user.pk))
|
||||
|
||||
def test_admin_accepts_a_password(self):
|
||||
self.client.post(
|
||||
reverse("admin:login"),
|
||||
{"username": "user@example.com", "password": "test-password"},
|
||||
)
|
||||
|
||||
self.assertEqual(self.client.session.get(SESSION_KEY), str(self.user.pk))
|
||||
|
||||
def test_api_basic_auth_works(self):
|
||||
response = self.client.get(
|
||||
"/api/accounts/",
|
||||
HTTP_AUTHORIZATION=_basic_auth("user@example.com", "test-password"),
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
@override_settings(
|
||||
AUTHENTICATION_BACKENDS=[SOCIAL_ONLY_BACKEND],
|
||||
OIDC_DISABLE_LOCAL_LOGIN=True,
|
||||
SOCIALACCOUNT_PROVIDERS=_oidc_providers(trust_email=True),
|
||||
STORAGES=PLAIN_STATIC_FILES,
|
||||
WHITENOISE_AUTOREFRESH=True,
|
||||
)
|
||||
class LocalLoginDisabledTests(TestCase):
|
||||
"""OIDC_DISABLE_LOCAL_LOGIN: passwords are refused everywhere, while OIDC
|
||||
logins and permission checks keep working."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = get_user_model().objects.create_superuser(
|
||||
email="user@example.com", password="test-password"
|
||||
)
|
||||
|
||||
def test_password_does_not_authenticate(self):
|
||||
self.assertIsNone(
|
||||
authenticate(username="user@example.com", password="test-password")
|
||||
)
|
||||
self.assertIsNone(
|
||||
authenticate(email="user@example.com", password="test-password")
|
||||
)
|
||||
|
||||
def test_login_page_hides_the_password_form(self):
|
||||
response = self.client.get(reverse("login"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotContains(response, 'name="password"')
|
||||
self.assertContains(response, "Test OIDC")
|
||||
|
||||
def test_login_page_refuses_a_password(self):
|
||||
self.client.post(
|
||||
reverse("login"),
|
||||
{"username": "user@example.com", "password": "test-password"},
|
||||
)
|
||||
|
||||
self.assertNotIn(SESSION_KEY, self.client.session)
|
||||
|
||||
def test_admin_refuses_a_password(self):
|
||||
self.client.post(
|
||||
reverse("admin:login"),
|
||||
{"username": "user@example.com", "password": "test-password"},
|
||||
)
|
||||
|
||||
self.assertNotIn(SESSION_KEY, self.client.session)
|
||||
|
||||
def test_api_basic_auth_is_refused(self):
|
||||
response = self.client.get(
|
||||
"/api/accounts/",
|
||||
HTTP_AUTHORIZATION=_basic_auth("user@example.com", "test-password"),
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_oidc_login_still_works(self):
|
||||
request = _complete_oidc_login(
|
||||
{"sub": "oidc-uid", "email": "user@example.com", "email_verified": True}
|
||||
)
|
||||
|
||||
self.assertEqual(request.session.get(SESSION_KEY), str(self.user.pk))
|
||||
self.assertEqual(request.session.get(BACKEND_SESSION_KEY), SOCIAL_ONLY_BACKEND)
|
||||
|
||||
def test_permission_checks_still_work(self):
|
||||
member = get_user_model().objects.create_user(
|
||||
email="member@example.com", password="member-password"
|
||||
)
|
||||
member.user_permissions.add(
|
||||
Permission.objects.get(
|
||||
content_type__app_label="accounts", codename="view_account"
|
||||
)
|
||||
)
|
||||
member = get_user_model().objects.get(pk=member.pk)
|
||||
|
||||
self.assertTrue(member.has_perm("accounts.view_account"))
|
||||
self.assertFalse(member.has_perm("accounts.delete_account"))
|
||||
@@ -23,11 +23,16 @@
|
||||
<div class="card bg-base-100 shadow-2xl">
|
||||
<div class="card-body">
|
||||
<h1 class="text-2xl card-title text-center mb-4">Login</h1>
|
||||
{% crispy form %}
|
||||
{% settings "OIDC_DISABLE_LOCAL_LOGIN" as local_login_disabled %}
|
||||
{% if not local_login_disabled %}
|
||||
{% crispy form %}
|
||||
{% endif %}
|
||||
|
||||
{% get_providers as socialaccount_providers %}
|
||||
{% if socialaccount_providers %}
|
||||
<hr class="hr my-3">
|
||||
{% if not local_login_disabled %}
|
||||
<hr class="hr my-3">
|
||||
{% endif %}
|
||||
<ul class="socialaccount_providers list-none flex flex-col gap-3">
|
||||
{% for provider in socialaccount_providers %}
|
||||
<li>
|
||||
|
||||
Reference in new issue
Block a user