diff --git a/pyproject.toml b/pyproject.toml index 9ce6e1c8b..ada6c2cdb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,9 +33,6 @@ dependencies = [ "drf-spectacular>=0.17", "factory_boy", "psycopg2", - "python-dataporten-auth", - "social-auth-core>=4.1", - "social-auth-app-django>=5.0", "whitenoise", "wheel", "httpx", @@ -44,8 +41,6 @@ dependencies = [ "django-htmx", "django-widget-tweaks==1.5.0", "fontawesomefree~=6.6", - "social-auth-core>=4.1", - "social-auth-app-django>=5.0", ] dynamic = ["version"] @@ -55,6 +50,17 @@ dynamic = ["version"] [project.optional-dependencies] htmx = [] # for outdated install procedures docs = ["sphinx>=2.2.0"] +psa = [ + "social-auth-core>=4.1", + "social-auth-app-django>=5.0", + "python-dataporten-auth", +] +allauth-mfa = [ + "django-allauth[mfa]", +] +allauth-social = [ + "django-allauth[socialaccount]", +] dev = [ "django-debug-toolbar", "coverage", diff --git a/src/argus/auth/allauth/__init__.py b/src/argus/auth/allauth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/argus/auth/allauth/adapter.py b/src/argus/auth/allauth/adapter.py new file mode 100644 index 000000000..712084faa --- /dev/null +++ b/src/argus/auth/allauth/adapter.py @@ -0,0 +1,26 @@ +from django.conf import settings + +from allauth.account.adapter import DefaultAccountAdapter +from allauth.account.adapter import get_adapter as get_account_adapter +from allauth.socialaccount.adapter import DefaultSocialAccountAdapter +from allauth.socialaccount import app_settings + + +class ArgusAccountAdapter(DefaultAccountAdapter): + def is_open_for_signup(self, request): + """ + Whether to allow sign ups with username/password + """ + allow_signups = super().is_open_for_signup(request) + # Override with setting, otherwise default to super. + return getattr(settings, "ACCOUNT_ALLOW_SIGNUPS", allow_signups) + + +class ArgusSocialAccountAdapter(DefaultSocialAccountAdapter): + def is_open_for_signup(self, request, sociallogin): + """ + Whether to allow sign ups via social account + """ + if app_settings.AUTO_SIGNUP: + return True + return get_account_adapter(request).is_open_for_signup(request) diff --git a/src/argus/auth/allauth/apps.py b/src/argus/auth/allauth/apps.py new file mode 100644 index 000000000..ec41fb839 --- /dev/null +++ b/src/argus/auth/allauth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AuthConfig(AppConfig): + name = "argus.auth.allauth" + label = "argus_auth_allauth" diff --git a/src/argus/auth/allauth/management/__init__.py b/src/argus/auth/allauth/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/argus/auth/allauth/management/commands/__init__.py b/src/argus/auth/allauth/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/argus/auth/allauth/management/commands/convert_from_psa.py b/src/argus/auth/allauth/management/commands/convert_from_psa.py new file mode 100644 index 000000000..0ace11bc4 --- /dev/null +++ b/src/argus/auth/allauth/management/commands/convert_from_psa.py @@ -0,0 +1,55 @@ +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand, CommandError + +from argus.auth.allauth.utils import convert_from_psa_socialaccount + + +User = get_user_model() + + +class Command(BaseCommand): + help = "Convert python social auth data to allauth data" + + def add_arguments(self, parser): + users = parser.add_mutually_exclusive_group() + users.add_argument( + "--uid", + type=int, + nargs="*", + help="Convert the python social auth data for users with the listed uids", + ) + users.add_argument( + "--all", + action="store_true", + help="Convert python social auth data for all users", + ) + parser.add_argument( + "--delete", + action="store_true", + help="Delete python social auth data", + ) + + def handle(self, *args, **options): + if options["all"]: + users = User.objects.all() + elif options["uid"]: + users = User.objects.filter(id__in=options["uid"]) + else: + raise CommandError("No users to convert") + for user in users: + ok, skipped, bad_extra_data = convert_from_psa_socialaccount(user) + if ok: + ok = ", ".join(ok) + self.stdout.write(self.style.SUCCESS(f'Successfully converted user "{user.username}, providers: {ok}"')) + if skipped: + skipped = ", ".join(skipped) + self.stderr.write( + self.style.ERROR(f'User "{user.username}" already has data for provider(s): {skipped}') + ) + if bad_extra_data: + data = repr(bad_extra_data) + self.stderr.write( + self.style.ERROR( + f'There are problems with the extra-data for user #{user.id} "{user.username}": {data}. The converter needs improving' + ) + ) diff --git a/src/argus/auth/allauth/management/commands/delete_allauth_data.py b/src/argus/auth/allauth/management/commands/delete_allauth_data.py new file mode 100644 index 000000000..9d65e871f --- /dev/null +++ b/src/argus/auth/allauth/management/commands/delete_allauth_data.py @@ -0,0 +1,41 @@ +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand, CommandError + +from allauth.socialaccount.models import SocialAccount + + +User = get_user_model() + + +class Command(BaseCommand): + help = "Delete allauth data for users" + + def add_arguments(self, parser): + users = parser.add_mutually_exclusive_group() + users.add_argument( + "--uid", + type=int, + nargs="*", + help="Delete the allauth data for users with the listed uids", + ) + users.add_argument( + "--all", + action="store_true", + help="Delete allauth data for all users", + ) + + def handle(self, *args, **options): + users = User.objects.exclude(socialaccount=None) + if options["all"]: + pass + elif options["uid"]: + users = users.filter(id__in=options["uid"]) + else: + raise CommandError("No users to delete allauth data for") + allauths = SocialAccount.objects.filter(user__in=users) + count = allauths.count() + if not count: + self.stderr.write(self.style.WARNING("Nothing to delete")) + else: + deleted = allauths.delete() + self.stdout.write(self.style.SUCCESS(f"Deleted {deleted[0]} of {count}")) diff --git a/src/argus/auth/allauth/urls.py b/src/argus/auth/allauth/urls.py new file mode 100644 index 000000000..cf6030b15 --- /dev/null +++ b/src/argus/auth/allauth/urls.py @@ -0,0 +1,21 @@ +from django.urls import path, include + +from allauth.urls import urlpatterns as allauth_urlpatterns + +from argus.auth.allauth.views import ArgusMFAAuthenticateView + + +mfa_urlpatterns = [path("authenticate/", ArgusMFAAuthenticateView.as_view(), name="mfa_authenticate")] + + +urlpatterns = [ + path( + "accounts/", + include( + [ + path("2fa/", include(mfa_urlpatterns)), + path("", include(allauth_urlpatterns)), + ] + ), + ), +] diff --git a/src/argus/auth/allauth/utils/__init__.py b/src/argus/auth/allauth/utils/__init__.py new file mode 100644 index 000000000..fa6b1ad58 --- /dev/null +++ b/src/argus/auth/allauth/utils/__init__.py @@ -0,0 +1,9 @@ +try: + from argus.auth.allauth.utils.psa import convert_from_psa_socialaccount +except ImportError: + + def convert_from_psa_socialaccount(_): + raise NotImplementedError + + +__all__ = ["convert_from_psa_socialaccount"] diff --git a/src/argus/auth/allauth/utils/psa.py b/src/argus/auth/allauth/utils/psa.py new file mode 100644 index 000000000..04ece2fbf --- /dev/null +++ b/src/argus/auth/allauth/utils/psa.py @@ -0,0 +1,99 @@ +from allauth.socialaccount.models import SocialAccount +from social_django.models import UserSocialAuth + +""" +Allauth format, example dataporten +================================== + +id: int +provider: str (dataporten) +uid: str (uuid) +last_login: datetime with timestamp +date_joined: datetime with timestamp +extra_data: json { + "name": str (full name), + "email": str (email), + "userid": str (uuid), + "userid_sec": List[str], str with internal structure, + "profilephoto": str ("p:" + uuid) +user_id: int + +PSA, example dataporten_feide +============================= + +id: int +provider: str (dataporten_feide) +uid: str (uuid) +user_id: int +created: datetime with timestamp +modified: datetime with timestamp +extra_data: json { + "scope": "userid userid-feide userinfo-name userinfo-photo", + "userid": str (uuid), + "fullname": str (full name), + "auth_time": int (epoch), + "token_type": "Bearer", + "access_token": str (uuid), + "profilephoto_url": str (url) +""" + + +def convert_from_psa_socialaccount(user): + "Convert data in the PSA socialaccount table to the allauth social_account table" + + allauths = SocialAccount.objects.filter(user=user) + psas = UserSocialAuth.objects.filter(user=user) + skipped = set() + ok = set() + bad_extra_data = {} + for psa in psas: + provider = _map_provider(psa.provider) + if allauths.filter(provider=provider).exists(): + skipped.add(provider) + continue + try: + extra_data = _convert_extra_data(psa.provider, psa.extra_data) + except KeyError: + bad_extra_data[psa.provider] = psa.extra_data + continue + else: + if not extra_data: + bad_extra_data[psa.provider] = psa.extra_data + continue + allauth = SocialAccount( + user=user, + provider=provider, + uid=psa.uid, + date_joined=psa.created, + last_login=psa.modified, + extra_data=extra_data, + ) + allauth.save() + ok.add(provider) + return ok, skipped, bad_extra_data + + +def _map_provider(provider): + mapping = {"dataporten_feide": "dataporten"} + return mapping.get(provider, provider) + + +def _convert_profilephoto_url(url): + if "/p:" not in url: + return "" + return "p:" + url.split("/p:")[1] + + +def _convert_extra_data(provider, extra_data): + data = {} + userid_key = "userid" + if provider == "oidc": + userid_key = "id" + data["userid"] = extra_data[userid_key] + photo = _convert_profilephoto_url(extra_data.get("profilephoto_url", "")) + if photo: + data["profilephoto"] = photo + name = extra_data.get("name", extra_data.get("fullname", "")) + if name: + data["name"] = name + return data diff --git a/src/argus/auth/allauth/views.py b/src/argus/auth/allauth/views.py new file mode 100644 index 000000000..38683c32d --- /dev/null +++ b/src/argus/auth/allauth/views.py @@ -0,0 +1,25 @@ +from django.utils.decorators import method_decorator + +from allauth.account.authentication import get_authentication_records +from allauth.account.internal.decorators import login_stage_required +from allauth.mfa.base.views import AuthenticateView as MFAAuthenticateView +from allauth.mfa.stages import AuthenticateStage + + +def is_socialaccount_login(request): + for method in get_authentication_records(request): + if method["method"] == "socialaccount": + return True + return False + + +@method_decorator( + login_stage_required(stage=AuthenticateStage.key, redirect_urlname="account_login"), + name="dispatch", +) +class ArgusMFAAuthenticateView(MFAAuthenticateView): + def dispatch(self, request, *args, **kwargs): + self.stage = request._login_stage + if is_socialaccount_login(request): + return self.stage.exit() + return super().dispath(request, *args, **kwargs) diff --git a/src/argus/auth/context_processors.py b/src/argus/auth/context_processors.py index 8780ff4b9..ff69029a1 100644 --- a/src/argus/auth/context_processors.py +++ b/src/argus/auth/context_processors.py @@ -8,6 +8,8 @@ """ import functools + +from argus.util.app_utils import is_using_psa, is_using_allauth from argus.auth.models import Preferences @@ -33,3 +35,19 @@ def preferences(request): for namespace, values in request.session["preferences"].items(): prefdict[namespace].update(values) return {"preferences": prefdict, "preferences_choices": preferences_choices} + + +@functools.lru_cache(maxsize=10) +def authentication_methods(request): + using_allauth = is_using_allauth() + context = { + "authmethods": { + "local": True, + "psa": is_using_psa(), + "allauth": using_allauth, + }, + "login_urlname": "account_login" if using_allauth else "login", + "logout_urlname": "account_logout" if using_allauth else "logout", + } + + return context diff --git a/src/argus/auth/psa/__init__.py b/src/argus/auth/psa/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/argus/auth/psa/htmx/__init__.py b/src/argus/auth/psa/htmx/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/argus/auth/psa/htmx/views.py b/src/argus/auth/psa/htmx/views.py new file mode 100644 index 000000000..90d179872 --- /dev/null +++ b/src/argus/auth/psa/htmx/views.py @@ -0,0 +1,10 @@ +from argus.htmx.auth.views import LoginView as PlainArgusLoginView +from argus.auth.psa.utils import serialize_psa_authentication_backends + + +class PSALoginView(PlainArgusLoginView): + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + psa_backends = serialize_psa_authentication_backends() + context["backends"].setdefault("external", []).extend(psa_backends) + return context diff --git a/src/argus/auth/psa/settings.py b/src/argus/auth/psa/settings.py new file mode 100644 index 000000000..6040008f2 --- /dev/null +++ b/src/argus/auth/psa/settings.py @@ -0,0 +1,61 @@ +# Copied and adapted from social_core.pipeline.DEFAULT_AUTH_PIPELINE +# fmt: off +SOCIAL_AUTH_PIPELINE = ( + # Get the information we can about the user and return it in a simple + # format to create the user instance later. In some cases the details are + # already part of the auth response from the provider, but sometimes this + # could hit a provider API. + 'social_core.pipeline.social_auth.social_details', + + # Get the social uid from whichever service we're authing thru. The uid is + # the unique identifier of the given user in the provider. + 'social_core.pipeline.social_auth.social_uid', + + # Verifies that the current auth process is valid within the current + # project, this is where emails and domains whitelists are applied (if + # defined). + 'social_core.pipeline.social_auth.auth_allowed', + + # Checks if the current social-account is already associated in the site. + 'social_core.pipeline.social_auth.social_user', + + # Make up a username for this person, appends a random string at the end if + # there's any collision. + 'social_core.pipeline.user.get_username', + + # Send a validation email to the user to verify its email address. + # Disabled by default. + # 'social_core.pipeline.mail.mail_validation', + + # Associates the current social details with another user account with + # a similar email address. Disabled by default. + # 'social_core.pipeline.social_auth.associate_by_email', + + # Create a user account if we haven't found one yet. + 'social_core.pipeline.user.create_user', + + # Create the record that associates the social account with the user. + 'social_core.pipeline.social_auth.associate_user', + + # Populate the extra_data field in the social record with the values + # specified by settings (and the default ones like access_token, etc). + 'social_core.pipeline.social_auth.load_extra_data', + + # Update the user record with any changed info from the auth service. + 'social_core.pipeline.user.user_details', +) +# fmt: on + +SOCIAL_AUTH_ADMIN_USER_SEARCH_FIELDS = ["username", "first_name", "email"] +SOCIAL_AUTH_LOGIN_REDIRECT_URL = "/" +SOCIAL_AUTH_NEW_USER_REDIRECT_URL = SOCIAL_AUTH_LOGIN_REDIRECT_URL + +# Set these somewhere +# SOCIAL_AUTH_DATAPORTEN_KEY = get_str_env("ARGUS_DATAPORTEN_KEY", required=True) +# SOCIAL_AUTH_DATAPORTEN_SECRET = get_str_env("ARGUS_DATAPORTEN_SECRET", required=True) +# +# SOCIAL_AUTH_DATAPORTEN_EMAIL_KEY = SOCIAL_AUTH_DATAPORTEN_KEY +# SOCIAL_AUTH_DATAPORTEN_EMAIL_SECRET = SOCIAL_AUTH_DATAPORTEN_SECRET +# +# SOCIAL_AUTH_DATAPORTEN_FEIDE_KEY = SOCIAL_AUTH_DATAPORTEN_KEY +# SOCIAL_AUTH_DATAPORTEN_FEIDE_SECRET = SOCIAL_AUTH_DATAPORTEN_SECRET diff --git a/src/argus/auth/psa/urls.py b/src/argus/auth/psa/urls.py new file mode 100644 index 000000000..2ddefafc3 --- /dev/null +++ b/src/argus/auth/psa/urls.py @@ -0,0 +1,11 @@ +from django.contrib.auth import views as django_auth_views +from django.urls import include, path + +from argus.auth.psa.htmx.views import PSALoginView + + +urlpatterns = [ + path("accounts/login/", PSALoginView.as_view(), name="login"), + path("accounts/logout/", django_auth_views.LogoutView.as_view(), name="logout"), + path("oidc/", include("social_django.urls", namespace="social")), +] diff --git a/src/argus/auth/psa/utils.py b/src/argus/auth/psa/utils.py new file mode 100644 index 000000000..4d5fdc1a4 --- /dev/null +++ b/src/argus/auth/psa/utils.py @@ -0,0 +1,33 @@ +from django.conf import settings +from django.urls import reverse + +from social_core.backends.oauth import BaseOAuth2 + +from argus.auth.utils import get_authentication_backend_classes + + +__all__ = [ + "get_psa_authentication_backends", +] + + +OIDC_METHOD_NAME = getattr(settings, "ARGUS_OIDC_METHOD_NAME", "OIDC") + + +def get_psa_authentication_backends(backends=None): + backends = backends if backends else get_authentication_backend_classes() + return [backend for backend in backends if issubclass(backend, BaseOAuth2)] + + +def serialize_psa_authentication_backends(backends=None): + data = [] + for backend in get_psa_authentication_backends(backends): + display_name = backend.name + if backend.name == "oidc": + display_name = OIDC_METHOD_NAME + psa_backend_data = { + "url": reverse("social:begin", kwargs={"backend": backend.name}), + "display_name": display_name, + } + data.append(psa_backend_data) + return data diff --git a/src/argus/auth/utils.py b/src/argus/auth/utils.py index 6a03ca099..a77dea5f0 100644 --- a/src/argus/auth/utils.py +++ b/src/argus/auth/utils.py @@ -8,8 +8,6 @@ from django.http import HttpRequest from django.utils.module_loading import import_string -from social_core.backends.oauth import BaseOAuth2 - from argus.auth.models import Preferences, SessionPreferences @@ -17,7 +15,6 @@ "get_authentication_backend_classes", "has_model_backend", "has_remote_user_backend", - "get_psa_authentication_backends", "get_preference_obj", "get_preference", "get_or_update_preference", @@ -42,11 +39,6 @@ def has_remote_user_backend(backends): return RemoteUserBackend in backends -def get_psa_authentication_backends(backends=None): - backends = backends if backends else get_authentication_backend_classes() - return [backend for backend in backends if issubclass(backend, BaseOAuth2)] - - def get_preference_obj(request, namespace) -> Preferences: if namespace not in Preferences.NAMESPACES: raise ValueError(f"Unkown namespace '{namespace}'") diff --git a/src/argus/htmx/appconfig.py b/src/argus/htmx/appconfig.py index 5743f8470..980b97ff8 100644 --- a/src/argus/htmx/appconfig.py +++ b/src/argus/htmx/appconfig.py @@ -1,4 +1,5 @@ from argus.site.settings._serializers import ListAppSetting +from argus.util.app_utils import is_using_psa __all__ = ["APP_SETTINGS"] @@ -52,4 +53,24 @@ }, ] +if is_using_psa(): + _app_settings += [ + { + "app_name": "social_django", + "middleware": { + "social_django.middleware.SocialAuthExceptionMiddleware": "end", + }, + "context_processors": [ + "social_django.context_processors.backends", + "social_django.context_processors.login_redirect", + ], + "urls": { + "path": "oidc/", + "urlpatterns_module": "social_django.urls", + "namespace": "social", + }, + } + ] + + APP_SETTINGS = ListAppSetting(_app_settings).root diff --git a/src/argus/htmx/auth/urls.py b/src/argus/htmx/auth/urls.py new file mode 100644 index 000000000..829fdf991 --- /dev/null +++ b/src/argus/htmx/auth/urls.py @@ -0,0 +1,11 @@ +from django.contrib.auth import views as django_auth_views +from django.urls import path + +from . import views as auth_views + + +urlpatterns = [ + path("accounts/login/", auth_views.LoginView.as_view(), name="login"), + path("accounts/logout/", django_auth_views.LogoutView.as_view(), name="logout"), + # path("accounts/", include("django.contrib.auth.urls")), +] diff --git a/src/argus/htmx/auth/views.py b/src/argus/htmx/auth/views.py index f3769b93e..0c8b0faee 100644 --- a/src/argus/htmx/auth/views.py +++ b/src/argus/htmx/auth/views.py @@ -5,23 +5,23 @@ from argus.auth.utils import ( has_model_backend, has_remote_user_backend, - get_psa_authentication_backends, get_authentication_backend_classes, ) +from argus.util.app_utils import is_using_allauth REMOTE_USER_METHOD_NAME = getattr(settings, "ARGUS_REMOTE_USER_METHOD_NAME", "REMOTE_USER") -OIDC_METHOD_NAME = getattr(settings, "ARGUS_OIDC_METHOD_NAME", "OIDC") def get_htmx_authentication_backend_name_and_type(): # Needed for HTMX LoginView backends = get_authentication_backend_classes() + login_urlname = "account_login" if is_using_allauth() else "login" data = {} if has_model_backend(backends): data["local"] = { - "url": reverse("htmx:login"), + "url": reverse(login_urlname), "display_name": "Log In", } @@ -31,17 +31,6 @@ def get_htmx_authentication_backend_name_and_type(): "display_name": REMOTE_USER_METHOD_NAME, } data.setdefault("external", []).append(remote_user_data) - - for backend in get_psa_authentication_backends(backends): - display_name = backend.name - if backend.name == "oidc": - display_name = OIDC_METHOD_NAME - psa_backend_data = { - "url": reverse("social:begin", kwargs={"backend": backend.name}), - "display_name": display_name, - } - data.setdefault("external", []).append(psa_backend_data) - return data diff --git a/src/argus/htmx/htmx_urls.py b/src/argus/htmx/htmx_urls.py index 5ac674b5b..addb9139b 100644 --- a/src/argus/htmx/htmx_urls.py +++ b/src/argus/htmx/htmx_urls.py @@ -1,10 +1,16 @@ -from django.urls import include, path - +from argus.htmx.appconfig import APP_SETTINGS from argus.site.utils import get_urlpatterns +from argus.util.app_utils import is_using_psa, is_using_allauth -from argus.htmx.appconfig import APP_SETTINGS +account_urlpatterns = [] urlpatterns = get_urlpatterns(APP_SETTINGS) -urlpatterns += [ - path("oidc/", include("social_django.urls", namespace="social")), -] + +if is_using_psa(): + from argus.auth.psa.urls import urlpatterns as account_urlpatterns +elif is_using_allauth(): + from argus.auth.allauth.urls import urlpatterns as account_urlpatterns +else: + from argus.htmx.auth.urls import urlpatterns as account_urlpatterns + +urlpatterns = account_urlpatterns + urlpatterns diff --git a/src/argus/htmx/settings.py b/src/argus/htmx/settings.py index e596f0601..d608f3095 100644 --- a/src/argus/htmx/settings.py +++ b/src/argus/htmx/settings.py @@ -12,3 +12,7 @@ LOGOUT_URL = "/accounts/logout/" LOGIN_REDIRECT_URL = "/incidents/" LOGOUT_REDIRECT_URL = "/incidents/" + +if USE_PYTHON_SOCIAL_AUTH: + INSTALLED_APPS += ["social_django"] + from argus.auth.psa.settings import * # noqa: F403 diff --git a/src/argus/htmx/static/styles.css b/src/argus/htmx/static/styles.css index f58b83502..0e3bda277 100644 --- a/src/argus/htmx/static/styles.css +++ b/src/argus/htmx/static/styles.css @@ -5033,6 +5033,10 @@ details.collapse summary::-webkit-details-marker { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); } +.outline { + outline-style: solid; +} + .ring { --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color); diff --git a/src/argus/htmx/templates/account/login.html b/src/argus/htmx/templates/account/login.html new file mode 100644 index 000000000..610c638bf --- /dev/null +++ b/src/argus/htmx/templates/account/login.html @@ -0,0 +1,67 @@ +{% extends "account/base_entrance.html" %} +{% load i18n %} +{% load allauth account %} +{% block head_title %} + {% trans "Log In" %} +{% endblock head_title %} +{% block content %} + {% element h1 %} + {% trans "Log In" %} +{% endelement %} +{% if not SOCIALACCOUNT_ONLY %} + {% setvar link %} + + {% endsetvar %} + {% setvar end_link %} + +{% endsetvar %} +{% element p %} +{% blocktranslate %}If you have not created an account yet, then please {{ link }}sign up{{ end_link }} first.{% endblocktranslate %} +{% endelement %} +{% url 'account_login' as login_url %} +{% element form form=form method="post" action=login_url tags="entrance,login" %} +{% slot body %} +{% csrf_token %} +{% element fields form=form unlabeled=True %} +{% endelement %} +{{ redirect_field }} +{% endslot %} +{% slot actions %} +{% element button type="submit" tags="prominent,login" %} +{% trans "Log In" %} +{% endelement %} +{% endslot %} +{% endelement %} +{% endif %} +{% if LOGIN_BY_CODE_ENABLED or PASSKEY_LOGIN_ENABLED %} + {% element hr %} +{% endelement %} +{% element button_group vertical=True %} +{% if PASSKEY_LOGIN_ENABLED %} + {% element button type="submit" form="mfa_login" id="passkey_login" tags="prominent,login,outline,primary" %} + {% trans "Sign in with a passkey" %} +{% endelement %} +{% else %} +

Login by passkey not enabled

+{% endif %} +{% if LOGIN_BY_CODE_ENABLED %} + {% element button href=request_login_code_url tags="prominent,login,outline,primary" %} + {% trans "Send me a sign-in code" %} +{% endelement %} +{% else %} +

Login by code not enabled

+{% endif %} +{% endelement %} +{% endif %} +{% if SOCIALACCOUNT_ENABLED %} + {% include "socialaccount/snippets/login.html" with page_layout="entrance" %} +{% else %} +

Login by OAuth2 or SAML not enabled

+{% endif %} +{% endblock content %} +{% block extra_body %} + {{ block.super }} + {% if PASSKEY_LOGIN_ENABLED %} + {% include "mfa/webauthn/snippets/login_script.html" with button_id="passkey_login" %} + {% endif %} +{% endblock extra_body %} diff --git a/src/argus/htmx/templates/account/signup.html b/src/argus/htmx/templates/account/signup.html new file mode 100644 index 000000000..9f718bed1 --- /dev/null +++ b/src/argus/htmx/templates/account/signup.html @@ -0,0 +1,45 @@ +{% extends "account/base_entrance.html" %} +{% load allauth i18n %} +{% block head_title %} + {% trans "Signup" %} +{% endblock head_title %} +{% block content %} + {% element h1 %} + {% trans "Sign Up" %} +{% endelement %} +{% setvar link %} + +{% endsetvar %} +{% setvar end_link %} + +{% endsetvar %} +{% element p %} +{% blocktranslate %}Already have an account? Then please {{ link }}sign in{{ end_link }}.{% endblocktranslate %} +{% endelement %} +{% if not SOCIALACCOUNT_ONLY %} + {% url 'account_signup' as action_url %} + {% element form form=form method="post" action=action_url tags="entrance,signup" %} + {% slot body %} + {% csrf_token %} + {% element fields form=form unlabeled=True %} +{% endelement %} +{{ redirect_field }} +{% endslot %} +{% slot actions %} +{% element button tags="prominent,signup" type="submit" %} +{% trans "Sign Up" %} +{% endelement %} +{% endslot %} +{% endelement %} +{% endif %} +{% if PASSKEY_SIGNUP_ENABLED %} + {% element hr %} +{% endelement %} +{% element button href=signup_by_passkey_url tags="prominent,signup,outline,primary" %} +{% trans "Sign up using a passkey" %} +{% endelement %} +{% endif %} +{% if SOCIALACCOUNT_ENABLED %} + {% include "socialaccount/snippets/login.html" with page_layout="entrance" %} +{% endif %} +{% endblock content %} diff --git a/src/argus/htmx/templates/allauth/elements/button.html b/src/argus/htmx/templates/allauth/elements/button.html new file mode 100644 index 000000000..18feb4275 --- /dev/null +++ b/src/argus/htmx/templates/allauth/elements/button.html @@ -0,0 +1,13 @@ +{% load allauth %} +{% comment %} djlint:off {% endcomment %} +<{% if attrs.href %}a href="{{ attrs.href }}"{% else %}button{% endif %} +{% if attrs.form %}form="{{ attrs.form }}"{% endif %} +{% if attrs.id %}id="{{ attrs.id }}"{% endif %} +{% if attrs.name %}name="{{ attrs.name }}"{% endif %} +{% if attrs.value %}value="{{ attrs.value }}"{% endif %} +{% if attrs.type %}type="{{ attrs.type }}"{% endif %} +class="btn btn-primary" +> +{% slot %} +{% endslot %} + diff --git a/src/argus/htmx/templates/allauth/elements/fields.html b/src/argus/htmx/templates/allauth/elements/fields.html new file mode 100644 index 000000000..9f59023ce --- /dev/null +++ b/src/argus/htmx/templates/allauth/elements/fields.html @@ -0,0 +1 @@ +{{ attrs.form.as_div }} diff --git a/src/argus/htmx/templates/allauth/elements/form.html b/src/argus/htmx/templates/allauth/elements/form.html new file mode 100644 index 000000000..d39e9b292 --- /dev/null +++ b/src/argus/htmx/templates/allauth/elements/form.html @@ -0,0 +1,11 @@ +{% load allauth %} +
+ {% slot body %} +{% endslot %} +
+ {% slot actions %} +{% endslot %} +
+
diff --git a/src/argus/htmx/templates/allauth/elements/h1.html b/src/argus/htmx/templates/allauth/elements/h1.html new file mode 100644 index 000000000..0c4b27ce7 --- /dev/null +++ b/src/argus/htmx/templates/allauth/elements/h1.html @@ -0,0 +1 @@ +{% comment %} djlint:off {% endcomment %}{% load allauth %}

{% slot %}{% endslot %}

diff --git a/src/argus/htmx/templates/allauth/elements/h2.html b/src/argus/htmx/templates/allauth/elements/h2.html new file mode 100644 index 000000000..76a2447ba --- /dev/null +++ b/src/argus/htmx/templates/allauth/elements/h2.html @@ -0,0 +1 @@ +{% comment %} djlint:off {% endcomment %}{% load allauth %}

{% slot %}{% endslot %}

diff --git a/src/argus/htmx/templates/allauth/elements/panel.html b/src/argus/htmx/templates/allauth/elements/panel.html new file mode 100644 index 000000000..192ae8050 --- /dev/null +++ b/src/argus/htmx/templates/allauth/elements/panel.html @@ -0,0 +1,14 @@ +{% load allauth %} +
+

+ {% slot title %} + {% endslot %} +

+{% slot body %} +{% endslot %} +{% if slots.actions %} + +{% endif %} +
diff --git a/src/argus/htmx/templates/allauth/layouts/base.html b/src/argus/htmx/templates/allauth/layouts/base.html new file mode 100644 index 000000000..918556346 --- /dev/null +++ b/src/argus/htmx/templates/allauth/layouts/base.html @@ -0,0 +1,48 @@ +{% extends "htmx/base.html" %} +{% load socialaccount %} +{% load i18n %} +{% block main %} + {% if user.is_authenticated %} + + {% endif %} +
+
+ {% block content %} + {% endblock content %} +
+
+{% endblock main %} diff --git a/src/argus/htmx/templates/htmx/base.html b/src/argus/htmx/templates/htmx/base.html index 47d471b13..3d10df1ec 100644 --- a/src/argus/htmx/templates/htmx/base.html +++ b/src/argus/htmx/templates/htmx/base.html @@ -38,7 +38,7 @@ {% include "htmx/user/_user_menu.html" %} {% else %} - Log in + Log in {% endif %} {% endblock userlink %} diff --git a/src/argus/htmx/templates/htmx/user/_user_menu.html b/src/argus/htmx/templates/htmx/user/_user_menu.html index e7bae17a7..1ed13eb56 100644 --- a/src/argus/htmx/templates/htmx/user/_user_menu.html +++ b/src/argus/htmx/templates/htmx/user/_user_menu.html @@ -4,6 +4,11 @@
{% block items %} + {% if authmethods.allauth %} +
  • + Account management +
  • + {% endif %} {% block user_preferences_links %} {% include "htmx/user/_user_menu_user_preferences.html" %} {% endblock user_preferences_links %} diff --git a/src/argus/htmx/templates/htmx/user/_user_menu_logout.html b/src/argus/htmx/templates/htmx/user/_user_menu_logout.html index d9fd2004c..aae3a8d73 100644 --- a/src/argus/htmx/templates/htmx/user/_user_menu_logout.html +++ b/src/argus/htmx/templates/htmx/user/_user_menu_logout.html @@ -1,4 +1,4 @@ -
    + {% csrf_token %}
    diff --git a/src/argus/htmx/templates/socialaccount/snippets/login.html b/src/argus/htmx/templates/socialaccount/snippets/login.html new file mode 100644 index 000000000..b3f1b9b42 --- /dev/null +++ b/src/argus/htmx/templates/socialaccount/snippets/login.html @@ -0,0 +1,17 @@ +{% load i18n %} +{% load allauth %} +{% load socialaccount %} +{% get_providers as socialaccount_providers %} +{% if socialaccount_providers %} + {% if not SOCIALACCOUNT_ONLY %} + {% element hr %} + {% endelement %} + {% element h2 %} + {% translate "Or use a third-party" %} +{% endelement %} +{% endif %} +{% include "socialaccount/snippets/provider_list.html" with process="login" %} +{% include "socialaccount/snippets/login_extra.html" %} +{% else %} +

    No OAUTH2 or SAML providers configured.

    +{% endif %} diff --git a/src/argus/htmx/templates/socialaccount/snippets/login_extra.html b/src/argus/htmx/templates/socialaccount/snippets/login_extra.html new file mode 100644 index 000000000..fe81dac3a --- /dev/null +++ b/src/argus/htmx/templates/socialaccount/snippets/login_extra.html @@ -0,0 +1,2 @@ +{% load socialaccount %} +{% providers_media_js %} diff --git a/src/argus/htmx/templates/socialaccount/snippets/provider_list.html b/src/argus/htmx/templates/socialaccount/snippets/provider_list.html new file mode 100644 index 000000000..cb09db409 --- /dev/null +++ b/src/argus/htmx/templates/socialaccount/snippets/provider_list.html @@ -0,0 +1,18 @@ +{% load allauth socialaccount %} +{% get_providers as socialaccount_providers %} +{% if socialaccount_providers %} + {% element provider_list %} + {% for provider in socialaccount_providers %} + {% if provider.id == "openid" %} + {% for brand in provider.get_brands %} + {% provider_login_url provider openid=brand.openid_url process=process as href %} + {% element provider name=brand.name provider_id=provider.id href=href %} + {% endelement %} + {% endfor %} + {% endif %} + {% provider_login_url provider process=process scope=scope auth_params=auth_params as href %} + {% element provider name=provider.name provider_id=provider.id href=href %} +{% endelement %} +{% endfor %} +{% endelement %} +{% endif %} diff --git a/src/argus/htmx/urls.py b/src/argus/htmx/urls.py index 64d4a625d..39943fec0 100644 --- a/src/argus/htmx/urls.py +++ b/src/argus/htmx/urls.py @@ -1,7 +1,5 @@ -from django.contrib.auth import views as django_auth_views from django.urls import path, include -from .auth import views as auth_views from .incident.urls import urlpatterns as incident_urls from .timeslot.urls import urlpatterns as timeslot_urls from .notificationprofile.urls import urlpatterns as notificationprofile_urls @@ -9,11 +7,9 @@ from .user.urls import urlpatterns as user_urls from .views import IncidentListRedirectView, StyleGuideView + app_name = "htmx" urlpatterns = [ - path("accounts/login/", auth_views.LoginView.as_view(), name="login"), - path("accounts/logout/", django_auth_views.LogoutView.as_view(), name="logout"), - # path("accounts/", include("django.contrib.auth.urls")), path("styleguide/", StyleGuideView.as_view(), name="styleguide"), path("incidents/", include(incident_urls)), path("timeslots/", include(timeslot_urls)), diff --git a/src/argus/site/settings/base.py b/src/argus/site/settings/base.py index 0be0a2beb..84f7902e2 100644 --- a/src/argus/site/settings/base.py +++ b/src/argus/site/settings/base.py @@ -43,7 +43,6 @@ # 3rd party apps "corsheaders", - "social_django", "rest_framework", "rest_framework.authtoken", "drf_spectacular", @@ -96,9 +95,8 @@ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", - "social_django.context_processors.backends", - "social_django.context_processors.login_redirect", "argus.auth.context_processors.preferences", + "argus.auth.context_processors.authentication_methods", "argus.htmx.context_processors.static_paths", ], }, @@ -242,71 +240,11 @@ # Don't spam by accident SEND_NOTIFICATIONS = get_bool_env("ARGUS_SEND_NOTIFICATIONS", default=False) -# 3rd party settings - -# Python social auth - -# Copied and adapted from social_core.pipeline.DEFAULT_AUTH_PIPELINE -# fmt: off -SOCIAL_AUTH_PIPELINE = ( - # Get the information we can about the user and return it in a simple - # format to create the user instance later. In some cases the details are - # already part of the auth response from the provider, but sometimes this - # could hit a provider API. - 'social_core.pipeline.social_auth.social_details', - - # Get the social uid from whichever service we're authing thru. The uid is - # the unique identifier of the given user in the provider. - 'social_core.pipeline.social_auth.social_uid', - - # Verifies that the current auth process is valid within the current - # project, this is where emails and domains whitelists are applied (if - # defined). - 'social_core.pipeline.social_auth.auth_allowed', - - # Checks if the current social-account is already associated in the site. - 'social_core.pipeline.social_auth.social_user', - - # Make up a username for this person, appends a random string at the end if - # there's any collision. - 'social_core.pipeline.user.get_username', - - # Send a validation email to the user to verify its email address. - # Disabled by default. - # 'social_core.pipeline.mail.mail_validation', - - # Associates the current social details with another user account with - # a similar email address. Disabled by default. - # 'social_core.pipeline.social_auth.associate_by_email', - - # Create a user account if we haven't found one yet. - 'social_core.pipeline.user.create_user', - - # Create the record that associates the social account with the user. - 'social_core.pipeline.social_auth.associate_user', - - # Populate the extra_data field in the social record with the values - # specified by settings (and the default ones like access_token, etc). - 'social_core.pipeline.social_auth.load_extra_data', - - # Update the user record with any changed info from the auth service. - 'social_core.pipeline.user.user_details', -) -# fmt: on +BANNER_MESSAGE = get_str_env("ARGUS_BANNER_MESSAGE", default=None) -SOCIAL_AUTH_ADMIN_USER_SEARCH_FIELDS = ["username", "first_name", "email"] -SOCIAL_AUTH_LOGIN_REDIRECT_URL = "/" -SOCIAL_AUTH_NEW_USER_REDIRECT_URL = SOCIAL_AUTH_LOGIN_REDIRECT_URL +USE_PYTHON_SOCIAL_AUTH = get_bool_env("ARGUS_USE_PYTHON_SOCIAL_AUTH", default=True) -# Set these somewhere -# SOCIAL_AUTH_DATAPORTEN_KEY = get_str_env("ARGUS_DATAPORTEN_KEY", required=True) -# SOCIAL_AUTH_DATAPORTEN_SECRET = get_str_env("ARGUS_DATAPORTEN_SECRET", required=True) -# -# SOCIAL_AUTH_DATAPORTEN_EMAIL_KEY = SOCIAL_AUTH_DATAPORTEN_KEY -# SOCIAL_AUTH_DATAPORTEN_EMAIL_SECRET = SOCIAL_AUTH_DATAPORTEN_SECRET -# -# SOCIAL_AUTH_DATAPORTEN_FEIDE_KEY = SOCIAL_AUTH_DATAPORTEN_KEY -# SOCIAL_AUTH_DATAPORTEN_FEIDE_SECRET = SOCIAL_AUTH_DATAPORTEN_SECRET +# 3rd party settings # App settings: override themes, urls, context processors @@ -321,5 +259,3 @@ EXTRA_APPS = validate_app_setting(_extra_apps_env) del _extra_apps_env update_settings(globals(), EXTRA_APPS) - -BANNER_MESSAGE = get_str_env("ARGUS_BANNER_MESSAGE", default=None) diff --git a/src/argus/site/urls.py b/src/argus/site/urls.py index 6b8779493..663bd0ddb 100644 --- a/src/argus/site/urls.py +++ b/src/argus/site/urls.py @@ -29,7 +29,6 @@ api_v1_gone = partial(api_gone, message="API v1 has been removed") - urlpatterns = [ path("favicon.ico", RedirectView.as_view(url="/static/favicon.svg", permanent=True)), # path(".error/", error), # Only needed when testing error pages and error behavior diff --git a/src/argus/util/app_utils.py b/src/argus/util/app_utils.py new file mode 100644 index 000000000..53c5f486f --- /dev/null +++ b/src/argus/util/app_utils.py @@ -0,0 +1,14 @@ +from django.conf import settings +from django.apps import apps + + +def is_using_psa(): + turn_on_psa = getattr(settings, "USE_PYTHON_SOCIAL_AUTH", True) + psa_is_installed = apps.is_installed("social_django") + return turn_on_psa and psa_is_installed + + +def is_using_allauth(): + turn_on_psa = getattr(settings, "USE_PYTHON_SOCIAL_AUTH", True) + allauth_is_installed = apps.is_installed("allauth") + return allauth_is_installed and not turn_on_psa