Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"]

Expand All @@ -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",
Expand Down
Empty file.
26 changes: 26 additions & 0 deletions src/argus/auth/allauth/adapter.py
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 6 additions & 0 deletions src/argus/auth/allauth/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class AuthConfig(AppConfig):
name = "argus.auth.allauth"
label = "argus_auth_allauth"
Empty file.
Empty file.
55 changes: 55 additions & 0 deletions src/argus/auth/allauth/management/commands/convert_from_psa.py
Original file line number Diff line number Diff line change
@@ -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'
)
)
41 changes: 41 additions & 0 deletions src/argus/auth/allauth/management/commands/delete_allauth_data.py
Original file line number Diff line number Diff line change
@@ -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}"))
21 changes: 21 additions & 0 deletions src/argus/auth/allauth/urls.py
Original file line number Diff line number Diff line change
@@ -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)),
]
),
),
]
9 changes: 9 additions & 0 deletions src/argus/auth/allauth/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
99 changes: 99 additions & 0 deletions src/argus/auth/allauth/utils/psa.py
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions src/argus/auth/allauth/views.py
Original file line number Diff line number Diff line change
@@ -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)
18 changes: 18 additions & 0 deletions src/argus/auth/context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
"""

import functools

from argus.util.app_utils import is_using_psa, is_using_allauth
from argus.auth.models import Preferences


Expand All @@ -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
Empty file added src/argus/auth/psa/__init__.py
Empty file.
Empty file.
10 changes: 10 additions & 0 deletions src/argus/auth/psa/htmx/views.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading