Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add basic support for SSO using OpenID Connect #1159

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
81 changes: 79 additions & 2 deletions docker-app/qfieldcloud/core/adapters.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import logging
import traceback
from random import randint

from allauth.account import app_settings
from allauth.account.adapter import DefaultAccountAdapter
from allauth.account.models import EmailConfirmationHMAC
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from allauth.socialaccount.providers.oauth2.provider import OAuth2Provider
from django.contrib.auth.models import AbstractUser
from django.core.exceptions import ValidationError
from django.http import HttpRequest
from invitations.adapters import BaseInvitationsAdapter

from qfieldcloud.core.models import Person
from allauth.account.models import EmailConfirmationHMAC
from django.http import HttpRequest

logger = logging.getLogger(__name__)


class AccountAdapter(DefaultAccountAdapter, BaseInvitationsAdapter):
Expand Down Expand Up @@ -53,6 +63,43 @@ def clean_username(self, username, shallow=False):

return result

def populate_username(self, request: HttpRequest, user: AbstractUser) -> None:
"""Customize username population for signups via social logins.

When a user signs up via username and password, we try to respect their
choice of username, and just delegate to the default implementation to
avoid collisions.

For users that directly sign up via a social login however, we:
- Take the local part of their email (part before the '@' sign)
- Append a random 4-digit suffix to make it likely to be unique and
not communicate any information about the existence of other users
- Let generate_unique_username() normalize the username and ensure its
uniqueness.
"""

from allauth.account.utils import user_email, user_username

email = user_email(user)
username = user_username(user)

if username:
# Manually chosen username - defer to default implementation
return super().populate_username(request, user)

# Signup via social login - automatically generate a unique username
localpart = email.split("@")[0]
suffix = str(randint(1000, 9999))
username_candidate = f"{localpart}{suffix}"

if app_settings.USER_MODEL_USERNAME_FIELD:
user_username(
user,
self.generate_unique_username(
[username_candidate], regex=r"[^\w\s\-_]"
),
)

