Skip to content
Merged
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
65 changes: 65 additions & 0 deletions src/backend/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,25 @@ def retry_send_messages_action(__, request, queryset):
)


class PasswordlessUserForm(forms.Form):
"""Minimal form to create a passwordless (sub-less) User from the admin."""

email = forms.EmailField(label="Email", required=True)

def clean_email(self):
"""Reject emails that are already in use."""
email = self.cleaned_data["email"]
if models.User.objects.filter(email=email).exists():
raise forms.ValidationError("A user with this email already exists.")
return email


@admin.register(models.User)
class UserAdmin(auth_admin.UserAdmin):
"""Admin class for the User model"""

change_list_template = "admin/core/user/change_list.html"

fieldsets = (
(
None,
Expand Down Expand Up @@ -213,6 +228,56 @@ class UserAdmin(auth_admin.UserAdmin):
)
search_fields = ("id", "sub", "admin_email", "email", "full_name")

def get_urls(self):
urls = super().get_urls()
custom_urls = [
path(
"add-passwordless/",
self.admin_site.admin_view(self.add_passwordless_view),
name="core_user_add_passwordless",
),
]
return custom_urls + urls

def add_passwordless_view(self, request):
"""Create a passwordless (sub-less) user from a single email field.

These users cannot authenticate locally (unusable password, no
``admin_email``) and will be claimed on first OIDC login by
``UserManager.get_user_by_sub_or_email``.
"""
if request.method == "POST":
form = PasswordlessUserForm(request.POST)
if form.is_valid():
email = form.cleaned_data["email"]
user = models.User.objects.filter(email=email).first()
if user is None:
user = models.User(email=email)
user.set_unusable_password()
user.save()
messages.success(
request,
f"Passwordless user created: {user.email}",
)
else:
messages.info(
request,
f"User already exists: {user.email}",
)
return redirect("admin:core_user_changelist")
else:
form = PasswordlessUserForm()

context = {
**self.admin_site.each_context(request),
"title": "Add passwordless user",
"form": form,
"opts": self.model._meta, # noqa: SLF001
}
return TemplateResponse(
request, "admin/core/user/add_passwordless.html", context
)
Comment thread
sylvinus marked this conversation as resolved.


class MailDomainAccessInline(admin.TabularInline):
"""Inline class for the MailDomainAccess model"""
Expand Down
32 changes: 16 additions & 16 deletions src/backend/core/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1085,23 +1085,23 @@ class Meta:
read_only_fields = fields # All fields are effectively read-only from this serializer's perspective


class UserField(serializers.PrimaryKeyRelatedField):
"""Custom field that accepts either UUID or email address for user lookup."""
class UserAccessWriteField(serializers.PrimaryKeyRelatedField):
"""Custom field that accepts either UUID or email address for user lookup.

When an email is provided and no matching user exists, a passwordless
"stub" user is created. The stub has no OIDC ``sub`` and will be claimed
on first OIDC login by ``UserManager.get_user_by_sub_or_email``.
"""

def to_internal_value(self, data):
"""Convert UUID string or email to User instance."""
if isinstance(data, str):
if "@" in data:
# It's an email address, look up the user
try:
return models.User.objects.get(email=data)
except models.User.DoesNotExist as e:
raise serializers.ValidationError(
f"No user found with email: {data}"
) from e
else:
# It's a UUID, use the parent method
return super().to_internal_value(data)
if isinstance(data, str) and "@" in data:
user = models.User.objects.filter(email=data).first()
if user is None:
user = models.User(email=data)
user.set_unusable_password()
user.save()
return user
Comment thread
sylvinus marked this conversation as resolved.
return super().to_internal_value(data)


Expand All @@ -1111,7 +1111,7 @@ class MailboxAccessWriteSerializer(serializers.ModelSerializer):
"""

role = IntegerChoicesField(choices_class=models.MailboxRoleChoices)
user = UserField(
user = UserAccessWriteField(
queryset=models.User.objects.all(), help_text="User ID (UUID) or email address"
)

Expand Down Expand Up @@ -1201,7 +1201,7 @@ class MaildomainAccessWriteSerializer(serializers.ModelSerializer):
"""

role = IntegerChoicesField(choices_class=models.MailDomainAccessRoleChoices)
user = UserField(
user = UserAccessWriteField(
queryset=models.User.objects.all(), help_text="User ID (UUID) or email address"
)

Expand Down
8 changes: 8 additions & 0 deletions src/backend/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,14 @@ def get_user_by_sub_or_email(self, sub, email):
if not email:
return None

# Always claim sub-less "stub" users (created via invite/admin) by email,
# regardless of OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION: a stub has no
# other way to ever be linked to its OIDC identity.
try:
return self.get(email=email, sub__isnull=True)
except self.model.DoesNotExist:
pass
Comment thread
sylvinus marked this conversation as resolved.

if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
try:
return self.get(email=email)
Expand Down
35 changes: 35 additions & 0 deletions src/backend/core/templates/admin/core/user/add_passwordless.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{% extends "admin/base_site.html" %}
{% load i18n admin_urls %}

{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url 'admin:core_user_changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
&rsaquo; {% translate "Add passwordless user" %}
</div>
{% endblock %}

{% block content %}
<form method="post">
{% csrf_token %}
<fieldset class="module aligned">
<p class="help">
{% translate "Creates a user with no password and no OIDC sub. The account will be claimed automatically on first OIDC login that matches this email." %}
</p>
{% for field in form %}
<div class="form-row{% if field.errors %} errors{% endif %}">
{{ field.errors }}
<div>
{{ field.label_tag }}
{{ field }}
</div>
</div>
{% endfor %}
</fieldset>
<div class="submit-row">
<input type="submit" value="{% translate 'Create' %}" class="default">
<a href="{% url 'admin:core_user_changelist' %}" class="button cancel-link">{% translate 'Cancel' %}</a>
</div>
</form>
{% endblock %}
11 changes: 11 additions & 0 deletions src/backend/core/templates/admin/core/user/change_list.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{% extends "admin/change_list.html" %}
{% load i18n admin_urls %}

{% block object-tools-items %}
{{ block.super }}
<li>
<a href="{% url 'admin:core_user_add_passwordless' %}" class="addlink">
{% translate "Add passwordless user" %}
</a>
</li>
{% endblock %}
28 changes: 27 additions & 1 deletion src/backend/core/tests/api/test_mailbox_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ def fixture_access_m2d1_alpha(mailbox2_domain1, user_alpha):
)


class TestMailboxAccessViewSet:
class TestMailboxAccessViewSet: # pylint: disable=too-many-public-methods
"""Tests for the MailboxAccessViewSet API endpoints."""

BASE_URL_LIST_CREATE_SUFFIX = "-list"
Expand Down Expand Up @@ -309,6 +309,32 @@ def test_admin_maildomain_mailbox_create_access_success(
mailbox=mailbox1_domain1, user=user_alpha, role=MailboxRoleChoices.EDITOR
).exists()

def test_admin_maildomain_mailbox_create_access_invites_unknown_email(
self,
api_client,
domain_admin_user,
mailbox1_domain1,
):
"""POSTing an unknown email should auto-create a passwordless stub user."""
api_client.force_authenticate(user=domain_admin_user)

unknown_email = "invitee@example.com"
assert not models.User.objects.filter(email=unknown_email).exists()

data = {"user": unknown_email, "role": "editor"}
response = api_client.post(
self.list_create_url(mailbox_id=mailbox1_domain1.pk), data
)

assert response.status_code == status.HTTP_201_CREATED
stub = models.User.objects.get(email=unknown_email)
assert stub.sub is None
assert not stub.has_usable_password()
assert response.data["user"] == stub.pk
assert models.MailboxAccess.objects.filter(
mailbox=mailbox1_domain1, user=stub, role=MailboxRoleChoices.EDITOR
).exists()

def test_admin_maildomain_mailbox_create_access_by_mailbox_admin_for_unmanaged_mailbox_forbidden(
self, api_client, mailbox1_admin_user, mailbox1_domain2, user_beta
):
Expand Down
25 changes: 25 additions & 0 deletions src/backend/core/tests/api/test_maildomain_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,31 @@ def test_admin_api_maildomain_accesses_create_access_success(
role=MailDomainAccessRoleChoices.ADMIN,
).exists()

def test_admin_api_maildomain_accesses_create_invites_unknown_email(
self, api_client, super_user, maildomain_1
):
"""POSTing an unknown email should auto-create a passwordless stub user."""
api_client.force_authenticate(user=super_user)

unknown_email = "domain-invitee@example.com"
assert not models.User.objects.filter(email=unknown_email).exists()

data = {"user": unknown_email, "role": "admin"}
response = api_client.post(
self.list_create_url(maildomain_pk=maildomain_1.pk), data
)

assert response.status_code == status.HTTP_201_CREATED
stub = models.User.objects.get(email=unknown_email)
assert stub.sub is None
assert not stub.has_usable_password()
assert response.data["user"] == stub.pk
assert models.MailDomainAccess.objects.filter(
maildomain=maildomain_1,
user=stub,
role=MailDomainAccessRoleChoices.ADMIN,
).exists()

def test_admin_api_maildomain_accesses_create_access_by_maildomain_admin_for_unmanaged_maildomain_forbidden(
self, api_client, maildomain_1, md2_access, md2_admin_user, regular_user
):
Expand Down
37 changes: 36 additions & 1 deletion src/backend/core/tests/authentication/test_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from cryptography.fernet import Fernet
from lasuite.oidc_login.backends import get_oidc_refresh_token

from core import models
from core import factories, models
from core.authentication.backends import OIDCAuthenticationBackend
from core.factories import UserFactory

Expand Down Expand Up @@ -151,6 +151,41 @@ def get_userinfo_mocked(*args):
assert models.User.objects.count() == 1


@override_settings(
MESSAGES_TESTDOMAIN=None,
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=False,
OIDC_ALLOW_DUPLICATE_EMAILS=False,
OIDC_CREATE_USER=True,
)
def test_authentication_getter_claims_passwordless_stub_by_email(monkeypatch):
"""
A user with no sub (an "invited" stub created via the access-grant API or
the admin) should always be claimed on first OIDC login by email, even
when OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION is disabled. Pre-existing
accesses must remain attached to the same row.
"""

klass = OIDCAuthenticationBackend()
stub = UserFactory(sub=None, email="invitee@example.com", full_name=None)
mailbox = factories.MailboxFactory()
factories.MailboxAccessFactory(user=stub, mailbox=mailbox)

def get_userinfo_mocked(*args):
return {"sub": "oidc-sub-xyz", "email": stub.email}

monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)

user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)

assert user.pk == stub.pk
assert user.sub == "oidc-sub-xyz"
assert models.User.objects.count() == 1
# The pre-existing access still references the merged user.
assert models.MailboxAccess.objects.filter(user=user, mailbox=mailbox).exists()


@override_settings(MESSAGES_TESTDOMAIN=None)
def test_authentication_getter_existing_user_with_email(monkeypatch):
"""
Expand Down
Loading