def send_confirmation_mail(
self,
request: HttpRequest,
Expand All @@ -69,3 +116,33 @@ def send_confirmation_mail(
)

super().send_confirmation_mail(request, email_confirmation, signup)


class SocialAccountAdapter(DefaultSocialAccountAdapter):
"""Custom SocialAccountAdapter to aid SSO integration in QFC.

Logs stack trace and error details on 3rd party authentication errors.
"""

def on_authentication_error(
self,
request: HttpRequest,
provider: OAuth2Provider,
error: str = None,
exception: Exception = None,
extra_context: dict = None,
) -> None:
logger.error("SSO Authentication error:", exc_info=True)
logger.error(f"Provider: {provider!r}")
logger.error(f"Error: {error!r}")

if not extra_context:
extra_context = {}

# Make stack strace available in template context.
extra_context["formatted_exception"] = "\n".join(
traceback.format_exception(exception)
)
super().on_authentication_error(
request, provider, error, exception, extra_context
)
4 changes: 0 additions & 4 deletions docker-app/qfieldcloud/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
from allauth.account.forms import EmailAwarePasswordResetTokenGenerator
from allauth.account.models import EmailAddress
from allauth.account.utils import user_pk_to_url_str
from allauth.socialaccount.models import SocialAccount, SocialApp, SocialToken
from auditlog.admin import LogEntryAdmin as BaseLogEntryAdmin
from auditlog.filters import ResourceTypeFilter
from auditlog.models import ContentType, LogEntry
Expand Down Expand Up @@ -147,9 +146,6 @@ def admin_urlname_by_obj(value, arg):
# Unregister admins from other Django apps
admin.site.unregister(Invitation)
admin.site.unregister(TokenProxy)
admin.site.unregister(SocialAccount)
admin.site.unregister(SocialApp)
admin.site.unregister(SocialToken)
admin.site.unregister(EmailAddress)

UserEmailDetails = namedtuple(
Expand Down
8 changes: 8 additions & 0 deletions docker-app/qfieldcloud/core/templates/admin/login.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{% extends "admin/login.html" %}

{% block content %}
{{ block.super }}
{% if request.GET.sso %}
{% include "socialaccount/snippets/login.html" %}
{% endif %}
{% endblock %}
47 changes: 47 additions & 0 deletions docker-app/qfieldcloud/core/tests/test_username_generation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import logging
import random

from django.http import HttpRequest
from django.test.testcases import TransactionTestCase

from qfieldcloud.core.adapters import AccountAdapter
from qfieldcloud.core.models import Person
from qfieldcloud.core.tests.utils import setup_subscription_plans

logging.disable(logging.CRITICAL)


class QfcTestCase(TransactionTestCase):
def setUp(self):
setup_subscription_plans()
self.existing_user = Person.objects.create_user(
username="existing9928", password="abc123"
)

def test_generated_usernames(self):
random.seed(42)
expectations = [
# Bases username on localpart of email plus random 4-digit suffix
("[email protected]", "john2824"),
# Collisions with existing usernames are avoided
("[email protected]", "existing99286"),
# Letters are lowercased
("[email protected]", "john1711"),
# Non-ASCII characters are transliterated
("fööbä[email protected]", "foobar8428"),
# Special characters are stripped
("[email protected]", "johndoe6168"),
# Almomst all of them...
("john.+*?%$/[email protected]", "johndoe7543"),
# Except underscores and dashes, which are preserved
("[email protected]", "john-peter_doe2876"),
]

adapter = AccountAdapter()
user = Person.objects.create_user(username="temp", password="abc123")

for email, expected in expectations:
user.username = ""
user.email = email
adapter.populate_username(HttpRequest(), user)
self.assertEqual(user.username, expected)
10 changes: 10 additions & 0 deletions docker-app/qfieldcloud/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@
"allauth",
"allauth.account",
"allauth.socialaccount",
"allauth.socialaccount.providers.google",
"allauth.socialaccount.providers.microsoft",
"storages", # Integration with S3 Storages
"invitations",
"django_cron",
Expand Down Expand Up @@ -407,6 +409,14 @@ def before_send(event, hint):
ACCOUNT_ADAPTER = "qfieldcloud.core.adapters.AccountAdapter"
ACCOUNT_LOGOUT_ON_GET = True

# Django allauth's social account configuration
# https://docs.allauth.org/en/dev/socialaccount/configuration.html
SOCIALACCOUNT_ADAPTER = "qfieldcloud.core.adapters.SocialAccountAdapter"
SOCIALACCOUNT_QUERY_EMAIL = True
SOCIALACCOUNT_EMAIL_AUTHENTICATION = True
SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True
SOCIALACCOUNT_LOGIN_ON_GET = True

# Django axes configuration
# https://django-axes.readthedocs.io/en/latest/4_configuration.html
###########################
Expand Down
2 changes: 1 addition & 1 deletion docker-app/requirements/requirements.in
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
boto3-stubs==1.35.90
boto3==1.35.90
deprecated==1.2.18
django-allauth==65.4.1
django-allauth[socialaccount]==65.4.1
django-auditlog==3.0.0
django-axes==7.0.2
django-bootstrap4==24.4
Expand Down
17 changes: 14 additions & 3 deletions docker-app/requirements/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ cffi==1.17.0
charset-normalizer==3.3.2
# via requests
cryptography==44.0.0
# via django-cryptography
# via
# django-cryptography
# pyjwt
deprecated==1.2.18
# via -r /requirements/requirements.in
django==4.2.19
Expand Down Expand Up @@ -68,7 +70,7 @@ django==4.2.19
# djangorestframework
# drf-spectacular
# jsonfield
django-allauth==65.4.1
django-allauth[socialaccount]==65.4.1
# via -r /requirements/requirements.in
django-appconf==1.0.6
# via django-cryptography
Expand Down Expand Up @@ -148,6 +150,8 @@ jsonschema-specifications==2023.12.1
# via jsonschema
mypy-boto3-s3==1.35.81
# via -r /requirements/requirements.in
oauthlib==3.2.2
# via requests-oauthlib
phonenumbers==8.13.55
# via -r /requirements/requirements.in
pillow==11.1.0
Expand All @@ -156,6 +160,8 @@ psycopg2==2.9.10
# via -r /requirements/requirements.in
pycparser==2.22
# via cffi
pyjwt[crypto]==2.10.1
# via django-allauth
pymemcache==4.0.0
# via -r /requirements/requirements.in
python-dateutil==2.9.0.post0
Expand All @@ -171,7 +177,12 @@ referencing==0.35.1
# jsonschema
# jsonschema-specifications
requests==2.32.3
# via stripe
# via
# django-allauth
# requests-oauthlib
# stripe
requests-oauthlib==2.0.0
# via django-allauth
rpds-py==0.20.0
# via
# jsonschema
Expand Down
Loading